Compare commits

..
672 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
rmcrackan 6fba3d61f3 Merge pull request #1966 from rmcrackan/cursor/release-13-7-10-8397
Bump version to 13.7.10
2026-08-17 15:20:10 -04:00
Cursor Agentandrmcrackan 294608d95c Bump version to 13.7.10
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 19:13:48 +00:00
rmcrackan 0d13200d58 Merge pull request #1964 from rmcrackan/cursor/test-hygiene-guards-d9d1
Fix the CI flake, the crash it was hiding, and widen the exception guard
2026-08-17 14:03:20 -04:00
Cursor Agentandrmcrackan e6e8ba73e0 Upgrade to Dinah.Core 10.2.4.1 and AudibleApi 11.0.3.1
Seven references across six projects, all now on published versions.

What the app gains is from Dinah.Core: OsSecretStore.Create bounds how
long it waits for the backend, so a Linux or macOS start with a keyring
that never answers falls through to the portable master key path instead
of hanging there. IdentityTokenStorageWiring.ResolveSecretStore is the
caller, and it runs at startup whenever no key file or env var is set.

AudibleApi 11.0.3.1 carries no code change for us - it is the release
where its nuspec finally declares the Dinah.Core floor its own code needs.

Dinah.Core.WindowsDesktop and Dinah.EntityFrameworkCore move to 10.2.4.1
as well, keeping every Dinah package on one version.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 17:53:53 +00:00
rmcrackan 4825acc2dd Merge pull request #1963 from tippfehlr/faq-heading-levels
docs(faq): use ## for question about audible standard
2026-08-17 13:07:23 -04:00
Jonathan Grotelüschen 2e49f0ddb5 docs(faq): use ## for audible standard question 2026-08-17 17:43:29 +02:00
Cursor Agentandrmcrackan d27445a128 Stop a disposing file watcher from crashing the process
A Windows CI leg failed with every test passing. FileLiberator.Tests
exited 0xE0434352:

  Unhandled exception. System.InvalidOperationException: The collection has
  been marked as complete with regards to additions.
     at BlockingCollection`1.Add(T item)
     at FileManager.BackgroundFileSystem.FileSystemWatcher_Changed(...)
     at FileSystemWatcher.ReadDirectoryChangesCallback(...)

Stop() disposes the watcher and then completes the collection, on the
assumption that disposing stops events. It does not stop the ones the OS
has already buffered, and on Windows those arrive on a native completion
callback, where an exception is not a failed call - it is a dead process.
So a Libation run that reinitialises or shuts down its file cache while
the Books directory is busy can take the app with it, which is the same
race the tests hit.

Adding to a completed collection is now caught and the event dropped.
That is the right answer rather than a swallow: whoever called Stop() is
either reinitialising, which rebuilds the cache from disk, or disposing.
Stop() also detaches its handlers before disposing and clears the fields,
which narrows the window and makes a second Stop() harmless - the
collection field is cleared only after CompleteAdding, since the
background scanner is waiting on that.

First tests for the class, since it had none: dispose under a flood of
events, dispose twice, and find a file created before and after
construction. They cannot prove this fix - the crash does not reproduce on
Linux even with the original code, because inotify does not deliver
post-dispose events the way Windows does. Windows CI is the only place
that can, so the guard is aimed there.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 13:33:52 +00:00
Cursor Agentandrmcrackan b9aa51cd00 Widen the guard so it can see every exception it should
The guard lived in AudibleUtilities.Tests, which meant it scanned three
assemblies and found four exception types - a test only sees what its own
project references. Moved to LibationUiBase.Tests, which reaches
ApplicationServices, AppScaffolding, FileLiberator and DataLayer as well,
it now covers 16 exception types across 10 assemblies.

Two of those are the AudibleApi packages, on purpose. Identity is theirs,
so an upstream exception that started carrying one would leak through
Libation, and a version bump is where we would want to find that out
rather than in someone's log. Nothing there reaches one today.

Checked by adding AccountSummary to the forbidden list, which fails the
test naming AuthenticationRequiredException.AccountInfo - so the walk does
reach exception properties rather than passing on an empty set.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 13:01:30 +00:00
Cursor Agentandrmcrackan 5beaad0498 Run FileLiberator's tests serially
Seven tests failed in a Windows CI leg, every one thrown from a
TestInitialize rather than an assertion, and on a path no test in the
failing class had created. The cause is [assembly: Parallelize] over
classes that reach for state which is process-wide by nature: the
Configuration singleton, the LIBATION_FILES_DIR environment variable, and
AudibleFileStorage's static file cache. Two classes running at once means
one swapping the config or deleting its temp directory while the other
enumerates it.

Opting out per class was the previous arrangement and it does not hold:
three classes carried [DoNotParallelize] while two touching the same
state did not, and nothing tells whoever adds the sixth. The attribute is
gone, with a comment saying why, and the existing [DoNotParallelize]
markers stay as a statement of intent if parallelism ever returns.

Costs nothing measurable: 68 tests in about 3.1 seconds serially, against
9.8 seconds for the same assembly in the CI leg that failed. Ran three
times over to check.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 12:58:32 +00:00
Cursor Agentandrmcrackan e6f8c6fae3 Merge pull request #1962 from rmcrackan/cursor/pdf-status-comment-accuracy-05e1
Recover a stranded comment fix from the #1956 branch

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 04:37:58 +00:00
Cursor Agentandrmcrackan 1e2c9450cf docs: name the PDF-only control that already existed
Visible Books > Set PDF 'Downloaded' status manually is PDF-only and does not touch
BookStatus, so the gap the row items fill is a per-title one, not the absence of any
way at all.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 04:29:48 +00:00
rmcrackan b80cf1b22d Merge pull request #1961 from rmcrackan/cursor/fix-persistentdictionary-concurrency-05e1
Fix concurrent-collection crash when reading settings from multiple threads (#1959)
2026-08-17 00:17:28 -04:00
Cursor Agentandrmcrackan c11268c6b4 fix(test): clear CA1416 platform warnings on the unix file mode calls
The platform compatibility analyzer flagged all four File.GetUnixFileMode /
SetUnixFileMode calls as reachable on Windows. Assert.Inconclusive is not
[DoesNotReturn], so the OperatingSystem.IsLinux/IsMacOS check did not narrow the
platform for anything after it, and narrowing from such a check does not reach
inside a lambda at all, which left the Timer restore callback flagged regardless.

Return explicitly after the skip, and move the body into a method attributed
[SupportedOSPlatform("linux")]/[SupportedOSPlatform("macos")] so the callback
inherits that context. Both files I touch now build warning free.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 04:07:42 +00:00
rmcrackan 14c56e4052 Merge pull request #1960 from rmcrackan/cursor/adopt-secretstring-packages-d9d1
Adopt Dinah.Core 10.2.2.1 and AudibleApi 11.0.0.1
2026-08-16 23:58:07 -04:00
Cursor Agentandrmcrackan bbb401ef87 fix(config): outlast a brief lock on Settings.json, and scope the reader test to unix
Windows CI caught the atomic replace failing with UnauthorizedAccessException:
renaming over a file is denied while another handle holds it open, however
generously that handle shares the file. In production the CLI, a second GUI
instance or a virus scanner can each hold Settings.json for a moment, so retry the
replace a few times before letting the caller see the failure. The previous
File.WriteAllText threw on the same holds, so this is strictly more forgiving.

ExternalReaderNeverSeesAPartiallyWrittenFile keeps a handle open almost
continuously, which no retry budget can outlast on Windows, so restrict it to unix
where it actually tests write atomicity. Write_SurvivesATemporarilyUnwritableDirectory
covers the retry instead by revoking write permission on the containing directory.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 03:47:54 +00:00
Cursor Agentandrmcrackan 9ea66ddaee Point at AudibleApi 11.0.2.1
The last reference still on 11.0.0.1. 11.0.2.1 is the release whose nuspec
declares the Dinah.Core floor its code actually needs, so the package now
agrees with what this repo resolves anyway.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 03:37:20 +00:00
Cursor Agentandrmcrackan e8d215e5eb Point at Dinah.Core.WindowsDesktop 10.2.3.1
The last reference still on 10.2.2.1, held back until the package was
published. LibationWinForms now resolves both Dinah.Core and
Dinah.Core.WindowsDesktop at 10.2.3.1 rather than relying on NuGet
unifying a lower pin upward.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 02:51:44 +00:00
Cursor Agentandrmcrackan 2b3325ed0d Point at Dinah.Core 10.2.3.1
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 02:31:49 +00:00
Cursor Agentandrmcrackan c813d6ee98 Drop AsScalar; SecretString reports its own shape now
Dinah.Core 10.2.2.2 gives SecretString a Redacted property, so a
destructured secret carries its length without the logger being told
anything. That was the only thing AsScalar was buying - safety never
depended on it - so the registration and its explanation go away, and the
knowledge lives in the type instead of in this file.

MaskedLogEntryPolicy stays: an ILogMasked has no equivalent property, and
without the policy one logged as {@Account} is still written out property
by property.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 02:26:46 +00:00
Cursor Agentandrmcrackan f472334f09 fix(config): replace Settings.json atomically instead of truncating it
File.WriteAllText truncates the destination before writing, so an interrupted
write leaves a half-written or empty Settings.json, and the in-process lock added
in the previous commit cannot help a reader in another process - the GUI and the
CLI share this file.

Route every write through Dinah.Core.IO.AtomicFileWriter, which writes a sibling
temp file, flushes to disk and renames it over the destination. Validate the temp
file parses as json before the swap, the same way JsonFilePersister<T> already
saves AccountsSettings.json, so a bad payload leaves the existing file untouched.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 02:23:17 +00:00
Cursor Agentandrmcrackan 4ac94d1ff2 Say AsScalar instead of restating ToString
Same output, but it states the intent: leave a SecretString whole rather
than transform it. The comment now separates the two lines by what they
do, because they are not the same kind of thing - the policy is the
protection, and without it a masked object is written out property by
property, while this line only decides whether a secret reads as its
length or as {"HasValue":true}.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 02:08:57 +00:00
Cursor Agentandrmcrackan 742e58d2e8 Mask the account on the paths that do not go through an exception
Account.ToString() returned "id - locale", so interpolating an account or
logging a non-destructured {Account} published the address. It now returns
the masked entry, with a DebuggerDisplay keeping the real values visible
while debugging. Nothing in the UI relied on it: both scan dialogs build
their own labels.

For structured logging, an ILogMasked type is reduced to its masked entry
by a destructuring policy, which covers the {@DebugInfo} shape most of
Libation's logging uses. And DecryptKey - the activation bytes - is now a
SecretString, so it has no plaintext for a reflective dump to find at all.
Its JSON stays the bare string it always was, so existing settings files
load unchanged.

A registered policy that nobody notices is missing protects nothing, so
the tests write through a logger built by ConfigureLogging itself rather
than a hand-made one. Deleting either registration fails them: the masked
object comes out whole, and a destructured secret renders as
{"HasValue":true} instead of its length.

The contribute guide now states the rule, since the reason for all of
this is invisible from the code alone: log files get attached to public
issues, so treat what goes in them as published.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 02:04:09 +00:00
Cursor Agentandrmcrackan d0cf459450 fix(config): serialize PersistentDictionary cache and file access
Configuration.Instance is a process-wide singleton read and written from the UI
thread, BackgroundWorker callbacks and download workers at the same time, but
PersistentDictionary guarded only its file writes. Its two Dictionary caches were
touched without synchronization, so concurrent inserts eventually corrupted them
and threw "Operations that change non-concurrent collections must have exclusive
access" (issue #1959, reported from MainVM.UpdateCountsBw_Completed reading
AutoDownloadEpisodes while a second GetCounts pass ran).

Reads on the file were unguarded too: Exists/GetJObject could observe a partially
written Settings.json, and readFile responds to empty contents by rewriting the
file, so a reader racing a writer could scramble the settings on disk.

Take one lock across each operation's cache and file access, and keep logging
outside it by having writeFile report whether it rewrote the file.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 01:58:54 +00:00
Cursor Agentandrmcrackan 625c0f2330 Keep the account out of the failure that reports it
This is the reported leak. AuthenticationRequiredException held the live
Account, and Serilog.Exceptions writes every public property of a logged
exception into the log file - following nested objects as it goes - so
pausing auto-scan wrote the reporter's real address into a file we ask
people to attach to public issues. Their DecryptKey happened to be empty;
with activation bytes set it would have published those too.

The exception now carries an AccountSummary: masked entry and a
credentials flag, both safe to log, plus the owner-facing label behind a
method rather than a property, because reflection reads properties and
never calls methods. The constructor still takes an Account, so callers
and tests are unchanged.

The thrown message named the account too, and it reaches the log twice -
once as {Exception}, once as ExceptionDetail.Message - so it is masked
now. The GUI dialog still shows the full name and address, since that is
the owner's own screen. For the CLI, stderr is not teed into Serilog, so
that is where a headless user is told which account in full.

Two tests, one for the bug and one for the class of bug: the first logs a
real exception through the same WithExceptionDetails enricher Libation
configures and asserts no address, activation bytes, tokens, or cookies
come out. The second walks the public property graph of every exception
type in these assemblies and fails if one can reach an Account or an
Identity. Restoring the old property makes all of it fail, naming
"jade@example.com" and the path AuthenticationRequiredException.Account.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 01:58:01 +00:00
Cursor Agentandrmcrackan 337cb3169a Adopt Dinah.Core 10.2.2.1 and AudibleApi 11.0.0.1
AudibleApi 11 holds token, key, and cookie values in a SecretString
rather than a string, so nothing public exposes plaintext for a reflective
logger to find. Picking it up is a breaking upgrade: the seven package
references move, and the nine places that read a secret now call Reveal().

Two of those needed thought rather than a mechanical edit. Mkb79Auth
exports to and imports from audible-cli's JSON format, which is plaintext
by definition, so the cookie projections reveal explicitly in both
directions and the file format is unchanged. And the account's own
DecryptKey stays a plain string here: converting it is separate work.

This is the dependency bump only. The log leak it enables fixing - an
AuthenticationRequiredException carrying a live Account, whose address and
activation bytes Serilog.Exceptions writes into a shared log - is still
open, and none of the account-side masking has landed yet.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-17 01:49:21 +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
rmcrackan 78a044697a Merge pull request #1956 from rmcrackan/cursor/empty-metadata-and-pdf-verification-ffa6
Keep real metadata when Audible's catalog is empty, verify PDFs, and set PDF status on its own
2026-08-16 20:11:39 -04:00
rmcrackan 3a3ff42b2f Merge pull request #1957 from rmcrackan/cursor/subtitle-search-fields-f914
Add HasSubtitle and TitleHasColon search fields
2026-08-16 19:59:53 -04:00
Cursor Agentandrmcrackan 14ec4804f4 feat(grid): set a title's PDF status without touching its audiobook
'Set Download status to Not Downloaded' moves both statuses together and was the only
way to reset a PDF, so a user who wanted a PDF re-fetched also queued the audiobook
for a fresh download - which then rewrote that title's other files. Reported in
issue #1947.

Add a PDF-only pair beside it, shown only for a selection that has a PDF, since for
anything else the existing pair is already audio-only.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 23:53:54 +00:00
Cursor Agentandrmcrackan 537f53d856 fix(pdf): do not save a non-PDF response as a title's PDF
That the file existed said only that the server sent a body, and an Audible error is
a 200 with a JSON body like any other response. Dinah's downloader renames by
Content-Disposition, so such a body landed in the book's folder under whatever
Audible called it and the title was recorded as having its PDF.

Check the payload: a file named .pdf must carry the PDF header, and nothing may begin
with the opening character of a JSON or markup document. A rejected download is
deleted rather than left in the library, which also lets the empty-folder cleanup
run, and the file is added to the path cache only once it has passed.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 23:53:44 +00:00
Cursor Agentandrmcrackan 6a6e1ce330 fix(metadata): keep a title's metadata when Audible's catalog has none
A storefront that no longer lists a title still answers a request for it: HTTP 200,
total_results 1, and a product carrying an asin and a few always-returned flags.
Nothing between the request and the file noticed, so a re-download replaced a
metadata file written while the title was still listed - the only copy of that data -
with the placeholder.

Fetch the product before touching the destination, and leave the file alone when the
product carries no title. Reported in issue #1947, where a Canada-only title produced
{"asin":...,"asset_details":[],"is_preview_enabled":false,"is_vvab":false,"rating":{...}}
against every other storefront.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 23:53:36 +00:00
Cursor Agentandrmcrackan 4187712c8d Add HasSubtitle and TitleHasColon search fields
<title short> stops at the first colon, so it shortens Audible titles that
contain one just as readily as it drops Audible's subtitle, and distinct books
then collapse onto the same name. A colon cannot be searched for: the analyzer
discards punctuation and Lucene reads a colon in a query as a field separator.
Two bool index fields find the affected books instead.

Document how the two title tags differ, since <audible title> already drops
Audible's subtitle without ever cutting a title, and how to audit for names
that actually collide in a spreadsheet export.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 23:43:53 +00: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
rmcrackan bb0ece9ee4 Merge pull request #1953 from rmcrackan/cursor/release-13-7-9-bf7c
Bump version to 13.7.9
2026-08-16 14:50:14 -04:00
Cursor Agentandrmcrackan 209fd3aa4c Bump version to 13.7.9
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 18:48:26 +00:00
rmcrackan a122a6917b Merge pull request #1951 from rmcrackan/cursor/heal-corrupt-lucene-search-index-3c5b
Heal a search index Lucene cannot open, and say which account needs a login
2026-08-16 14:44:17 -04:00
Cursor Agentandrmcrackan 9afcb09885 Keep account addresses out of the auto-scan pause log line
The dialog names the account in full because it is shown to whoever owns it, but
log files get attached to public issue reports, which is why the codebase has
MaskedLogEntry. Naming the account in the log the same way the dialog does would
have put real email addresses into every shared log.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 18:32:37 +00:00
rmcrackan 1999307dfa Merge pull request #1952 from rmcrackan/cursor/no-rights-retry-backoff-0844
Fix #1947: refused licenses re-requested every run, missing and misplaced PDFs, unbounded log
2026-08-16 14:28:32 -04:00
Cursor Agentandrmcrackan a297715b8a Revert "docs: the PDF and log fixes ship in 13.7.10, not 13.7.9"
This reverts 6daaf33d. Master is 13.7.8 and the next release is the 0.0.1
increment from it, so the original 13.7.9 references were correct.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 18:24:25 +00:00
Cursor Agentandrmcrackan 6daaf33dbc docs: the PDF and log fixes ship in 13.7.10, not 13.7.9
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 18:21:12 +00:00
Cursor Agentandrmcrackan bb55fb40a3 Stop the filter box looping on dialogs when the search index is at fault
Both grids restored the last good filter by recursing into the filter handler, which
never terminated once the search index rather than the query was the problem: the
restore fails the same way, and the retry uses the same filter. The user got an
endless run of dialogs, each of them blaming a filter string that was fine. Only an
empty last-good filter broke the loop, because that short-circuits before reaching
the search engine.

The fallback is now a bounded sequence -- last good filter, then no filter -- and
the message distinguishes an index Libation cannot reach from a query it cannot
parse. Only the first failure is reported, so restoring is quiet. A malformed query
never surfaces as an IO-family exception, which QueryFailureShapeTests pins against
the real engine, so a typo is never mistaken for index trouble or made to trigger a
rebuild.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 18:06:40 +00:00
Cursor Agentandrmcrackan d7a6ef0302 test(pdf): compare paths the way Libation produces them
The two path assertions added with the PDF fix compared a path the test built
itself against one that had been through LongPath, which on Windows prefixes a
drive-rooted path with \\?\ so paths past the 260 character limit work. Linux
adds no prefix, so this only showed up on the Windows job.

Normalising both sides is not just about the false failure. The inequality
assertion guarding 'the PDF was saved loose in the Books directory' compared a
raw temp path against a prefixed one, so on Windows it passed on the prefix
alone and would not have caught the bug it exists to catch.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 18:01:37 +00:00
Cursor Agentandrmcrackan 77bbc1b0a0 Treat a Windows sharing violation on write.lock as a lock conflict
Windows CI caught real over-reach. When another holder has write.lock, Windows
raises the sharing violation before Lucene can turn it into a
LockObtainFailedException, so it arrives as a plain IOException. Repairing anything
that is not a recognised lock conflict then meant deleting the index the other
holder was using -- exactly the second-instance case the retry exists for.

An IOException naming Lucene's write lock now counts as a lock conflict. Matching
the file name rather than the message wording keeps it working on non-English
Windows. The end-to-end test asserts the property instead of the exception type,
since the type legitimately differs by platform.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 17:25:01 +00:00
Cursor Agentandrmcrackan 4409fb6801 Make the lock conflict test deterministic on Windows
Releasing the write.lock part way through the retry budget raced with Lucene 3's
own lock bookkeeping: on Windows a competing Obtain left a handle on the file, so
Release and the temp directory cleanup both failed with a sharing violation. Hold
the lock for the whole budget instead and assert what actually matters, that a lock
conflict is retried and leaves the index files alone.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 17:14:57 +00:00
Cursor Agentandrmcrackan b212c47cad docs: correct how the wait applies to a title's PDF
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 17:08:27 +00:00
Cursor Agentandrmcrackan 8fd3b91858 fix(cli): do not fetch the PDF of a title Audible just refused
A PDF is fetched through the same license request as the audiobook, so following
a refusal with a PDF request reproduced, through the PDF, exactly the per-run
refusal the wait exists to stop. The follow-up pass now skips the titles the
first pass deliberately left alone as well as the ones it attempted.

Also stop a failed PDF download leaving an empty folder in the library: a
PDF-only download is the one case that has to create the book's folder before it
has anything to put in it, so it now removes a folder it created and did not
fill. GetProposedDownloadFilePath goes back to being a pure path computation.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 17:08:13 +00:00
Cursor Agentandrmcrackan 87e2ea0ad3 docs: cover both PDF fixes and the cost of dropping <id> from a template
Troubleshooting gains an entry for missing and misplaced PDFs, including the
naming-template cause, since a library with no <id> in its folder and file
templates is one Libation cannot recognise the output of at all. The CLI
reference notes what a plain liberate run now covers, and that the
Audiobookshelf upload is deliberately not part of the PDF back-fill.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 17:03:01 +00:00
Cursor Agentandrmcrackan 870b596d3e fix(cli): download the PDFs of titles whose audio is already downloaded
A plain 'libationcli liberate' iterates the titles DownloadDecryptBook selects,
and that step selects on '!AudioExists'. A title needing nothing but its PDF was
therefore never reached by the verb documented as 'book and pdf backups' - only
'liberate --pdf' picked it up. For a library that was liberated before its PDFs
were, that is every title with a PDF.

Give the bulk run an optional second pass and have liberate use it for PDFs, the
way the app's Liberate All always has. Skipped when the first pass stopped early
so a run cut short by its download limit does not carry on doing other work, and
titles the first pass attempted are excluded by product id rather than by asking
Validate again, so a step that just failed is not immediately retried.

Left alone: the Audiobookshelf upload stays tied to a fresh liberation. Its
Validate passes for any liberated title, so including it here would walk the
whole library on the next run. 'abs upload' already exists for that.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 17:01:29 +00:00
Cursor Agentandrmcrackan 54485c0825 fix(pdf): save a PDF with its book instead of loose in the Books directory
getProposedDownloadFilePath looked for the book's audio file and fell back to
the Books directory itself when it found none. That lookup matches on the
product id appearing in the path, so it finds nothing for a library whose
folder and file templates omit <id>, and nothing for a title marked downloaded
whose files are not on this machine. Those PDFs landed in the library root,
where they also shared one namespace and so could collide with each other.

Fall back to the folder template instead - the same folder the audiobook itself
would go in - and create it, since nothing else does on the PDF-only path.

Also give MockLibraryBook a three-field version: ToVersionString formats to at
least three fields, so the two-field default threw as soon as anything rendered
a naming template for a mock book.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 17:01:29 +00:00
Cursor Agentandrmcrackan 0f4cfac3b0 Name the account and the real cause when auto-scan pauses for a login
Ported from #1949. The reporter's log paused auto-scan on a second account that
had never been logged in, while the dialog blamed an expired session and named no
account, so there was nothing to act on.

AccountCredentialStatus tells a never-registered account apart from one holding an
expired access token, by looking for a refresh token to renew from. AutoScanRunner
now hands the AuthenticationRequiredException to the notification so the prompt can
name the account, which means digging that exception back out of the wrappers the
scan adds on the way up. Same distinction in the log line and in the exception
message ApiExtended throws when interactive login is unavailable, which is what the
CLI and Docker users see.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:55:46 +00:00
Cursor Agentandrmcrackan 79bdbe6d76 Tell the user how to delete the search index when repair fails
Ported from #1949, which surfaces the manual recovery steps the maintainer had
been giving out by hand instead of leaving the user with a raw Lucene error.

Adapted to the failure now being contained: with the exception no longer escaping
into the library change, the scan-failure catch blocks #1949 hooked would never
see it, and hooking only those would still miss every other trigger -- removing
books is what crashed the GUI. So the guard moves from AppScaffolding into
SearchEngineCommands next to the update commands it protects, and raises
UpdateFailed from there. Both GUIs subscribe, so any trigger is covered, and the
event carries the exception rather than needing #1949's StackTrace string sniffing
to find it. The dialog is shown once per session: a damaged index fails on every
library change and these steps only need following once.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:44:58 +00:00
Cursor Agentandrmcrackan 178715499f refactor: keep the deferral gating and its messaging in one place each
Fold the pdf-only exclusion into HonorsDeferredRetries instead of also checking
the processable type in the run loop, split the user-facing message building
into its own file next to the store, and leave GC.Collect on the success path
where it was.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:35:41 +00:00
Cursor Agentandrmcrackan 69c4f9f0d1 docs: document waiting before retrying a refused download
Adds a Features page covering the wait schedule, what clears it, and what the
CLI and app show, plus cross-links from the CLI reference, the daily download
limit page and troubleshooting. Troubleshooting also gains an entry for a log
too large to attach to a bug report, which is how the reporter in issue #1947
found this.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:32:24 +00:00
Cursor Agentandrmcrackan cacff3c71b Cover a garbled segments.gen in the search index recovery tests
Lucene 3's base-36 filename formatter overruns its buffer when segments.gen names
an absurd generation, so the rebuild path has to survive an IndexOutOfRangeException
as well as the IOException shapes. Damage does not always announce itself as IO.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:30:46 +00:00
Cursor Agentandrmcrackan 252decb3ad test(logging): prove the log actually rolls on size
Asserting on the generated JSON alone would pass just as happily with a
misspelled sink argument, which Serilog ignores in silence - and silently not
rolling is the bug. These build a real logger from Libation's own config and
write until it rolls, including a test that pins the old unbounded behaviour so
a future change to the defaults cannot quietly restore it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:30:23 +00:00
Cursor Agentandrmcrackan 854cb280a0 test: cover the retry backoff, the store and the failure classifier
The classifier's inputs are the actual denials from the log attached to issue
#1947: owned titles on an inactive account, a Plus title no longer in the
catalog, and an unreleased preorder Audible has no audio for.

The backoff tests also caught a real overflow: first * 2^n exceeds a TimeSpan
long before the cap matters.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:23:34 +00:00
Cursor Agentandrmcrackan 824ff10dd9 fix(download): stop asking Audible for a license it just refused
A license denial left no trace: BookStatus stayed NotLiberated, so every
liberate run asked again. Only the GUI's bad-book dialog could mark a title
Error, and license denials take their own path and never reach that dialog, so
a headless install had no way at all to stop the retries. A cron schedule then
re-requested the same refused licenses every run and printed the same warning
block for each one, which is both wasted API traffic and the log noise reported
in issue #1947.

Record the refusal instead, with a wait that doubles per consecutive failure:
one day for an eligibility denial (up to 30), six hours for a title Audible has
no audio for yet such as an unreleased preorder (up to 7 days), one hour when
the denial names GenericError, which the GUI already reads as an outage
(up to 12). Nothing is permanent - every kind is attempted again on its own.

Only failures attributable to Audible are recorded. A dropped connection, a
decrypt error or a full disk keeps being retried on the next run as before.

Naming a title, --force, and setting a download status all clear the record:
asking for a title explicitly overrides the wait.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:13:17 +00:00
Cursor Agentandrmcrackan 0aa9cb0019 Heal a search index Lucene cannot open, instead of retrying it as a lock conflict
A truncated or zero-length segments file is reported by Lucene 3 as a plain
IOException ("read past EOF") rather than a CorruptIndexException, so it was
misclassified as a write.lock conflict: CreateNewIndex burned its whole backoff
budget and rethrew, and the delete-and-rebuild recovery never ran. Passing
create/overwrite to IndexWriter does not repair it either, because
IndexFileDeleter reads every segments_* file in the directory and tolerates only
missing ones, so a single unreadable segments file -- even a stale one from an
older commit -- leaves the index permanently unopenable. The user's only cure
was deleting the SearchEngine folder by hand.

Retries are now reserved for genuine lock conflicts (LockObtainFailedException,
which derives from IOException, and UnauthorizedAccessException), and any other
open failure gets one delete-and-rebuild pass before giving up with a message
that says which folder to remove. The query path recovers too, since
IsRecoverableCorruptIndexException now recognizes the truncated-segments
signature.

Search index updates are also no longer allowed to fail the library change that
triggered them. Both events fire after the database is committed, so an escaping
exception reported a successful scan as "Error importing library" and, being the
first subscriber, stopped the handlers that refresh the grid and backup counts.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:05:45 +00:00
rmcrackan eea9667987 Merge pull request #1948 from tippfehlr/faq-audible-standard
docs(faq): add entry about Audible Standard
2026-08-16 12:04:07 -04:00
Cursor Agentandrmcrackan 4d8075927f fix(logging): roll the log on size, not only on the calendar month
The default Serilog config set rollingInterval only, so Serilog's own defaults
applied: no size-based roll and a 1 GB ceiling after which the sink silently
stops writing. A busy install (many accounts scanned several times an hour)
reaches tens of MB in a month, past the point where the log can be attached to
a bug report.

Add fileSizeLimitBytes, rollOnFileSizeLimit and retainedFileCountLimit to the
default File sink, and fill in whichever of the three an existing Settings.json
is missing so installs that already have a Serilog section benefit too. Only
absent keys are written, so a hand-tuned config is left alone.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:03:27 +00:00
Cursor Agentandrmcrackan 4a200c6862 chore: add tippfehlr to the contributors list
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:03:12 +00:00
Cursor Agentandrmcrackan bc69d6fc22 docs(faq): clarify Audible Standard after subscription ends
Reflect community testing: once the subscription is inactive, Libation
cannot obtain a license and will not download those books.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-16 16:00:04 +00:00
Jonathan Grotelüschen 5d2aad045d docs(faq): add entry about Audible Standard 2026-08-16 11:39:06 +02:00
rmcrackan f0eddacca7 Merge pull request #1945 from rmcrackan/cursor/release-13-7-8-b21b
Bump version to 13.7.8
2026-08-14 17:31:31 -04:00
Cursor Agentandrmcrackan cf5903e5be Bump version to 13.7.8
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 21:29:53 +00:00
rmcrackan 12e1afeeaa Merge pull request #1942 from rmcrackan/cursor/silent-multi-book-download-657d
fix(queue): explain a multi-book download that queues nothing (#1940)
2026-08-14 16:54:41 -04:00
Cursor Agentandrmcrackan 2cceabb10e refactor(queue): trim the skip-reason breakdown
Drop the series-parent reason, which no caller can reach: parents are expanded
into their children before anything is queued. Replace the enum and its three
switches with a record carrying the label and the advice, which is all the
switches were mapping to.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 20:46:31 +00:00
Cursor Agentandrmcrackan 41de40e6cb fix(queue): put the count before the guidance in the skip breakdown
"Absent from your last library scan (run Scan, ...): 2" buries the number behind a
parenthetical. Lead with the label and count, then the advice.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 20:34:02 +00:00
Cursor Agentandrmcrackan 219abe0126 fix(queue): let Liberate All Episodes report why it queued nothing
The menu item is enabled from the grid's display status, so it can be clicked for
episodes the queue will reject - episodes absent from the last scan are the usual
case. Pre-filtering the children threw that reason away and left the queue with an
empty request it could only answer in general terms.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 20:34:02 +00:00
rmcrackan d4a18aa0a5 Merge pull request #1943 from rmcrackan/cursor/cli-per-run-download-limit-9be6
feat(cli): per-run download limit for liberate
2026-08-14 16:04:56 -04:00
Cursor Agentandrmcrackan 031627d4a7 refactor(cli): pin the stop message and rename the run loop's helper
Names the local function ProcessOrStopAsync so it does not shadow
Processable.TryProcessAsync, which means something else entirely, and asserts
the whole sentence a stopped run prints rather than a fragment of it.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 19:58:18 +00:00
Cursor Agentandrmcrackan fe92737066 docs: document the per-run download limit
Gives the three liberate options their own section on the command line page,
and cross-references them from the daily download limit page so the two
limits are not confused for each other.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 19:53:17 +00:00
Cursor Agentandrmcrackan 28e5ed3be2 test(cli): cover the per-run download limit
Two levels. The pure tests supply a run's history directly and check the
decision to stop: the count and size thresholds, the always-allow-one rule,
and that titles another process downloaded, or that this run attempted
without downloading, are not counted.

The loop tests drive the real run loop against a real library database with
a processable that records downloads the way DownloadDecryptBook does, which
is as close to a limited run as is possible without an Audible account. They
pin the behavior that matters at the boundary: five books under a limit of
two download two, and a run whose books end exactly at its limit reports
nothing, because nothing was cut short.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 19:52:29 +00:00
Cursor Agentandrmcrackan d52037c398 test(queue): cover backup requests that queue nothing
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 19:52:08 +00:00
Cursor Agentandrmcrackan f806af9632 fix(queue): explain a multi-book download that queues nothing
The multi-book branch of QueueDownloadDecryptAsync returned false with no log
entry and no message whenever UnLiberated() came back empty, so a request
Libation understood and declined looked exactly like a dead button. Callers that
pre-filter with UnLiberated() land here with an empty list, which is the common
way to hit it.

Classify each title that cannot be queued and report the breakdown: already
downloaded, previously failed, absent from the last scan, or a series parent with
no audio of its own. Log it either way; show it only when a person is waiting,
so the automatic post-scan download stays silent.

Fixes #1940

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 19:52:04 +00:00
Cursor Agentandrmcrackan 562b563477 feat(cli): stop a liberate run at a per-run download limit
Adds three mutually exclusive options to `liberate`: --limit-books,
--limit-mb and --limit-gb. Each stops the run once it has downloaded that
much, leaving the rest of the library un-liberated for the next run.

Requested in #1920: a scripted or scheduled run has no way to take only a
slice of a large library, so users resort to feeding the CLI a handful of
ASINs at a time. The GUI needs no equivalent, where selecting rows already
says exactly which titles to download.

Counting reuses the daily limit's history rows rather than a private tally,
so a book and a byte mean the same thing to both limits, and failed,
cancelled and pdf-only work is never counted. Only titles this run attempted
are counted, so a Libation window or a second container downloading at the
same time does not consume this run's allowance.

The limit is checked before each title rather than at the top of the run, so
a run whose books happen to end exactly at the limit reports nothing: nothing
was cut short. The daily download limit keeps applying on top, unchanged.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 19:46:47 +00:00
rmcrackan d51f816355 Merge pull request #1939 from rmcrackan/cursor/daily-download-limit-245f
feat: opt-in daily download limit (rolling 24h)
2026-08-14 14:34:44 -04:00
Cursor Agentandrmcrackan b35276c7a1 fix(limit): let newly queued books withdraw a pending Cancel All
Cancel All sets a flag the daily-limit wait loop watches. If books were queued
while the cancelled book was still settling, the queue was still running, so
those new books inherited the cancellation at the gate. Queueing work now
clears the flag.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 18:11:03 +00:00
Cursor Agentandrmcrackan 3129221531 docs(limit): document the daily download limit and add a history seeding script
Adds a feature page covering the rolling window, what counts, the MB/GB
estimate and the Docker/CLI keys, plus a one-line pointer from getting started
where a new user with a large Plus library clicks Begin Book Backups.

Scripts/seed-download-history.cs seeds fake completed downloads so the limit
can be exercised without downloading, including dating rows just under 24 hours
old to turn the multi-day pause-and-resume behavior into a one minute test.

Also shortens the queue status text: the process queue column clips rather than
wrapping, so the resume time was being cut off.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 18:03:23 +00:00
Cursor Agentandrmcrackan 59066b7139 feat(limit): add settings UI in both dialogs, throttling suggestion and tests
Avalonia and WinForms both get a Daily download limit group on the
Download/Decrypt tab: scope drop-down, and when a limit is chosen a quantity
spinner (minimum 1, no practical maximum) plus a Books/MB/GB unit, with the
MB/GB approximation note shown only for those units.

A license denial that looks like Audible throttling now suggests turning the
limit on, quoting the real number of downloads Libation recorded in the last 24
hours. Audible reports no distinct throttling reason, so the suggestion stays
silent unless that record makes throttling plausible.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 17:27:09 +00:00
Cursor Agentandrmcrackan 818510d1b0 feat(limit): add opt-in daily download limit core, history store and enforcement
Records every successful audiobook download in the library database (a new
DownloadHistory table) and, when the user opts in, stops downloading once the
rolling 24 hour window is full.

The history lives in the database rather than a file under LibationFiles
because in Docker only the database is on a volume; a file there is discarded
on every container restart.

The limit is checked immediately before each book downloads, never at queueing
time, so a full queue stays full and the user can raise or disable the limit
mid-run. When nothing in the queue can proceed the queue pauses and re-checks
every 15 seconds, recomputing settings, history and clock from scratch, so a
queue left running for days drip-feeds itself as downloads age out. The CLI
never waits: it skips covered titles and reports a count.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-14 17:16:47 +00:00
rmcrackan 85191d2984 Merge pull request #1936 from rmcrackan/cursor/cli-abs-help-polish-f0df
fix(cli): polish abs group help discoverability and branding
2026-08-13 15:52:35 -04:00
Cursor Agentandrmcrackan 1652f63770 test(cli): replace obsolete DataTestMethod with TestMethod
MSTest 4 marks DataTestMethod obsolete (MSTEST0044); TestMethod + DataRow
is the supported form.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 19:48:04 +00:00
Cursor Agentandrmcrackan 80e88cb303 fix(cli): polish abs group help discoverability and branding
Empty-args usage now lists nested command groups alongside root verbs.
Nested `abs upload --help` brands the public command path instead of the
internal parser verb.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 19:42:42 +00:00
rmcrackan 5af29b6bed Merge pull request #1918 from caiowilson/caiowilson/cli-audiobookshelf-upload
Add CLI abs upload for Audiobookshelf backfill
2026-08-13 15:36:25 -04:00
rmcrackan 5cf16133d8 Merge pull request #1935 from rmcrackan/cursor/plus-catalog-badge-0fe5
Badge Audible Plus titles on the Liberate stoplight icon
2026-08-13 15:11:39 -04:00
Cursor Agentandrmcrackan 12d499a901 Seed books absent from the last scan for unavailable-row testing
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 19:06:44 +00:00
Cursor Agentandrmcrackan b6e0f4b6ab Correct the claim that solution-level dotnet test fails on Linux
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 19:06:44 +00:00
Cursor Agentandrmcrackan 325be98bc0 Fix the solution filename in the dev getting-started guide
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 18:38:30 +00:00
Cursor Agentandrmcrackan 739f1a273a Document how to test changes in the developer docs
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 18:34:41 +00:00
Cursor Agentandrmcrackan 3c35e63bc8 Remove demo library files committed by mistake
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 18:09:48 +00:00
Cursor Agentandrmcrackan 2eb12a26de Let the Plus badge overlap the PDF instead of displacing it
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 18:09:35 +00:00
Cursor Agentandrmcrackan 71e2cbb271 Add a script that seeds a demo library for manual icon testing
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 17:39:15 +00:00
Caio Wilson edc73629be fix(cli): match nested commands case-insensitively 2026-08-13 14:14:20 -03:00
Cursor Agentandrmcrackan c5b7f836c1 Remove demo library files committed by mistake
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 16:03:41 +00:00
Cursor Agentandrmcrackan ee94c6f3d6 Separate the Plus badge from the stoplight with a knocked-out rim
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 16:03:20 +00:00
Cursor Agentandrmcrackan 69ab7f1c3f Mention Plus in the icon descriptor's cache-key remarks
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 15:27:02 +00:00
Cursor Agentandrmcrackan c09a08b7b6 Test the Audible Plus badge and widen the WinForms Liberate column
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 15:14:35 +00:00
Cursor Agentandrmcrackan 99b52103a7 Badge Audible Plus titles on the Liberate stoplight icon
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 15:09:29 +00:00
rmcrackan 2a4442b5f4 Merge pull request #1934 from rmcrackan/cursor/shared-stoplight-images-df83
Compose the Liberate column's icons from one shared source
2026-08-13 09:32:55 -04:00
Cursor Agentandrmcrackan 7c21fc6d25 Remove the demo library seeding script
It served the manual verification of the Liberate icons, which is done.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 13:25:11 +00:00
Cursor Agentandrmcrackan 6e4f4bb67d Add a script that seeds a demo library for manual icon testing
Every Liberate-column icon needs a book in a particular state to show up, and
the yellow lamp additionally needs a partial download on disk. Reproducing that
by hand to eyeball the column is tedious, so script it.

It is a file-based app outside any project directory, so nothing compiles it:
run it with 'dotnet run Scripts/seed-demo-library.cs'.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 13:16:29 +00:00
Cursor Agentandrmcrackan ac4186ac25 Show a plus on collapsed series rows, not on expanded ones
The Liberate icon offers an action rather than reporting a state, so an expanded
series shows a minus to collapse it and a collapsed one shows a plus to expand
it. Naming the geometry after the state it belonged to had these swapped.

Name it after its shape instead, and pin the direction with a test: the plus is
the minus plus a bar, so it is strictly the inkier of the two.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 06:08:59 +00:00
Cursor Agentandrmcrackan f278b3314b Test the shared Liberate icon renderings
Cover every icon in the finite set: each renders a valid PNG, no two render
alike, stoplights keep a common height, a PDF overlay only widens them, and
repeat requests are served from the cache.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 05:58:22 +00:00
Cursor Agentandrmcrackan dcaa42d21d Draw Avalonia Liberate icons from the shared generator
Replace the hand-composed vector layers with the shared rendering bound through
EntryStatus.ButtonImage, and delete the geometry and stoplight brushes that only
that composition used.

The rendering is per-theme rather than a set of DynamicResource brushes, so each
button re-renders its icon when the actual theme variant changes.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 05:56:29 +00:00
Cursor Agentandrmcrackan 7b0bd603a6 Draw WinForms Liberate icons from the shared generator
Delete the 24 pre-baked stoplight PNGs and their resx/designer entries, and
register the dark-mode probe the shared generator needs to pick a palette.

The shared renderings are supersampled, so DrawButtonImage takes the scale they
were rendered at and keeps drawing them at their logical size.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 05:49:53 +00:00
Cursor Agentandrmcrackan c04b24f722 Add shared Liberate icon generator to LibationUiBase
Both UIs need the same stoplight/PDF/error/series icons. WinForms selected
pre-baked PNGs by name while Avalonia composed vector layers in XAML, so the
two drifted and every new combination needed another pair of PNGs.

Port the Avalonia artwork and palette into LibationUiBase and rasterize it with
SkiaSharp, keyed by a descriptor of the finite combinations and cached in memory.
EntryStatus.ButtonImage now serves that shared rendering to both UIs.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-13 05:46:55 +00:00
Caio Wilson 8240d29eae feat(cli): remove legacy abs-upload alias 2026-08-13 01:51:39 -03:00
Caio Wilson 877912e6e8 test: normalize Windows Audiobookshelf scan paths 2026-08-13 01:37:07 -03:00
Caio Wilson a9af8a71c6 fix(cli): finalize nested command group review 2026-08-13 00:16:01 -03:00
Caio Wilson 26e603d94d docs: document nested Audiobookshelf CLI command 2026-08-13 00:03:28 -03:00
Caio Wilson 2257cdbb9d feat: complete nested CLI help routing 2026-08-12 23:59:16 -03:00
Caio Wilson fde3c98b98 test: cover ABS command dispatch 2026-08-12 23:52:36 -03:00
Caio Wilson 5b8e6eaeac feat: route ABS upload command 2026-08-12 23:47:55 -03:00
Caio Wilson a0d1c59281 feat: add ABS command group help 2026-08-12 23:43:28 -03:00
Caio Wilson a5a20dc2c9 test: cover repeatable CLI IDs 2026-08-12 23:39:30 -03:00
Caio Wilson 198d399803 feat: add CLI command group router 2026-08-12 23:37:24 -03:00
rmcrackan abdc14c555 Merge pull request #1932 from rmcrackan/cursor/harden-library-load-crashes-b47d
Harden startup against library-load crashes (#1931)
2026-08-12 15:11:21 -04:00
Cursor Agentandrmcrackan a5b1df0d29 Centralize best-effort file deletion in FileUtility
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:55:42 +00:00
Cursor Agentandrmcrackan f8bd2cee03 Log advisory when CLI runs alongside GUI
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:38:00 +00:00
Cursor Agentandrmcrackan 6ca78b04a4 Use Dinah.Core EqualsInsensitive for account id comparison
Matches the codebase convention for case-insensitive string comparison
instead of calling string.Equals with StringComparison.OrdinalIgnoreCase.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:31:52 +00:00
Cursor Agentandrmcrackan 958a31a306 test: cover case-insensitive account lookup and single-instance guard
Add AccountsSettings.GetAccount case-insensitivity test (issue #1931) and a
SingleInstanceTests suite verifying first/second acquisition, per-folder keying,
release-and-reacquire, and trailing-separator/case-insensitive folder matching.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:17:05 +00:00
Cursor Agentandrmcrackan 90856b29a6 Add single-instance guard and make SQLite WAL/SHM cleanup data-safe
Concurrent Libation instances against one LibationFiles folder raced on the
SQLite database, Lucene search index, and log file, and the startup routine
unconditionally deleted LibationContext.db-wal/-shm - discarding committed
transactions when a prior run died abruptly (issue #1931).

- Add SingleInstance (named mutex keyed on the LibationFiles folder). Wire it
  into the Avalonia and WinForms startup so a second launch shows a message and
  exits before any database access, and holds the lock for the process lifetime.
- DeleteOpenSqliteFiles now skips cleanup when the DB is held by another process
  and preserves a non-empty (unrecovered) WAL so SQLite can recover it on open.

Also compare AccountId case-insensitively in AccountsSettings.GetAccount so a
capitalization difference no longer causes spurious 'No account found' errors.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:13:58 +00:00
Cursor Agentandrmcrackan ac30cb0469 Degrade to an empty, recoverable grid on library load/bind failure
MainWindow_Loaded is an async void handler whose only catch was filtered to
install-folder assembly failures, so any other library-load exception escaped
and crashed Libation at startup (issue #1931). Add a general fallback catch
that logs, shows a non-fatal message, and continues with an empty grid.

Also make UpdateGridAsync rebuild the grid when GridEntries is null instead of
throwing 'Must call BindToGridAsync first', which previously wedged the grid
and spammed 'Library Size Change Error' on every auto-scan after a failed
initial bind.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:08:06 +00:00
Cursor Agentandrmcrackan 813b5eeb6e Guard BackgroundWorker e.Result behind e.Error/e.Cancelled to prevent startup crashes
Reading RunWorkerCompletedEventArgs.Result rethrows any exception from the
count DoWork. In the Avalonia async void handler this became an unhandled
exception that killed the app during library load (issue #1931). Check
e.Cancelled/e.Error first, log once, and degrade to empty stats in both the
Avalonia and WinForms backup-count completion handlers.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 18:05:36 +00:00
rmcrackan d49ba270cc Merge pull request #1930 from rmcrackan/cursor/fix-has-tag-docs-example-4221
docs: fix missing `->` in the `<has series#->` example
2026-08-12 00:27:31 -04:00
Cursor Agentandrmcrackan 31eb9b5e4b test: use platform-safe separator in new template tests
Unit tests run on windows-latest, where '|' is an invalid filename
character and is rewritten by ReplacementCharacters, so the assertions
failed on CI. Use ' - ' instead, which is valid on all platforms.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 04:21:35 +00:00
Cursor Agentandrmcrackan f36e302c93 docs: fix missing -> in <has series#-> example
The conditional open tag requires the -> delimiter. Written as
<has series#>, the engine does not recognize a conditional, so the
later <-has> closes the enclosing <if series-> instead and the template
reports "Missing <-if series> closing conditional." while leaking the
literal text "<has series#>" into the name.

Also add a nested-conditional example, since the reference table only
shows each conditional in isolation, plus regression tests for
<has series#-> nested inside <if series-> when a book is in a series
but has no series number.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-12 04:11:39 +00:00
rmcrackan 895e47d513 Merge pull request #1929 from rmcrackan/cursor/single-book-download-menu-d7c9
Add single-book download to right-click context menu
2026-08-11 17:02:36 -04:00
Cursor Agentandrmcrackan 44db619c0c Add single-book download to context menu and clarify stoplight tooltip
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-11 20:44:25 +00:00
rmcrackan d7c757ed57 Merge pull request #1928 from rmcrackan/cursor/1927-defer-fluent-theme-reload-b900
Defer fluent theme reloads while a color picker is open
2026-08-10 23:54:44 -04:00
Cursor Agentandrmcrackan cba29d94ba Defer fluent theme reloads while a color picker is open
Editing a fluent palette color removes and re-adds the app's FluentTheme.
Doing that while the theme editor's color picker flyout is open leaves the
flyout's tab content parented to its old presenter, so reopening the picker
throws 'The control Grid already has a visual parent'.

Fixes #1927

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-11 03:40:03 +00:00
Cursor Agentandrmcrackan dc556d61b6 Add a color picker which reports its flyout state
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-11 03:40:03 +00:00
Caio Wilson 9ac1f64056 Fix Audiobookshelf CLI upload follow-ups 2026-08-10 18:47:21 -03:00
rmcrackan b88da2e993 Merge pull request #1926 from rmcrackan/cursor/1925-diagnose-silent-scan-drops-7ce7
bugfix #1925: stop library scans from silently dropping titles
2026-08-10 00:00:27 -04:00
Cursor Agentandrmcrackan 2fb48bfd0c docs: correct AGENTS.md guidance on the blocking OS secret store
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 03:51:25 +00:00
Cursor Agentandrmcrackan fdb259ebbd test: stop unit tests from blocking on the OS secret store
ConfigureFrom always calls ResolveSecretStore, which falls through to
OsSecretStore.Create(...).IsAvailable when no master key file or env var is
present. That is a blocking libsecret call: on a headless machine, or one whose
login keyring is locked, it waits on a desktop unlock prompt that never gets
answered. Five tests reached it and one reached it twice, so the project took
12+ minutes instead of seconds.

Probing availability first does not help, because the probe is the blocking call.

Tests that only assert which write method gets configured now resolve the master
key from a temp key file, so they short-circuit before the OS store. This also
stops them minting a last-resort key into the real Libation folder.

The two tests that exist to exercise the real OS store are opt-in via
LIBATION_TEST_OS_SECRET_STORE=1.

AudibleUtilities.Tests: 12m 20s -> 1.8s, 72 passed / 2 skipped / 0 failed.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 03:51:02 +00:00
Cursor Agentandrmcrackan 91c00d0970 style: prefer var for local declarations
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 03:38:01 +00:00
Cursor Agentandrmcrackan 3a61cae163 test: provide grid synchronization context
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 03:37:13 +00:00
Cursor Agentandrmcrackan 5664551bd2 test: verify orphaned episodes enter the grid
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 03:36:47 +00:00
Cursor Agentandrmcrackan 4e6bba54b5 bugfix #1925: show orphaned episodes as standalone rows
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 03:20:07 +00:00
Cursor Agentandrmcrackan 8f56046a11 test: cover episode-to-series linking and the drop it performs
Extract the link-then-prune step out of getItemsAsync so the behavior that
removes a podcast episode from a scan can be exercised directly, and pin it
down with tests, including the season-container case where an episode's parent
is not something Libation treats as a series parent.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 03:06:43 +00:00
Cursor Agentandrmcrackan a01fece235 bugfix #1925: stop library scans from silently dropping titles
Audible's catalog endpoint can answer 200 while omitting products from the
response. getProductsAsync returned whatever came back, so any podcast episode
Audible skipped simply vanished from the scan. The reporter's log shows this:
across 1117 consecutive scans of an unchanged 440-item library, the post-scan
item total drifted between 2147 and 2151.

Re-request the omitted asins before accepting the loss, and warn with the asins
that are still unaccounted for afterwards.

The rest of the scan's exclusions were equally invisible at the default log
level, which is why the reporter found nothing in the log about the missing
book:
- episodes dropped for having no series parent were logged at Debug, without
  identifying them. Warn instead, and name them.
- titles excluded by ImportEpisodes / ImportPlusTitles were not logged at all.
  Tally them, and record both settings in the startup state block.

Read the two import filters once per scan so a settings change mid-scan can't
produce a half-filtered library.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-10 02:58:42 +00:00
rmcrackan 791425670b Merge pull request #1924 from rmcrackan/cursor/release-13.7.7-f0df
release: bump version to 13.7.7
2026-08-09 14:53:23 -04:00
Cursor Agentandrmcrackan a40e9e7cf7 release: bump version to 13.7.7
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 18:48:30 +00:00
rmcrackan e36db71534 Merge pull request #1922 from rmcrackan/cursor/1919-unix-disk-space-mount-f0df
bugfix #1919: Unix mount-aware disk-space preflight
2026-08-09 14:06:15 -04:00
Cursor Agentandrmcrackan da23a7ddb0 fix: use '/' in Unix mount prefix matching (Windows CI)
FindLongestMountPointPrefix is exercised on Windows CI with Unix-style
paths. Path.DirectorySeparatorChar is '\\' there, so '/var/home' never
matched '/var/home/...'. Hardcode '/' for Unix mount identity.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 17:55:38 +00:00
Cursor Agentandrmcrackan 16e6a6f659 bugfix #1919: resolve Unix mount points for disk-space preflight
Path.GetPathRoot always returns "/" for absolute Unix paths, so bulk
backup preflight queried composefs root free space (often 0 on Bazzite)
instead of the filesystem containing Books/In progress.

On Unix, symlink-canonicalize paths (so /home -> /var/home) and pick the
longest DriveInfo.GetDrives() mount prefix. Route GetBackupDriveSpaces
through the shared helper so grouping and free-space queries agree.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 17:51:36 +00:00
rmcrackan 742d2b140a Merge pull request #1921 from rmcrackan/cursor/setup-dev-environment-f0df
Add AGENTS.md with Cursor Cloud dev environment instructions
2026-08-09 13:41:50 -04:00
Cursor Agentandrmcrackan befd1a4c3c docs: correct AGENTS.md — AudibleUtilities hang was GNOME Keyring, not network
The first slow AudibleUtilities.Tests run was blocked on a desktop keyring
password prompt for Libation's OS secret-store master key. Document that
gotcha and remove the incorrect network-timeout theory.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 17:39:45 +00:00
Cursor Agentandrmcrackan e859af0296 docs: add AGENTS.md with Cursor Cloud dev environment instructions
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 14:36:11 +00:00
Caio WilsonandClaude Opus 5 4a5c81b5e6 Add CLI 'upload' verb to send already-liberated books to Audiobookshelf
Auto-upload only fires when a book is liberated. Books liberated before
Audiobookshelf was configured, or while it was disabled, had no path to the
server short of re-downloading the whole library.

'libationcli upload' backfills them from the files already on disk. Bulk or
targeted by ASIN. Nothing is re-downloaded and no local file is deleted.

Also fixes a latent defect this exposes. Validate() reads a database status
(Book.AudioExists) while GetFilesToUpload() read only FilePathCache. A book
liberated long ago passes validation but has no cache entry, so the upload
found no files and returned success having sent nothing. File lookup now uses
AudibleFileStorage.Audio.GetPaths, which unions the cache with a live scan of
the Books directory.

Other changes:
- Validate() now requires LiberatedStatus.Liberated. It previously accepted
  Error too, whose partial files should not be uploaded.
- New OutcomeDetermined event classifies each book as Uploaded, AlreadyExists,
  NoFilesFound or Failed. Failures travel on this event rather than through
  StatusHandler: the GUI process queue treats a non-success StatusHandler as a
  bad book and raises the Abort/Retry/Ignore dialog, and an upload problem must
  never fail a liberation.
- The verb prints an end-of-run summary and exits 0, matching other verbs.

No database migration. Duplicate detection already runs server-side inside
UploadBookAsync, so repeat runs are safe without local upload state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:16:34 -03:00
rmcrackan eebc96f439 Merge pull request #1916 from rmcrackan/rmcrackan/fail-fast-invalid-settings
Fail fast on invalid Settings.json enums and broken Serilog structure
2026-08-07 11:15:57 -04:00
Robert McRackan 065118cf6c Fail fast on invalid Settings.json enums and broken Serilog structure: reject unknown enum values at startup with clear errors, accept case-insensitive names, migrate ZipFile sinks, and validate Serilog shape without blocking hand-edited custom sinks 2026-08-07 11:10:43 -04:00
rmcrackan 8ea95ee812 Merge pull request #1915 from rmcrackan/rmcrackan/1914-series-entry-sort-crash
bugfix #1914 : fix race when linking podcast episodes under series parents
2026-08-06 16:09:18 -04:00
Robert McRackan c144c20c53 bugfix #1914 : fix race when linking podcast episodes under series parents 2026-08-06 16:08:57 -04:00
rmcrackan 7b09a3bfef Merge pull request #1913 from rmcrackan/rmcrackan/docs-encrypt-fail-plaintext-fallback
Encrypt-fail plaintext fallback
2026-08-05 10:08:35 -04:00
Robert McRackan e0d49b57e1 Encrypt-fail plaintext fallback 2026-08-05 10:02:53 -04:00
rmcrackan 6ba0741393 Merge pull request #1912 from rmcrackan/rmcrackan/last-resort-portable-master-key
Last-resort portable master key when OS secret store is unavailable
2026-08-03 10:32:16 -04:00
rmcrackan ea3fc17cfa Merge pull request #1911 from rmcrackan/dependabot/github_actions/docker/login-action-4.6.0
Bump docker/login-action from 4.5.2 to 4.6.0
2026-08-03 10:25:09 -04:00
Robert McRackan f64824e283 Document last-resort portable master key as a Docker fallback 2026-08-03 10:22:31 -04:00
dependabot[bot] 5334827c09 Bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 14:12:42 +00:00
Robert McRackan fcefc27a29 Warn loudly when auto-creating a last-resort portable master key 2026-08-03 10:10:06 -04:00
Robert McRackan 6bfcfb239c Merge remote-tracking branch 'origin/master' into rmcrackan/last-resort-portable-master-key 2026-08-03 10:01:28 -04:00
rmcrackan 917f44fe57 Merge pull request #1910 from rmcrackan/rmcrackan/docker-cli-noise-and-docs
Quiet Docker ConfigApp.dll noise and document login-external via /config
2026-08-03 09:59:43 -04:00
Robert McRackan 134f895c82 Add last-resort portable master key when OS secret store is unavailable 2026-08-03 09:59:02 -04:00
Robert McRackan 4513a8007b Quiet Docker ConfigApp.dll noise and document login-external via /config 2026-08-03 09:09:59 -04:00
rmcrackan 8581c0e1af Merge pull request #1909 from rmcrackan/rmcrackan/cli-scan-auth-and-locale
Surface real CLI/Docker auth failures and fix login-external locales
2026-08-02 12:01:01 -04:00
Robert McRackan 3872c10c81 incr ver 2026-08-02 11:56:17 -04:00
Robert McRackan 712568b188 Surface real CLI/Docker auth failures and fix login-external locales 2026-08-02 11:55:48 -04:00
rmcrackan 5d1a31f876 Merge pull request #1908 from rmcrackan/rmcrackan/add-oxivanisher-contributor
Add @oxivanisher to contributors
2026-08-02 11:14:08 -04:00
Robert McRackan 00fdd903aa Add @oxivanisher to contributors 2026-08-02 11:14:06 -04:00
Robert McRackan 8a61e4d7f4 incr ver 2026-08-02 11:08:19 -04:00
rmcrackan c02563204d Merge pull request #1907 from oxivanisher/feature/improve_environment_in_docker
Feature/improve environment in docker and documentation
2026-08-02 11:03:14 -04:00
Marc Urben 2e4c0a10b0 Setup libation-master.key when not set via variable 2026-08-02 10:04:26 +02:00
Marc Urben ccc814ce1c Ensure .sh files are unix style 2026-08-02 03:22:16 +02:00
Marc Urben 98f657a40b Correct log file name and remove superfluous spaces. 2026-08-02 03:03:02 +02:00
Marc Urben 28ce71c1b7 Document on how to access or redirect log files from within docker. 2026-08-02 03:00:17 +02:00
Marc Urben a438dfd82f Offer occational support for docker problems ;:) 2026-08-02 02:51:43 +02:00
Marc Urben 12875993fa Add missing LOG_LEVEL variable to the env variables table. 2026-08-02 02:35:56 +02:00
Marc Urben 40b75185c2 Support user specified cmd by not entering the loop and running the cmd while still setting up the environment. Also expand documentation and example for the interactive shell. 2026-08-02 02:27:21 +02:00
Marc Urben c5a88de8a6 Use entrypoint instead of cmd to allow the user to supply their own cmd 2026-08-02 02:23:21 +02:00
Marc Urben cb1074e7fd Create a symlink to LibationCli to not require the full path for commands 2026-08-02 02:21:04 +02:00
rmcrackan f5ed61fa65 Merge pull request #1906 from rmcrackan/rmcrackan/incr-ver
incr ver
2026-08-01 12:22:03 -04:00
Robert McRackan 680e48638a incr ver 2026-08-01 12:21:51 -04:00
rmcrackan b49384cfcf Merge pull request #1905 from rmcrackan/rmcrackan/portable-master-key
Make encrypted account tokens portable for Docker
2026-08-01 12:20:08 -04:00
Robert McRackan 2b4cde62e2 Document portable master-key export for Docker and CLI 2026-08-01 12:14:22 -04:00
Robert McRackan 636b85f8e4 UI: add Settings export for the portable encryption master key 2026-08-01 12:09:13 -04:00
Robert McRackan 961ee3372e Harden docker CI against GHA cache write failures 2026-08-01 11:55:38 -04:00
Robert McRackan 7d9882c500 Add LibationCli export-master-key for portable encryption keys 2026-08-01 11:43:29 -04:00
Robert McRackan 785ec7bf96 Resolve portable master key from file or env before OS store 2026-08-01 11:39:51 -04:00
rmcrackan 6c917ad751 Merge pull request #1904 from rmcrackan/rmcrackan/safer-identity-token-handling
Detect identity decrypt failures by exception type
2026-08-01 11:29:30 -04:00
Robert McRackan 5f4a75aade Detect identity decrypt failures by exception type 2026-08-01 11:25:56 -04:00
rmcrackan e78a4392f6 Merge pull request #1903 from rmcrackan/rmcrackan/docker-decrypt-feedback
surfaced encrypted-token decrypt failure for Docker and CLI
2026-08-01 09:47:38 -04:00
Robert McRackan f12f5d42a1 incr ver 2026-08-01 09:40:20 -04:00
Robert McRackan 79b6521a4e surfaced encrypted-token decrypt failure for Docker and CLI 2026-08-01 09:38:50 -04:00
rmcrackan cdcbd28cca Merge pull request #1902 from rmcrackan/rmcrackan/docker-tokens
warn that Windows-encrypted tokens fail in Docker
2026-08-01 09:19:11 -04:00
Robert McRackan f35530d18b warn that Windows-encrypted tokens fail in Docker 2026-08-01 09:19:00 -04:00
rmcrackan d8dd86123d Merge pull request #1901 from cclements/update-aaxclean-codecs-3.1.0
Update AAXClean.Codecs to 3.1.0 — fixes xHE-AAC seeking in Apple players
2026-08-01 08:58:22 -04:00
Chris ClementsandClaude Fable 5 0dfee1ff5c Update AAXClean.Codecs to 3.1.0
Fixes xHE-AAC (USAC) seeking in Apple players: AAXClean now writes the
sync sample table (stss) required by ISO/IEC 23003-3 for USAC output,
and chapter-split files are sample-accurate via edit lists
(Mbucari/AAXClean#18, Mbucari/AAXClean#19).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:24:53 -05:00
rmcrackan 842b0c0933 Merge pull request #1900 from rmcrackan/rmcrackan/audiobookshelf-connect-hardening
Harden and refactor ABS code
2026-07-31 14:21:33 -04:00
Robert McRackan 1c5119e24d Share Audiobookshelf connect orchestration across Classic and Chardonnay 2026-07-31 14:08:57 -04:00
Robert McRackan a6d637c718 Harden Audiobookshelf Server URL handling and connection errors 2026-07-31 14:05:36 -04:00
rmcrackan 9579c3157b Merge pull request #1899 from rmcrackan/dependabot/github_actions/docker/login-action-4.5.2
Bump docker/login-action from 4 to 4.5.2
2026-07-31 10:34:54 -04:00
dependabot[bot] 31258c7951 Bump docker/login-action from 4 to 4.5.2
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 14:13:32 +00:00
rmcrackan 16379168d4 Merge pull request #1898 from rmcrackan/rmcrackan/abs-docs
Update docs for new Audiobookshelf feature
2026-07-28 14:43:26 -04:00
Robert McRackan 6bc82f7c6e missing entries in index.md 2026-07-28 14:41:25 -04:00
Robert McRackan 43216b2947 Update docs for new Audiobookshelf feature 2026-07-28 14:39:08 -04:00
Robert McRackan a079752c71 incr ver. add contributor @Demoniskk 2026-07-28 14:22:35 -04:00
rmcrackan a2b4e432b5 Merge pull request #1896 from Demoniskk/feature/audiobookshelf-uploader
Add Audiobookshelf auto-upload integration
2026-07-28 14:18:34 -04:00
rmcrackan 272dcd87be Merge pull request #1897 from rmcrackan/dependabot/github_actions/actions/stale-11
Bump actions/stale from 10 to 11
2026-07-28 10:36:06 -04:00
dependabot[bot] 6268fb0332 Bump actions/stale from 10 to 11
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:12:41 +00:00
John Doe a7f6075a12 fix: ABS duplicate detection, Connect button toggle, token encryption edge cases 2026-07-28 04:44:13 +02:00
John Doe b5e87847ff fix: Address review feedback - UI parity, soft-fail, token encryption, pagination, CLI wiring 2026-07-26 19:12:31 +02:00
John Doe ad61618cc9 Add Audiobookshelf auto-upload integration
- Persist settings: enabled, server URL, API token, library/folder IDs

- Add AudiobookshelfApiService for login, library listing, and multipart upload

- Add UploadToAudiobookshelf post-download processable

- Add settings tab to WinForms and Avalonia with library/folder dropdowns

- Match Avalonia layout to WinForms with aligned columns
2026-07-26 09:58:12 +02:00
rmcrackan 3580f6d53a Merge pull request #1895 from rmcrackan/dependabot/npm_and_yarn/postcss-8.5.23
Bump postcss from 8.5.12 to 8.5.23
2026-07-25 22:45:58 -04:00
dependabot[bot] 1493777396 Bump postcss from 8.5.12 to 8.5.23
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.12 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.12...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-26 02:17:31 +00:00
rmcrackan 0836a79990 Merge pull request #1894 from rmcrackan/rmcrackan/token-encryption
Add encrypted AccountsSettings token storage with Important settings UI
2026-07-25 22:16:32 -04:00
Robert McRackan 2c52513ea9 incr ver 2026-07-25 22:08:49 -04:00
Robert McRackan 20150cc4ed update dependencies 2026-07-25 22:03:23 -04:00
Robert McRackan 3e03fad490 'welcome to libation' box should appear in windows taskbar 2026-07-25 21:33:31 -04:00
Robert McRackan abea874db8 Add Important settings UI for encrypted token storage 2026-07-25 21:32:42 -04:00
Robert McRackan bb18e9adc4 Add TokenStorageMethod preference and AudibleApi token-storage wiring 2026-07-25 21:17:11 -04:00
Robert McRackan a9e6f75443 update dependencies 2026-07-25 20:57:52 -04:00
Robert McRackan b5485f77bf update dependencies 2026-07-25 20:08:11 -04:00
Robert McRackan 936b307d3e Add characterization tests for legacy plaintext AccountsSettings tokens 2026-07-25 18:14:19 -04:00
rmcrackan 1c33a253a2 Merge pull request #1893 from rmcrackan/rmcrackan/pre-amazon-account-docs
Pre-Amazon Audible login. Walkthrough refactor
2026-07-25 12:42:04 -04:00
Robert McRackan b4f7cf4288 Shared walkthrough helpers and constants 2026-07-25 12:35:22 -04:00
Robert McRackan e1bede5a6a Centralize walkthrough copy/text 2026-07-25 12:28:43 -04:00
Robert McRackan 5c67351aba Document pre-Amazon Audible username login in docs and guided walk-through 2026-07-25 12:18:31 -04:00
rmcrackan df6db12f77 Merge pull request #1892 from rmcrackan/rmcrackan/localhost-link
don't live-link localhost
2026-07-25 11:39:00 -04:00
Robert McRackan 89155e0b0b don't live-link localhost 2026-07-25 11:38:41 -04:00
rmcrackan 934e7c6618 Merge pull request #1891 from rmcrackan/rmcrackan/tips
Add donate page and wire it through site, docs, and About dialogs
2026-07-25 11:33:16 -04:00
Robert McRackan e372bae144 Add donate link to 'About' form 2026-07-25 11:22:45 -04:00
Robert McRackan 270985341a add icon to external links on main /docs/ 2026-07-25 11:18:46 -04:00
Robert McRackan fd03c1bd9c point to new donate page 2026-07-25 11:15:24 -04:00
Robert McRackan 123da3c653 new donate page 2026-07-25 11:11:09 -04:00
rmcrackan 15838fb2cf Create CNAME 2026-07-25 10:24:45 -04:00
rmcrackan 746f620f95 Merge pull request #1890 from rmcrackan/rmcrackan/homepage-edit
edit homepage
2026-07-25 10:16:04 -04:00
Robert McRackan 1117eb378a edit homepage 2026-07-25 10:15:47 -04:00
Robert McRackan 07c2f2b2a1 incr ver 2026-07-17 14:53:27 -04:00
rmcrackan a0c59f15e1 Merge pull request #1889 from rmcrackan/rmcrackan/homebrew-fix
Mark macOS app bundle as APPL
2026-07-17 14:52:14 -04:00
Robert McRackan 353a5b8800 Mark macOS app bundle as APPL so Gatekeeper and Homebrew cask audits accept it. Thanks, @m-j-r ! 2026-07-17 14:46:38 -04:00
rmcrackan 190818323d Merge pull request #1888 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-6
Bump actions/setup-dotnet from 5 to 6
2026-07-16 10:31:43 -04:00
dependabot[bot] f180430a3f Bump actions/setup-dotnet from 5 to 6
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 14:12:50 +00:00
rmcrackan dec86bdee1 Merge pull request #1887 from rmcrackan/dependabot/github_actions/actions/setup-node-7
Bump actions/setup-node from 6 to 7
2026-07-14 10:25:15 -04:00
dependabot[bot] 6e4acd8046 Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 14:12:49 +00:00
rmcrackan 80c6c6b9d4 Merge pull request #1886 from rmcrackan/rmcrackan/pause-autoscan
Pause auto-scan on auth failure instead of spamming login dialogs
2026-07-07 09:45:57 -04:00
Robert McRackan 3cdaa09aff Pause auto-scan on auth failure instead of spamming login dialogs 2026-07-07 09:40:14 -04:00
rmcrackan ecc7e49a21 Merge pull request #1883 from rmcrackan/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 6 to 7
2026-06-21 08:55:22 -04:00
dependabot[bot] fb41955015 Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 14:12:34 +00:00
rmcrackan a8e3404500 Merge pull request #1882 from rmcrackan/rmcrackan/1881-linux-upgrade
#1881 - Fix Linux in-app upgrades
2026-06-17 15:51:26 -04:00
rmcrackan 9ff15a07aa #1881 - Fix Linux in-app upgrades on RHEL/Fedora by selecting .rpm packages instead of .deb when dnf is the native package manager. 2026-06-17 15:36:28 -04:00
rmcrackan 674abd1707 Merge pull request #1880 from rmcrackan/rmcrackan/1878-upgrader
#1878 : Detect incomplete in-app upgrades, roll back install files, and show clear recovery messages
2026-06-17 10:45:12 -04:00
rmcrackan d30086e53a bugfix 2026-06-17 10:38:24 -04:00
rmcrackan 82d4001f0a #1878 : Detect incomplete in-app upgrades, roll back install files, and show clear recovery messages 2026-06-17 10:18:52 -04:00
rmcrackan 8ed4874c76 Merge pull request #1879 from rmcrackan/rmcrackan/1876-troubleshooting
#1876 - update troubleshooting doc
2026-06-17 09:07:12 -04:00
rmcrackan 8898174507 updtae troubleshooting doc 2026-06-17 09:06:21 -04:00
rmcrackan 2b4923112e Merge pull request #1877 from rmcrackan/rmcrackan/win-app-ctrl
#1873 , #1876 - Handle Windows Application Control blocking after in-app upgrades
2026-06-16 17:30:01 -04:00
rmcrackan d955cb7605 #1873 , #1876 - Handle Windows Application Control blocking after in-app upgrades 2026-06-16 17:25:18 -04:00
rmcrackan 23f36e2ef1 Merge pull request #1875 from rmcrackan/rmcrackan/vite-security
fix vite vulnerability
2026-06-16 11:28:57 -04:00
rmcrackan 46dc400b14 fix vite vulnerability 2026-06-16 11:28:39 -04:00
rmcrackan 4f2e832327 Merge pull request #1874 from rmcrackan/rmcrackan/1872-widevine
#1872 : Suggest Widevine when ADRM licenserequest fails with Sable acr:null
2026-06-16 11:20:12 -04:00
rmcrackan 4bd7796f70 #1872 : Suggest Widevine when ADRM licenserequest fails with Sable acr:null 2026-06-16 11:12:58 -04:00
rmcrackan b6d8378728 Merge pull request #1871 from rmcrackan/rmcrackan/security
Fix esbuild GHSA-gv7w-rqvm-qjhr
2026-06-14 15:25:54 -04:00
rmcrackan 348056691f Fix esbuild GHSA-gv7w-rqvm-qjhr by pinning 0.28.1 : Override transitive esbuild to the patched release and add a VitePress esbuild compatibility setting so docs builds keep working with 0.28.x. 2026-06-14 15:24:21 -04:00
rmcrackan 7bd14bd5af Merge pull request #1870 from rmcrackan/rmcrackan/incr-ver
incr ver
2026-06-12 11:54:35 -04:00
rmcrackan a85179f50a incr ver 2026-06-12 11:54:14 -04:00
rmcrackan 158ee3d5ba Merge pull request #1869 from rmcrackan/rmcrackan/1867-duplicate-asin
#1867 - Fix duplicate-ASIN crashes. New Hangover recovery
2026-06-12 11:52:42 -04:00
rmcrackan b654a8b551 #1867 - Fix duplicate-ASIN crashes. New Hangover recovery 2026-06-12 11:48:32 -04:00
rmcrackan bc28398e1c Merge pull request #1868 from rmcrackan/rmcrackan/1865-bad-book-setting
#1865 - Add in-dialog "Apply to all" and "Remember in Settings"
2026-06-12 10:10:29 -04:00
rmcrackan 4d1b78f7e4 #1865 - Add in-dialog "Apply to all" and "Remember in Settings" for bad-book errors. Also a testing section when run in debug (ie: from visual studio) 2026-06-12 09:53:57 -04:00
rmcrackan a50712256c Merge pull request #1864 from rmcrackan/rmcrackan/1862-security
Rmcrackan/1862 security
2026-06-10 14:31:47 -04:00
rmcrackan 4345971e81 escape string to avoid accidental json injection 2026-06-10 14:29:57 -04:00
rmcrackan 1ad458fe5b CSPRNG-safe 2026-06-10 12:12:38 -04:00
Robert 1547e6bec3 incr ver 2026-06-08 15:41:20 -04:00
rmcrackan 81175e5427 The edit targets two problems in the macOS bundle script.
Reliability: `hdiutil create` can fail on GitHub’s macOS runners with `Resource busy` even when the app build is fine. The retry wrapper gives it several attempts, waits between tries, and cleans up partial DMGs so transient runner issues are less likely to break the release.

Correctness: The script used to keep going after `hdiutil` failed and still exit successfully, which left `./bundle/` empty and made artifact upload fail with a misleading error. `set -euo pipefail` and explicit failure exits make a DMG creation failure stop the job immediately instead of pretending the bundle step succeeded.
2026-06-08 14:42:05 -04:00
rmcrackan 14551c745f Merge pull request #1861 from rmcrackan/rmcrackan/elevate-upgrader
Only elevate upgrader when necessary
2026-06-08 14:31:09 -04:00
rmcrackan e3cfe3a8f0 Only elevate upgrader when necessary 2026-06-08 14:30:35 -04:00
rmcrackan 51fbe73ea1 make vitepress happy 2026-06-08 14:23:00 -04:00
rmcrackan 41fa7b7142 Merge pull request #1860 from rmcrackan/rmcrackan/win-installer-phase-1
Finish windows installers, phase 1
2026-06-08 14:19:41 -04:00
rmcrackan b7ce96e91d Finish windows installers, phase 1 2026-06-08 14:03:59 -04:00
rmcrackan a25fd35f29 Fix release upload path for merge-multiple artifact download 2026-06-08 13:19:10 -04:00
rmcrackan 20a99c6e20 Exclude Docker cache blobs from release artifact download 2026-06-08 11:59:03 -04:00
rmcrackan f39cfaccc7 Fix release artifact pattern for Libation-Classic assets 2026-06-08 11:21:20 -04:00
rmcrackan 6db78e4268 Merge pull request #1859 from rmcrackan/rmcrackan/windows-installer-fix
Fix arm64 Windows zip artifact name in CI matrix
2026-06-08 11:03:49 -04:00
rmcrackan 74e9cf24a8 Fix arm64 Windows zip artifact name in CI matrix 2026-06-08 11:01:42 -04:00
rmcrackan 6ce88072c5 Merge pull request #1858 from rmcrackan/rmcrackan/windows-installer-fix
yaml bug fix
2026-06-08 10:53:50 -04:00
rmcrackan 9058c7368c yaml bug fix 2026-06-08 10:53:05 -04:00
rmcrackan 37d5517756 Merge pull request #1857 from rmcrackan/rmcrackan/windows-installer
Add Windows installers to release build
2026-06-08 10:45:39 -04:00
rmcrackan 0a33863635 Merge branch 'master' into rmcrackan/windows-installer 2026-06-08 10:44:58 -04:00
rmcrackan d03955a2ca Add Windows installers to release build 2026-06-08 10:43:32 -04:00
Robert b2c5ae3c2e incr ver 2026-06-08 10:27:17 -04:00
rmcrackan fb7e0857c6 Merge pull request #1856 from rmcrackan/rmcrackan/releaseindex
update backwards compat releaseindex in prep for future changes
2026-06-08 10:15:26 -04:00
rmcrackan 60965902ca update backwards compat releaseindex in prep for future changes 2026-06-08 10:15:00 -04:00
rmcrackan ef62ee15e2 Merge branch 'master' of https://github.com/rmcrackan/Libation 2026-06-07 12:56:21 -04:00
rmcrackan c285522a0c Add contributor from PR #1854 2026-06-07 12:56:13 -04:00
rmcrackan 0fb3eb152f Merge pull request #1854 from matalvernaz/fix/1729-sqlite-migration-lock-deadlock
Skip EF migration lock when no migrations are pending (fixes SQLite scan-hang #1729)
2026-06-07 12:55:20 -04:00
Matthew Alvernaz 63325b9cad Skip EF migration lock when no migrations are pending (#1729)
DbContexts.GetContext() runs ApplyMigrations on every context creation,
calling Database.Migrate(). Migrate() acquires the EF migration lock
before checking whether anything is pending. For SQLite that lock is a
persisted row in __EFMigrationsLock with no timeout (SQLite has no
connection-scoped lock that frees on disconnect). A process killed after
acquiring the lock but before releasing it - even during an otherwise
no-op Migrate() - orphans the row, and every later Migrate() then spins
forever in SqliteHistoryRepository.AcquireDatabaseLock(). The row lives
in the db file, so restarts never clear it, matching the "scan hangs,
reboot doesn't help" reports in #1729.

Guard Migrate()/MigrateAsync() behind GetPendingMigrations(), which only
reads __EFMigrationsHistory and never acquires the lock. The steady-state
path (schema already current) no longer touches the lock at all; first
run and real migrations are unchanged.
2026-06-06 22:23:12 -07:00
rmcrackan 37a60ca654 Merge pull request #1853 from rmcrackan/rmcrackan-patch-1
increm ver
2026-06-05 10:42:50 -04:00
rmcrackan 6623300883 Update AppScaffolding.csproj 2026-06-05 10:42:21 -04:00
rmcrackan 329df0882a Merge pull request #1852 from rmcrackan/rmcrackan/1851-threading
#1851 - threading bug
2026-06-04 10:23:11 -04:00
rmcrackan da000694d7 upgrade dependency 2026-06-04 10:17:23 -04:00
rmcrackan f1a7a837f3 CI bug. try upgrading 2026-06-04 10:13:22 -04:00
rmcrackan 869917e0ce #1851 - threading bug 2026-06-04 09:59:12 -04:00
rmcrackan d532e40fe9 Merge pull request #1850 from rmcrackan/rmcrackan/doc-cleanup
Clean up docs
2026-06-03 11:05:49 -04:00
rmcrackan 9ef5e95801 Clean up docs 2026-06-03 11:04:59 -04:00
rmcrackan c1c3c33ea8 Merge pull request #1849 from rmcrackan/rmcrackan/docs-better-atmos
better atmos docs
2026-06-02 16:45:36 -04:00
rmcrackan b31e7cb819 better atmos docs 2026-06-02 16:44:54 -04:00
rmcrackan 314a46d59d Merge pull request #1848 from rmcrackan/rmcrackan/docs-spacial-audio
new page for spacial audio info
2026-06-02 16:39:21 -04:00
rmcrackan 721f09a62b new page for spacial audio info 2026-06-02 16:38:12 -04:00
rmcrackan 545b3057d4 Merge pull request #1847 from rmcrackan/rmcrackan/1845-better-non-json
#1845 - improve error message for common VPN blocking errors
2026-06-02 12:58:30 -04:00
rmcrackan 2c2ae2c8b0 #1845 - improve error message for common VPN blocking errors. improve troubleshooting docs 2026-06-02 12:53:20 -04:00
rmcrackan f456c68176 Merge pull request #1846 from rmcrackan/rmcrackan/1844-nre
#1844 - add lock to avoid race/NRE
2026-06-02 10:59:25 -04:00
rmcrackan e6c17053f7 #1844 - add lock to avoid race/NRE 2026-06-02 10:54:14 -04:00
rmcrackan 0c22b821bf incr ver 2026-05-25 11:16:38 -04:00
rmcrackan 8902ddc565 Merge pull request #1838 from rmcrackan/rmcrackan/1836-disk-space
#1836 - Fix misleading "Not enough disk space" warnings
2026-05-25 11:15:08 -04:00
rmcrackan 9ed98cbfff unit test fix: this is always going be a weird test. it's trying to balance machine speed vs long running process -- and test 'unreasonable' response time 2026-05-25 10:58:57 -04:00
rmcrackan 3cf5d0b3fa unit test bug fix: culture info 2026-05-25 10:46:34 -04:00
rmcrackan 6bd1ea7ca4 fix unit tests for windows-only logic 2026-05-25 10:41:20 -04:00
rmcrackan f8c5f0da68 #1836 - Fix misleading "Not enough disk space" warnings when Books and In progress are on different drives 2026-05-25 10:25:46 -04:00
rmcrackan a3f272a48e #1835 - add log file guidance to FAQ 2026-05-22 15:00:34 -04:00
Robert 9ee59738eb incr ver 2026-05-21 10:36:21 -04:00
rmcrackan f57839c104 Merge pull request #1834 from rmcrackan/rmcrackan/1833-pdf-bug-fix
Download works but queue goes red with Download PDF
2026-05-21 10:33:52 -04:00
rmcrackan d63fabd73d Download works but queue goes red with Download PDF 2026-05-21 10:33:13 -04:00
rmcrackan 54a97cc3c4 Merge pull request #1830 from rmcrackan/fix/docs-vite-security-override
Pin vite 6.4.2 for docs site (Dependabot GHSA-4w7w-66w2-5vf9)
2026-05-19 09:44:28 -04:00
rmcrackanandCursor 1e3daa40dc Pin vite 6.4.2 for docs site to resolve Dependabot alert.
VitePress 1.6.4 requires vite ^5.4.14, which has no backport for
GHSA-4w7w-66w2-5vf9. An npm override keeps VitePress 1.x while
using a patched vite. Deploy workflow now watches package files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 09:40:44 -04:00
rmcrackan 8f3f069d54 Merge pull request #1829 from rmcrackan/rmcrakan/13-4-2
incr ver
2026-05-19 08:57:00 -04:00
rmcrackan 88e3bbab89 Merge pull request #1828 from rmcrackan/rmcrackan/1827-cli-liberate-single
#1827 - CLI option to liberate single book
2026-05-19 08:56:49 -04:00
rmcrackan ca52ed4b7a incr ver 2026-05-19 08:55:57 -04:00
rmcrackan bb4a5298ae #1827 - CLI option to liberate single book 2026-05-19 08:51:21 -04:00
rmcrackan 9512e62deb Merge pull request #1824 from rmcrackan/rmcrackan/1819-ntfs-note
#1819 - Better NTFS invalid character note
2026-05-18 16:47:25 -04:00
rmcrackan 91174ed8dd Merge pull request #1826 from rmcrackan/rmcrackan/1823-full-disk
#1823 - Address full disk better
2026-05-18 10:09:33 -04:00
rmcrackan b9490966bb * Multi-step backup: PDF and later steps now run in the same awaited chain, so disk full on PDF stops the queue instead of counting as success.
* Detection: Quota / NAS “out of space” messages are treated like disk full, not generic retries.
2026-05-18 10:00:32 -04:00
rmcrackan d1114b410e sync/async bug fixes 2026-05-18 09:53:05 -04:00
rmcrackan 49e9b0c9ce oops, wrong branch. revert 2026-05-18 09:42:08 -04:00
rmcrackan 0b0d1afc48 Fix build failures on non-windows 2026-05-18 09:39:57 -04:00
rmcrackan 77f518f8d3 #1823 - Address full disk better. Fail clearly and early when possible, fail safely when space cannot be queried, and do not take down the UI or spam the user when the disk is full. 2026-05-18 09:20:51 -04:00
rmcrackan 9f1d4a383b Merge pull request #1825 from rmcrackan/rmcrackan/1822-crash-after-upgrade
#1822 - Fix the startup crash after auto-upgrade
2026-05-18 09:02:42 -04:00
rmcrackan 74b18c170a #1822 - Fix the startup crash after auto-upgrade by loading the library only after InteropFactory assembly resolution is ready, checking that Microsoft.EntityFrameworkCore.Sqlite.dll is present in the install folder, and showing a clear reinstall message instead of a generic "Unexpected error" when that still fails. 2026-05-18 08:54:40 -04:00
rmcrackan c85c11b27a #1819 - Better NTFS invalid character note 2026-05-18 08:21:51 -04:00
rmcrackan c8ac585ab3 Merge pull request #1817 from rmcrackan/rmcrackan/1811-threading
#1811 - fix UI threading
2026-05-15 12:10:39 -04:00
rmcrackan 55b45ae45d #1811 - fix UI threading 2026-05-15 12:02:23 -04:00
rmcrackan 776cd5215f Merge pull request #1816 from rmcrackan/rmcrackan/docker-yaml-context
Avoid default git context refs/pull/*/merge (breaks after PR merge or…
2026-05-15 11:45:03 -04:00
rmcrackan 1b8840c3b0 Avoid default git context refs/pull/*/merge (breaks after PR merge or on some re-runs) 2026-05-15 11:13:04 -04:00
rmcrackan 97537df933 Merge pull request #1815 from rmcrackan/rmcrackan/update-contribs
Add @CryptoJones to contributor list
2026-05-15 09:41:54 -04:00
rmcrackan eb4ab59737 Add @CryptoJones to contributor list 2026-05-15 09:41:33 -04:00
rmcrackan 55c2bf3be7 Merge pull request #1814 from CryptoJones/fix/tag-culture-test-de-ch-locale
Fix Tag_culture_test platform dependency on de-CH NumberGroupSeparator
2026-05-15 09:39:06 -04:00
AK ClarkandClaude Opus 4.7 ebce0c073b Fix Tag_culture_test platform dependency on de-CH NumberGroupSeparator
The de-CH row hard-coded U+2019 as the expected thousands separator,
which only matches certain .NET/ICU/CLDR data versions. Linux .NET hosts
return U+0027 (ASCII apostrophe) for the same culture, so every Linux CI
run failed this test regardless of the actual change under test.

Resolve U+2019 in DataRow expectations to the runtime culture's
NumberGroupSeparator before comparison so the test stays stable across
hosts while still verifying the engine respects culture-specific
formatting.

- Added DataRows for de-DE (period) and ja-JP (comma)
- Added Samplerate_template_uses_culture_NumberGroupSeparator as an
  explicit regression guard that asserts the engine uses whatever the
  runtime CultureInfo reports

Fixes #1813.

Verified locally: 584 tests pass, 0 failed (565 succeeded, 19 skipped
Windows-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:10:57 -05:00
rmcrackan 0499bbae7e Merge pull request #1803 from Jo-Be-Co/1762_filter
1762 add filter() to list properties
2026-05-14 11:29:26 -04:00
Jo-Be-Co 4be0361e42 fixing typos and naming of test method 2026-05-14 16:51:51 +02:00
Jo-Be-Co c2c7b04acd minor fixes and comment corrections 2026-05-14 02:12:25 +02:00
Jo-Be-Co f194032139 #1762 add filter() to list properties 2026-05-14 02:10:38 +02:00
Jo-Be-Co a21bb8174d #1762 introduce CommonFormatters.TryGetLiteral 2026-05-14 02:04:26 +02:00
rmcrackan 3fc5ab0f8d Merge pull request #1807 from rmcrackan/rmcrackan/acct-export-disable
Account 'export' should be disabled when the account has no keys/tokens
2026-05-12 12:52:34 -04:00
rmcrackan 591a6d1594 Account 'export' should be disabled when the account has no keys/tokens 2026-05-12 12:05:11 -04:00
rmcrackan c4ac900045 Merge pull request #1806 from rmcrackan/rmcrackan/1804-win-folder-picker-custom-paths
#1804 - Fix Windows folder picker crash on custom paths
2026-05-12 09:04:18 -04:00
rmcrackan 50fde66a4c #1804 - Fix Windows folder picker crash on custom paths. Normalize stored paths before opening the OS folder dialog (\\?\ / UNC handling, existence check) in shared FolderPickerInitialPath, use it from WinForms and Avalonia folder pickers, and retry once if the dialog still throws. Adds small FileManager tests. 2026-05-12 08:55:12 -04:00
rmcrackan 597c8f7b3d Merge pull request #1795 from Jo-Be-Co/1762_unique_and_count
#1762 add unique() and count() to list properties
2026-05-09 23:21:29 -04:00
Jo-Be-Co 4ffcbeb1db Fix typos 2026-05-10 00:37:02 +02:00
Jo-Be-Co 6996fb1b81 When formatting ContributorDto, retain the IFormatProvider even if the format is empty 2026-05-10 00:36:49 +02:00
Jo-Be-Co 7ef10b1e7d #1762 add unique() and count() to list properties 2026-05-09 21:15:24 +02:00
Jo-Be-Co 0a644695d8 #1762 extract comparison to separate reusable class 2026-05-09 00:59:35 +02:00
rmcrackan 86b50b45ef Merge pull request #1796 from rmcrackan/rmcrackan/1711-linux-safety
#1711 : Linux/Docker: the default in-progress download/decrypt folder…
2026-05-08 09:10:23 -04:00
rmcrackan 727c869ba9 #1711 : Linux/Docker: the default in-progress download/decrypt folder is now per-user (/tmp/Libation-<username>) and is created with 0700 permissions, fixing failures caused by leftover /tmp/Libation directories from previous root installs and hardening against another local user reading partial download artifacts. Auto-upgrade downloads now land in a randomly-named per-run temp directory. 2026-05-08 09:04:39 -04:00
Robert f330d8bed6 incr ver 2026-05-05 13:45:27 -04:00
rmcrackan 9aecd26aea Merge pull request #1791 from rmcrackan/rmcrackan/1732-bug-fixes
#1732 bug fixes
2026-05-05 13:41:32 -04:00
rmcrackan 0cc2ef773d * Default Scan library to on for new accounts from Upsert / Mkb79 import (matches GUI)
* CLI liberate: print short license-denial reasons to stderr
* GUI: message when stoplight can’t queue (e.g. absent from last scan)
2026-05-05 13:32:28 -04:00
Robert 78371e3522 incr ver 2026-05-05 09:41:26 -04:00
rmcrackan 24a896e61b Merge pull request #1790 from Mbucari/master
Update Avalonia to v12
2026-05-05 09:39:52 -04:00
MBucari e4daa5b8e8 Fix macos bundle build failure 2026-05-04 21:20:42 -06:00
MBucari 505c614210 Code Cleanup
Remove unused parameters
Remove unnecessary casts
Make fields readonly
Order modifiers
Format document
Sort usings
Remove unnecessary nullable directive
Apply namespace preferences (file-level)
2026-05-04 21:10:28 -06:00
Michael Bucari-Tovo df531de255 Update Avalonia to v12
Avalonia 12 uses compiled bindings by default. Converted all remaining reflection bindings into compiled bindings,

Fixed binding errors
2026-05-04 21:08:26 -06:00
rmcrackan 7b1dd64936 incr ver 2026-05-04 13:43:05 -04:00
rmcrackan c226e8ca38 Merge pull request #1789 from rmcrackan/rmcrackan/1732-feedback
Rmcrackan/1732 feedback
2026-05-04 13:42:01 -04:00
rmcrackan 673ff153b0 #1732 - new CLI list accounts option 2026-05-04 13:28:36 -04:00
rmcrackan 5b898dcd48 #1732 - fix: --help, -h, /?, /h, /help 2026-05-04 13:20:58 -04:00
rmcrackan b1df70f66c Merge pull request #1788 from rmcrackan/rmcrackan/1786-win-10
#1786 : win 10+ only
2026-05-04 11:06:02 -04:00
rmcrackan 220c916258 #1786 : win 10+ only 2026-05-04 11:05:32 -04:00
rmcrackan c3e0247940 Merge pull request #1787 from rmcrackan/rmcrackan/1783-chardonnay-x-filter
Bug fix #1783 : X to clear filter in Chardonnay
2026-05-04 10:12:01 -04:00
rmcrackan 0891fcf256 Bug fix #1783 : X to clear filter in Chardonnay 2026-05-04 10:02:18 -04:00
rmcrackan b259fdc9a5 Merge pull request #1785 from rmcrackan/rmcrackan/634-too-small
troubleshooting: if UI too small then AVALONIA_GLOBAL_SCALE_FACTOR
2026-05-04 09:35:56 -04:00
rmcrackan ecee08aaf7 troubleshooting: if UI too small then AVALONIA_GLOBAL_SCALE_FACTOR 2026-05-04 09:35:22 -04:00
rmcrackan b53a092327 Merge pull request #1784 from rmcrackan/rmcrackan/1199-docs
Add to linux troubleshooting, per solution in #1199
2026-05-04 09:07:21 -04:00
rmcrackan 0c838a483e Add to linux troubleshooting, per solution in #1199 2026-05-04 09:06:50 -04:00
rmcrackan d934511c32 incr ver 2026-05-01 10:11:51 -04:00
rmcrackan 8c88739644 Merge pull request #1747 from rmcrackan/rmcrackan/1732-cli-auth
add CLI auth options
2026-05-01 10:05:41 -04:00
rmcrackan 05e1de78da Merge pull request #1779 from rmcrackan/rmcrackan/1776-snap-docs
Make Linux Snap SQLite startup failures easier to understand
2026-05-01 10:01:45 -04:00
rmcrackan dd059a0ce9 #1776 - Make Linux Snap SQLite startup failures easier to understand and fix (stale LibationFiles in appsettings.json after a revision change), improve crash and log detail (inner exceptions + paths), and document that in the docs and FAQ. Tests lock in exception formatting behavior. 2026-05-01 10:01:03 -04:00
rmcrackan 4f401fe6c7 Merge pull request #1778 from rmcrackan/rmcrackan/1744-imgs
trying again to fix the intermittent cover image issue
2026-05-01 09:46:35 -04:00
rmcrackan 849f5f508e trying again to fix the intermittent cover image issue 2026-05-01 09:38:29 -04:00
rmcrackan ac2b5ef3e2 Merge pull request #1777 from rmcrackan/rmcrackan/1776-improve-errors
#1776. better messages to users. improve all exception display
2026-04-30 11:11:26 -04:00
rmcrackan 13bdebbe9d #1776. better messages to users. improve all exception display 2026-04-30 11:04:50 -04:00
rmcrackan 6445a758a2 Merge pull request #1772 from rmcrackan/dependabot/npm_and_yarn/postcss-8.5.12
Bump postcss from 8.5.6 to 8.5.12
2026-04-27 19:08:01 -04:00
dependabot[bot] f6b0765dbc Bump postcss from 8.5.6 to 8.5.12
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.12.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 23:04:47 +00:00
rmcrackan 95dfee130e Merge pull request #1771 from Mbucari/master
Re-add Category Name Column to database
2026-04-27 19:03:47 -04:00
Michael Bucari-Tovo b8616a6da0 Re-add Category Name Column to database
Category names were accidently removed during the last DB migration in d67692355f.

Re-add the Names column and modify CategoryImporter to upsert the names on update.
2026-04-27 14:45:13 -06:00
rmcrackan 0b975a168e Merge pull request #1769 from rmcrackan/rmcrackan/1744-folder-icons
#1744 Make Explorer folder icons reliable
2026-04-27 10:31:05 -04:00
rmcrackan f610ccdf58 #1744 Make Explorer folder icons reliable by running them after other post-download work, retrying briefly when the 300×300 cover or ICO write fails, and logging clear, actionable messages when it still cannot succeed. 2026-04-27 10:30:27 -04:00
rmcrackan f0c61ec7db typo 2026-04-23 11:20:32 -04:00
rmcrackan 14bd9d9deb Merge pull request #1759 from rmcrackan/rmcrackan/1694-mac-docs
Improve mac troubleshooting docs per discoveries in #1694
2026-04-23 11:13:42 -04:00
rmcrackan 01487690d4 Improve mac troubleshooting docs per discoveries in #1694 2026-04-23 11:13:17 -04:00
rmcrackan acfde519f5 Merge pull request #1758 from rmcrackan/rmcrackan/fix-naming-template
fix naming template markdown
2026-04-22 13:50:14 -04:00
rmcrackan 7fb6ea4701 fix {{ literal 2026-04-22 13:47:29 -04:00
rmcrackan db1566a32d This font is too clever with rendering things like <- as arrows. Let's just make this page monospace 2026-04-22 13:47:01 -04:00
rmcrackan ca48767cc1 syntax fixes 2026-04-22 10:45:57 -04:00
rmcrackan 310abe951a Merge pull request #1757 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-7
Bump apple-actions/import-codesign-certs from 6 to 7
2026-04-21 10:24:34 -04:00
dependabot[bot] aed2aec38e Bump apple-actions/import-codesign-certs from 6 to 7
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 6 to 7.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v6...v7)

---
updated-dependencies:
- dependency-name: apple-actions/import-codesign-certs
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 14:14:34 +00:00
rmcrackan 531d98a0d5 Merge pull request #1756 from rmcrackan/rmcrackan/fix-docs
fix vitepress/vue syntax
2026-04-21 08:56:00 -04:00
rmcrackan dd8bba959d fix vitepress/vue syntax 2026-04-21 08:54:53 -04:00
Robert 9d6f0f7700 incr ver 2026-04-20 22:01:31 -04:00
rmcrackan 274cbf37ca Merge pull request #1755 from Jo-Be-Co/Conditional-output-on-format-templates
#1754 Conditional output on format templates
2026-04-20 21:59:02 -04:00
Jo-Be-Co 2b25ced65b documentation for format-templates 2026-04-21 00:18:14 +02:00
Jo-Be-Co 417e654799 #1754 Conditional output on format templates 2026-04-20 23:15:54 +02:00
rmcrackan e9550055c6 Merge pull request #1750 from Jo-Be-Co/refactor-format-escapes
fixed quoting errors in format-strings
2026-04-20 16:46:43 -04:00
Jo-Be-Co fa64be7ad0 fixed typoo ;-) 2026-04-20 22:41:34 +02:00
Jo-Be-Co e3508b2998 rebased and small fixes 2026-04-20 22:06:40 +02:00
Jo-Be-Co 30809fb38d fixed quoting errors in format-strings 2026-04-20 21:38:45 +02:00
rmcrackan 5b89f29a4f Merge pull request #1735 from Jo-Be-Co/bifunctions
1714 conditional tag cmp working on two properties
2026-04-20 14:36:24 -04:00
Jo-Be-Co ea33d8933a test added with colons in tag comparison 2026-04-20 19:24:21 +02:00
Jo-Be-Co c1e9911ade Small fixes on auto checks 2026-04-20 18:43:33 +02:00
Jo-Be-Co 962a5055b2 Fixed PR comments 2026-04-20 18:07:17 +02:00
rmcrackan 3108bfa469 Merge pull request #1753 from rmcrackan/rmcrackan/1748-upgrader
In-app upgrades now wait for the real installer and treat failures as failures
2026-04-20 10:21:13 -04:00
rmcrackan ccc74cef56 #1748: In-app upgrades now wait for the real installer and treat failures as failures. 2026-04-20 10:20:29 -04:00
rmcrackan 1de778adef Merge pull request #1752 from rmcrackan/rmcrackan/1751-resizeable
#1751 - text should always be readable
2026-04-20 09:48:38 -04:00
rmcrackan 4a30acf0eb #1751 - text should always be readable. Allow for user-resizing when it's not the case. 2026-04-20 09:48:13 -04:00
rmcrackan 6d326ebabc #1732 add CLI auth options. Add to the CLI these features which are already available in the GUI: importing from @mkb79 's audible-cli , and the external browser login (aka: alternative login). Caveat: 2nd feature doesn't work for Brazil (gui or cli) 2026-04-18 15:29:57 -04:00
rmcrackan f2ef616203 Merge pull request #1745 from Jo-Be-Co/refactor-LocaleDto
Some optimizations after last update to AudibleApi
2026-04-17 17:53:07 -04:00
Jo-Be-Co 87e8c6a32a fixed pr remarks 2026-04-17 23:48:22 +02:00
rmcrackan 62dfc20255 Merge pull request #1746 from rmcrackan/rmcrackan/1744-images
#1744 - attempt to harded code for cover image/folder image
2026-04-17 16:11:21 -04:00
rmcrackan 9f255a3bc1 #1744 - attempt to harded code for cover image/folder image 2026-04-17 16:07:00 -04:00
Jo-Be-Co 6d7af0a8ae Some optimizations after last update to AudibleApi 2026-04-17 21:43:42 +02:00
Jo-Be-Co 650b9c7f5b added docs for cmp tag 2026-04-17 21:22:30 +02:00
Jo-Be-Co dc353aa7e5 Merge branch 'master' into bifunctions 2026-04-17 08:50:53 +02:00
Jo-Be-Co d5258b7e64 fixed merge collision 2026-04-17 08:46:52 +02:00
Jo-Be-Co 475f20b3cb unit tests added for new list operations 2026-04-17 08:35:43 +02:00
rmcrackan 6efc64b927 incr ver 2026-04-16 12:27:11 -04:00
rmcrackan d2cc9ff26f Merge pull request #1743 from rmcrackan/rmcrackan/corrupt-search
Attempt to recover from corrupt search engine
2026-04-15 13:32:14 -04:00
rmcrackan cfe4770387 Attempt to recover from corrupt search engine 2026-04-15 13:24:26 -04:00
rmcrackan 33967adb15 Merge pull request #1742 from rmcrackan/rmcrackan/harden-webview
harden webview for linux
2026-04-15 10:22:39 -04:00
rmcrackan 9ef8dca4d5 Document Linux runtime dependency 2026-04-15 09:58:36 -04:00
rmcrackan af1029f4b5 Honor "don't use WebView" for this path 2026-04-15 09:50:18 -04:00
rmcrackan 63e7f0f44e Automatic fallback when WebKit is missing. If embedded WebView cannot load, route the same “add account” flow to non-WebView login, so Arch minimal installs work without WebKit. 2026-04-15 09:45:35 -04:00
rmcrackan 65623e8e81 Catch WebKit load failures on account login. Wrap the code path that opens NativeWebDialog / WebView for Audible login in a try/catch for DllNotFoundException (and related load failures). On failure, show a short dialog: install WebKit2GTK and do not crash the app. 2026-04-15 09:41:47 -04:00
rmcrackan a5912efa0c upgrade nuget 2026-04-15 08:10:09 -04:00
rmcrackan dba3e6ca8e Merge pull request #1725 from Jo-Be-Co/language_and_region
Extend <language> and <locale> tags
2026-04-15 08:00:49 -04:00
Jo-Be-Co a4bc39ee6b switched from RegionInfo to AudibleApi.Locale 2026-04-15 00:45:55 +02:00
rmcrackan 675fecec67 Merge pull request #1737 from rmcrackan/dependabot/github_actions/actions/upload-pages-artifact-5
Bump actions/upload-pages-artifact from 4 to 5
2026-04-14 10:22:55 -04:00
dependabot[bot] be1eac5692 Bump actions/upload-pages-artifact from 4 to 5
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 14:14:02 +00:00
Jo-Be-Co 8bad7d90c3 List os and ui tag in documentation 2026-04-14 03:04:26 +02:00
Jo-Be-Co c72e5a3568 fixed matching of first property 2026-04-14 02:48:03 +02:00
rmcrackan e145e46579 Merge pull request #1731 from Jo-Be-Co/1729_docker_resilient-creation-of-db-link
1729 docker resilient creation of db link
2026-04-13 19:38:44 -04:00
Jo-Be-Co 5fd8ac481b review fixes 2026-04-14 01:14:45 +02:00
Jo-Be-Co b6c7612ec4 Merge remote-tracking branch 'upstream/master' into language_and_region 2026-04-14 00:55:42 +02:00
Jo-Be-Co 3456eb6c35 Merge remote-tracking branch 'upstream/master' into 1729_docker_resilient-creation-of-db-link
# Conflicts:
#	Docker/liberate.sh
2026-04-14 00:51:09 +02:00
Jo-Be-Co c1b8cc894a add mathematical symbols 2026-04-14 00:41:22 +02:00
Jo-Be-Co 56c15d3108 add checks on lists 2026-04-14 00:38:41 +02:00
Jo-Be-Co 810ea90770 introduce cmp tag as check with two operands 2026-04-14 00:30:34 +02:00
rmcrackan b90cc67b09 Merge pull request #1734 from rmcrackan/dependabot/github_actions/softprops/action-gh-release-3
Bump softprops/action-gh-release from 2 to 3
2026-04-13 11:34:55 -04:00
dependabot[bot] 550a99ac90 Bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 15:30:12 +00:00
rmcrackan 9ed89eca9e Merge pull request #1733 from rmcrackan/rmcrackan/1729-docker-tweak
#1729 minor docker tweak
2026-04-13 08:51:28 -04:00
rmcrackan 197d645e10 #1729 minor docker tweak 2026-04-13 08:51:07 -04:00
rmcrackan bdfc99f209 Merge pull request #1730 from rmcrackan/dependabot/github_actions/actions/github-script-9
Bump actions/github-script from 8 to 9
2026-04-13 08:37:44 -04:00
Jo-Be-Co a010da5251 #1714 Convert the evaluation of the 'is' tag into two-parameter logic 2026-04-13 01:39:16 +02:00
Jo-Be-Co 4c212e752f some refinements 2026-04-11 22:19:31 +02:00
Jo-Be-Co e8d2ac5c62 #1729 added resilience when creating the link to the database file 2026-04-11 21:22:54 +02:00
Jo-Be-Co 6c552623d9 Adding documentation for region and language tags 2026-04-11 01:59:49 +02:00
dependabot[bot] 5a0d62687c Bump actions/github-script from 8 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 14:13:05 +00:00
Jo-Be-Co f4e61d0445 hardend an commented unit tests for region output 2026-04-10 11:44:28 +02:00
Jo-Be-Co 54765fc181 Fixed cross-platform problems on region tests 2026-04-10 10:14:19 +02:00
Jo-Be-Co d292cb0472 Merge remote-tracking branch 'origin/language_and_region' into language_and_region
# Conflicts:
#	Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs
2026-04-10 09:47:00 +02:00
Jo-Be-Co b4f48cb54c Extend <language> and <locale> tags to allow different outputs like ISO-codes or names in different languages 2026-04-10 09:43:11 +02:00
rmcrackan e7d08d30b0 Merge pull request #1727 from rmcrackan/rmcrackan/log-init-db
Log initial db state, esp to capture books in trash
2026-04-09 11:05:36 -04:00
rmcrackan 26946e8a87 Log initial db state, esp to capture books in trash 2026-04-09 10:57:41 -04:00
rmcrackan ecf1cf4404 Merge pull request #1726 from rmcrackan/rmcrackan/vulnerable-nuget
copilot fix to vulnerable nuget pkg
2026-04-09 10:03:54 -04:00
rmcrackan 8213521404 copilot fix to vulnerable nuget pkg 2026-04-09 09:57:41 -04:00
Jo-Be-Co 0ef73464b8 Extend <language> and <locale> tags to allow different outputs like ISO-codes or names in different languages 2026-04-09 02:33:05 +02:00
rmcrackan 3030b748d7 Merge pull request #1723 from rmcrackan/rmcrackan/sqlite-readonly
Better handling of sqlite readonly
2026-04-08 17:48:55 -04:00
rmcrackan ccef50d681 move new check into datalayer 2026-04-08 17:43:21 -04:00
rmcrackan 78e6d7c5c1 Better handling of sqlite readonly 2026-04-08 17:33:07 -04:00
rmcrackan 846b93c97c Merge pull request #1720 from rmcrackan/rmcrackan/1719-corrupt-search-engine
#1719 - add defensive code to recover from a corrupt search engine
2026-04-07 15:05:45 -04:00
rmcrackan b0e79faacf #1719 - add defensive code to recover from a corrupt search engine 2026-04-07 15:00:18 -04:00
622 changed files with 44662 additions and 3959 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# https://editorconfig.org
root = true
[*.md]
charset = utf-8
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+2
View File
@@ -40,4 +40,6 @@ Attach your Libation log file here. If your user folder contains the file "Libat
|macOS|`~/Library/Application Support/Libation`|
|Linux|`~/.local/share/Libation`|
**macOS:** If that folder does not exist because Libation never starts, try launching from Terminal and say whether it works: `/Applications/Libation.app/Contents/MacOS/Libation` (see [Troubleshooting for macOS](https://github.com/rmcrackan/Libation/blob/main/docs/advanced/troubleshoot.md#macos)).
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+2 -2
View File
@@ -37,9 +37,9 @@ jobs:
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
+3 -3
View File
@@ -34,15 +34,15 @@ jobs:
RUNTIME_ID: "osx-${{ inputs.architecture }}"
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
steps:
- uses: apple-actions/import-codesign-certs@v6
- uses: apple-actions/import-codesign-certs@v7
if: ${{ inputs.sign-app }}
with:
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
+66 -8
View File
@@ -16,6 +16,10 @@ on:
type: boolean
publish-r2r:
type: boolean
build-installer:
type: boolean
description: "Build *-setup.exe on all Windows matrix jobs when release (zips always built for .releaseindex.json)"
default: false
retention-days:
type: number
@@ -28,18 +32,20 @@ jobs:
ui: [Avalonia]
architecture: [x64]
release_name: [chardonnay]
artifact_stem: [Libation]
include:
- architecture: x64
ui: WinForms
release_name: classic
prefix: Classic-
artifact_stem: Libation-Classic
- architecture: arm64
ui: Avalonia
release_name: chardonnay
artifact_stem: Libation
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
@@ -60,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
@@ -75,7 +91,12 @@ jobs:
"WindowsConfigApp.deps.json")
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
# 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"
@@ -85,3 +106,40 @@ jobs:
path: ./bin/${{ steps.zip.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}
- name: Install Inno Setup
if: ${{ inputs.build-installer }}
run: choco install innosetup -y --no-progress
- name: Build Windows installer
id: setup
if: ${{ inputs.build-installer }}
shell: pwsh
run: |
if ("${{ matrix.ui }}" -eq "WinForms") {
$setupName = "Libation-Classic.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
$ui = "WinForms"
} else {
$setupName = "Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
$ui = "Avalonia"
}
./Scripts/Windows/Build-WindowsInstaller.ps1 `
-Version "${{ inputs.libation-version }}" `
-Ui $ui `
-Architecture "${{ matrix.architecture }}" `
-SkipPublish `
-BinDir "${{ github.workspace }}/bin"
if (-not (Test-Path "${{ github.workspace }}/bin/$setupName")) {
throw "Installer not found: bin/$setupName"
}
"setup=$setupName" >> $env:GITHUB_OUTPUT
- name: Upload installer artifact
if: ${{ inputs.build-installer }}
uses: actions/upload-artifact@v7
with:
name: ${{ steps.setup.outputs.setup }}
path: ./bin/${{ steps.setup.outputs.setup }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}
+3
View File
@@ -35,6 +35,7 @@ jobs:
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
build-installer: ${{ inputs.release }}
retention-days: ${{ inputs.retention-days }}
macOS:
@@ -67,3 +68,5 @@ jobs:
architecture: ${{ matrix.architecture }}
OS: ${{ matrix.OS }}
+5 -3
View File
@@ -10,6 +10,8 @@ on:
- docs/**
- .vitepress/**
- index.md
- package.json
- package-lock.json
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -32,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # Not needed if lastUpdated is not enabled
# - uses: pnpm/action-setup@v4 # Uncomment this block if you're using pnpm
@@ -40,7 +42,7 @@ jobs:
# version: 9 # Not needed if you've set "packageManager" in package.json
# - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm # or pnpm / yarn
@@ -51,7 +53,7 @@ jobs:
- name: Build with VitePress
run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: .vitepress/dist
+7 -3
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -36,7 +36,7 @@ jobs:
- name: Login to Docker Hub
if: ${{ inputs.release }}
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.docker_username }}
password: ${{ secrets.docker_token }}
@@ -55,9 +55,13 @@ jobs:
- name: Build and push image
uses: docker/build-push-action@v7
with:
# Avoid default git context refs/pull/*/merge (breaks after PR merge or on some re-runs).
context: .
platforms: linux/amd64,linux/arm64
push: ${{ steps.metadata.outputs.tags != ''}}
# Always read cache. Only write on release so PR validate cannot fail
# when GHA cache is full or the token cannot write cache.
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: ${{ inputs.release && 'type=gha,mode=max,ignore-error=true' || '' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
@@ -15,7 +15,7 @@ jobs:
backfill:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v9
with:
script: |
const exemptLabel = 'exempt';
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.issue.user.login)) ||
(github.event_name == 'pull_request' &&
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.pull_request.user.login))
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const number = context.issue?.number || context.payload.pull_request?.number;
+12 -4
View File
@@ -41,15 +41,23 @@ jobs:
needs: [prerelease, build]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
- name: Download Chardonnay, Linux, and Mac artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
pattern: "*(Classic-)Libation.*"
pattern: "Libation.*"
merge-multiple: true
- name: Download Classic Windows artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
pattern: "Libation-Classic.*"
merge-multiple: true
- name: Release
id: release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
name: Libation ${{ needs.prerelease.outputs.version }}
body: "<Put a body here>"
@@ -57,4 +65,4 @@ jobs:
draft: true
prerelease: false
files: |
artifacts/*/*
artifacts/*
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
days-before-stale: 30
days-before-close: 14
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
sync-labels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Sync labels
uses: micnncim/action-label-syncer@v1
@@ -17,6 +17,6 @@ jobs:
container:
image: ghcr.io/flathub/flatpak-builder-lint:latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Check the MetaInfo file
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
validate-desktop-file:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: sudo apt --yes install desktop-file-utils
- name: Check the desktop file
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
+1 -1
View File
@@ -1,5 +1,5 @@
{
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
"WindowsClassic": "(?:Classic-Libation|Libation-Classic)\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"WindowsAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-arm64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
+49 -3
View File
@@ -2,6 +2,29 @@ import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
vite: {
// esbuild 0.28+ will not downlevel destructuring for safari14 / chrome87.
// Raise the target so docs:dev and docs:build do not need that transform.
build: {
target: "es2022",
},
optimizeDeps: {
esbuildOptions: {
target: "es2022",
},
},
esbuild: {
supported: {
destructuring: true,
},
},
// Avoid watching the C# solution / VS locks (EBUSY on .vsidx files).
server: {
watch: {
ignored: ["**/Source/**", "**/.vs/**", "**/bin/**", "**/obj/**"],
},
},
},
title: "Libation",
description:
"Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
@@ -35,7 +58,7 @@ export default defineConfig({
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
{ text: "Donate", link: "/donate" },
],
sidebar: [
{
@@ -47,7 +70,7 @@ export default defineConfig({
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
{ text: "Donate", link: "/donate" },
],
},
{
@@ -67,7 +90,23 @@ export default defineConfig({
text: "Audio File Formats",
link: "/docs/features/audio-file-formats",
},
{
text: "Audiobookshelf Auto-Upload",
link: "/docs/features/audiobookshelf",
},
{
text: "Daily Download Limit",
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",
},
{
text: "Searching & Filtering",
link: "/docs/features/searching-and-filtering",
@@ -76,6 +115,7 @@ export default defineConfig({
text: "Easy guide to searching",
link: "/docs/features/lucene",
},
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
],
},
{
@@ -84,7 +124,9 @@ export default defineConfig({
items: [
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" }
{ text: "Device registration", link: "/docs/advanced/device-registration" },
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
],
},
{
@@ -96,6 +138,7 @@ export default defineConfig({
link: "/docs/development/getting-started",
},
{ text: "Contribute", link: "/docs/development/contribute" },
{ text: "Testing Changes", link: "/docs/development/testing" },
{ text: "Website & Docs", link: "/docs/development/website" },
{ text: "Linux Setup (Nix)", link: "/docs/development/nix-linux-setup" },
],
@@ -113,5 +156,8 @@ export default defineConfig({
search: {
provider: "local",
},
// Show the external-link arrow on outbound links in markdown (nav/sidebar already do).
externalLinkIcon: true,
},
});
+54
View File
@@ -1,5 +1,59 @@
/* Custom styles for Libation documentation */
/* Naming Templates page: monospace in the doc column + outline only.
pageClass is applied to .Layout in VitePress; limit scope to #VPContent
so the top nav, sidebar, and site footer keep the default font. */
.Layout.naming-templates-mono #VPContent,
.Layout.naming-templates-mono #VPContent * {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
}
/* Donate page: primary options */
.donate-options {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: 1.25rem 0 0.5rem;
}
.donate-option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
min-width: 11rem;
padding: 1rem 1.25rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
text-decoration: none !important;
color: var(--vp-c-text-1) !important;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.donate-option:hover {
border-color: var(--vp-c-brand-1);
background: var(--vp-c-bg-elv);
}
.donate-option__icon {
width: 1.75rem;
height: 1.75rem;
margin-bottom: 0.35rem;
}
.donate-option__label {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.3;
}
.donate-option__hint {
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
/* Hide certain nav items on tablet devices to prevent horizontal scroll */
@media (min-width: 640px) and (max-width: 959px) {
/* Target specific nav items by their position */
+25 -3
View File
@@ -102,6 +102,7 @@ export function getDetectOS() {
if (lower.endsWith('.deb')) packageType = 'deb'
else if (lower.endsWith('.rpm')) packageType = 'rpm'
else if (lower.endsWith('.dmg')) packageType = 'dmg'
else if (lower.endsWith('-setup.exe')) packageType = 'installer'
else if (lower.endsWith('.zip') || lower.endsWith('.msi')) packageType = 'archive'
return { platform, arch, packageType: packageType || 'unknown', name }
}
@@ -141,6 +142,8 @@ export function getDetectOS() {
if (wantOs === 'linux' && p.packageType === 'deb' && preferDeb) s += 20
if (wantOs === 'linux' && p.packageType === 'rpm' && preferRpm) s += 20
if (wantOs === 'linux' && !preferDeb && !preferRpm && p.packageType === 'deb') s += 5
if (wantOs === 'windows' && p.packageType === 'installer') s += 30
if (wantOs === 'windows' && p.packageType === 'archive') s -= 10
if (nameLower.includes('chardonnay')) s += 10
if (nameLower.includes('classic')) s -= 5
return s
@@ -154,6 +157,20 @@ export function getDetectOS() {
if (wantArch) altCandidates = altCandidates.filter(e => e.parsed.arch === wantArch)
const alternatives = altCandidates.map(e => ({ name: e.name, url: e.url }))
const classicVsChardonnayFaqUrl = 'https://getlibation.com/docs/frequently-asked-questions#what-s-the-difference-between-classic-and-chardonnay'
function isWindowsSetup(name) {
return (name || '').toLowerCase().endsWith('-setup.exe')
}
function preferWindowsAsset(current, candidate) {
if (!candidate) return current
if (!current) return candidate
const candSetup = isWindowsSetup(candidate.name)
const currSetup = isWindowsSetup(current.name)
if (candSetup && !currSetup) return candidate
return current
}
function isClassicWindowsAsset(nameLower) {
return nameLower.includes('libation-classic') || nameLower.includes('classic-libation')
}
let windowsX64Choice = null
if (wantOs === 'windows' && wantArch === 'x64') {
let winChardonnay = null
@@ -162,10 +179,15 @@ export function getDetectOS() {
const e = parsed[j]
if (e.parsed.platform !== 'windows' || e.parsed.arch !== 'x64') continue
const n = (e.name || '').toLowerCase()
if (n.includes('chardonnay')) winChardonnay = { name: e.name, url: e.url }
if (n.includes('classic')) winClassic = { name: e.name, url: e.url }
if (n.includes('chardonnay')) winChardonnay = preferWindowsAsset(winChardonnay, e)
else if (isClassicWindowsAsset(n)) winClassic = preferWindowsAsset(winClassic, e)
}
if (winChardonnay && winClassic) {
windowsX64Choice = {
chardonnay: { name: winChardonnay.name, url: winChardonnay.url },
classic: { name: winClassic.name, url: winClassic.url },
}
}
if (winChardonnay && winClassic) windowsX64Choice = { chardonnay: winChardonnay, classic: winClassic }
}
if (!recommended && wantOs) {
reason = 'No asset matched your system (OS: ' + os + ', arch: ' + arch + '). Try choosing a download manually.'
+75
View File
@@ -0,0 +1,75 @@
# AGENTS.md
## Cursor Cloud specific instructions
Libation is a cross-platform .NET desktop/CLI app for downloading and de-DRMing Audible
audiobooks. The core product is the Avalonia GUI (`LibationAvalonia`, assembly name `Libation`)
plus a headless CLI (`LibationCli`) that shares the same config and SQLite library database.
### Toolchain / environment
- Requires the **.NET 10 SDK** pinned by `global.json` (`10.0.101`). It is preinstalled at
`~/.dotnet`; `~/.bashrc` exports `DOTNET_ROOT` and adds `~/.dotnet` (and `~/.dotnet/tools`) to
`PATH`. New non-login shells may not have it — use `dotnet` after a login shell, or call
`~/.dotnet/dotnet` directly.
- The startup update script runs `dotnet tool restore` (restores `dotnet-ef`) and
`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 — 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`
- There is no dedicated lint step; the repo's `.editorconfig` is minimal and CI
(`.github/workflows/validate.yml`) only builds and tests. The compiler/analyzer warnings from a
normal build serve as the static-analysis check. (A known transitive `NU1903` SQLite
vulnerability warning is expected and harmless.)
### Testing
- Test projects live under `Source/_Tests/` and use MSTest on **Microsoft.Testing.Platform**
(configured via `global.json` `test.runner`). Because of this runner, `dotnet test` requires
`--project` for a single project (a positional project path is rejected):
`dotnet test --project Source/_Tests/FileManager.Tests/FileManager.Tests.csproj`
- CI runs `dotnet test` from `Source/` on every platform, Linux included, and it works here too: the
restore covers the whole solution but only the test projects and their references are compiled, so
the Windows-only projects never break the run. Naming one project is simply quicker.
- **Manual UI testing:** `dotnet run Scripts/seed-demo-library.cs` fills the library with fake books
covering every Liberate-column icon and prints the expected result for each row
(`-- --clean` removes them). Read `docs/development/testing.md` before seeding library state by
hand: the yellow lamp is an `.aaxc` file on disk rather than a stored status, `AudioExists` is a
database check so green/error need no files, and a podcast's series is keyed off the parent
book's own ASIN or the grid silently drops the parent row.
- **GNOME Keyring / OS secret store:** Libation's default `TokenStorageMethod` is `Encrypted`,
and the AES-GCM master key is stored via the OS secret store (`OsSecretStore` /
`IdentityTokenStorageWiring`). On Linux that is GNOME Keyring (Secret Service), which **blocks
indefinitely** here: the login keyring is locked with a password nobody has, and the D-Bus call
hangs even with no prompt on screen. Probing availability does not help - the probe is the
blocking call.
Every test project should finish in about a second. If one runs for minutes, assume something
reached the OS secret store; do not sit through it and do not re-run it hoping for a different
result. `ResolveSecretStore` short-circuits on `LIBATION_MASTER_KEY_FILE`, an existing
`libation-master.key` under the Libation files dir, or `LIBATION_MASTER_KEY`, so setting one of
those keeps a test off the OS store entirely.
The tests that deliberately exercise the real store are opt-in via
`LIBATION_TEST_OS_SECRET_STORE=1` and are skipped otherwise. Leave them skipped on Linux; they
will hang if enabled. Always run tests under `timeout` so a regression here cannot stall a session.
### Running the apps
- GUI: a display is available on `DISPLAY=:1`. Run with
`cd Source/LibationAvalonia && dotnet run`. First launch shows a Welcome/walkthrough and creates
config under `~/.local/share/Libation/` (`Settings.json`, `AccountsSettings.json`,
`LibationContext.db`). The same keyring note above applies when the GUI/CLI first encrypts
account tokens.
- CLI: `cd Source/LibationCli && dotnet run -- <command>` (e.g. `version`, `get-setting`,
`list-accounts`, `scan`, `liberate`). The CLI reads the same `~/.local/share/Libation/` config
and database as the GUI.
- Actually scanning/downloading a library requires signing into a real Audible account, so full
end-to-end liberation cannot be exercised without credentials.
+1
View File
@@ -0,0 +1 @@
getlibation.com
+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>
+81 -22
View File
@@ -38,6 +38,25 @@ init_config_file() {
fi
}
init_master_key() {
FILE=libation-master.key
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
if [ -n "${LIBATION_MASTER_KEY_FILE}" ]; then
debug "LIBATION_MASTER_KEY_FILE is already set, not looking for ${FILE}"
return 0
fi
if [ -n "${LIBATION_MASTER_KEY}" ]; then
debug "LIBATION_MASTER_KEY is already set, not looking for ${FILE}"
return 0
fi
if [ -f ${FULLPATH} ]; then
info "loading ${FILE}"
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
else
debug "${FULLPATH} not found, assuming accounts don't use encrypted tokens"
fi
}
update_settings() {
FILE=$1
KEY=$2
@@ -72,48 +91,80 @@ create_db() {
}
setup_db() {
DBPATH=$1
dbpattern="*.db"
local DBPATH=$1
local dbpattern="*.db"
local FILE LINK LINK_ORIGIN
local -a files
debug "using database directory ${DBPATH}"
# Figure out the right databse file
if [[ -z "${LIBATION_DB_FILE}" ]];
if [[ -z $LIBATION_DB_FILE ]]
then
dbCount=$(find "${DBPATH}" -maxdepth 1 -type f -name "${dbpattern}" | wc -l)
if [ "${dbCount}" -gt 1 ];
then
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
exit 1
elif [ "${dbCount}" -eq 1 ];
then
files=( ${DBPATH}/${dbpattern} )
FILE=${files[0]}
else
FILE="${DBPATH}/LibationContext.db"
fi
shopt -s nullglob # files shall be empty if no match is found
files=( "${DBPATH}"/${dbpattern} )
shopt -u nullglob
case ${#files[@]} in
0)
FILE="${DBPATH}/LibationContext.db"
;;
1)
FILE="${files[0]}"
;;
*)
debug "found ${#files[@]} database files matching '${dbpattern}'"
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
exit 1
;;
esac
else
FILE="${DBPATH}/${LIBATION_DB_FILE}"
fi
debug "planning to use database ${FILE}"
debug "planning to use database '${FILE}'"
if [ -f "${FILE}" ]; then
if [[ -f $FILE ]]
then
info "database found at ${FILE}"
elif [ ${LIBATION_CREATE_DB} = "true" ];
elif [[ $LIBATION_CREATE_DB == "true" ]]
then
warn "database not found, creating one at ${FILE}"
create_db ${FILE}
create_db "${FILE}"
else
error "database not found and creation is disabled"
exit 1
fi
ln -s "${FILE}" "${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
LINK="${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
if [[ -L $LINK ]]
then
# check if the link is correct
LINK_ORIGIN=$(readlink -fn "$LINK")
if [[ $LINK_ORIGIN == $(readlink -fn "$FILE") ]]
then
warn "symlink '${LINK_ORIGIN}' to '${LINK}' already established"
return 0
fi
warn "removing existing symlink '${LINK_ORIGIN}' to '${LINK}'"
rm -f "$LINK"
elif [[ -e $LINK ]]
then
error "found blocking file at '${LINK}' - can't create symlink"
exit 1
fi
ln -s "${FILE}" "${LINK}"
}
run() {
info "scanning accounts"
/libation/LibationCli scan
local scan_exit=$?
if [ "${scan_exit}" -ne 0 ]; then
error "scan failed (exit ${scan_exit}); skipping liberate. If the log shows Failed to decrypt ExistingAccessToken, see https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"
exit "${scan_exit}"
fi
info "liberating books"
/libation/LibationCli liberate
}
@@ -122,7 +173,8 @@ main() {
info "initializing libation"
init_config_file AccountsSettings.json
init_config_file Settings.json
init_master_key
info "loading settings"
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
update_settings Settings.json InProgress /tmp
@@ -144,6 +196,13 @@ main() {
warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved"
fi
# If a command was passed to the container (e.g. `docker run -it libation bash`),
# run that instead of the liberate loop so the environment is still set up
if [ "$#" -gt 0 ]; then
info "executing '$*' instead of the liberate loop"
exec "$@"
fi
# Let the user know what the run type will be
if [[ -z "${SLEEP_TIME}" ]]; then
SLEEP_TIME=-1
@@ -171,4 +230,4 @@ main() {
info "exiting"
}
main
main "$@"
+3 -1
View File
@@ -37,6 +37,8 @@ RUN apt-get update && apt-get -y upgrade && \
COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation
COPY Docker/* /libation
RUN ln -s /libation/LibationCli /usr/local/bin/LibationCli
USER ${USER_UID}:${USER_GID}
CMD ["/libation/liberate.sh"]
ENTRYPOINT ["/libation/liberate.sh"]
+4 -2
View File
@@ -32,6 +32,7 @@ Comprehensive documentation is available in the `docs` directory and on our [Doc
- [Getting Started](https://getlibation.com/docs/getting-started)
- [FAQ](https://getlibation.com/docs/frequently-asked-questions)
- [Spatial Audio & DRM](https://getlibation.com/docs/advanced/spatial-audio)
## Development & Contributing
@@ -39,13 +40,14 @@ We welcome contributions!
- **[Development Getting Started](https://getlibation.com/docs/development/getting-started)**: Setup your environment.
- **[Contribute](https://getlibation.com/docs/development/contribute)**: How to contribute code.
- **[Testing Changes](https://getlibation.com/docs/development/testing)**: Run the tests and seed a demo library for UI testing.
- **[Website & Docs](https://getlibation.com/docs/development/website)**: How to run and improve the documentation.
- **[Linux Setup (Nix)](https://getlibation.com/docs/development/nix-linux-setup)**: Nix-based environment setup.
## 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
@@ -53,5 +55,5 @@ Libation is released under the GPL-3.0 License
---
If you found this useful, tell a friend. If you found this REALLY useful, you can click here to PayPal.me
If you found this useful, tell a friend. If you found this REALLY useful, you can [donate here](https://getlibation.com/donate)
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.
+49 -8
View File
@@ -1,4 +1,5 @@
#!/bin/bash
set -euo pipefail
BIN_DIR=$1; shift
VERSION=$1; shift
@@ -8,25 +9,25 @@ SIGN_WITH_KEY=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation macos bins directory as an argument."
exit
exit 1
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
exit 1
fi
if [ -z $VERSION ]
if [ -z "${VERSION:-}" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
exit 1
fi
if [ -z $ARCH ]
if [ -z "${ARCH:-}" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
exit 1
fi
if [ "$SIGN_WITH_KEY" != "true" ]
@@ -40,9 +41,46 @@ echo "Bundle dir: $BUNDLE"
if [[ -d $BUNDLE ]]
then
echo "$BUNDLE directory already exists, aborting."
exit
exit 1
fi
# hdiutil create intermittently fails on CI with "Resource busy" (macOS background scanning).
create_dmg_with_retry() {
local src_folder="$1"
local output_path="$2"
local max_attempts=10
local attempt=1
local delay=3
while [ "$attempt" -le "$max_attempts" ]; do
echo "Creating disk image (attempt ${attempt}/${max_attempts}): ${output_path}"
rm -f "${output_path}"
sync
sleep 1
if hdiutil create -srcFolder "${src_folder}" -ov -format UDZO -o "${output_path}"; then
if [ -f "${output_path}" ]; then
return 0
fi
fi
echo "hdiutil create failed on attempt ${attempt}"
hdiutil detach -force /Volumes/Libation 2>/dev/null || true
rm -f "${output_path}" "${output_path}.temp.dmg" 2>/dev/null || true
sync
if [ "$attempt" -lt "$max_attempts" ]; then
echo "Waiting ${delay}s before retry..."
sleep "${delay}"
delay=$((delay + 2))
fi
attempt=$((attempt + 1))
done
echo "Error: hdiutil create failed after ${max_attempts} attempts"
return 1
}
BUNDLE_CONTENTS=$BUNDLE/Contents
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
@@ -72,6 +110,9 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
echo "Writing PkgInfo file..."
printf 'APPL????' > $BUNDLE_CONTENTS/PkgInfo
echo "Moving Libation_DS_Store file..."
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
@@ -118,7 +159,7 @@ mkdir Libation/.background
mv background.png Libation/.background/
ln -s /Applications "./Libation/ "
mkdir ./bundle
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
create_dmg_with_retry ./Libation "./bundle/$DMG_FILE"
# Create a .DS_Store by:
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
# - Open the folder and edit it to your liking.
+15
View File
@@ -0,0 +1,15 @@
# Scripts
Developer utilities. None of these ship in a Libation install - they exist only in a source checkout.
| Script | Purpose | Documented in |
|--------|---------|---------------|
| `seed-demo-accounts.cs` | Seed fake Audible accounts, to reach what is gated on having one without signing in | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `seed-demo-library.cs` | Seed a library covering every Liberate-column icon, for manual UI testing | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `seed-download-history.cs` | Seed completed downloads so the daily download limit can be tested without downloading | [Testing Changes](https://getlibation.com/docs/development/testing) |
| `Bundle_Debian.sh` | Build the Linux `.deb` package | Used by `.github/workflows/build-linux.yml` |
| `Bundle_Redhat.sh` | Build the Linux RPM package | Used by `.github/workflows/build-linux.yml` |
| `Bundle_MacOS.sh` | Build the macOS app bundle | Used by `.github/workflows/build-mac.yml` |
| `Windows/` | Windows installer (Inno Setup) | [Windows/README.md](Windows/README.md) |
Usage for the testing scripts lives in the docs rather than here, so there is only one copy to keep current.
+164
View File
@@ -0,0 +1,164 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Builds a Libation Windows setup.exe from a published bin directory using Inno Setup.
.DESCRIPTION
Optionally runs dotnet publish (same projects as CI), removes standalone WindowsConfigApp
artifacts, then compiles Scripts/Windows/Libation.iss.
.PARAMETER Version
Libation version string (e.g. 13.4.1).
.PARAMETER Ui
Avalonia (Chardonnay) or WinForms (Classic). Classic is x64 only.
.PARAMETER Architecture
x64 or arm64 (Avalonia only).
.PARAMETER BinDir
Folder containing published files. Defaults to repo-root bin.
.PARAMETER SkipPublish
Skip dotnet publish; BinDir must already contain a complete publish output.
.EXAMPLE
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
.EXAMPLE
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $Version,
[ValidateSet('Avalonia', 'WinForms')]
[string] $Ui = 'Avalonia',
[ValidateSet('x64', 'arm64')]
[string] $Architecture = 'x64',
[string] $BinDir = '',
[switch] $SkipPublish
)
$ErrorActionPreference = 'Stop'
if ($Ui -eq 'WinForms' -and $Architecture -ne 'x64') {
throw 'Classic (WinForms) is only published for x64.'
}
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$sourceDir = Join-Path $repoRoot 'Source'
if (-not $BinDir) {
$BinDir = Join-Path $repoRoot 'bin'
}
if (-not (Test-Path $BinDir)) {
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
}
$BinDir = (Resolve-Path $BinDir).Path
$runtime = "win-$Architecture"
$releaseName = if ($Ui -eq 'WinForms') { 'classic' } else { 'chardonnay' }
$outputBase = if ($Ui -eq 'WinForms') {
"Libation-Classic.$Version-windows-$releaseName-$Architecture-setup"
} else {
"Libation.$Version-windows-$releaseName-$Architecture-setup"
}
$architecturesAllowed = if ($Architecture -eq 'arm64') { 'arm64' } else { 'x64compatible' }
function Find-ISCC {
$candidates = @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"${env:LocalAppData}\Programs\Inno Setup 6\ISCC.exe"
)
foreach ($path in $candidates) {
if (Test-Path $path) { return $path }
}
return $null
}
if (-not $SkipPublish -and (Test-Path $BinDir)) {
Write-Host "Clearing $BinDir before publish ..."
Get-ChildItem -LiteralPath $BinDir -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
if (-not $SkipPublish) {
Write-Host "Publishing Libation ($Ui, $runtime) to $BinDir ..."
Push-Location $sourceDir
try {
$publishArgs = @(
'--runtime', $runtime,
'--configuration', 'Release',
'--output', $BinDir,
'-p:PublishProtocol=FileSystem',
'-p:PublishReadyToRun=false',
'-p:SelfContained=true'
)
dotnet publish "Libation$Ui/Libation$Ui.csproj" @publishArgs
dotnet publish 'LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj' @publishArgs
dotnet publish 'LibationCli/LibationCli.csproj' @publishArgs
dotnet publish "Hangover$Ui/Hangover$Ui.csproj" @publishArgs
}
finally {
Pop-Location
}
}
Write-Host 'Removing standalone WindowsConfigApp launcher artifacts ...'
$removeFiles = @(
'WindowsConfigApp.exe',
'WindowsConfigApp.runtimeconfig.json',
'WindowsConfigApp.deps.json'
)
Push-Location $BinDir
try {
foreach ($file in $removeFiles) {
if (Test-Path $file) { Remove-Item $file -Force }
}
if (-not (Test-Path 'Libation.exe')) {
throw "Libation.exe not found in $BinDir. Publish or point -BinDir at a complete output folder."
}
if (-not (Test-Path 'ZipExtractor.exe')) {
throw "ZipExtractor.exe not found in $BinDir. It is required for in-app upgrades."
}
}
finally {
Pop-Location
}
$iscc = Find-ISCC
if (-not $iscc) {
throw @"
Inno Setup 6 (ISCC.exe) was not found. Install from https://jrsoftware.org/isdl.php
and ensure ISCC.exe is under Program Files (x86)\Inno Setup 6\ or similar.
"@
}
$setupIcon = Join-Path $PSScriptRoot 'libation.ico'
if (-not (Test-Path $setupIcon)) {
throw "Installer icon not found: $setupIcon"
}
$iss = Join-Path $PSScriptRoot 'Libation.iss'
$defines = @(
"/DMyVersion=$Version",
"/DMySetupIcon=$setupIcon",
"/DMyReleaseName=$releaseName",
"/DMyArchitecture=$Architecture",
"/DOutputBaseFilename=$outputBase",
"/DSourceDir=$BinDir",
"/DOutputDir=$BinDir",
"/DArchitecturesAllowed=$architecturesAllowed"
)
Write-Host "Compiling $outputBase.exe ..."
& $iscc $defines $iss
if ($LASTEXITCODE -ne 0) {
throw "ISCC failed with exit code $LASTEXITCODE."
}
$setupPath = Join-Path $BinDir "$outputBase.exe"
Write-Host "Done: $setupPath"
+93
View File
@@ -0,0 +1,93 @@
; Libation Windows installer (Inno Setup 6.x)
;
; Compile with ISCC and /D defines. See Scripts/Windows/README.md and
; Build-WindowsInstaller.ps1 for local builds; CI will pass the same defines.
;
; Required /D defines from build script:
; MyVersion, MyReleaseName, MyArchitecture, MySetupIcon,
; OutputBaseFilename, SourceDir, OutputDir, ArchitecturesAllowed
#ifndef MyVersion
#define MyVersion "0.0.0-dev"
#endif
#ifndef MyReleaseName
#define MyReleaseName "chardonnay"
#endif
#ifndef MyArchitecture
#define MyArchitecture "x64"
#endif
#ifndef MySetupIcon
#define MySetupIcon "libation.ico"
#endif
#ifndef OutputBaseFilename
#define OutputBaseFilename "Libation.0.0.0-dev-windows-chardonnay-x64-setup"
#endif
#ifndef SourceDir
#define SourceDir "."
#endif
#ifndef OutputDir
#define OutputDir "."
#endif
#ifndef ArchitecturesAllowed
#define ArchitecturesAllowed "x64compatible"
#endif
#if MyReleaseName == 'classic'
#define InstallFolderName "Libation-Classic"
#define AppName "Libation (Classic)"
#else
#define InstallFolderName "Libation"
#define AppName "Libation (Chardonnay)"
#endif
[Setup]
#if MyReleaseName == 'classic'
AppId={{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}}
#elif MyArchitecture == 'arm64'
AppId={{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}}
#else
AppId={{A7B8C9D0-E1F2-4A3B-8C9D-0E1F2A3B4C5D}}
#endif
AppName={#AppName}
AppVersion={#MyVersion}
AppVerName={#AppName} {#MyVersion}
AppPublisher=Libation
AppPublisherURL=https://getlibation.com/
AppSupportURL=https://github.com/rmcrackan/Libation/issues
AppUpdatesURL=https://github.com/rmcrackan/Libation/releases
DefaultDirName={localappdata}\{#InstallFolderName}
DefaultGroupName={#AppName}
PrivilegesRequired=lowest
OutputDir={#OutputDir}
OutputBaseFilename={#OutputBaseFilename}
SourceDir={#SourceDir}
ArchitecturesAllowed={#ArchitecturesAllowed}
ArchitecturesInstallIn64BitMode=x64compatible
DisableProgramGroupPage=no
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupIconFile={#MySetupIcon}
UninstallDisplayIcon={app}\Libation.exe
UninstallDisplayName={#AppName}
VersionInfoVersion={#MyVersion}
VersionInfoProductName={#AppName}
VersionInfoCompany=Libation
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\Libation"; Filename: "{app}\Libation.exe"; Comment: "Libation audiobook manager"
Name: "{group}\Hangover"; Filename: "{app}\Hangover.exe"; Comment: "Hangover license viewer"
Name: "{group}\Libation CLI"; Filename: "{app}\LibationCli.exe"; Comment: "Libation command-line tools"
Name: "{autodesktop}\Libation"; Filename: "{app}\Libation.exe"; Tasks: desktopicon; Comment: "Libation audiobook manager"
[Run]
Filename: "{app}\Libation.exe"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+89
View File
@@ -0,0 +1,89 @@
# Windows installer (Inno Setup)
Step 8.1 of the Windows installers plan: a parameterized Inno Setup script that installs the same flat publish layout as the release zip, under a per-user directory, so the in-app upgrader can still overlay files via `ZipExtractor.exe`.
## Prerequisites
1. [.NET SDK](https://dotnet.microsoft.com/download) (same version as the repo; see `build.yml`).
2. [Inno Setup 6](https://jrsoftware.org/isdl.php) with `ISCC.exe` on PATH or in a default install location.
## Quick build (publish + installer)
From PowerShell, repo root or this folder:
```powershell
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
```
Outputs:
- Publish tree: `bin\` (repo root)
- Installer: `bin\Libation.13.4.1-windows-chardonnay-x64-setup.exe`
### Variants
| UI | `-Ui` | `-Architecture` | Install folder | Output prefix |
|----|-------|-----------------|----------------|---------------|
| Chardonnay x64 | `Avalonia` | `x64` | `%LocalAppData%\Libation` | `Libation.*` |
| Chardonnay arm64 | `Avalonia` | `arm64` | `%LocalAppData%\Libation` | `Libation.*` |
| Classic x64 | `WinForms` | `x64` (default) | `%LocalAppData%\Libation-Classic` | `Libation-Classic.*-setup.exe` |
Examples:
```powershell
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture arm64
```
### Reuse an existing publish folder
```powershell
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -BinDir C:\path\to\bin -SkipPublish
```
`BinDir` must already match CI layout (including `ZipExtractor.exe`, without standalone `WindowsConfigApp.exe`).
## Manual ISCC (without the helper script)
After publish and cleanup (remove `WindowsConfigApp.exe`, `WindowsConfigApp.runtimeconfig.json`, `WindowsConfigApp.deps.json` from `bin`), from `Scripts\Windows`:
```text
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" Libation.iss ^
/DMyVersion=13.4.1 ^
/DMyPrefix= ^
/DMyReleaseName=chardonnay ^
/DMyArchitecture=x64 ^
/DInstallFolderName=Libation ^
/DAppName=Libation ^
/DOutputBaseFilename=Libation.13.4.1-windows-chardonnay-x64-setup ^
/DSourceDir=C:\Dev\_rmcrackan\Libation\bin ^
/DOutputDir=C:\Dev\_rmcrackan\Libation\bin ^
/DArchitecturesAllowed=x64compatible
```
Classic x64: use `MyReleaseName=classic` and matching `OutputBaseFilename` (`Libation-Classic.{version}-windows-classic-x64-setup`). Install folder and display name are set in `Libation.iss`. GitHub zip assets use `Libation-Classic.{version}-windows-classic-x64.zip`; `.releaseindex.json` also accepts legacy `Classic-Libation.*` zips on older releases.
arm64: set `MyArchitecture=arm64` and `ArchitecturesAllowed=arm64`.
## Installer behavior
- **Per-user install** (`PrivilegesRequired=lowest`, default dir under `{localappdata}`).
- **Shortcuts** target `{app}\Libation.exe` (required for upgrade path detection).
- **Does not** create `appsettings.json` or Libation Files; same as zip install.
- **Includes** full publish output so `ZipExtractor.exe` remains for in-app updates.
- **Settings -> Apps:** shows `Libation (Chardonnay)` or `Libation (Classic)` with version in `DisplayVersion` (Inno at install; synced after in-app zip upgrades via `WindowsUninstallRegistrySync`); icon from `SetupIconFile` / `UninstallDisplayIcon` (`Scripts/Windows/libation.ico` and `{app}\Libation.exe`).
## Next plan step (8.2)
Install via `*-setup.exe`, run the app, then verify an older release can upgrade in-app using the existing zip-based upgrader.
CI wiring (`build-windows.yml`) is step 8.3.
## Files in this folder
| File | Purpose |
|------|---------|
| `Libation.iss` | Parameterized Inno Setup script |
| `libation.ico` | Setup/uninstaller icon (copied from Avalonia assets) |
| `Build-WindowsInstaller.ps1` | Local publish + ISCC helper |
| `README.md` | This document |
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+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;
}
+483
View File
@@ -0,0 +1,483 @@
#:package Microsoft.Data.Sqlite@10.0.7
using Microsoft.Data.Sqlite;
using System.Text.Json;
// Seeds a Libation library with one book per Liberate-column state, plus a few titles that
// subtitle removal changes, for manual UI testing.
//
// dotnet run Scripts/seed-demo-library.cs [--clean] [path-to-Libation-files-folder]
//
// Run Libation at least once first so the database exists, and close it before seeding.
// Pass --clean to delete previously seeded demo books instead of adding them.
//
// 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;
const string AsinPrefix = "DEMO";
var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase);
var pathArg = args.FirstOrDefault(a => !a.StartsWith("--"));
if (FindLibationFiles(pathArg) is not string libationFiles)
{
Console.Error.WriteLine("Could not find a Libation database.");
Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:");
Console.Error.WriteLine(@" dotnet run Scripts/seed-demo-library.cs C:\Users\me\AppData\Local\Libation");
return 1;
}
var dbPath = Path.Combine(libationFiles, "LibationContext.db");
Console.WriteLine($"Database: {dbPath}");
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString());
connection.Open();
using var transaction = connection.BeginTransaction();
if (clean)
{
// Books cascade to UserDefinedItem, LibraryBooks, Supplement, BookContributor and SeriesBook,
// but the Series and Contributors rows those pointed at are left behind.
var removed = Execute($"delete from Books where AudibleProductId like '{AsinPrefix}%'");
Execute($"delete from Series where AudibleSeriesId like '{AsinPrefix}%' and SeriesId not in (select SeriesId from SeriesBook)");
Execute("delete from Contributors where AudibleContributorId = 'DEMOAUTH' and ContributorId not in (select ContributorId from BookContributor)");
transaction.Commit();
foreach (var partial in Directory.Exists(DownloadsInProgress())
? Directory.EnumerateFiles(DownloadsInProgress(), $"{AsinPrefix}*.aaxc")
: [])
File.Delete(partial);
Console.WriteLine($"Removed {removed} demo book(s) and their placeholder downloads.");
InvalidateSearchIndex();
return 0;
}
var books = BuildDemoBooks();
var contributorId = GetOrCreateId(
"select ContributorId from Contributors where AudibleContributorId = $id",
"insert into Contributors (Name, AudibleContributorId) values ('Demo Author', $id)",
"DEMOAUTH");
var added = 0;
var partials = new List<string>();
foreach (var book in books)
{
if (Scalar("select count(*) from Books where AudibleProductId = $asin", ("$asin", book.Asin)) > 0)
{
Console.WriteLine($" {book.Asin} already present, skipping");
continue;
}
Execute(
"""
insert into Books
(AudibleProductId, ContentType, Description, IsAbridged, IsSpatial, LengthInMinutes,
Locale, Rating_OverallRating, Rating_PerformanceRating, Rating_StoryRating, Subtitle, Title)
values ($asin, $contentType, 'Seeded by seed-demo-library.cs', 0, 0, 600, 'us', 0, 0, 0, $subtitle, $title)
""",
("$asin", book.Asin), ("$contentType", book.ContentType), ("$title", book.Title), ("$subtitle", book.Subtitle));
var bookId = Scalar("select BookId from Books where AudibleProductId = $asin", ("$asin", book.Asin));
// PartialDownload is never persisted - the BookStatus setter rewrites it to NotLiberated,
// because it is derived from a file on disk instead.
Execute(
"""
insert into UserDefinedItem
(BookId, BookStatus, IsFinished, PdfStatus, Rating_OverallRating,
Rating_PerformanceRating, Rating_StoryRating, Tags)
values ($bookId, $bookStatus, 0, $pdfStatus, 0, 0, 0, '')
""",
("$bookId", bookId), ("$bookStatus", book.BookStatus), ("$pdfStatus", (object?)book.PdfStatus ?? DBNull.Value));
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, $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),
("$isDeleted", book.IsDeleted ? 1 : 0));
Execute(
"insert into BookContributor (BookId, ContributorId, Role, [Order]) values ($bookId, $contributorId, 1, 0)",
("$bookId", bookId), ("$contributorId", contributorId));
// The PDF glyph itself keys off UserDefinedItem.PdfStatus, but Book.HasPdf - which drives the
// context menu and the "Has PDF" detail - needs an actual supplement.
if (book.PdfStatus is not null)
Execute(
"insert into Supplement (BookId, Url) values ($bookId, 'https://example.com/demo.pdf')",
("$bookId", bookId));
if (book.SeriesAsin is not null)
{
var seriesId = GetOrCreateId(
"select SeriesId from Series where AudibleSeriesId = $id",
"insert into Series (AudibleSeriesId, Name) values ($id, 'Demo Series')",
book.SeriesAsin);
Execute(
"insert into SeriesBook (SeriesId, BookId, [Order]) values ($seriesId, $bookId, $order)",
("$seriesId", seriesId), ("$bookId", bookId), ("$order", (object?)book.SeriesOrder ?? DBNull.Value));
}
if (book.NeedsPartialDownload)
partials.Add(book.Asin);
added++;
}
transaction.Commit();
Console.WriteLine($"Added {added} book(s).");
InvalidateSearchIndex();
if (partials.Count > 0)
{
var inProgress = DownloadsInProgress();
Directory.CreateDirectory(inProgress);
foreach (var asin in partials)
File.WriteAllText(Path.Combine(inProgress, $"{asin}.aaxc"), "not a real download");
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.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;
static List<DemoBook> BuildDemoBooks()
{
var books = new List<DemoBook>();
var n = 0;
// The whole lamp/PDF/Plus matrix, so every stoplight rendering is on screen at once.
foreach (var (lamp, bookStatus, needsPartial) in new[]
{
("Green", Liberated, false),
("Yellow", NotLiberated, true),
("Red", NotLiberated, false),
})
foreach (var (pdfLabel, pdfStatus) in new (string, int?)[]
{
("no PDF", null),
("PDF done", Liberated),
("PDF todo", NotLiberated),
})
foreach (var isPlus in new[] { false, true })
{
var owner = isPlus ? "PLUS" : "purchased";
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: $"{n:00} {lamp} | {pdfLabel} | {owner}",
ContentType: Product,
BookStatus: bookStatus,
PdfStatus: pdfStatus,
IsPlus: isPlus,
NeedsPartialDownload: needsPartial,
Expectation: $"{lamp} lamp, {pdfLabel}, {(isPlus ? "orange PLUS badge" : "NO badge")}"));
}
// The error icon replaces the stoplight entirely, and ignores both PDF and Plus.
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: $"{n:00} Error | purchased",
ContentType: Product,
BookStatus: Error,
PdfStatus: null,
IsPlus: false,
NeedsPartialDownload: false,
Expectation: "red error sign, no stoplight"));
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: $"{n:00} Error | PLUS",
ContentType: Product,
BookStatus: Error,
PdfStatus: null,
IsPlus: true,
NeedsPartialDownload: false,
Expectation: "red error sign, still NO badge"));
// A podcast: the parent row shows a plus/minus expander rather than a stoplight, and its
// episodes show ordinary stoplights. Libation identifies a podcast's series by the parent
// book's own ASIN, and hides any series whose children it cannot match, so the episodes have
// to point at that same ASIN or the parent row silently disappears.
var seriesAsin = $"{AsinPrefix}{++n:000}";
books.Add(new DemoBook(
Asin: seriesAsin,
Title: $"{n:00} Demo Series (parent)",
ContentType: Parent,
BookStatus: NotLiberated,
PdfStatus: null,
IsPlus: false,
NeedsPartialDownload: false,
Expectation: "square plus/minus expander, never a badge",
SeriesAsin: seriesAsin));
foreach (var (episode, bookStatus, isPlus) in new[]
{
(1, Liberated, false),
(2, NotLiberated, true),
})
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: $"{n:00} Demo Series - episode {episode}",
ContentType: Episode,
BookStatus: bookStatus,
PdfStatus: null,
IsPlus: isPlus,
NeedsPartialDownload: false,
Expectation: $"{(bookStatus == Liberated ? "green" : "red")} lamp, {(isPlus ? "orange PLUS badge" : "NO badge")}",
SeriesAsin: seriesAsin,
SeriesOrder: episode.ToString()));
// Books missing from the last scan. These keep their ordinary icon - there is no "unavailable"
// glyph - and the grid dims and disables the row around it instead, so they are the rows to
// check when an icon change might read badly under that treatment.
//
// Being absent is not enough on its own: a book which is fully liberated with nothing left to
// fetch stays available, because there is nothing the row would do if you clicked it.
foreach (var (label, bookStatus, pdfStatus, isPlus, expectation) in new (string, int, int?, bool, string)[]
{
("not downloaded | purchased", NotLiberated, null, false, "red lamp, dimmed and unclickable"),
("not downloaded | PLUS", NotLiberated, null, true, "red lamp with badge, dimmed and unclickable"),
("downloaded | purchased", Liberated, null, false, "green lamp, NOT dimmed - nothing left to fetch"),
("downloaded, PDF todo | purchased", Liberated, NotLiberated, false, "green lamp with PDF arrow, dimmed again - the PDF is outstanding"),
})
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: $"{n:00} Absent | {label}",
ContentType: Product,
BookStatus: bookStatus,
PdfStatus: pdfStatus,
IsPlus: isPlus,
NeedsPartialDownload: false,
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.
foreach (var (title, subtitle, match) in new[]
{
("A Book Series Omnibus", "Volume One", "HasSubtitle"),
("A Book Series Omnibus", "Volume Two", "HasSubtitle"),
("Star Trek: The Next Generation", "", "TitleHasColon"),
("Dune: Book One", "The Graphic Novel", "both HasSubtitle and TitleHasColon"),
})
books.Add(new DemoBook(
Asin: $"{AsinPrefix}{++n:000}",
Title: title,
ContentType: Product,
BookStatus: NotLiberated,
PdfStatus: null,
IsPlus: false,
NeedsPartialDownload: false,
Expectation: $"red lamp; filter match: {match}",
Subtitle: subtitle));
return books;
}
/// <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 (JsonDocument.Parse(File.ReadAllText(appSettings)).RootElement
.TryGetProperty("LibationFiles", out var redirect) && redirect.GetString() is string path)
candidates.Add(path);
}
catch (JsonException) { }
}
return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "LibationContext.db")));
}
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;
}
/// <summary>Where Libation looks for resumable downloads, honouring the InProgress setting.</summary>
string DownloadsInProgress()
{
var inProgress = Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}");
var settings = Path.Combine(libationFiles, "Settings.json");
if (File.Exists(settings))
{
try
{
if (JsonDocument.Parse(File.ReadAllText(settings)).RootElement
.TryGetProperty("InProgress", out var configured)
&& configured.GetString() is string path && !string.IsNullOrWhiteSpace(path))
inProgress = path;
}
catch (JsonException) { }
}
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
/// would happily insert a duplicate every time this script runs.
/// </remarks>
int GetOrCreateId(string selectSql, string insertSql, string audibleId)
{
using (var select = Command(selectSql, [("$id", audibleId)]))
if (select.ExecuteScalar() is object existing and not DBNull)
return Convert.ToInt32(existing);
Execute(insertSql, ("$id", audibleId));
return Scalar("select last_insert_rowid()");
}
int Scalar(string sql, params (string Name, object Value)[] parameters)
{
using var command = Command(sql, parameters);
return Convert.ToInt32(command.ExecuteScalar());
}
int Execute(string sql, params (string Name, object Value)[] parameters)
{
using var command = Command(sql, parameters);
return command.ExecuteNonQuery();
}
SqliteCommand Command(string sql, (string Name, object Value)[] parameters)
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = sql;
foreach (var (name, value) in parameters)
command.Parameters.AddWithValue(name, value);
return command;
}
record DemoBook(
string Asin,
string Title,
int ContentType,
int BookStatus,
int? PdfStatus,
bool IsPlus,
bool NeedsPartialDownload,
string Expectation,
string? SeriesAsin = null,
string? SeriesOrder = null,
bool IsAbsent = false,
string Subtitle = "",
bool IsDeleted = false);
+116
View File
@@ -0,0 +1,116 @@
#:package Microsoft.Data.Sqlite@10.0.7
using Microsoft.Data.Sqlite;
// Seeds Libation's DownloadHistory table with fake completed downloads, for manual testing of the
// daily download limit without downloading anything.
//
// dotnet run Scripts/seed-download-history.cs [--count 50] [--age-seconds 7200] [--owned]
// [--mb 300] [--clean] [path-to-Libation-files-folder]
//
// The limit uses a rolling 24 hour window, so --age-seconds controls how soon the rows fall out of it:
//
// --age-seconds 7200 two hours ago: the limit stays reached for the next 22 hours
// --age-seconds 86325 just under 24 hours ago: a paused queue resumes itself about a minute from now
//
// Pass --clean to delete all rows. Libation can be running; each row is just a row.
const string FakeAsinPrefix = "FAKE";
var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase);
var owned = args.Contains("--owned", StringComparer.OrdinalIgnoreCase);
var count = IntArg("--count") ?? 50;
var ageSeconds = IntArg("--age-seconds") ?? 7200;
var megabytes = IntArg("--mb") ?? 300;
var pathArg = args.FirstOrDefault(a => !a.StartsWith("--") && !int.TryParse(a, out _));
if (FindLibationFiles(pathArg) is not string libationFiles)
{
Console.Error.WriteLine("Could not find a Libation database.");
Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:");
Console.Error.WriteLine(@" dotnet run Scripts/seed-download-history.cs C:\Users\me\AppData\Local\Libation");
return 1;
}
var dbPath = Path.Combine(libationFiles, "LibationContext.db");
Console.WriteLine($"Database: {dbPath}");
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString());
connection.Open();
if (!TableExists())
{
Console.Error.WriteLine("This database has no DownloadHistory table yet. Start Libation once so it applies migrations, then retry.");
return 1;
}
if (clean)
{
Console.WriteLine($"Removed {Execute("delete from DownloadHistory")} download history row(s).");
return 0;
}
// Stored as UTC ticks so the rolling window is exact on every provider and across DST changes.
var completedAt = DateTimeOffset.Now.AddSeconds(-ageSeconds);
var bytes = megabytes * 1024L * 1024;
using var transaction = connection.BeginTransaction();
for (var i = 0; i < count; i++)
{
Execute(
"""
insert into DownloadHistory (CompletedAtUtcTicks, AudibleProductId, IsAudiblePlus, Bytes)
values ($ticks, $asin, $isPlus, $bytes)
""",
("$ticks", completedAt.AddSeconds(i).UtcTicks),
("$asin", $"{FakeAsinPrefix}{i:0000}"),
("$isPlus", owned ? 0 : 1),
("$bytes", bytes));
}
transaction.Commit();
Console.WriteLine(
$"Inserted {count} {(owned ? "purchased" : "Audible Plus")} download(s) of {megabytes} MB each, " +
$"completed {TimeSpan.FromSeconds(ageSeconds):g} ago.");
Console.WriteLine($"Total rows: {Scalar("select count(*) from DownloadHistory")}");
Console.WriteLine($"The oldest leaves the 24 hour window at {completedAt.AddHours(24):yyyy-MM-dd HH:mm:ss}");
return 0;
int? IntArg(string name)
{
var index = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value) ? value : null;
}
bool TableExists()
=> Scalar("select count(*) from sqlite_master where type = 'table' and name = 'DownloadHistory'") > 0;
int Execute(string sql, params (string Name, object Value)[] parameters)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
command.Parameters.AddWithValue(name, value);
return command.ExecuteNonQuery();
}
long Scalar(string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
return Convert.ToInt64(command.ExecuteScalar());
}
static string? FindLibationFiles(string? explicitPath)
{
if (explicitPath is not null)
return File.Exists(Path.Combine(explicitPath, "LibationContext.db")) ? explicitPath : null;
string[] candidates =
[
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"),
];
return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "LibationContext.db")));
}
+5 -1
View File
@@ -14,11 +14,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="3.0.2" />
<PackageReference Include="AAXClean.Codecs" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<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.3.3.1</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>
+175 -233
View File
@@ -41,6 +41,7 @@ public static class LibationScaffolding
{
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
public const string DonateUrl = WebsiteUrl + "/donate";
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
/// <summary>Documentation for naming template syntax</summary>
public const string NamingTemplatesDocUrl = WebsiteUrl + "/docs/features/naming-templates";
@@ -69,6 +70,8 @@ public static class LibationScaffolding
// // outdated. kept here as an example of what belongs in this area
// // Migrations.migrate_to_v5_2_0__pre_config();
InstallFolderUnblock.TryUnblockProcessDirectoryIfWindows();
Configuration.SetLibationVersion(BuildVersion);
//***********************************************//
@@ -91,16 +94,15 @@ public static class LibationScaffolding
{
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
}
config.ValidateEnumSettings();
DeleteOpenSqliteFiles(config);
AudibleApiStorage.EnsureAccountsSettingsFileExists();
IdentityTokenStorageWiring.Apply(config);
//
// migrations go below here
//
Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_to_v11_5_0(config);
Migrations.migrate_to_v11_6_5(config);
Migrations.migrate_to_v12_0_1(config);
}
@@ -111,29 +113,74 @@ public static class LibationScaffolding
/// </summary>
private static void DeleteOpenSqliteFiles(Configuration config)
{
var walFile = SqliteStorage.DatabasePath + "-wal";
var shmFile = SqliteStorage.DatabasePath + "-shm";
var dbFile = SqliteStorage.DatabasePath;
var walFile = dbFile + "-wal";
var shmFile = dbFile + "-shm";
// If another Libation process currently has the database open, its WAL/SHM are live state.
// Deleting them would corrupt that process's database (and ours), so leave everything alone.
// See issue #1931.
if (IsFileLockedByAnotherProcess(dbFile))
{
Log.Logger.Information("Skipping SQLite WAL/SHM cleanup: the database appears to be open in another process.");
return;
}
// A non-empty WAL from a previous run that ended abruptly can hold committed-but-uncheckpointed
// transactions. Deleting it would silently discard that data; instead, leave it in place and let
// SQLite recover it when the database is next opened. Only remove an already-checkpointed WAL.
if (File.Exists(walFile))
{
try
{
FileManager.FileUtility.SaferDelete(walFile);
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {WalFile}", walFile);
}
if (WalHoldsUnrecoveredTransactions(walFile))
Log.Logger.Information("Leaving SQLite WAL in place so SQLite can recover it on open: {WalFile}", walFile);
else
FileManager.FileUtility.TrySaferDelete(walFile);
}
// The SHM (shared-memory index) is rebuilt from the WAL/database, so it is safe to remove once
// no other process holds the database.
if (File.Exists(shmFile))
FileManager.FileUtility.TrySaferDelete(shmFile);
}
/// <summary>True if <paramref name="path"/> exists and cannot be opened exclusively, i.e. another process holds it.</summary>
private static bool IsFileLockedByAnotherProcess(string path)
{
if (!File.Exists(path))
return false;
try
{
try
{
FileManager.FileUtility.SaferDelete(shmFile);
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {ShmFile}", shmFile);
}
using var _ = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
return false;
}
catch (IOException)
{
return true;
}
catch (UnauthorizedAccessException)
{
// Can't prove another process holds it, but we also can't safely touch it. Be conservative.
return true;
}
}
/// <summary>
/// True if the WAL may contain committed transactions SQLite still needs to recover. A WAL longer
/// than its 32-byte header may hold frames; treat any such file as unrecovered so it is preserved.
/// </summary>
private static bool WalHoldsUnrecoveredTransactions(string walFile)
{
const long walHeaderSize = 32;
try
{
return new FileInfo(walFile).Length > walHeaderSize;
}
catch (Exception ex)
{
// If we can't measure it, assume it matters and keep it rather than risk data loss.
Log.Logger.Warning(ex, "Could not inspect SQLite WAL file; leaving it in place: {WalFile}", walFile);
return true;
}
}
static bool migrationsRun = false;
@@ -159,65 +206,28 @@ public static class LibationScaffolding
}
private static void ensureSerilogConfig(Configuration config)
{
if (config.GetObject("Serilog") is JObject serilog)
=> 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 =>
{
bool fileChanged = false;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
var level = entry.Level switch
{
zipFileSink["Name"] = "File";
fileChanged = true;
}
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
{
fileSinkArgs["hooks"] = hooks;
fileChanged = true;
}
StartupLogLevel.Debug => Serilog.Events.LogEventLevel.Debug,
StartupLogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
StartupLogLevel.Error => Serilog.Events.LogEventLevel.Error,
_ => Serilog.Events.LogEventLevel.Information,
};
if (fileChanged)
config.SetNonString(serilog.DeepClone(), "Serilog");
return;
}
var serilogObj = new JObject
{
{ "MinimumLevel", "Information" },
{ "WriteTo", new JArray
{
// ABOUT SINKS
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
new JObject
{
{ "Name", "File" },
{ "Args",
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
// {Properties:j} needed for expanded exception logging
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
}
}
}
}
},
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
};
config.SetNonString(serilogObj, "Serilog");
}
// 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;
@@ -225,6 +235,13 @@ public static class LibationScaffolding
private static void configureLogging(Configuration config)
{
config.ConfigureLogging();
replayStartupLog();
Log.Information(
"Paths: LibationFiles={LibationFiles} AppsettingsJson={AppsettingsJson} SQLiteDb={SqliteDb}",
config.LibationFiles.Location,
config.LibationFiles.AppsettingsJsonFile ?? "(null, LIBATION_FILES_DIR may be set)",
Path.Combine(config.LibationFiles.Location, "LibationContext.db"));
DbContexts.TryEmitPendingInitialDatabaseStatistics();
// capture most Console.WriteLine() and write to serilog. See below tests for details.
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
@@ -282,70 +299,89 @@ public static class LibationScaffolding
}
private static void logStartupState(Configuration config)
{
{
#if DEBUG
var mode = "Debug";
var mode = "Debug";
#else
var mode = "Release";
#endif
if (Debugger.IsAttached)
mode += " (Debugger attached)";
if (Debugger.IsAttached)
mode += " (Debugger attached)";
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
static int fileCount(FileManager.LongPath? longPath)
{
if (longPath is null)
return -1;
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
catch { return -1; }
}
// -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;
Log.Logger.Information("Begin. {@DebugInfo}", new
{
AppName = EntryAssembly?.GetName().Name,
Version = BuildVersion?.ToString(),
ReleaseIdentifier,
Configuration.OS,
Environment.OSVersion,
InteropFactory.InteropFunctionsType,
Mode = mode,
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
var complete = true;
try
{
var count = FileManager.FileUtility.SaferEnumerateFiles(longPath, onIncomplete: _ => complete = false).Count();
return complete ? count : -1;
}
catch { return -1; }
}
config.AutoScan,
config.BetaOptIn,
config.UseCoverAsFolderIcon,
config.LibationFiles,
AudibleFileStorage.BooksDirectory,
Log.Logger.Information("Begin. {@DebugInfo}", new
{
AppName = EntryAssembly?.GetName().Name,
Version = BuildVersion?.ToString(),
ReleaseIdentifier,
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(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
config.InProgress,
config.AutoScan,
// These silently exclude titles from every scan, so a log without them can't explain a missing book
config.ImportEpisodes,
config.ImportPlusTitles,
config.DownloadEpisodes,
// Off means no startup upgrade prompt, which is otherwise indistinguishable from a broken check
config.CheckForUpgradesAtStartup,
config.UseCoverAsFolderIcon,
config.LibationFiles,
AudibleFileStorage.BooksDirectory,
AudibleFileStorage.DownloadsInProgressDirectory,
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
config.InProgress,
AudibleFileStorage.DecryptInProgressDirectory,
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
AudibleFileStorage.DownloadsInProgressDirectory,
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
});
AudibleFileStorage.DecryptInProgressDirectory,
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
}
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
});
private static void wireUpSystemEvents(Configuration configuration)
if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
}
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (object? _, List<DataLayer.LibraryBook> libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
=> SearchEngineCommands.OnLibrarySizeChanged(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
=> SearchEngineCommands.UpdateBooks(books);
=> SearchEngineCommands.OnBookUserDefinedItemCommitted(books);
}
public static VersionCheckResult GetLatestRelease()
@@ -415,85 +451,44 @@ public static class LibationScaffolding
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
string? regexPattern;
string? releaseIdString = null;
try
{
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
releaseIdString = InteropFactory.Create().ReleaseIdString;
regexPattern = releaseIndex.Value<string>(releaseIdString);
}
catch
{
regexPattern = null;
}
if (string.IsNullOrEmpty(regexPattern) && Configuration.IsLinux)
{
var baseId = ReleaseIdentifier.ToString();
regexPattern = releaseIndex.Value<string>($"{baseId}_RPM")
?? releaseIndex.Value<string>($"{baseId}_DEB");
releaseIdString ??= $"{baseId}_RPM";
}
if (string.IsNullOrEmpty(regexPattern))
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
if (string.IsNullOrEmpty(regexPattern))
{
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}). Version check inconclusive.", ReleaseIdentifier);
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}, ReleaseIdString: {ReleaseIdString}). Version check inconclusive.", ReleaseIdentifier, releaseIdString);
return (null, null, null, false);
}
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var zip = latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name));
if (zip is not null && !string.IsNullOrEmpty(releaseIdString))
Log.Logger.Information("Update asset matched using {ReleaseIdString}: {AssetName}", releaseIdString, zip.Name);
return (releaseVersion, latestRelease, zip, true);
}
}
internal static class Migrations
{
public static void migrate_to_v6_6_9(Configuration config)
{
var writeToPath = $"Serilog.WriteTo";
// remove WriteTo[].Name == Console
{
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
{
for (var i = length1 - 1; i >= 0; i--)
{
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
if (exists && value == "Console")
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
}
}
}
// add Serilog.Exceptions -- WithExceptionDetails
{
// outputTemplate should contain "{Properties:j}"
{
// re-calculate. previous loop may have changed the length
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
{
var propertyName = "outputTemplate";
for (var i = 0; i < length2; i++)
{
var jsonPath = $"{writeToPath}[{i}].Args";
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
if (exists && value != newValue)
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
}
}
}
// Serilog.Using must include "Serilog.Exceptions"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
// Serilog.Enrich must include "WithExceptionDetails"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
}
}
class FilterState_6_6_9
{
public bool UseDefault { get; set; }
public List<string> Filters { get; set; } = new();
}
public static void migrate_to_v12_0_1(Configuration config)
{
//Migrate from version 1 file cache to the dictionary-based version 2 cache
@@ -512,7 +507,7 @@ internal static class Migrations
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
return;
Dictionary<string, JArray> cache = new();
Dictionary<string, JArray> cache = [];
//Convert to c# objects to speed up searching by ID inside the iterator
var allItems
@@ -566,57 +561,4 @@ internal static class Migrations
catch { /* eat */ }
}
}
public static void migrate_to_v11_6_5(Configuration config)
{
//Settings migration for unsupported sample rates (#1116)
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
}
public static void migrate_to_v11_5_0(Configuration config)
{
// Read file, but convert old format to new (with Name field) as necessary.
if (!File.Exists(QuickFilters.JsonFile))
{
QuickFilters.InMemoryState = new();
return;
}
try
{
if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile))
is QuickFilters.FilterState inMemState)
{
QuickFilters.InMemoryState = inMemState;
return;
}
}
catch
{
// Eat
}
try
{
if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile))
is FilterState_6_6_9 inMemState)
{
// Copy old structure to new.
QuickFilters.InMemoryState = new();
QuickFilters.InMemoryState.UseDefault = inMemState.UseDefault;
foreach (var oldFilter in inMemState.Filters)
QuickFilters.InMemoryState.Filters.Add(new QuickFilters.NamedFilter(oldFilter, null));
return;
}
Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue");
}
catch
{
// Eat
}
}
}
+3 -3
View File
@@ -29,7 +29,7 @@ public partial record UpgradeProperties
ZipUrl = zipUrl;
LatestRelease = latestRelease;
var text = NoAppBlockRegex().Replace(notes, "");
var text = NoAppBlockRegex().Replace(notes, "");
text = LinkStripRegex().Replace(text, "$1");
Notes = text.Trim();
}
@@ -37,6 +37,6 @@ public partial record UpgradeProperties
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
private static partial Regex LinkStripRegex();
[GeneratedRegex(@"<!-- BEGIN NO-APP -->.*?<!-- END NO-APP -->", RegexOptions.Singleline)]
private static partial Regex NoAppBlockRegex();
[GeneratedRegex(@"<!-- BEGIN NO-APP -->.*?<!-- END NO-APP -->", RegexOptions.Singleline)]
private static partial Regex NoAppBlockRegex();
}
@@ -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>
@@ -0,0 +1,566 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
namespace ApplicationServices;
public static class AudiobookshelfApiService
{
public record Library(string Id, string Name, string MediaType, List<Folder> Folders);
public record Folder(string Id, string FullPath);
public enum UploadResult { Success, AlreadyExists, Failed }
/// <summary>
/// Audiobookshelf client (browser) route segments. If present in a pasted URL,
/// everything from that segment onward is UI path, not part of the API base.
/// </summary>
private static readonly HashSet<string> ClientRouteSegments = new(StringComparer.OrdinalIgnoreCase)
{
"library",
"item",
"collection",
"playlist",
"author",
"series",
"audiobook",
"config",
"login",
"account",
};
/// <summary>
/// Normalize a user-supplied Audiobookshelf server URL to an API base address.
/// Compensates for common mistakes such as pasting a browser library/item page URL,
/// including a trailing <c>/api</c> path, query strings, or omitting the scheme.
/// </summary>
/// <returns>Absolute http(s) base URL with no trailing slash.</returns>
public static string NormalizeServerUrl(string? serverUrl)
{
if (string.IsNullOrWhiteSpace(serverUrl))
throw new ArgumentException("Server URL is required.", nameof(serverUrl));
var trimmed = serverUrl.Trim();
// Allow host:port without a scheme (common when copying from docs or local setups).
if (!trimmed.Contains("://", StringComparison.Ordinal))
trimmed = "http://" + trimmed;
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|| string.IsNullOrWhiteSpace(uri.Host))
{
throw new ArgumentException(
"Server URL must be an absolute http(s) address, for example http://localhost:13378 or https://abs.example.com",
nameof(serverUrl));
}
var path = StripClientRoutesAndApi(uri.AbsolutePath);
var authority = uri.GetLeftPart(UriPartial.Authority);
if (string.IsNullOrEmpty(path) || path == "/")
return authority;
return authority + path.TrimEnd('/');
}
private static string StripClientRoutesAndApi(string absolutePath)
{
if (string.IsNullOrEmpty(absolutePath) || absolutePath == "/")
return "";
var parts = absolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
var prefixLength = 0;
for (var i = 0; i < parts.Length; i++)
{
var part = parts[i];
// Trailing or mid-path /api/... means the user included the API root.
if (string.Equals(part, "api", StringComparison.OrdinalIgnoreCase))
break;
if (ClientRouteSegments.Contains(part))
break;
prefixLength += 1 + part.Length;
}
if (prefixLength <= 0)
return "";
return absolutePath[..prefixLength];
}
private static HttpClient CreateClient(string serverUrl)
{
var normalized = NormalizeServerUrl(serverUrl);
var client = new HttpClient();
client.BaseAddress = new Uri(normalized + "/");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
public static async Task<List<Library>> GetLibrariesAsync(string serverUrl, string apiToken)
{
var normalizedUrl = NormalizeServerUrl(serverUrl);
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
using var client = CreateClient(normalizedUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/libraries");
}
catch (TaskCanceledException ex)
{
throw new HttpRequestException(
$"Timed out connecting to Audiobookshelf at {normalizedUrl}. Check the Server URL and that the server is running.",
ex);
}
catch (HttpRequestException ex)
{
throw new HttpRequestException(
$"Could not reach Audiobookshelf at {normalizedUrl}. Check the Server URL, network, and that the server is running. Details: {ex.Message}",
ex);
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw CreateLibrariesFetchException(normalizedUrl, response.StatusCode, errorBody);
}
var json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
var libraries = obj["libraries"] as JArray ?? new JArray();
var allLibraries = libraries.Select(l => new Library(
l["id"]?.Value<string>() ?? "",
l["name"]?.Value<string>() ?? "",
l["mediaType"]?.Value<string>() ?? "book",
(l["folders"] as JArray ?? new JArray())
.Select(f => new Folder(
f["id"]?.Value<string>() ?? "",
f["fullPath"]?.Value<string>() ?? ""))
.ToList()))
.ToList();
// Only return libraries with book media type
return allLibraries.Where(l => string.Equals(l.MediaType, "book", StringComparison.OrdinalIgnoreCase)).ToList();
}
private static HttpRequestException CreateLibrariesFetchException(string normalizedUrl, HttpStatusCode statusCode, string errorBody)
{
var code = (int)statusCode;
var bodyPreview = SummarizeErrorBody(errorBody);
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
return new HttpRequestException(
$"Audiobookshelf rejected the API token ({code} {statusCode}). " +
$"Check that the token is correct and not expired. Server: {normalizedUrl}");
}
if (statusCode == HttpStatusCode.NotFound)
{
return new HttpRequestException(
$"Audiobookshelf returned 404 when calling {normalizedUrl}/api/libraries. " +
"Server URL should be the Audiobookshelf base address only " +
"(for example http://localhost:13378 or https://abs.example.com), " +
"not a library or book page from the browser. " +
"If Audiobookshelf is served under a subpath, include that subpath " +
$"(for example http://host/audiobookshelf). Response: {bodyPreview}");
}
return new HttpRequestException(
$"Audiobookshelf API returned {code} ({statusCode}) when fetching libraries from {normalizedUrl}. Response: {bodyPreview}");
}
private static string SummarizeErrorBody(string errorBody)
{
if (string.IsNullOrWhiteSpace(errorBody))
return "(empty)";
var trimmed = errorBody.Trim();
const int maxLen = 280;
if (trimmed.Length <= maxLen)
return trimmed;
return trimmed[..maxLen] + "...";
}
public sealed record ConnectResult(
bool Success,
string StatusMessage,
string? NormalizedServerUrl,
bool ServerUrlAdjusted,
IReadOnlyList<Library> Libraries);
/// <summary>
/// Normalize URL, fetch book libraries, and produce UI status text.
/// Callers apply busy-state and bind <see cref="ConnectResult.Libraries"/> to controls.
/// </summary>
public static async Task<ConnectResult> ConnectAsync(string? serverUrl, string? apiToken)
{
if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(apiToken))
{
return new ConnectResult(
Success: false,
StatusMessage: "Please enter both server URL and API token.",
NormalizedServerUrl: null,
ServerUrlAdjusted: false,
Libraries: []);
}
string normalizedUrl;
bool urlAdjusted;
try
{
var trimmedUrl = serverUrl.Trim();
normalizedUrl = NormalizeServerUrl(trimmedUrl);
urlAdjusted = !string.Equals(trimmedUrl.TrimEnd('/'), normalizedUrl, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
return new ConnectResult(
Success: false,
StatusMessage: $"Connection failed: {ex.Message}",
NormalizedServerUrl: null,
ServerUrlAdjusted: false,
Libraries: []);
}
try
{
var libraries = await GetLibrariesAsync(normalizedUrl, apiToken.Trim());
if (libraries.Count == 0)
{
return new ConnectResult(
Success: false,
StatusMessage: "No book libraries found. Check your settings.",
NormalizedServerUrl: normalizedUrl,
ServerUrlAdjusted: urlAdjusted,
Libraries: []);
}
return new ConnectResult(
Success: true,
StatusMessage: FormatConnectedStatus(libraries.Count, urlAdjusted),
NormalizedServerUrl: normalizedUrl,
ServerUrlAdjusted: urlAdjusted,
Libraries: libraries);
}
catch (Exception ex)
{
return new ConnectResult(
Success: false,
StatusMessage: $"Connection failed: {ex.Message}",
NormalizedServerUrl: normalizedUrl,
ServerUrlAdjusted: urlAdjusted,
Libraries: []);
}
}
public static string FormatConnectedStatus(int libraryCount, bool urlAdjusted)
{
var libraryWord = libraryCount == 1 ? "library" : "libraries";
var message = $"Connected. Found {libraryCount} {libraryWord}.";
if (urlAdjusted)
message += " Server URL adjusted to the API base address.";
return message;
}
/// <summary>
/// Normalize for persistence when possible; otherwise keep the trimmed raw value
/// so the user can fix it on the next connect attempt.
/// </summary>
public static string TryNormalizeServerUrlForSave(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return url?.Trim() ?? "";
try
{
return NormalizeServerUrl(url);
}
catch (ArgumentException)
{
return url.Trim();
}
}
public static async Task<bool> BookExistsAsync(string serverUrl, string apiToken, string libraryId, string title, string? author = null)
{
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
using var client = CreateClient(serverUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.Timeout = TimeSpan.FromSeconds(30);
var normalizedTitle = NormalizeTitle(title);
var baseTitle = GetBaseTitle(normalizedTitle);
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: looking for '{Title}' (base: '{BaseTitle}') in library {LibraryId}", normalizedTitle, baseTitle, libraryId);
try
{
// Search with base title first (shorter = broader fuzzy matches, better recall)
var candidates = await SearchLibraryAsync(client, libraryId, baseTitle, 20);
// Also search with full title to catch cases where ABS stores the longer/subtitled version
if (!string.Equals(normalizedTitle, baseTitle, StringComparison.Ordinal))
{
var fullCandidates = await SearchLibraryAsync(client, libraryId, normalizedTitle, 10);
foreach (var c in fullCandidates)
{
if (!candidates.Any(existing => string.Equals(existing.Id, c.Id, StringComparison.Ordinal)))
candidates.Add(c);
}
}
if (candidates.Count == 0)
{
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: no candidates found for '{Title}' in library {LibraryId}", normalizedTitle, libraryId);
return false;
}
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: found {Count} candidate(s) for '{Title}' in library {LibraryId}", candidates.Count, normalizedTitle, libraryId);
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate.Title))
continue;
bool titleMatch = false;
// 1) Exact match against normalized title
if (string.Equals(candidate.Title, normalizedTitle, StringComparison.OrdinalIgnoreCase))
titleMatch = true;
// 2) Exact match against base title (without subtitle)
if (!titleMatch && string.Equals(candidate.Title, baseTitle, StringComparison.OrdinalIgnoreCase))
titleMatch = true;
// 3) Exact match against combined title:subtitle
if (!titleMatch && !string.IsNullOrWhiteSpace(candidate.FullTitle)
&& string.Equals(candidate.FullTitle, normalizedTitle, StringComparison.OrdinalIgnoreCase))
titleMatch = true;
if (titleMatch && AuthorsCompatible(author, candidate.Authors))
{
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: found match for '{Title}' (id={ItemId})", title, candidate.Id);
return true;
}
}
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: '{Title}' not found in library {LibraryId}", normalizedTitle, libraryId);
return false;
}
catch (Exception ex)
{
Serilog.Log.Logger.Debug(ex, "Audiobookshelf duplicate check failed for '{Title}' in library {LibraryId}", title, libraryId);
throw; // Let caller decide whether to proceed with upload
}
}
private record SearchCandidate(string Id, string Title, string FullTitle, List<string> Authors);
private static async Task<List<SearchCandidate>> SearchLibraryAsync(HttpClient client, string libraryId, string query, int limit)
{
var url = $"api/libraries/{Uri.EscapeDataString(libraryId)}/search?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Audiobookshelf search returned {(int)response.StatusCode} for query '{query}'. Body: {errorBody}");
}
var json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
var books = obj["book"] as JArray ?? new JArray();
var results = new List<SearchCandidate>();
foreach (var entry in books)
{
var item = entry["libraryItem"] ?? entry;
var id = item["id"]?.Value<string>() ?? entry["id"]?.Value<string>() ?? "";
var itemTitle = item["media"]?["metadata"]?["title"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
var itemSubtitle = item["media"]?["metadata"]?["subtitle"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
var itemFullTitle = string.IsNullOrWhiteSpace(itemSubtitle)
? itemTitle
: $"{itemTitle}: {itemSubtitle}";
var authors = item["media"]?["metadata"]?["authors"] as JArray;
var authorList = authors?.Select(a => a["name"]?.Value<string>()?.Trim() ?? "")
.Where(n => !string.IsNullOrWhiteSpace(n))
.ToList() ?? [];
results.Add(new SearchCandidate(
id,
NormalizeTitle(itemTitle),
NormalizeTitle(itemFullTitle),
authorList));
}
return results;
}
private static bool AuthorsCompatible(string? libationAuthor, List<string> absAuthors)
{
// If either side has no author info, we can't use authors to disambiguate
if (string.IsNullOrWhiteSpace(libationAuthor))
return true;
if (absAuthors == null || absAuthors.Count == 0)
return true;
var libationAuthors = libationAuthor.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(a => NormalizeAuthor(a))
.Where(a => !string.IsNullOrWhiteSpace(a))
.ToList();
if (libationAuthors.Count == 0)
return true;
foreach (var absAuthor in absAuthors.Select(NormalizeAuthor))
{
foreach (var la in libationAuthors)
{
if (string.Equals(absAuthor, la, StringComparison.OrdinalIgnoreCase))
return true;
}
}
return false;
}
private static string NormalizeAuthor(string? author)
{
if (string.IsNullOrWhiteSpace(author))
return author ?? "";
return author.Replace("\u00A0", " ").Trim();
}
private static string NormalizeTitle(string? title)
{
if (string.IsNullOrWhiteSpace(title))
return title ?? "";
var normalized = title
.Replace("\u00A0", " ")
.Replace("(Unabridged)", "", StringComparison.OrdinalIgnoreCase)
.Replace("(Abridged)", "", StringComparison.OrdinalIgnoreCase)
.Trim();
// Collapse multiple spaces to single space
while (normalized.Contains(" ", StringComparison.Ordinal))
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
return normalized;
}
private static string GetBaseTitle(string fullTitle)
{
if (string.IsNullOrWhiteSpace(fullTitle))
return fullTitle;
var colonIndex = fullTitle.IndexOf(": ", StringComparison.Ordinal);
return colonIndex > 0 ? fullTitle[..colonIndex].Trim() : fullTitle;
}
public static async Task<UploadResult> UploadBookAsync(
string serverUrl,
string apiToken,
string libraryId,
string folderId,
string title,
string? author,
string? series,
IEnumerable<string> filePaths)
{
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
// Pre-check for existing item
try
{
if (await BookExistsAsync(serverUrl, apiToken, libraryId, title, author))
{
Serilog.Log.Logger.Information("Skipping Audiobookshelf upload: book '{Title}' already exists in library {LibraryId}", title, libraryId);
return UploadResult.AlreadyExists;
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Warning(ex, "Pre-check for existing book on Audiobookshelf failed; will still attempt upload");
// Continue to upload attempt below
}
using var client = CreateClient(serverUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.Timeout = TimeSpan.FromMinutes(30);
using var form = new MultipartFormDataContent();
form.Add(new StringContent(title), "title");
if (!string.IsNullOrWhiteSpace(author))
form.Add(new StringContent(author), "author");
if (!string.IsNullOrWhiteSpace(series))
form.Add(new StringContent(series), "series");
form.Add(new StringContent(libraryId), "library");
form.Add(new StringContent(folderId), "folder");
int fileIndex = 0;
var streams = new List<Stream>();
try
{
foreach (var path in filePaths.Where(File.Exists))
{
var stream = File.OpenRead(path);
streams.Add(stream);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(fileContent, fileIndex.ToString(), Path.GetFileName(path));
fileIndex++;
}
if (fileIndex == 0)
{
Serilog.Log.Logger.Warning("No audio files found to upload to Audiobookshelf for '{Title}'", title);
return UploadResult.Failed;
}
var response = await client.PostAsync("api/upload", form);
if (response.IsSuccessStatusCode)
return UploadResult.Success;
var responseBody = await response.Content.ReadAsStringAsync();
Serilog.Log.Logger.Error("Audiobookshelf upload failed for '{Title}' with status {(int)response.StatusCode} ({StatusCode}). Response body: {ResponseBody}",
title, (int)response.StatusCode, response.StatusCode, responseBody);
// Treat "already exists" 500 errors as skip/success
if (response.StatusCode == System.Net.HttpStatusCode.InternalServerError
&& responseBody.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
return UploadResult.AlreadyExists;
}
return UploadResult.Failed;
}
finally
{
foreach (var stream in streams)
{
try { stream.Dispose(); } catch { /* ignored */ }
}
}
}
}
@@ -0,0 +1,68 @@
using AudibleApi.Authorization;
using LibationFileManager;
namespace ApplicationServices;
public static class AudiobookshelfTokenStorage
{
private const string EncryptedPrefix = "enc:";
private const string AssociatedData = "audiobookshelf-api-token";
public static string? EncryptToken(string? plaintextToken)
{
if (string.IsNullOrEmpty(plaintextToken))
return plaintextToken;
// Guard against double-encrypting an already-encrypted value
if (plaintextToken.StartsWith(EncryptedPrefix))
return plaintextToken;
if (Configuration.Instance.TokenStorageMethod != TokenStorageMethod.Encrypted)
return plaintextToken;
var protector = IdentityTokenStorage.Protector;
if (protector is null)
{
Serilog.Log.Logger.Warning("Audiobookshelf API token encryption requested but no protector is available; storing plaintext");
return plaintextToken;
}
try
{
var encrypted = protector.Protect(plaintextToken, AssociatedData);
return EncryptedPrefix + encrypted;
}
catch (System.Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to encrypt Audiobookshelf API token; storing plaintext");
return plaintextToken;
}
}
public static string? DecryptToken(string? storedToken)
{
if (string.IsNullOrEmpty(storedToken))
return storedToken;
if (!storedToken.StartsWith(EncryptedPrefix))
return storedToken;
var payload = storedToken[EncryptedPrefix.Length..];
var protector = IdentityTokenStorage.Protector;
if (protector is null)
{
Serilog.Log.Logger.Warning("Audiobookshelf API token decryption requested but no protector is available; returning stored value");
return storedToken;
}
try
{
return protector.Unprotect(payload, AssociatedData);
}
catch (System.Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to decrypt Audiobookshelf API token; returning stored value");
return storedToken;
}
}
}
@@ -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;
}
}
+107 -4
View File
@@ -1,7 +1,10 @@
using DataLayer;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using Serilog;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
namespace ApplicationServices;
@@ -9,13 +12,46 @@ public static class DbContexts
{
private static bool _sqliteDbValidated;
private static readonly object _initialDatabaseStatisticsCaptureLock = new();
/// <summary>
/// True after initial DB statistics were read and either written to Serilog or stored for <see cref="TryEmitPendingInitialDatabaseStatistics"/>.
/// False if capture has not run yet or the last attempt threw (a later <see cref="GetContext"/> may retry).
/// </summary>
private static bool _initialDatabaseStatisticsCaptured;
/// <summary>Shape of the initial DB statistics log event; edit here only when changing what is logged.</summary>
private sealed class InitialDatabaseStatistics
{
public required int LibraryBooksNotInTrash { get; init; }
public required int LibraryBooksInTrash { get; init; }
public required int BookRecords { get; init; }
}
private static InitialDatabaseStatistics? _pendingInitialDbStats;
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
{
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
context.Database.Migrate();
try
{
LibationContextFactory.ApplyMigrations(
context,
string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString) ? SqliteStorage.DatabasePath : null);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("Libation cannot write its SQLite database", StringComparison.Ordinal))
{
Log.Error(
ex,
"SQLite migrations failed (read-only or blocked). LibationFiles={LibationFiles} DatabasePath={DatabasePath} AppsettingsJson={AppsettingsJson}",
Configuration.Instance.LibationFiles.Location,
SqliteStorage.DatabasePath,
Configuration.Instance.LibationFiles.AppsettingsJsonFile ?? "(null, LIBATION_FILES_DIR may be set)");
throw;
}
// Validate SQLite DB file was created and is accessible (once per process; OS may delay availability)
if (!_sqliteDbValidated && string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString))
@@ -24,9 +60,59 @@ public static class DbContexts
_sqliteDbValidated = true;
}
TryCaptureInitialDatabaseStatistics(context);
return context;
}
private static void TryCaptureInitialDatabaseStatistics(LibationContext context)
{
lock (_initialDatabaseStatisticsCaptureLock)
{
if (_initialDatabaseStatisticsCaptured)
return;
try
{
var (notInTrash, inTrash) = context.GetLibraryBookCountsByTrashFlag();
var bookRecords = context.GetBookCount();
var stats = new InitialDatabaseStatistics
{
LibraryBooksNotInTrash = notInTrash,
LibraryBooksInTrash = inTrash,
BookRecords = bookRecords,
};
if (Configuration.Instance.SerilogInitialized)
LogInitialDatabaseStatistics(stats);
else
_pendingInitialDbStats = stats;
_initialDatabaseStatisticsCaptured = true;
}
catch (Exception ex)
{
if (Configuration.Instance.SerilogInitialized)
Log.Warning(ex, "Could not capture initial database statistics");
}
}
}
/// <summary>
/// Writes initial DB statistics that were captured before Serilog was configured (e.g. WinForms early library load).
/// Call once after <see cref="Configuration.ConfigureLogging"/>.
/// </summary>
public static void TryEmitPendingInitialDatabaseStatistics()
{
var pending = Interlocked.Exchange(ref _pendingInitialDbStats, null);
if (pending is not null)
LogInitialDatabaseStatistics(pending);
}
private static void LogInitialDatabaseStatistics(InitialDatabaseStatistics stats) =>
Log.Logger.Information("Initial database statistics. {@DbStats}", stats);
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{
@@ -46,9 +132,26 @@ public static class DbContexts
return context.GetDeletedLibraryBooks();
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
/// <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.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
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();
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative, account);
}
}
@@ -0,0 +1,62 @@
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices;
/// <summary>One title Libation is waiting on before attempting it again, and why.</summary>
public sealed record DeferredDownload(
string Account,
string AudibleProductId,
DownloadFailureKind Kind,
int ConsecutiveFailures,
DateTimeOffset LastFailedAt,
DateTimeOffset RetryAfter,
string? Reason)
{
/// <summary>The label used in the log, the CLI summary and the GUI's skipped-titles breakdown.</summary>
public string KindLabel => Kind switch
{
DownloadFailureKind.LicenseDenied => "Audible denied a download license",
DownloadFailureKind.AssetUnavailable => "Audible has no downloadable audio yet",
DownloadFailureKind.ServiceInterruption => "A possible Audible service interruption",
_ => "A previous failure"
};
}
/// <summary>
/// The titles a bulk or automatic download run should leave alone for now, looked up by library book.
/// <para>
/// Read once per run: a run that takes hours must not have its own failures start suppressing the titles
/// still ahead of it in the same pass.
/// </para>
/// </summary>
public sealed class DownloadDeferrals
{
/// <summary>No title is deferred. Used by targeted and forced runs, which must attempt what was asked.</summary>
public static DownloadDeferrals None { get; } = new([]);
private readonly Dictionary<(string Account, string AudibleProductId), DeferredDownload> byBook;
private DownloadDeferrals(IEnumerable<DeferredDownload> deferred)
=> byBook = deferred.ToDictionary(d => (d.Account, d.AudibleProductId));
public static DownloadDeferrals Create(IEnumerable<DeferredDownload> deferred) => new(deferred);
/// <summary>Reads the store. Never throws: a failure here must not stop a download run.</summary>
public static DownloadDeferrals Load(DateTimeOffset now)
=> Create(DownloadAttemptFailureStore.GetDeferred(now));
public int Count => byBook.Count;
public bool Any => byBook.Count > 0;
public DeferredDownload? Find(LibraryBook libraryBook)
=> libraryBook.Account is { } account
&& byBook.TryGetValue((account, libraryBook.Book.AudibleProductId), out var deferred)
? deferred
: null;
public bool IsDeferred(LibraryBook libraryBook) => Find(libraryBook) is not null;
}
@@ -0,0 +1,59 @@
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices;
/// <summary>What to tell the user about titles a run held back, instead of the full warning per title per run.</summary>
public static class DeferredDownloadUserMessage
{
/// <summary>
/// A compact breakdown for the log, eg:
/// "Audible denied a download license: 3, Audible has no downloadable audio yet: 1".
/// </summary>
public static string BuildLogBreakdown(IEnumerable<DeferredDownload> deferred)
{
var breakdown = string.Join(", ", GroupByKind(deferred).Select(g => $"{g.First().KindLabel}: {g.Count()}"));
return breakdown is "" ? "none" : breakdown;
}
/// <summary>
/// The lines a CLI run prints in place of a full warning per title. Says how many were held back, why,
/// when the soonest will be attempted again, and how to override.
/// </summary>
public static IEnumerable<string> BuildCliSkippedLines(IReadOnlyCollection<DeferredDownload> skipped, DateTimeOffset now)
{
if (skipped.Count == 0)
yield break;
yield return $"Skipped {"title".PluralizeWithCount(skipped.Count)} that recently failed to download. Libation will try again by itself.";
foreach (var group in GroupByKind(skipped))
yield return $" {group.First().KindLabel}: {group.Count()} (next attempt {DescribeWhen(group.Min(d => d.RetryAfter), now)})";
yield return " To try one now: libationcli liberate <ASIN>. For all of them: libationcli liberate --force.";
}
/// <summary>"in about 3 hours" / "in about 12 days (9/14/2026)" - a summary should not need a clock to read.</summary>
public static string DescribeWhen(DateTimeOffset when, DateTimeOffset now)
{
var wait = when - now;
if (wait <= TimeSpan.Zero)
return "on the next run";
if (wait < TimeSpan.FromHours(1))
return $"in about {"minute".PluralizeWithCount(Math.Max(1, (int)wait.TotalMinutes))}";
// Rounded to hours first, so a wait of exactly one day does not read as 23 or 24 hours depending on
// how long the run took to reach this line.
var hours = (int)Math.Round(wait.TotalHours);
return hours < 24
? $"in about {"hour".PluralizeWithCount(hours)}"
: $"in about {"day".PluralizeWithCount((int)Math.Round(hours / 24d))} ({when.ToLocalTime():d})";
}
private static IEnumerable<IGrouping<DownloadFailureKind, DeferredDownload>> GroupByKind(IEnumerable<DeferredDownload> deferred)
=> deferred.GroupBy(d => d.Kind).OrderBy(g => g.Key);
}
@@ -0,0 +1,176 @@
using DataLayer;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices;
/// <summary>
/// Reads and writes the record of refused download attempts that keeps a scheduled run from asking Audible
/// for the same license every time.
/// <para>
/// Every method swallows its own errors. This is bookkeeping that makes downloading quieter; it must never
/// be the reason a download fails, and a broken query must leave downloading exactly as it was before this
/// existed.
/// </para>
/// </summary>
public static class DownloadAttemptFailureStore
{
/// <summary>
/// Records a failed attempt, extending the wait before the title is attempted again. A failure of a
/// different kind than last time restarts the count: Audible changed its mind about why, so the previous
/// wait no longer describes the situation.
/// </summary>
public static void Record(LibraryBook libraryBook, DownloadFailureKind kind, string? reason, DateTimeOffset? failedAt = null)
{
ArgumentNullException.ThrowIfNull(libraryBook);
if (string.IsNullOrWhiteSpace(libraryBook.Account) || string.IsNullOrWhiteSpace(libraryBook.Book.AudibleProductId))
return;
try
{
var when = failedAt ?? DateTimeOffset.Now;
var account = libraryBook.Account;
var productId = libraryBook.Book.AudibleProductId;
using var context = DbContexts.GetContext();
var existing = context.DownloadAttemptFailures
.SingleOrDefault(f => f.Account == account && f.AudibleProductId == productId);
var consecutiveFailures = existing is null || existing.Kind != kind ? 1 : existing.ConsecutiveFailures + 1;
var retryAfter = DownloadRetryBackoff.GetRetryAfter(kind, consecutiveFailures, when);
if (existing is null)
context.DownloadAttemptFailures.Add(new DownloadAttemptFailure(productId, account, kind, consecutiveFailures, when, retryAfter, Truncate(reason)));
else
existing.Record(kind, consecutiveFailures, when, retryAfter, Truncate(reason));
context.SaveChanges();
Serilog.Log.Logger.Information(
"Not attempting {audibleProductId} again until {retryAfter}. {@DebugInfo}",
productId,
retryAfter.ToLocalTime(),
new { Title = libraryBook.Book.TitleWithSubtitle, Account = account.ToMask(), kind, consecutiveFailures, reason });
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(
ex,
"Failed to record a refused download attempt. The title will be attempted again on the next run. {@DebugInfo}",
new { libraryBook.Book.AudibleProductId, Title = libraryBook.Book.TitleWithSubtitle, kind });
}
}
/// <summary>
/// Forgets any record for this title, so it is attempted again at the next opportunity. Called when a
/// download succeeds and when the user asks for the title explicitly.
/// </summary>
public static void Clear(LibraryBook libraryBook)
{
ArgumentNullException.ThrowIfNull(libraryBook);
Clear(libraryBook.Account, libraryBook.Book.AudibleProductId);
}
public static void Clear(string? account, string? audibleProductId)
{
if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(audibleProductId))
return;
try
{
using var context = DbContexts.GetContext();
// ExecuteDelete rather than a load-then-remove so the common case (nothing recorded) is one
// statement. Called after every successful download.
var deleted = context.DownloadAttemptFailures
.Where(f => f.Account == account && f.AudibleProductId == audibleProductId)
.ExecuteDelete();
if (deleted > 0)
Serilog.Log.Logger.Debug("Cleared the recorded download failure for {audibleProductId}", audibleProductId);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to clear the recorded download failure for {audibleProductId}", audibleProductId);
}
}
/// <summary>Titles whose wait has not elapsed. Empty when the query fails, so downloading carries on.</summary>
public static IReadOnlyList<DeferredDownload> GetDeferred(DateTimeOffset now)
{
try
{
var ticks = now.UtcTicks;
using var context = DbContexts.GetContext();
return Project(context.DownloadAttemptFailures.AsNoTracking().Where(f => f.RetryAfterUtcTicks > ticks));
}
catch (Exception ex)
{
// Failing open is the safer default: a broken query must not stop titles from being downloaded.
Serilog.Log.Logger.Error(ex, "Failed to read recorded download failures. Treating every title as ready to attempt.");
return [];
}
}
/// <summary>The current wait for one title, or null when it is ready to be attempted. Null when the query fails.</summary>
public static DeferredDownload? Find(LibraryBook libraryBook, DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(libraryBook);
var account = libraryBook.Account;
var productId = libraryBook.Book.AudibleProductId;
if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(productId))
return null;
try
{
var ticks = now.UtcTicks;
using var context = DbContexts.GetContext();
return Project(
context.DownloadAttemptFailures
.AsNoTracking()
.Where(f => f.Account == account && f.AudibleProductId == productId && f.RetryAfterUtcTicks > ticks))
.FirstOrDefault();
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to read the recorded download failure for {audibleProductId}", productId);
return null;
}
}
private static List<DeferredDownload> Project(IQueryable<DownloadAttemptFailure> query)
=> query
// Materialise the columns first: the record's constructor is not translatable to SQL.
.Select(f => new
{
f.Account,
f.AudibleProductId,
f.Kind,
f.ConsecutiveFailures,
f.LastFailedAtUtcTicks,
f.RetryAfterUtcTicks,
f.Reason
})
.ToList()
.Select(f => new DeferredDownload(
f.Account,
f.AudibleProductId,
f.Kind,
f.ConsecutiveFailures,
new DateTimeOffset(f.LastFailedAtUtcTicks, TimeSpan.Zero),
new DateTimeOffset(f.RetryAfterUtcTicks, TimeSpan.Zero),
f.Reason))
.ToList();
/// <summary>Audible's messages can run long; the full text is already in the log.</summary>
private static string? Truncate(string? reason)
=> reason is null || reason.Length <= 400 ? reason : reason[..400];
}
@@ -0,0 +1,85 @@
using DataLayer;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices;
/// <summary>
/// Reads and writes the record of successful audiobook downloads backing the daily download limit.
/// <para>
/// Caches nothing: every read is a fresh query, so a queue paused for hours sees entries age out of the
/// rolling window, and downloads performed by a concurrently running CLI or second container are counted too.
/// </para>
/// </summary>
public static class DownloadHistoryStore
{
/// <summary>Kept a little beyond the 24 hour window so the table stays small without losing anything in use.</summary>
private static readonly TimeSpan RetentionPeriod = TimeSpan.FromHours(48);
/// <summary>
/// Records a finished download. Never throws: bookkeeping must not fail a download that already succeeded.
/// </summary>
public static void Record(string? audibleProductId, bool isAudiblePlus, long bytes, DateTimeOffset? completedAt = null)
{
try
{
var when = completedAt ?? DateTimeOffset.Now;
using var context = DbContexts.GetContext();
context.DownloadHistory.Add(new DownloadHistory(when, audibleProductId, isAudiblePlus, bytes));
var cutoff = (when - RetentionPeriod).UtcTicks;
var expired = context.DownloadHistory.Where(dh => dh.CompletedAtUtcTicks < cutoff);
context.DownloadHistory.RemoveRange(expired);
context.SaveChanges();
Serilog.Log.Logger.Debug(
"Recorded download for the daily download limit. {@DebugInfo}",
new { audibleProductId, isAudiblePlus, bytes, completedAt = when });
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(
ex,
"Failed to record a completed download for the daily download limit. The download itself succeeded. {@DebugInfo}",
new { audibleProductId, isAudiblePlus, bytes });
}
}
/// <summary>Downloads completed at or after <paramref name="since"/>. Empty when the query fails.</summary>
public static IReadOnlyList<DownloadHistoryEntry> GetSince(DateTimeOffset since)
{
try
{
var ticks = since.UtcTicks;
using var context = DbContexts.GetContext();
return context.DownloadHistory
.AsNoTracking()
.Where(dh => dh.CompletedAtUtcTicks >= ticks)
.OrderBy(dh => dh.CompletedAtUtcTicks)
.Select(dh => new { dh.CompletedAtUtcTicks, dh.AudibleProductId, dh.IsAudiblePlus, dh.Bytes })
.ToList()
.Select(dh => new DownloadHistoryEntry(
new DateTimeOffset(dh.CompletedAtUtcTicks, TimeSpan.Zero),
dh.AudibleProductId,
dh.IsAudiblePlus,
dh.Bytes))
.ToList();
}
catch (Exception ex)
{
// Failing open is the safer default: a broken query must not block downloading.
Serilog.Log.Logger.Error(ex, "Failed to read download history. Treating the last 24 hours as empty.");
return [];
}
}
/// <summary>The rolling window the daily download limit uses.</summary>
public static IReadOnlyList<DownloadHistoryEntry> GetCurrentWindow(DateTimeOffset now)
=> GetSince(now - DailyDownloadLimit.Window);
}
@@ -0,0 +1,53 @@
using DataLayer;
using System;
using System.Collections.Generic;
namespace ApplicationServices;
/// <summary>
/// How long to leave a title alone after a download attempt Audible refused, so a scheduled run stops asking
/// for the same license every time.
/// <para>
/// The wait doubles with each failure in a row, up to a cap, and every kind of failure has a finite cap: a
/// title held back is always attempted again eventually. Audible never distinguishes "you will never have
/// rights to this" from "not right now", so nothing here may be permanent.
/// </para>
/// </summary>
public static class DownloadRetryBackoff
{
private static readonly Dictionary<DownloadFailureKind, (TimeSpan First, TimeSpan Max)> schedule = new()
{
// An eligibility refusal changes only when the account or the catalog changes. A day matches the
// advice Libation already gives for a Plus title ("try again in 1 to 2 days"), which is the most
// common refusal that clears by itself.
[DownloadFailureKind.LicenseDenied] = (TimeSpan.FromDays(1), TimeSpan.FromDays(30)),
// A preorder becomes downloadable on its release date, which nobody can predict from the error, so
// keep checking within a week.
[DownloadFailureKind.AssetUnavailable] = (TimeSpan.FromHours(6), TimeSpan.FromDays(7)),
// Short: an outage that has passed should not delay a title any longer than it has to.
[DownloadFailureKind.ServiceInterruption] = (TimeSpan.FromHours(1), TimeSpan.FromHours(12)),
};
/// <summary>How long to wait after the <paramref name="consecutiveFailures"/>th failure in a row.</summary>
public static TimeSpan GetWait(DownloadFailureKind kind, int consecutiveFailures)
{
var (first, max) = schedule.TryGetValue(kind, out var found)
? found
: schedule[DownloadFailureKind.ServiceInterruption];
// Doubled one step at a time and stopped at the cap: first * 2^n overflows a TimeSpan long before a
// plausible failure count, let alone the absurd ones a corrupt row could hold.
var doublings = Math.Max(0, consecutiveFailures - 1);
var wait = first;
for (var i = 0; i < doublings && wait < max; i++)
wait += wait;
return wait > max ? max : wait;
}
/// <summary>When a title becomes eligible for another automatic attempt.</summary>
public static DateTimeOffset GetRetryAfter(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset failedAt)
=> failedAt + GetWait(kind, consecutiveFailures);
}
+1 -2
View File
@@ -1,5 +1,4 @@
using CsvHelper.Configuration.Attributes;
using DataLayer;
using DataLayer;
using Newtonsoft.Json;
using System;
using System.Linq;
+248 -128
View File
@@ -11,7 +11,9 @@ 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;
using static DtoImporterService.PerfLogger;
@@ -23,7 +25,12 @@ public static class LibraryCommands
public static event EventHandler<int>? ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
/// <summary>
/// Serializes library scan and import operations so only one path reads/writes
/// <see cref="Book"/> / <see cref="LibraryBook"/> rows at a time (prevents duplicate ASIN inserts).
/// </summary>
private static readonly SemaphoreSlim ImportGate = new(1, 1);
static LibraryCommands()
{
@@ -35,151 +42,175 @@ public static class LibraryCommands
{
logRestart();
lock (_lock)
{
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
if (accounts is null || accounts.Length == 0)
return new List<LibraryBook>();
await ImportGate.WaitAsync();
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
ScanBegin?.Invoke(null, accounts.Length);
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
return missingBookList;
}
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 scanning library. Login failed. {@DebugInfo}", new
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
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}");
var existing = existingLibrary?.ToList() ?? [];
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 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;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, 0);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, 0);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
ImportGate.Release();
}
}
/// <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 async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Account[]? accounts, bool allowInteractiveLogin)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
await ImportGate.WaitAsync();
int newCount = 0;
try
{
lock (_lock)
{
if (Scanning)
return (0, 0);
}
ScanBegin?.Invoke(null, accounts.Length);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
try
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
| LibraryOptions.ResponseGroupOptions.IsFinished,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
var importItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
| 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
};
//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;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
if (totalCount == 0)
return default;
if (totalCount == 0)
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
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
return (totalCount, newCount);
}
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
{
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
throw;
}
catch (Exception ex)
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, newCount);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, newCount);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
ImportGate.Release();
}
}
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
{
await ImportGate.WaitAsync();
try
{
return importSingleToDb(item, accountId, localeName);
}
finally
{
ImportGate.Release();
}
}
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
{
ArgumentValidator.EnsureNotNull(item, nameof(item));
@@ -250,9 +281,18 @@ public static class LibraryCommands
return null;
}
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
/// <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()
@@ -265,42 +305,64 @@ 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);
// 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))
{
throw;
}
catch (Exception ex)
{
//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);
@@ -317,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") },
};
@@ -375,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>())
@@ -390,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));
@@ -399,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)
@@ -408,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)
{
@@ -423,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)
{
@@ -550,13 +648,19 @@ public static class LibraryCommands
return 0;
int qtyChanges;
var statusChanged = new List<LibraryBook>();
using (var context = DbContexts.GetContext())
{
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in nonNullBooks)
{
var statusBefore = book.Book.UserDefinedItem.BookStatus;
action?.Invoke(book.Book.UserDefinedItem);
if (book.Book.UserDefinedItem.BookStatus != statusBefore)
statusChanged.Add(book);
var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
@@ -567,7 +671,16 @@ public static class LibraryCommands
qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0)
{
// Changing a title's download status is the user saying they want a different outcome for it,
// so drop any wait Libation was observing before attempting it again. Compared against the
// previous value rather than acting on every call: editing tags or a rating must not quietly
// put a title Audible just refused back into the next scheduled run.
foreach (var book in statusChanged)
DownloadAttemptFailureStore.Clear(book);
BookUserDefinedItemCommitted?.Invoke(null, nonNullBooks);
}
return qtyChanges;
}
@@ -629,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();
@@ -645,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()
@@ -655,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;
@@ -1,5 +1,6 @@
using DataLayer;
using LibationSearchEngine;
using Serilog;
using System;
using System.Collections.Generic;
using System.IO;
@@ -22,6 +23,7 @@ public static class SearchEngineCommands
lock (IndexLock)
{
var engine = new SearchEngine();
repairShortIndex(engine);
try
{
return func(engine);
@@ -31,15 +33,106 @@ public static class SearchEngineCommands
fullReIndex(engine);
return func(engine);
}
catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex))
{
Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying query.");
fullReIndex(engine);
return func(engine);
}
}
}
/// <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;
/// <summary>
/// Occurs when the index could not be updated even after automatic repair, so it needs the user's help.
/// </summary>
public static event EventHandler<Exception>? UpdateFailed;
#region Update
private static bool isUpdating;
/// <summary>Updates the index after books were added to or removed from the library.</summary>
public static void OnLibrarySizeChanged(List<LibraryBook> libraryBooks)
=> tryUpdate(() => FullReIndex(libraryBooks));
/// <summary>Updates the index after book details, tags or statuses were committed.</summary>
public static void OnBookUserDefinedItemCommitted(IEnumerable<LibraryBook> books)
=> tryUpdate(() => UpdateBooks(books));
/// <summary>
/// The database change that triggers an update is committed before the update runs, and this index is derived
/// from that database, so a failure here is reported instead of propagated. Letting it escape reported a
/// successful scan as "Error importing library", and, since this is the first subscriber to those events,
/// stopped the handlers that refresh the grid and the backup counts from running at all.
/// </summary>
private static void tryUpdate(Action update)
{
try
{
update();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to update the search index. Library changes are saved; search and filter results may be stale until the next update succeeds.");
UpdateFailed?.Invoke(null, ex);
}
}
public static void UpdateBooks(IEnumerable<LibraryBook> books)
{
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
@@ -53,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()));
@@ -76,6 +169,12 @@ public static class SearchEngineCommands
fullReIndex(new SearchEngine());
update(action);
}
catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex))
{
Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying.");
fullReIndex(new SearchEngine());
update(action);
}
}
private static void update(Action<SearchEngine> action)
@@ -102,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 [];
}
}
}
+135 -8
View File
@@ -1,13 +1,18 @@
using AudibleApi;
using AudibleApi.Authorization;
using Dinah.Core;
using Dinah.Core.Security;
using LibationFileManager;
using Newtonsoft.Json;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace AudibleUtilities;
public class Account : IUpdatable
[DebuggerDisplay("{AccountId,nq} - {Locale}")]
public class Account : IUpdatable, ILogMasked
{
public event EventHandler? Updated;
private void update(object? sender = null, EventArgs? e = null)
@@ -46,15 +51,18 @@ public class Account : IUpdatable
}
}
/// <summary>aka: activation bytes</summary>
[AllowNull]
public string? DecryptKey
/// <summary>
/// aka: activation bytes. A <see cref="SecretString"/> so that no reflective dump - Serilog's structured
/// logging, or Serilog.Exceptions walking a logged exception - can reach the value. Persists as the same
/// bare JSON string it always did.
/// </summary>
public SecretString DecryptKey
{
get => field;
set
{
var v = (value ?? "").Trim();
if (v == field)
var v = (value.Reveal() ?? "").Trim();
if (v == field.Reveal())
return;
field = v;
update();
@@ -80,16 +88,135 @@ public class Account : IUpdatable
}
}
/// <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();
}
public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}";
/// <summary>
/// Masked, because this is what interpolation and non-destructured logging reach for. Use
/// <see cref="AccountCredentialStatus.FormatAccountLabel"/> for dialogs shown to the account's owner, and see
/// the DebuggerDisplay above for the unmasked form while debugging.
/// </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]"
@@ -0,0 +1,37 @@
using Dinah.Core;
namespace AudibleUtilities;
/// <summary>Describes whether an account has usable stored Audible tokens.</summary>
public static class AccountCredentialStatus
{
/// <summary>
/// True when identity tokens are absent or carry no refresh token, meaning the account was never fully logged
/// in or its credentials were cleared, rather than merely holding an expired access token.
/// </summary>
public static bool LooksLikeMissingCredentials(Account? account)
{
if (account?.IdentityTokens is not { } tokens)
return true;
if (tokens.IsValid)
return false;
// without a refresh token there is nothing to renew from, so this needs a fresh login rather than a retry
return string.IsNullOrWhiteSpace(tokens.RefreshToken?.Reveal());
}
/// <summary>
/// Account label for dialogs shown to the person who owns the account. Not for logs: those get attached to
/// public issue reports, which is what <see cref="Account.MaskedLogEntry"/> is for.
/// </summary>
public static string FormatAccountLabel(Account? account)
{
if (account is null)
return "an Audible account";
return string.IsNullOrWhiteSpace(account.AccountName) || account.AccountName.EqualsInsensitive(account.AccountId)
? $"'{account.AccountId}'"
: $"'{account.AccountName}' ({account.AccountId})";
}
}
@@ -0,0 +1,89 @@
using AudibleApi.Authorization;
namespace AudibleUtilities;
/// <summary>
/// Libation-facing helpers when AccountsSettings.json contains encrypted tokens
/// that cannot be unlocked on this OS/machine (common when copying Windows config into Docker).
/// </summary>
public static class AccountSettingsDecryptFailure
{
public const string LoadErrorCaption = "Error Loading Account Settings";
public const string FaqUrl
= "https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken";
private static readonly string[] ThingsToTryBullets =
[
"Keep tokens encrypted: on the machine that created the file, export the master key (Settings -> Important -> Export encryption key..., or LibationCli export-master-key libation-master.key), then copy that file into the Docker/config folder next to AccountsSettings.json (or set LIBATION_MASTER_KEY_FILE / LIBATION_MASTER_KEY).",
"Or convert to plaintext: Settings -> Important, uncheck \"Store authentication tokens encrypted\", convert existing tokens when prompted, then copy the updated AccountsSettings.json again.",
"Or re-authenticate on this machine/container with LibationCli login-external or import-account.",
$"Details: {FaqUrl}",
];
/// <summary>
/// True when <paramref name="ex"/> (or an inner exception) is a decrypt failure from identity token JSON.
/// </summary>
public static bool TryFindInTree(Exception ex, out IdentityTokenDecryptException? match)
{
ArgumentNullException.ThrowIfNull(ex);
IdentityTokenDecryptException? found = null;
walk(ex);
match = found;
return found is not null;
void walk(Exception? e)
{
if (e is null || found is not null)
return;
if (e is IdentityTokenDecryptException decryptEx)
{
found = decryptEx;
return;
}
if (e is AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
walk(inner);
}
walk(e.InnerException);
}
}
public static IEnumerable<string> GetExplainerLines(Exception ex)
{
ArgumentNullException.ThrowIfNull(ex);
yield return "Encrypted authentication tokens in AccountsSettings.json could not be decrypted on this machine.";
yield return "The encryption key is stored in the OS secret store where the tokens were encrypted (for example Windows DPAPI) and does not travel when you copy the file to Docker or another computer.";
if (TryFindInTree(ex, out var decryptEx) && decryptEx is not null)
yield return $"Underlying error: {decryptEx.Message.TrimEnd('.')}.";
yield return string.Empty;
yield return "Things to try:";
foreach (var bullet in ThingsToTryBullets)
yield return "• " + bullet;
}
public static string GetExplainerBody(Exception ex)
=> string.Join("\r\n", GetExplainerLines(ex));
/// <summary>
/// Dialog body after backup-and-empty recovery when load failed due to token decrypt.
/// </summary>
public static string GetRecoveredDialogBody(Exception ex, string backupPath)
{
ArgumentNullException.ThrowIfNull(ex);
ArgumentException.ThrowIfNullOrWhiteSpace(backupPath);
return $"""
{GetExplainerBody(ex)}
Libation created a new, empty account settings file so the app can start. You will need to re-add your Audible account(s) (or copy a plaintext AccountsSettings.json) before scanning or downloading.
The previous account settings file was archived at '{backupPath}'
""";
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace AudibleUtilities;
/// <summary>
/// What a failure needs to say about an account, captured so a live <see cref="Account"/> - with its tokens and
/// its activation bytes - never rides along on an exception into a log file.
/// <para>
/// Logs get attached to public issue reports, and Serilog.Exceptions writes every public property of a logged
/// exception into one, following nested objects as it goes. So everything public here is masked, and the label
/// meant for the account's owner is reachable only through a method: reflection reads properties and never calls
/// methods.
/// </para>
/// </summary>
public sealed class AccountSummary
{
/// <summary>Safe to log. Also what <see cref="ToString"/> returns, so interpolating this cannot leak.</summary>
public string MaskedLogEntry { get; }
/// <summary>
/// True when the account was never fully logged in or its credentials were cleared, rather than merely
/// holding an expired session.
/// </summary>
public bool LooksLikeMissingCredentials { get; }
private readonly string ownerFacingLabel;
private AccountSummary(Account account)
{
MaskedLogEntry = account.MaskedLogEntry;
LooksLikeMissingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account);
ownerFacingLabel = AccountCredentialStatus.FormatAccountLabel(account);
}
/// <summary>
/// The name and address to show the person who owns the account, on their own screen. Never log this: use
/// <see cref="MaskedLogEntry"/>.
/// </summary>
public string RevealOwnerFacingLabel() => ownerFacingLabel;
public override string ToString() => MaskedLogEntry;
public static AccountSummary? From(Account? account)
=> account is null ? null : new AccountSummary(account);
}
@@ -0,0 +1,59 @@
using AudibleApi.Authorization;
namespace AudibleUtilities;
/// <summary>
/// Alignment and conversion helpers for identity tokens in <c>AccountsSettings.json</c>.
/// Preference changes alone do not convert; conversion is explicit via <see cref="ConvertAllAccounts"/>.
/// </summary>
public static class AccountTokenStorage
{
/// <summary>
/// Compare persisted encryption metadata for all account identities to <paramref name="method"/>.
/// </summary>
public static TokenStorageAlignment GetAccountsAlignment(TokenStorageMethod method, string? accountsSettingsFile = null)
{
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
if (!File.Exists(path))
return TokenStorageAlignment.NoApplicableTokens;
using var persister = new AccountsSettingsPersister(path);
var alignments = new List<TokenStorageAlignment>();
foreach (var account in persister.AccountsSettings.Accounts)
{
if (account.IdentityTokens is null)
continue;
alignments.Add(IdentityTokenConversion.GetAlignment(account.IdentityTokens, method));
}
return CombineAlignments(alignments);
}
/// <summary>
/// Convert every identity in the accounts settings file to <paramref name="method"/>.
/// All-or-nothing: failure leaves the original file unchanged.
/// </summary>
public static IdentityTokenConversionResult ConvertAllAccounts(TokenStorageMethod method, string? accountsSettingsFile = null)
{
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
return IdentityTokenConversion.ConvertAllIdentitiesInFile(path, method);
}
private static TokenStorageAlignment CombineAlignments(IReadOnlyList<TokenStorageAlignment> alignments)
{
if (alignments.Count == 0)
return TokenStorageAlignment.NoApplicableTokens;
if (alignments.Any(a => a == TokenStorageAlignment.Indeterminate))
return TokenStorageAlignment.Indeterminate;
if (alignments.All(a => a is TokenStorageAlignment.AllMatch or TokenStorageAlignment.NoApplicableTokens))
{
return alignments.Any(a => a == TokenStorageAlignment.AllMatch)
? TokenStorageAlignment.AllMatch
: TokenStorageAlignment.NoApplicableTokens;
}
return TokenStorageAlignment.SomeMismatch;
}
}
+48 -17
View File
@@ -88,7 +88,8 @@ public class AccountsSettings : IUpdatable
var l = Localization.Get(locale);
var id = new Identity(l);
var account = new Account(accountId) { IdentityTokens = id };
// Match GUI default for new rows (WinForms/Avalonia): include account in library scans.
var account = new Account(accountId) { IdentityTokens = id, LibraryScan = true };
Add(account);
return account;
}
@@ -96,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();
}
@@ -107,12 +109,46 @@ 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)
return null;
return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.Locale?.Name == locale);
// 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.
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)
@@ -130,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");
}
}
+280 -50
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;
@@ -23,11 +19,24 @@ public class ApiExtended
private const int MaxConcurrency = 10;
private const int BatchSize = 50;
/// <summary>Extra attempts to re-request catalog products that Audible omitted from an otherwise successful response.</summary>
private const int MissingAsinRetries = 2;
/// <summary>Upper bound on how many dropped titles a single log entry will name.</summary>
private const int MaxLoggedTitles = 50;
private ApiExtended(Api api) => Api = api;
private static readonly SemaphoreSlim InteractiveLoginGate = new(1, 1);
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account)
public static Task<ApiExtended> CreateAsync(Account account)
=> CreateAsync(account, allowInteractiveLogin: true);
/// <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));
@@ -37,34 +46,82 @@ 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
catch (Exception ex)
{
if (LoginChoiceFactory is null)
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
Serilog.Log.Logger.Information("{@DebugInfo}", new
if (!allowInteractiveLogin || LoginChoiceFactory is null)
{
LoginType = nameof(ILoginChoiceEager),
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name
});
// an account that was never logged in needs a first login, not a renewal, and saying so saves the
// user hunting for an expired session that never existed
var missingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account);
var api = await EzApiCreator.GetApiAsync(
LoginChoiceFactory(account),
locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
Serilog.Log.Logger.Error(ex,
missingCredentials
? "Stored credentials are missing or incomplete and interactive login is not available. {@DebugInfo}"
: "Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
new
{
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name,
AllowInteractiveLogin = allowInteractiveLogin,
LoginChoiceFactorySet = LoginChoiceFactory is not null,
LooksLikeMissingCredentials = missingCredentials
});
return new ApiExtended(api);
// masked: this message is logged verbatim, both as {Exception} and as ExceptionDetail.Message
throw new AuthenticationRequiredException(
account,
message: $"Stored credentials for {account.MaskedLogEntry} "
+ (missingCredentials ? "are missing or incomplete" : "could not be used")
+ (LoginChoiceFactory is null
? " and interactive login is not available in this context (CLI/Docker)."
: " and interactive login was not allowed.")
+ $" {ex.Message}",
innerException: ex);
}
await InteractiveLoginGate.WaitAsync();
try
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginChoiceEager),
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name
});
var api = await EzApiCreator.GetApiAsync(
LoginChoiceFactory(account),
locale,
AudibleApiStorage.AccountsSettingsFile,
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);
}
finally
{
InteractiveLoginGate.Release();
}
}
}
@@ -103,7 +160,14 @@ public class ApiExtended
{
Serilog.Log.Logger.Debug("Beginning library scan.");
List<Item> items = new();
//Read the import filters once so a settings change mid-scan can't produce a half-filtered library.
var importEpisodes = Configuration.Instance.ImportEpisodes;
var importPlusTitles = Configuration.Instance.ImportPlusTitles;
var items = new List<Item>();
var libraryItemCount = 0;
var episodeItemsExcluded = 0;
var plusTitlesExcluded = 0;
var sw = Stopwatch.StartNew();
var totalTime = TimeSpan.Zero;
using var semaphore = new SemaphoreSlim(MaxConcurrency);
@@ -115,7 +179,9 @@ public class ApiExtended
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
{
if (Configuration.Instance.ImportEpisodes)
libraryItemCount += itemsBatch.Length;
if (importEpisodes)
{
var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList();
var series = itemsBatch.Where(i => i.IsSeriesParent).ToList();
@@ -138,11 +204,18 @@ public class ApiExtended
items.AddRange(episodes);
items.AddRange(series);
}
else
episodeItemsExcluded += itemsBatch.Count(i => i.IsSeriesParent || i.IsEpisodes);
var booksInBatch = itemsBatch.Where(i => !i.IsSeriesParent && !i.IsEpisodes).ToList();
if (!importPlusTitles)
{
var ownedInBatch = booksInBatch.Where(i => i.IsAyce is not true).ToList();
plusTitlesExcluded += booksInBatch.Count - ownedInBatch.Count;
booksInBatch = ownedInBatch;
}
var booksInBatch
= itemsBatch
.Where(i => !i.IsSeriesParent && !i.IsEpisodes)
.Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles);
items.AddRange(booksInBatch);
}
@@ -165,21 +238,30 @@ public class ApiExtended
Serilog.Log.Logger.Debug("Begin indexing series episodes");
items.AddRange(allEps);
//Set the Item.Series info for episodes and parents.
foreach (var parent in items.Where(i => i.IsSeriesParent))
{
var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true);
SetSeries(parent, children);
}
int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null);
if (orphansRemoved > 0)
Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved);
//Name the casualties: an unlinked episode silently vanishes from the library, and without this
//there is nothing in the log to explain why.
var orphans = LinkEpisodesToSeries(items).Select(describe).Distinct().ToList();
if (orphans.Count > 0)
Serilog.Log.Logger.Warning(
"{orphansRemoved} podcast episodes were not imported because their series parent was missing from this scan. {@DebugInfo}",
orphans.Count,
new { Orphans = orphans.Take(MaxLoggedTitles).ToList(), Truncated = orphans.Count > MaxLoggedTitles });
sw.Stop();
totalTime += sw.Elapsed;
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
Serilog.Log.Logger.Information("Library scan tally. {@DebugInfo}", new
{
LibraryItems = libraryItemCount,
EpisodesFetched = allEps.Count,
OrphanedEpisodesDropped = orphans.Count,
ImportEpisodes = importEpisodes,
EpisodeItemsExcluded = episodeItemsExcluded,
ImportPlusTitles = importPlusTitles,
PlusTitlesExcluded = plusTitlesExcluded,
ItemsToImport = items.Count
});
Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items));
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
@@ -187,6 +269,32 @@ public class ApiExtended
throw new ImportValidationException(items, allExceptions);
return items;
static string describe(Item item) => $"[{item.Asin}] {item.Title}";
}
/// <summary>
/// Set <see cref="Item.Series"/> on every series parent in <paramref name="items"/> and on the episodes
/// belonging to it, then drop the episodes and parents left unlinked. An episode is left unlinked when
/// its parent is not among <paramref name="items"/>, which happens when Audible omits the parent from a
/// catalog response or when the parent is a container Libation does not treat as a series (e.g. a season).
/// </summary>
/// <returns>The unlinked items removed from <paramref name="items"/>.</returns>
public static List<Item> LinkEpisodesToSeries(List<Item> items)
{
ArgumentValidator.EnsureNotNull(items, nameof(items));
foreach (var parent in items.Where(i => i.IsSeriesParent))
{
var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true);
SetSeries(parent, children);
}
var unlinked = items.Where(isUnlinked).ToList();
items.RemoveAll(isUnlinked);
return unlinked;
static bool isUnlinked(Item item) => (item.IsEpisodes || item.IsSeriesParent) && item.Series is null;
}
#region episodes and podcasts
@@ -228,15 +336,27 @@ public class ApiExtended
try
{
var sw = Stopwatch.StartNew();
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
//Audible sometimes omits products from a response that is otherwise successful. Those episodes
//would silently disappear from the library, so re-request them before accepting the loss.
var (items, missing) = await FetchRetryingMissingAsync(
asins,
getCatalogProductsAsync,
MissingAsinRetries,
attempt => Serilog.Log.Logger.Debug($"Batch {batchNum} Retry {attempt}: Re-fetching asins Audible did not return"));
sw.Stop();
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
if (missing.Count > 0)
Serilog.Log.Logger.Warning(
"Audible did not return {missingCount} of the {requestedCount} catalog products requested in batch {batchNum}. Those titles are missing from this scan. {@DebugInfo}",
missing.Count,
asins.Count,
batchNum,
new { MissingAsins = missing });
return items;
}
catch (Exception ex)
@@ -247,6 +367,52 @@ public class ApiExtended
finally { semaphore.Release(); }
}
private Task<List<Item>> getCatalogProductsAsync(List<string> asins)
=> Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
/// <summary>Requested asins for which <paramref name="received"/> holds no matching <see cref="Item"/>.</summary>
public static List<string> GetMissingAsins(IEnumerable<string> requested, IEnumerable<Item> received)
{
ArgumentValidator.EnsureNotNull(requested, nameof(requested));
ArgumentValidator.EnsureNotNull(received, nameof(received));
var found = received.Select(i => i.Asin).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase);
return requested.Where(a => !found.Contains(a)).ToList();
}
/// <summary>
/// Fetch <paramref name="asins"/>, re-requesting any that <paramref name="fetch"/> fails to return.
/// Audible's catalog endpoint can answer 200 while quietly omitting products.
/// </summary>
/// <returns>Everything that was returned, plus the asins still unaccounted for after the last attempt.</returns>
public static async Task<(List<Item> Items, List<string> Missing)> FetchRetryingMissingAsync(
List<string> asins,
Func<List<string>, Task<List<Item>>> fetch,
int maxRetries,
Action<int>? onRetry = null)
{
ArgumentValidator.EnsureNotNull(asins, nameof(asins));
ArgumentValidator.EnsureNotNull(fetch, nameof(fetch));
var items = await fetch(asins);
var missing = GetMissingAsins(asins, items);
for (var attempt = 1; attempt <= maxRetries && missing.Count > 0; attempt++)
{
onRetry?.Invoke(attempt);
var retriedItems = await fetch(missing);
items.AddRange(retriedItems);
missing = GetMissingAsins(missing, retriedItems);
}
return (items, missing);
}
public static void SetSeries(Item parent, IEnumerable<Item> children)
{
ArgumentValidator.EnsureNotNull(parent, nameof(parent));
@@ -275,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();
}
@@ -308,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
}
+9 -17
View File
@@ -62,24 +62,16 @@ public static class AudibleApiStorage
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
public static string GetIdentityTokensJsonPath(string username, string? localeName)
{
var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username));
var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName));
var usernameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(username);
var localeNameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(localeName ?? string.Empty);
return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens";
return $"$.Accounts[?(@.AccountId == '{usernameEscaped}' && @.IdentityTokens.LocaleName == '{localeNameEscaped}')].IdentityTokens";
}
private static string trimSurroundingQuotes(string str)
{
// SubString algo is better than .Trim("\"")
// orig string "
// json string "\""
// Eg:
// => str.Trim("\"")
// output \
// vs
// => str.Substring(1, str.Length - 2)
// output \"
// also works with surrounding single quotes
return str.Substring(1, str.Length - 2);
}
/// <summary>
/// Escape a value for use inside single-quoted Newtonsoft JSONPath filter literals.
/// See https://www.newtonsoft.com/json/help/html/QueryJsonSelectTokenEscaped.htm
/// </summary>
internal static string EscapeNewtonsoftJsonPathSingleQuotedLiteral(string value)
=> value.Replace("\\", @"\\").Replace("'", @"\'");
}
@@ -3,11 +3,14 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="10.1.4.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.5" />
<PackageReference Include="Google.Protobuf" Version="3.36.1">
<PrivateAssets>all</PrivateAssets>
<PublicAssets>runtime</PublicAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -27,4 +30,8 @@
<DependentUpon>Cdm.cs</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,49 @@
namespace AudibleUtilities;
public static class AuthenticationExceptionHelper
{
public static bool IsAuthenticationFailure(Exception ex)
{
if (ex is AggregateException aggregate)
{
return aggregate.InnerExceptions.Any(IsAuthenticationFailure)
|| (aggregate.InnerException is not null && IsAuthenticationFailure(aggregate.InnerException));
}
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is AuthenticationRequiredException)
return true;
if (current is InvalidOperationException { Message: var message }
&& message.Contains("ADP token is null", StringComparison.Ordinal))
return true;
}
return false;
}
/// <summary>
/// Finds the <see cref="AuthenticationRequiredException"/> in <paramref name="ex"/> or its inner chain, which is
/// the one that knows which account needs a login.
/// </summary>
public static AuthenticationRequiredException? FindAuthenticationRequired(Exception ex)
{
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is AuthenticationRequiredException auth)
return auth;
}
if (ex is AggregateException aggregate)
{
foreach (var inner in aggregate.InnerExceptions)
{
if (FindAuthenticationRequired(inner) is { } found)
return found;
}
}
return null;
}
}
@@ -0,0 +1,19 @@
namespace AudibleUtilities;
/// <summary>
/// Stored Audible credentials are missing or invalid and interactive login is required.
/// Thrown instead of opening login UI when the caller disallows interactive login (e.g. auto-scan).
/// </summary>
public sealed class AuthenticationRequiredException : Exception
{
/// <summary>
/// A log-safe summary rather than the <see cref="Account"/> itself. Serilog.Exceptions reflects over every
/// public property of a logged exception, so holding the live account published its address - and would have
/// published its activation bytes - into logs people attach to public issue reports.
/// </summary>
public AccountSummary? AccountInfo { get; }
public AuthenticationRequiredException(Account? account, string? message = null, Exception? innerException = null)
: base(message ?? "Audible authentication is required.", innerException)
=> AccountInfo = AccountSummary.From(account);
}
+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();
}
@@ -0,0 +1,286 @@
using AudibleApi.Authorization;
using Dinah.Core.IO;
using Dinah.Core.Security;
using LibationFileManager;
using System.ComponentModel;
using System.Security.Cryptography;
namespace AudibleUtilities;
/// <summary>
/// Applies Libation's <see cref="Configuration.TokenStorageMethod"/> to AudibleApi identity persistence.
/// Changing the preference alone does not convert existing tokens.
/// </summary>
public static class IdentityTokenStorageWiring
{
public const string ApplicationName = "Libation";
/// <summary>Env var: path to a raw 32-byte master key file (from <c>export-master-key</c>).</summary>
public const string MasterKeyFileEnvVar = "LIBATION_MASTER_KEY_FILE";
/// <summary>Env var: Base64-encoded 32-byte master key.</summary>
public const string MasterKeyEnvVar = "LIBATION_MASTER_KEY";
/// <summary>Default master key file name under the Libation files directory.</summary>
public const string DefaultMasterKeyFileName = "libation-master.key";
private static Lock Gate { get; } = new();
private static Configuration? _wiredConfig;
/// <summary>
/// Configure AudibleApi token persistence from <paramref name="config"/> and keep it in sync when the preference changes.
/// </summary>
public static void Apply(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
ConfigureFrom(config);
lock (Gate)
{
if (ReferenceEquals(_wiredConfig, config))
return;
if (_wiredConfig is not null)
_wiredConfig.PropertyChanged -= OnPropertyChanged;
_wiredConfig = config;
_wiredConfig.PropertyChanged += OnPropertyChanged;
}
}
/// <summary>
/// Configure AudibleApi from the current preference without attaching change listeners.
/// Prefer <see cref="Apply"/> at application startup.
/// </summary>
public static void ConfigureFrom(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
var method = config.TokenStorageMethod;
var store = ResolveSecretStore(config);
AesGcmSecretProtector? protector = store.IsAvailable
? new AesGcmSecretProtector(store)
: null;
// Encrypted + unavailable store => protector null => fail-closed on encrypt/decrypt.
IdentityTokenStorage.Configure(method, protector);
}
/// <summary>
/// Resolve the secret store used for the AES-GCM master key.
/// Supported priority: <see cref="MasterKeyFileEnvVar"/> -> existing default <see cref="DefaultMasterKeyFileName"/>
/// under Libation files -> <see cref="MasterKeyEnvVar"/> -> OS-bound <see cref="OsSecretStore"/>.
/// If the OS store is unavailable, falls through to
/// <see cref="TryCreateLastResortPortableMasterKeyStore"/> (headless compatibility path; not the preferred setup).
/// </summary>
public static IOsSecretStore ResolveSecretStore(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
var keyFileEnv = Environment.GetEnvironmentVariable(MasterKeyFileEnvVar);
if (!string.IsNullOrWhiteSpace(keyFileEnv))
return LoadMasterKeyFileOrUnavailable(keyFileEnv.Trim());
var defaultKeyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
if (File.Exists(defaultKeyPath))
return LoadMasterKeyFileOrUnavailable(defaultKeyPath);
var keyEnv = Environment.GetEnvironmentVariable(MasterKeyEnvVar);
if (!string.IsNullOrWhiteSpace(keyEnv))
return LoadMasterKeyBase64OrUnavailable(keyEnv.Trim());
var osStore = OsSecretStore.Create(ApplicationName);
if (osStore.IsAvailable)
return osStore;
return TryCreateLastResortPortableMasterKeyStore(config);
}
/// <summary>True when the OS secret store can hold Libation's encryption master key.</summary>
public static bool IsOsSecretStoreAvailable(out string? unavailableReason)
{
var store = OsSecretStore.Create(ApplicationName);
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
return store.IsAvailable;
}
/// <summary>True when any resolved secret store (portable or OS) can supply an encryption master key.</summary>
public static bool IsEncryptionKeyAvailable(Configuration config, out string? unavailableReason)
{
ArgumentNullException.ThrowIfNull(config);
var store = ResolveSecretStore(config);
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
return store.IsAvailable;
}
/// <summary>Sidecar notice written beside an auto-minted last-resort master key.</summary>
public const string LastResortMasterKeyNoticeFileName = "libation-master.key.NOTICE.txt";
/// <summary>
/// Headless / Docker compatibility path when encryption is enabled but there is no OS secret store
/// and the user did not supply a master key.
/// Prefer setting <see cref="Configuration.TokenStorageMethod"/> to plaintext, or supplying an exported
/// <see cref="DefaultMasterKeyFileName"/> / env key, instead of relying on this path.
/// Creates <see cref="DefaultMasterKeyFileName"/> under Libation files if missing, then loads it.
/// </summary>
internal static IOsSecretStore TryCreateLastResortPortableMasterKeyStore(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
var keyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
try
{
if (!File.Exists(keyPath))
{
MintRawMasterKeyFile(keyPath);
AnnounceLastResortPortableMasterKey(keyPath);
}
return LoadMasterKeyFileOrUnavailable(keyPath);
}
catch (Exception ex)
{
return new UnavailablePortableSecretStore(
"Last-resort portable master key",
$"Could not create or load last-resort portable master key ({keyPath}): {SafeMessage(ex)}");
}
}
/// <summary>
/// Warn that a last-resort portable master key was auto-created. Not the recommended setup.
/// </summary>
internal static void AnnounceLastResortPortableMasterKey(string keyPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyPath);
var fullKeyPath = Path.GetFullPath(keyPath);
var noticePath = Path.Combine(
Path.GetDirectoryName(fullKeyPath) ?? ".",
LastResortMasterKeyNoticeFileName);
var message =
"LAST-RESORT: Token encryption is enabled, but no OS secret store is available and no master key was supplied. "
+ $"Libation created a portable master key at '{fullKeyPath}'. "
+ "This is a compatibility fallback, not the recommended setup. "
+ "Prefer: set TokenStorageMethod to Plaintext in Settings, "
+ $"OR supply your own key (export from desktop and place '{DefaultMasterKeyFileName}' next to AccountsSettings.json, "
+ $"or set {MasterKeyFileEnvVar} / {MasterKeyEnvVar}). "
+ "Treat the key file like a password. It will not decrypt tokens encrypted under a different machine's key.";
try
{
AtomicFileWriter.WriteAllText(noticePath, message + Environment.NewLine);
}
catch (Exception ex)
{
Serilog.Log.Logger.Warning(ex, "Could not write last-resort master key notice file at {NoticePath}", noticePath);
}
Serilog.Log.Logger.Warning(message);
Console.Error.WriteLine(message);
}
/// <summary>Write a new raw 32-byte master key file (same format as <c>export-master-key</c>).</summary>
private static void MintRawMasterKeyFile(string keyPath)
{
var key = new byte[AesGcmSecretProtector.KeySizeBytes];
RandomNumberGenerator.Fill(key);
try
{
var directory = Path.GetDirectoryName(Path.GetFullPath(keyPath));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
AtomicFileWriter.WriteAllBytes(keyPath, key);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
private static IOsSecretStore LoadMasterKeyFileOrUnavailable(string path)
{
var store = new MemoryOsSecretStore();
try
{
MasterKeyPortability.ImportFromFile(store, path);
return store;
}
catch (Exception ex)
{
return new UnavailablePortableSecretStore(
"Portable master key file",
$"Portable master key file is unusable ({path}): {SafeMessage(ex)}");
}
}
private static IOsSecretStore LoadMasterKeyBase64OrUnavailable(string base64)
{
byte[] key;
try
{
key = Convert.FromBase64String(base64);
}
catch (FormatException ex)
{
return new UnavailablePortableSecretStore(
"Portable master key env",
$"{MasterKeyEnvVar} is not valid Base64: {SafeMessage(ex)}");
}
try
{
if (key.Length != AesGcmSecretProtector.KeySizeBytes)
{
return new UnavailablePortableSecretStore(
"Portable master key env",
$"{MasterKeyEnvVar} must decode to {AesGcmSecretProtector.KeySizeBytes} bytes.");
}
var store = new MemoryOsSecretStore();
store.Set(AesGcmSecretProtector.DefaultMasterKeyName, key);
return store;
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
private static string SafeMessage(Exception ex)
=> string.IsNullOrWhiteSpace(ex.Message) ? ex.GetType().Name : ex.Message;
private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(Configuration.TokenStorageMethod))
return;
if (sender is not Configuration config)
return;
ConfigureFrom(config);
}
private sealed class UnavailablePortableSecretStore : IOsSecretStore
{
public UnavailablePortableSecretStore(string name, string reason)
{
Name = name;
UnavailableReason = reason;
}
public string Name { get; }
public bool IsAvailable => false;
public string? UnavailableReason { get; }
public void Set(string key, ReadOnlySpan<byte> value)
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
public bool TryGet(string key, out byte[] value)
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
public void Delete(string key)
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
}
}
+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);
}
}
}
@@ -0,0 +1,39 @@
using Dinah.Core.Security;
namespace AudibleUtilities;
/// <summary>
/// Export Libation's OS-bound AES-GCM master key for portable use (e.g. Docker).
/// Never creates a new key; the desktop OS store must already hold one.
/// </summary>
public static class MasterKeyExport
{
/// <summary>
/// Write the existing Libation master key as raw bytes to <paramref name="filePath"/>.
/// </summary>
/// <exception cref="InvalidOperationException">OS secret store unavailable or master key missing.</exception>
public static void ExportToFile(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason))
{
throw new InvalidOperationException(
"Cannot export the encryption master key because the OS secret store is unavailable."
+ (string.IsNullOrWhiteSpace(unavailableReason) ? "" : " " + unavailableReason));
}
var store = OsSecretStore.Create(IdentityTokenStorageWiring.ApplicationName);
try
{
MasterKeyPortability.ExportToFile(store, filePath);
}
catch (SecretProtectionException ex)
{
throw new InvalidOperationException(
"Cannot export the encryption master key because it was not found. "
+ "Encrypt tokens on this machine at least once (Settings -> Important, store tokens encrypted) so a key exists, then try again.",
ex);
}
}
}
+19 -10
View File
@@ -2,6 +2,7 @@
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using Dinah.Core;
using Dinah.Core.Security;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
@@ -208,7 +209,7 @@ public partial class Mkb79Auth
var authorize = new Authorize(Locale);
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
AccessToken = newToken.TokenValue;
AccessToken = newToken.Reveal();
AccessTokenExpires = newToken.Expires;
var api = new Api(this);
@@ -217,7 +218,8 @@ public partial class Mkb79Auth
{
DecryptKey = ActivationBytes,
AccountName = $"{email} - {Locale.Name}",
IdentityTokens = new Identity(Locale)
IdentityTokens = new Identity(Locale),
LibraryScan = true,
};
account.IdentityTokens.Update(
@@ -225,7 +227,7 @@ public partial class Mkb79Auth
adpToken,
newToken,
refreshToken,
WebsiteCookies?.Select(c => new KeyValuePair<string, string?>(c.Key, c.Value)),
WebsiteCookies?.Select(c => new KeyValuePair<string, SecretString>(c.Key, c.Value)),
DeviceSerialNumber,
DeviceType,
AmazonAccountId,
@@ -235,12 +237,18 @@ 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()
{
AccessToken = account.IdentityTokens?.ExistingAccessToken.TokenValue,
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
AdpToken = account.IdentityTokens?.AdpToken?.Value,
AccessToken = account.IdentityTokens?.ExistingAccessToken.Reveal(),
ActivationBytes = account.DecryptKey.HasValue ? account.DecryptKey.Reveal() : null,
AdpToken = account.IdentityTokens?.AdpToken?.Reveal(),
CustomerInfo = new CustomerInfo
{
AccountPool = "Amazon",
@@ -255,13 +263,14 @@ public partial class Mkb79Auth
DeviceSerialNumber = account.IdentityTokens?.DeviceSerialNumber,
DeviceType = account.IdentityTokens?.DeviceType,
},
DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Value,
DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Reveal(),
AccessTokenExpires = account.IdentityTokens?.ExistingAccessToken.Expires ?? default,
LocaleCode = account.Locale?.CountryCode,
WithUsername = account.Locale?.WithUsername ?? false,
RefreshToken = account.IdentityTokens?.RefreshToken?.Value,
StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie,
WebsiteCookies = new(account.IdentityTokens?.Cookies ?? []),
RefreshToken = account.IdentityTokens?.RefreshToken?.Reveal(),
StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie.Reveal(),
WebsiteCookies = new(account.IdentityTokens?.Cookies
.Select(c => new KeyValuePair<string, string?>(c.Key, c.Value.Reveal())) ?? []),
};
}
@@ -0,0 +1,72 @@
using System;
using System.Threading.Tasks;
namespace AudibleUtilities;
public enum Mkb79ImportOutcome
{
Success,
DuplicateAccount,
InvalidFile,
}
/// <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>
public static async Task<Mkb79ImportResult> ImportFromJsonTextAsync(string jsonText)
{
var mkbAuth = Mkb79Auth.FromJson(jsonText);
if (mkbAuth is null)
{
return new Mkb79ImportResult(
Mkb79ImportOutcome.InvalidFile,
null,
"File did not contain valid mkb79/audible-cli account data.");
}
var account = await mkbAuth.ToAccountAsync();
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
// 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);
}
}
@@ -0,0 +1,69 @@
using AudibleApi;
using System;
using System.Collections.Generic;
namespace AudibleUtilities;
/// <summary>
/// Libation-facing helpers when Audible returns HTML instead of JSON (library scan, catalog, etc.).
/// </summary>
public static class NonJsonResponseExceptionExtensions
{
public const string LibraryScanFailedCaption = "Library scan failed";
private static readonly string[] ThingsToTryBullets =
[
"Scan again after a few minutes.",
"Sign in to Audible in a browser on the same network.",
"Disable VPN or proxy and scan again.",
"Remove and re-add the account in Libation.",
"If it still fails, check the log for Full body: near the error and attach it to a bug report.",
];
public static IEnumerable<string> GetExplainerLines(this NonJsonResponseException ex)
{
ArgumentNullException.ThrowIfNull(ex);
yield return getIntro(ex);
yield return string.Empty;
yield return "Things to try:";
foreach (var bullet in ThingsToTryBullets)
yield return "• " + bullet;
}
public static string GetExplainerBody(this NonJsonResponseException ex)
=> string.Join("\r\n", ex.GetExplainerLines());
public static bool TryFindInTree(Exception ex, out NonJsonResponseException? match)
{
ArgumentNullException.ThrowIfNull(ex);
NonJsonResponseException? found = null;
walk(ex);
match = found;
return found is not null;
void walk(Exception? e)
{
if (e is null || found is not null)
return;
if (e is NonJsonResponseException nonJson)
found = nonJson;
if (found is not null)
return;
if (e is AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
walk(inner);
}
walk(e.InnerException);
}
}
private static string getIntro(NonJsonResponseException ex)
=> ex.HtmlTitle is null
? "Audible returned an HTML page instead of the expected library data."
: $"Audible returned an HTML page ({ex.HtmlTitle}) instead of the expected library data.";
}
+1 -1
View File
@@ -290,7 +290,7 @@ public partial class Cdm
private static uint RandomUint()
{
var bts = new byte[4];
new Random().NextBytes(bts);
RandomNumberGenerator.Fill(bts);
return BitConverter.ToUInt32(bts, 0);
}
}
@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<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.2">
<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,512 @@
// <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("20260427201825_ReAddCategoryName2")]
partial class ReAddCategoryName2
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.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<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.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,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Postgres.Migrations;
/// <inheritdoc />
public partial class ReAddCategoryName2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Name",
table: "Categories",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Name",
table: "Categories");
}
}
@@ -0,0 +1,539 @@
// <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("20260814170831_AddDownloadHistory")]
partial class AddDownloadHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.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<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.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,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddDownloadHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DownloadHistory",
columns: table => new
{
DownloadHistoryId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CompletedAtUtcTicks = table.Column<long>(type: "bigint", nullable: false),
AudibleProductId = table.Column<string>(type: "text", nullable: true),
IsAudiblePlus = table.Column<bool>(type: "boolean", nullable: false),
Bytes = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId);
});
migrationBuilder.CreateIndex(
name: "IX_DownloadHistory_CompletedAtUtcTicks",
table: "DownloadHistory",
column: "CompletedAtUtcTicks");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DownloadHistory");
}
}
}
@@ -0,0 +1,578 @@
// <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("20260816160446_AddDownloadAttemptFailures")]
partial class AddDownloadAttemptFailures
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.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<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,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddDownloadAttemptFailures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DownloadAttemptFailures",
columns: table => new
{
DownloadAttemptFailureId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AudibleProductId = table.Column<string>(type: "text", nullable: false),
Account = table.Column<string>(type: "text", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
ConsecutiveFailures = table.Column<int>(type: "integer", nullable: false),
LastFailedAtUtcTicks = table.Column<long>(type: "bigint", nullable: false),
RetryAfterUtcTicks = table.Column<long>(type: "bigint", nullable: false),
Reason = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId);
});
migrationBuilder.CreateIndex(
name: "IX_DownloadAttemptFailures_Account_AudibleProductId",
table: "DownloadAttemptFailures",
columns: new[] { "Account", "AudibleProductId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DownloadAttemptFailures");
}
}
}
@@ -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.2")
.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");
@@ -148,6 +151,10 @@ namespace DataLayer.Postgres.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
@@ -197,6 +204,72 @@ namespace DataLayer.Postgres.Migrations
});
});
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")
@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<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.2">
<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,495 @@
// <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("20260427201829_ReAddCategoryName2")]
partial class ReAddCategoryName2
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
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<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.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,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations;
/// <inheritdoc />
public partial class ReAddCategoryName2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Name",
table: "Categories",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Name",
table: "Categories");
}
}
@@ -0,0 +1,520 @@
// <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("20260814170839_AddDownloadHistory")]
partial class AddDownloadHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
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<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.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,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddDownloadHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DownloadHistory",
columns: table => new
{
DownloadHistoryId = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
CompletedAtUtcTicks = table.Column<long>(type: "INTEGER", nullable: false),
AudibleProductId = table.Column<string>(type: "TEXT", nullable: true),
IsAudiblePlus = table.Column<bool>(type: "INTEGER", nullable: false),
Bytes = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId);
});
migrationBuilder.CreateIndex(
name: "IX_DownloadHistory_CompletedAtUtcTicks",
table: "DownloadHistory",
column: "CompletedAtUtcTicks");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DownloadHistory");
}
}
}
@@ -0,0 +1,557 @@
// <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("20260816160439_AddDownloadAttemptFailures")]
partial class AddDownloadAttemptFailures
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
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<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,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddDownloadAttemptFailures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DownloadAttemptFailures",
columns: table => new
{
DownloadAttemptFailureId = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AudibleProductId = table.Column<string>(type: "TEXT", nullable: false),
Account = table.Column<string>(type: "TEXT", nullable: false),
Kind = table.Column<int>(type: "INTEGER", nullable: false),
ConsecutiveFailures = table.Column<int>(type: "INTEGER", nullable: false),
LastFailedAtUtcTicks = table.Column<long>(type: "INTEGER", nullable: false),
RetryAfterUtcTicks = table.Column<long>(type: "INTEGER", nullable: false),
Reason = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId);
});
migrationBuilder.CreateIndex(
name: "IX_DownloadAttemptFailures_Account_AudibleProductId",
table: "DownloadAttemptFailures",
columns: new[] { "Account", "AudibleProductId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DownloadAttemptFailures");
}
}
}
@@ -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.2");
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");
@@ -139,6 +142,10 @@ namespace DataLayer.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
@@ -184,6 +191,68 @@ namespace DataLayer.Migrations
});
});
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")
Loaded 100 of 622 files, more files were not shown because too many files have changed in this diff. Show more