Compare commits

...
2241 Commits
Author SHA1 Message Date
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
rmcrackan 3fece6aa44 Merge pull request #1718 from rmcrackan/rmcrackan/1716
#1716 - fix NAS bug
2026-04-05 15:47:41 -04:00
rmcrackan a7c7bd9925 #1716 - fix NAS bug 2026-04-05 15:41:58 -04:00
rmcrackan 3373b0979a Merge pull request #1713 from Jo-Be-Co/first-tag
Add `<first tag[...]>`
2026-04-03 09:19:50 -04:00
Jo-Be-Co 3e3da2298e Add <first tag[...]> 2026-04-03 03:48:09 +02:00
rmcrackan a9cb560f95 Merge pull request #1709 from rmcrackan/rmcrackan/plus-warning
Give better feedback to user with 'plus' license denied
2026-03-31 17:42:51 -04:00
rmcrackan e4282f014e remove close issues search link 2026-03-31 17:38:32 -04:00
rmcrackan b694e319f7 Give better feedback to user when they're getting persistent 'license denied' errors which are likely caused by too many Plus downloads 2026-03-31 17:31:40 -04:00
rmcrackan 67e3e41b15 Merge pull request #1708 from rmcrackan/rmcrackan/1704-mkb79import
fix bug with importing mkb79 auth with website_cookies: null vs empty
2026-03-31 13:22:33 -04:00
rmcrackan ad2101ab8c fix bug with importing mkb79 auth with website_cookies: null vs empty 2026-03-30 15:10:55 -04:00
rmcrackan d7ea8eb8f1 Merge pull request #1707 from rmcrackan/rmcrackan/webview-err-msg
better error messages when webview is the problem
2026-03-30 14:22:51 -04:00
rmcrackan 5c1153e187 better error messages when webview is the problem. shouldn't appear like a scan error 2026-03-30 14:22:09 -04:00
rmcrackan e929ab3062 Merge pull request #1706 from rmcrackan/dependabot/github_actions/actions/configure-pages-6
Bump actions/configure-pages from 5 to 6
2026-03-30 11:43:22 -04:00
dependabot[bot] d64fd564ab Bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 15:38:21 +00:00
rmcrackan 340b0a0d29 Merge pull request #1705 from rmcrackan/rmcrackan/fix-template-esc
fix escaped html tags
2026-03-30 11:27:51 -04:00
rmcrackan 1f217b3966 fix escaped html tags 2026-03-30 11:26:53 -04:00
rmcrackan accb43456b Merge pull request #1700 from rmcrackan/rmcrackan/fix-templates-docs
fix tags in templates doc
2026-03-27 16:22:57 -04:00
rmcrackan b3a781de5e fix tags in templates doc 2026-03-27 16:21:32 -04:00
rmcrackan 34613f7e97 Merge pull request #1699 from rmcrackan/rmcrackan-patch-1
Update naming-templates.md
2026-03-27 16:02:38 -04:00
rmcrackan 1def16a157 Update naming-templates.md 2026-03-27 16:02:27 -04:00
rmcrackan 33a209df3c Merge pull request #1690 from Jo-Be-Co/improve-formatting-tags
Improve formatting tags
2026-03-27 15:56:51 -04:00
Jo-Be-Co 2daf2da30a Test fixes 2026-03-27 09:11:00 +01:00
Jo-Be-Co dd2bc7c461 Documentation for TimeSpan. 2026-03-27 01:14:42 +01:00
Jo-Be-Co 1d1936734b Test hardening.
Ensure tests check error and warning output to confirm assertions are correctly formulated.
2026-03-27 00:06:17 +01:00
rmcrackan e7f6e71d3b Merge pull request #1698 from rmcrackan/1692-mac-rt-clk-attempt2
#1692 : 2nd attempt to fix mac rt-clk issue
2026-03-26 16:23:51 -04:00
rmcrackan 0da8af3b8f incr ver 2026-03-26 16:17:51 -04:00
rmcrackan f159bcdbb9 2nd attempt 2026-03-26 15:26:06 -04:00
rmcrackan fc85aa457c Merge pull request #1695 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-03-26 10:40:55 -04:00
rmcrackan 687a51b7bc Merge pull request #1696 from rmcrackan/dependabot/github_actions/actions/deploy-pages-5
Bump actions/deploy-pages from 4 to 5
2026-03-26 10:40:25 -04:00
dependabot[bot] 477d0088e2 Bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 14:12:37 +00:00
dependabot[bot] 38138d8272 Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [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/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 14:12:33 +00:00
Jo-Be-Co fc92c633e1 No made up format for minutes as .NET already knows how to format them.
User documentation needs to follow.
2026-03-26 03:12:24 +01:00
Jo-Be-Co 3c71cb10b2 some minor fixes 2026-03-26 03:10:43 +01:00
rmcrackan 65d8adc018 Merge pull request #1693 from rmcrackan/rmcrackan/close-stale
#1532 -- issue cleanup. Close stale tickets. Thanks @HenraL !
2026-03-25 11:13:30 -04:00
rmcrackan 9e739e48d7 #1532 -- issue cleanup. Close stale tickets. Thanks @HenraL ! 2026-03-25 10:59:12 -04:00
Jo-Be-Co 6b26280aa8 Get formats for properties in given form to the formatters by no unescaping them in advance. 2026-03-25 14:23:03 +01:00
Jo-Be-Co 14d8bb2205 As stated in the "Naming Templates" documentation, support custom formatters on numbers.
Only trim spaces on tags where they are disruptive.
2026-03-25 02:34:32 +01:00
rmcrackan 0960edeafb Add contributor: Jo-Be-Co 2026-03-24 15:16:17 -04:00
Jo-Be-Co 6d2b0b952d - Inspect exception on regexp timeout
- special tests for checks with whitespace
- Escaping allows whitespace at the edges
2026-03-24 00:18:40 +01:00
Jo-Be-Co fb277cff81 Faulty regex patterns will throw InvalidOperationException during evaluation. 2026-03-23 15:29:32 +01:00
Jo-Be-Co dbd5af8ab0 QS second part:
Made Evaluate() more resilient.
Added tests to CommonFormatters.
2026-03-22 22:22:02 +01:00
Jo-Be-Co 02b9b4fa1c QS updates:
- fixed documentation
- regexp-checks running with timeout and culture-invariant matching
- changed check-building in ConditionalTagCollection to use NonNull parameters. So no warnings occure.
- add tests for <!is ...> and escaped chars
2026-03-22 19:18:55 +01:00
Jo-Be-Co 7ecd571364 Corrections to documentation and comments. 2026-03-22 13:55:28 +01:00
Jo-Be-Co 32f3e6a886 Introduces the slice() format directive 2026-03-22 13:55:27 +01:00
Jo-Be-Co e477c29890 New tags <minutes>, <tag> and <if abridged->...<-if abridged> 2026-03-22 13:55:27 +01:00
Jo-Be-Co a8621699c1 Add numerical checks for conditional tag.
- numbers are compared with their value
- strings are compared by checking the length
- Collections are evaluated on cheking their size
2026-03-22 13:55:27 +01:00
Jo-Be-Co cd9a070784 Allow two step formatting to get checkable values and not only single stringa
Introduce <is-> Tag. Like <has-> but with additional check on content.

Retrieve objects instead of string for conditions

Pass undefined formats as null instead of empty strings
2026-03-22 13:55:26 +01:00
Jo-Be-Co d8ce7cc9b0 Formats are now escapable and the evaluation of HasValue has been transformed into a configuration parameter. 2026-03-22 13:55:26 +01:00
Jo-Be-Co 1d6d4ff9ac GetTagExpression uses Dictionary for capture names from Regexes 2026-03-22 13:55:26 +01:00
Jo-Be-Co db3a810f47 Regular Expressions with capture names on coments 2026-03-22 13:55:26 +01:00
Jo-Be-Co 508ea1032d Tests are using empty values for various fields 2026-03-22 13:55:25 +01:00
Jo-Be-Co 10fc5837cb Allow sorting in reverse order by specifying the sort key with lowercase 2026-03-22 13:55:25 +01:00
Jo-Be-Co 812e0c3b60 Format details on fields specified in format. Introduce by colon.
Use dictionaries for field access and sorting.

Added sorting to series lists.
2026-03-22 13:55:25 +01:00
Jo-Be-Co d161bdfaeb Introduce culture parameter for formatting. 2026-03-22 13:55:24 +01:00
Jo-Be-Co a6aa14c7f8 Collect formatters into CommonFormatters 2026-03-22 13:55:24 +01:00
Jo-Be-Co 7dd55a2545 Refactorings 2026-03-22 13:55:24 +01:00
rmcrackan 1081dfe8df Merge pull request #1689 from rmcrackan/rmcrackan/mac-yaml
mac yaml. print stdout, stderr
2026-03-18 14:53:27 -04:00
rmcrackan 3f716cd1ac - **Fix:** When xcrun notarytool submit failed (e.g. Apple TOS / "sign agreement" required), the job failed immediately and the real error never appeared in the workflow log.
- **Change:** Capture notarytool stdout+stderr with `|| true` so the script always runs `echo "$RESPONSE"`, then fail the step explicitly if no submission id is found. The job still fails on errors, but the full notarytool output (including stderr) is now visible in the Actions log.
- **Result:** Failures like "you must accept the agreement" show up in the run so you don’t have to reproduce them locally to see the message.
2026-03-18 14:49:02 -04:00
Robert 338715d52a incr ver 2026-03-17 23:07:30 -04:00
rmcrackan d275ad034b Merge pull request #1688 from rmcrackan/rmcrackan/macos-build
attempt to fix Bundle_MacOS
2026-03-17 23:04:23 -04:00
rmcrackan b5ecff7e27 attempt to fix Bundle_MacOS 2026-03-17 23:03:43 -04:00
Robert 3144506ad5 incr ver 2026-03-17 22:30:05 -04:00
rmcrackan 99629c3c25 Merge pull request #1678 from rmcrackan/rmcrackan/screenreader-msg
Add info in walkthrough for screen-reader users
2026-03-17 22:19:07 -04:00
rmcrackan 3fdb8ef15d Merge pull request #1679 from rmcrackan/rmcrackan/1677-linux-crash
Rmcrackan/1677 linux crash
2026-03-17 22:18:54 -04:00
rmcrackan a43a700e37 Merge pull request #1686 from rmcrackan/rmcrackan/empty-cookies
don't set cookies without values. Attempting to solve #1682
2026-03-17 22:18:45 -04:00
rmcrackan 80823bf15c don't set cookies without values. Attempting to solve #1682 2026-03-17 16:59:11 -04:00
rmcrackan 0a576069df clean up essential file validation 2026-03-12 15:27:04 -04:00
rmcrackan 0b0f5184d2 Validate essential files early 2026-03-12 14:55:14 -04:00
rmcrackan 24d825607d * Reads relative paths from appsettings and resolves them to absolute once.
* Writes only absolute paths for LibationFiles, so future reads and all callers see a single, consistent path.
2026-03-12 13:57:39 -04:00
rmcrackan 14e4ea4592 Resolve to an absolute path when reading 2026-03-12 13:53:27 -04:00
rmcrackan 56720124b8 Add info in walkthrough for screen-reader users 2026-03-12 11:43:06 -04:00
Robert 20c7d72181 incr ver 2026-03-12 09:30:22 -04:00
rmcrackan 0d00cf2b10 Merge pull request #1676 from rmcrackan/rmcrackan/1625-mac-key-bindings
Bug fix #1625 -- fix mac key bindings
2026-03-12 09:23:16 -04:00
rmcrackan a79da66b0e Bug fix #1625 -- fix mac key bindings 2026-03-12 09:16:41 -04:00
rmcrackan fdc56e9ad6 Merge pull request #1673 from rmcrackan/rmcrackan/fix-naming-template-links
Fix naming template links
2026-03-11 13:54:23 -04:00
rmcrackan ddef3b649b Fix naming template links 2026-03-11 13:45:59 -04:00
rmcrackan 01c2a3b1c3 Merge pull request #1672 from rmcrackan/rmcrackan/linux-snap-no-webview
WebView breaks catastrophically under Linux Snap
2026-03-11 13:27:34 -04:00
rmcrackan 4eec61a659 another build fix attempt 2026-03-11 13:23:21 -04:00
rmcrackan c983513f45 attempt to fix build failures 2026-03-11 13:20:44 -04:00
rmcrackan 0172c7b8eb add 'linux' to wording 2026-03-11 13:07:10 -04:00
rmcrackan 5cf710756f Bug fix #1664 - WebView breaks catastrophically under Linux Snap -- segfault with no logged errors 2026-03-11 13:00:19 -04:00
rmcrackan 760b0dcb6b Merge pull request #1670 from rmcrackan/rmcrackan/access1668-1
First first 2 items in #1668 -- accessibility bugs
2026-03-10 15:53:44 -04:00
rmcrackan 6c602286c0 First first 2 items in #1668 -- accessibility bugs 2026-03-10 15:51:25 -04:00
rmcrackan 8e69191bbf Merge branch 'master' of https://github.com/rmcrackan/Libation 2026-03-07 16:21:11 -05:00
rmcrackan 58095f291b When displaying upgrade notes, exclude <!-- NO-APP --> blocks 2026-03-07 16:21:04 -05:00
rmcrackan 50319defa9 Merge pull request #1666 from rmcrackan/rmcrackan-patch-2
Update command-line-interface.md
2026-03-07 15:29:26 -05:00
rmcrackan fd35b6dd85 Update command-line-interface.md 2026-03-07 15:29:10 -05:00
rmcrackan 2da2d1c29e Merge pull request #1665 from rmcrackan/rmcrackan-cli-details-block
Update command-line-interface.md
2026-03-07 15:27:26 -05:00
rmcrackan 64c5807daa Update command-line-interface.md 2026-03-07 15:27:09 -05:00
rmcrackan 1e8358eeeb Merge pull request #1663 from cvigano/add-replacement-char-documentation
Add documentation on how to customize replacement characters on CLI
2026-03-07 15:22:34 -05:00
Christoph Vigano 2868039772 Add documentation on how to customize replacement characters on CLI 2026-03-07 13:55:34 +01:00
rmcrackan 45af5aa913 Merge pull request #1662 from rmcrackan/dependabot/github_actions/docker/build-push-action-7
Bump docker/build-push-action from 6 to 7
2026-03-06 09:54:49 -05:00
rmcrackan 487fcb007c Merge pull request #1661 from rmcrackan/dependabot/github_actions/docker/metadata-action-6
Bump docker/metadata-action from 5 to 6
2026-03-06 09:54:19 -05:00
dependabot[bot] 76d4a44040 Bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:13:14 +00:00
dependabot[bot] 07d9fff2b3 Bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 14:13:10 +00:00
rmcrackan bf1e90c33c incr ver 2026-03-05 12:11:03 -05:00
rmcrackan c30a79d97b Fix release workflow YAML: quote body to avoid < parse error 2026-03-05 12:08:03 -05:00
Robert 737121963f incr ver 2026-03-05 11:54:07 -05:00
rmcrackan 6b319fe1a7 Merge pull request #1658 from rmcrackan/rmcrackan/bugfix-save-templates
Bug fix #1657 - error in saving templates
2026-03-05 11:47:23 -05:00
rmcrackan 70ef1c52a4 Bug fix #1657 - error in saving templates 2026-03-05 11:46:35 -05:00
rmcrackan bdf5f2faea Merge pull request #1656 from rmcrackan/rmcrackan/widget-edits
#1652 - widget eidts
2026-03-05 10:16:57 -05:00
rmcrackan 5ff3dc7581 #1652 - widget eidts 2026-03-05 10:16:25 -05:00
rmcrackan 41fcef207b Merge pull request #1655 from rmcrackan/rmcrackan/widget-styling
#1652 - edit widget styling
2026-03-05 10:08:02 -05:00
rmcrackan 4edc77aa00 #1652 - edit widget styling 2026-03-05 10:07:31 -05:00
rmcrackan 6d05391151 Merge pull request #1654 from rmcrackan/rmcrackan/vitepress-fix
#1652: Fix vitepress publish
2026-03-05 09:49:16 -05:00
rmcrackan f9e2af6916 Fix vitepress publish 2026-03-05 09:48:57 -05:00
rmcrackan 75e9a7db1f Merge pull request #1653 from rmcrackan/rmcrackan/dl-suggester
#1652: add a homepage widget: download version suggester
2026-03-05 09:41:02 -05:00
rmcrackan ee92988125 #1652: add a homepage widget: download version suggester 2026-03-05 09:35:53 -05:00
rmcrackan c005af2457 Merge pull request #1651 from rmcrackan/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 3 to 4
2026-03-05 09:25:24 -05:00
dependabot[bot] 89ae5709a9 Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-05 14:13:42 +00:00
rmcrackan 9c9f1c67c8 Merge pull request #1650 from rmcrackan/rmcrackan/autoscan-default-false
AutoScan defaultValue: false
2026-03-04 15:12:55 -05:00
rmcrackan d637068f64 AutoScan defaultValue: false 2026-03-04 15:12:25 -05:00
rmcrackan 490d292c18 Merge pull request #1648 from rmcrackan/dependabot/github_actions/docker/setup-qemu-action-4
Bump docker/setup-qemu-action from 3 to 4
2026-03-04 09:39:09 -05:00
rmcrackan 8417b121a4 Merge pull request #1647 from rmcrackan/dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 3 to 4
2026-03-04 09:38:45 -05:00
dependabot[bot] 72edd1c896 Bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 14:14:26 +00:00
dependabot[bot] 4e8306470c Bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 14:14:23 +00:00
rmcrackan bdbeeb8935 Merge pull request #1646 from rmcrackan/rmcrackan/cli-doc-progressbar
document CLI progress bar option
2026-03-03 17:19:47 -05:00
rmcrackan 1b12dd04ff document CLI progress bar option 2026-03-03 17:18:38 -05:00
rmcrackan 589b806aff Merge pull request #1645 from rmcrackan/rmcrackan/cli-docs
Break out CLI into its own page
2026-03-03 16:45:28 -05:00
rmcrackan c248bc0a04 Break out CLI into its own page 2026-03-03 16:44:56 -05:00
Robert db4ef4b321 incr ver 2026-03-03 15:44:12 -05:00
rmcrackan ca7d3feaf9 Merge pull request #1642 from rmcrackan/rmcrackan/improved-upgrade-check
Upgrade checks to include: yes, no, don't know
2026-03-02 08:47:55 -05:00
rmcrackan 2b0b1ac798 comment 2026-03-02 08:47:28 -05:00
rmcrackan 41e0aa0dfb Upgrade checks to include: yes, no, don't know 2026-03-02 08:44:18 -05:00
rmcrackan f3eb4d9f87 Merge pull request #1641 from rmcrackan/rmcrackan-linux-deb-version
Update .releaseindex.json. Add Linux DEB
2026-03-02 08:41:14 -05:00
rmcrackan ae157a1506 Update .releaseindex.json 2026-03-02 08:40:55 -05:00
rmcrackan a7654745b6 Merge pull request #1640 from rmcrackan/rmcrackan/lucene-exp-backoff
Lucene search engine retries to use exponential backoff
2026-03-02 08:05:21 -05:00
rmcrackan 8dad104121 fix file encoding 2026-03-02 08:04:48 -05:00
rmcrackan 5bf1438e00 exp bak comments 2026-03-02 08:04:04 -05:00
rmcrackan da3d057372 Lucene search engine retries to use exponential backoff 2026-03-02 07:59:45 -05:00
Robert 9fec47e8ab incr ver 2026-03-01 10:07:44 -05:00
rmcrackan a218ed5e66 update AudibleApi 2026-02-28 17:03:51 -05:00
rmcrackan 4f28268785 Merge pull request #1638 from rmcrackan/rmcrackan/lucene-corrupt
Bug fix #1628 , #1636 : Address lucene search engine corruption
2026-02-28 16:29:06 -05:00
rmcrackan e3e55a30e1 Bug fix #1628 , #1636 : Address lucene search engine corruption 2026-02-28 16:28:33 -05:00
rmcrackan b157d760ec Merge pull request #1634 from rmcrackan/dependabot/npm_and_yarn/rollup-4.59.0
Bump rollup from 4.53.4 to 4.59.0
2026-02-27 09:55:55 -05:00
dependabot[bot] 60c532a3bc Bump rollup from 4.53.4 to 4.59.0
Bumps [rollup](https://github.com/rollup/rollup) from 4.53.4 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.53.4...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 14:54:46 +00:00
rmcrackan 2a348df4ac Merge pull request #1633 from rmcrackan/dependabot/github_actions/actions/upload-artifact-7
Bump actions/upload-artifact from 6 to 7
2026-02-27 09:54:24 -05:00
rmcrackan bd881d4dc4 Merge pull request #1632 from rmcrackan/dependabot/github_actions/actions/download-artifact-8
Bump actions/download-artifact from 7 to 8
2026-02-27 09:53:47 -05:00
dependabot[bot] a6a9b33d01 Bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 14:13:38 +00:00
dependabot[bot] a77fe73153 Bump actions/download-artifact from 7 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 14:13:29 +00:00
rmcrackan 8d77787df7 Merge pull request #1622 from rmcrackan/rmcrackan-lucene-attrib
Update lucene.md
2026-02-22 09:03:30 -05:00
rmcrackan 44fe5b95f1 Update lucene.md 2026-02-22 09:03:03 -05:00
rmcrackan b8d7a956cd Merge pull request #1615 from rmcrackan/rmcrackan-lucene-4
Update searching-and-filtering.md
2026-02-18 09:06:27 -05:00
rmcrackan 002bdde444 Update searching-and-filtering.md
Attempting to force update for docs sidebar
2026-02-18 09:06:14 -05:00
rmcrackan 642eeecc0a Merge pull request #1614 from rmcrackan/rmcrackan-lucene-docs-3
Update config.js
2026-02-18 09:02:53 -05:00
rmcrackan 3343ef263c Update config.js 2026-02-18 09:02:33 -05:00
rmcrackan 6cf51c63c6 Merge pull request #1613 from rmcrackan/rmcrackan-patch-1
Update index.md
2026-02-18 08:58:09 -05:00
rmcrackan c34342ecb5 Update index.md 2026-02-18 08:57:56 -05:00
rmcrackan 53e5b635a3 Merge pull request #1612 from rmcrackan/rmcrackan-lucene-docs
Update searching-and-filtering.md
2026-02-18 08:54:41 -05:00
rmcrackan e6b12914a4 Update searching-and-filtering.md 2026-02-18 08:54:09 -05:00
rmcrackan 28327efd7b Create lucene.md 2026-02-18 08:52:38 -05:00
rmcrackan f17af04908 Merge pull request #1601 from Mbucari/master
Enable project-wide nullable reference types
2026-02-09 17:30:06 -05:00
Michael Bucari-Tovo 3ab1edc076 Code Cleanup
Make fields readonly
Remove unnecessary casts
Format document
Remove unnecessary usings
Sort usings
Use file-level namespaces
Order modifiers
2026-02-05 12:48:44 -07:00
Michael Bucari-Tovo d67692355f Enable project-wide nullable reference types
Added DB migration for nullable types.
2026-02-05 12:43:05 -07:00
rmcrackan f15e073204 Merge pull request #1589 from rmcrackan/rmcrackan-master-1
Update config.js
2026-02-03 10:07:39 -05:00
rmcrackan f00951d615 Update config.js
"Edit this page" went to 'main' instead of 'master'
2026-02-03 10:07:17 -05:00
Robert c7e844a54c incr ver 2026-02-03 09:38:30 -05:00
rmcrackan 178cc82d65 Merge pull request #1587 from Mbucari/master
Documentation and bug fixes
2026-02-03 09:05:06 -05:00
Michael Bucari-Tovo 054f7437d1 Fix importer NRE 2026-02-02 16:02:44 -07:00
Mbucari 99051b6975 Merge branch 'rmcrackan:master' into master 2026-01-31 23:52:05 -07:00
Robert eed7f1811b incr ver 2026-01-31 23:33:29 -05:00
rmcrackan 33cd6b8639 Merge pull request #1585 from thoughtbox/patch-2
Fix typos
2026-01-31 23:31:16 -05:00
rmcrackan 06014f467e Merge pull request #1581 from scagood/patch-1
Remove progress bar in non interactive mode (#1481)
2026-01-31 23:29:04 -05:00
Tor Houghton b2eef18217 Fix typos
buit-in -> built-in
broswer -> browser
2026-01-31 22:19:53 +01:00
Robert 443d1f64ca incr ver 2026-01-31 12:36:46 -05:00
Robert c43e88d269 verbose null checking to debug #1578 2026-01-31 12:36:29 -05:00
Michael Bucari-Tovo dc1919f411 Update AAXClean to 3.0.2 and fix deprecation warnings 2026-01-30 14:46:11 -07:00
Mbucari fdfae2f806 Merge branch 'rmcrackan:master' into master 2026-01-30 14:42:13 -07:00
Sebastian Good 5c56e1d39b Correct small spelling mistake 2026-01-30 16:12:48 +00:00
Sebastian Good b39e2c3e0b Remove progress bar in non interactive mode (#1481) 2026-01-30 16:12:44 +00:00
Robert 946d4779a9 incr ver 2026-01-28 07:47:10 -05:00
Robert e620d0be24 remove unneeded coalesce 2026-01-28 07:44:32 -05:00
Mbucari d58f5abe35 Add Troubleshooting link to Advanced topics
Added 'Troubleshooting' link to the Advanced section.
2026-01-27 14:58:37 -07:00
Mbucari d957d6d5d7 Update troubleshooting guide with Hangover app details
Added troubleshooting information for the Hangover app and SQLite Error 10.
2026-01-27 14:53:08 -07:00
Michael Bucari-Tovo cb159336a6 Enable NTRs on main form and fix resulting warnings. 2026-01-27 12:42:21 -07:00
Michael Bucari-Tovo 807bb56c49 Refactor Chardonnay startup to prevent loading library twice. 2026-01-27 12:31:17 -07:00
Mbucari 3d56554aa5 Merge branch 'rmcrackan:master' into master 2026-01-27 12:29:03 -07:00
rmcrackan f720127a68 Merge pull request #1579 from rmcrackan/rmcrackan/bug-1578
Bug #1578 Add null checks
2026-01-27 13:22:20 -05:00
Robert c6c3b39e9c Bug #1578 Adding null checks to the line that's logged with NRE. This bit of code has been untouched for years and is run hundreds of times per day. I'm documenting this suspiciously 2026-01-27 09:06:46 -05:00
rmcrackan e70cc5342c Merge pull request #1572 from shuvashish76/patch-1
Add AppImage installation instructions
2026-01-23 09:45:12 -05:00
shuvashish76 bf0d380dd2 Add AppImage installation instructions
Added installation instructions for AppImage using AppMan and AM.
2026-01-23 14:41:46 +05:30
Michael Bucari-Tovo 471fd1e757 Remove unwanted meta box from dash files 2026-01-22 16:33:12 -07:00
Robert a43f25db23 incr ver 2026-01-22 17:50:31 -05:00
rmcrackan e1f4168599 Merge pull request #1567 from Mbucari/master
Minor big fixes and performance improvements
2026-01-22 17:48:31 -05:00
Michael Bucari-Tovo 29501bddf3 Clean up queries
Use .NET 10 extensions
Refactor some extensions for clarity
2026-01-22 13:36:25 -07:00
Michael Bucari-Tovo e9016ace03 Make IncludedUntil and LastDownload nulls always sort to bottom 2026-01-21 13:41:47 -07:00
Michael Bucari-Tovo d498c094bf Update AAXClean
Some minor performance improvements, especially when opening a file/stream.
2026-01-21 13:40:59 -07:00
Michael Bucari-Tovo ce92e79cd8 Fix SaferEnumerateFiles creating too manu open file handles (#1565)
Using the EnumerationOptions overload of EnumerateFiles() serves the same function as the previous manual implementation.
2026-01-21 10:34:59 -07:00
Michael Bucari-Tovo f54a789ae8 Improve querying UnLiberated books
Add GetUnliberated_Flat_NoTracking() which queries only unliberated books/episodes and does not load the entire library.

Fix UnLiberated() query to only return products or episodes (not parents) (#1564)
2026-01-19 16:57:32 -07:00
Michael Bucari-Tovo 3ef0bce909 No reason to restrict CDM type to Android or Level to L3 2026-01-19 14:16:47 -07:00
Robert 413da72bf0 logging typo 2026-01-19 10:31:34 -05:00
Robert db9c810c2d incr ver 2026-01-13 20:05:28 -05:00
rmcrackan ae18ae1b8d Merge pull request #1558 from Mbucari/master
Fix export, remove spatial audio option, and add file move progress.
2026-01-13 19:56:24 -05:00
Michael Bucari-Tovo c1298e9ff6 Update dependencies 2026-01-13 12:02:36 -07:00
Michael Bucari-Tovo 647eb8b9d9 Use MemoryPool 2026-01-13 11:35:59 -07:00
Michael Bucari-Tovo fd64d394c2 Remove spatial audio options #1553 2026-01-13 09:43:51 -07:00
Michael Bucari-Tovo f026d415bd Fix library export #1552 2026-01-13 09:35:08 -07:00
Michael Bucari-Tovo 3948e25c99 Add file move progress 2026-01-12 14:34:52 -07:00
Robert 6df47d4d9c incr ver 2026-01-12 16:32:16 -05:00
rmcrackan 0103c879f2 Merge pull request #1551 from Mbucari/master
Update dependencies
2026-01-12 14:17:05 -05:00
Michael Bucari-Tovo 79e2bca8fc Update dependencies 2026-01-12 10:42:04 -07:00
rmcrackan 7bbc681767 Merge pull request #1548 from Mbucari/master
Improved trash bin, new book properties, and bug fixes
2026-01-11 17:54:53 -05:00
Mbucari c95dccd246 Add confirmation dialog when removing books from Audible 2026-01-11 15:45:56 -07:00
Mbucari 94cf665be7 Fix books not being marked absent on large imports 2026-01-11 15:40:11 -07:00
Mbucari 7d38874257 Merge branch 'rmcrackan:master' into master 2026-01-09 16:49:09 -07:00
Michael Bucari-Tovo 659f793eb8 Improve TrashBinDialog functionality
- Use the main display grid control to display deleted books
- Added search functionality for deleted books. This required creating a temporary search index in the `InProgress` folder. The products grid control now uses an instance of `ISearchEngine` to filter its grid entries.  The main grid uses a singleton instance of `MainSearchEngine`, which merely wraps `SearchEngineCommands.Search()`.  The TrashBinDialogs use `TempSearchEngine`.
- Users can now batch select `Everyting` as well as `Audible Plus Books`

Avalonia:
  - Refactor main grid context menus to no longer require reflection
2026-01-09 16:47:37 -07:00
MBucari 6f7cd4d5b5 Fix narrators with null ASINs not importing (#1545 ) 2026-01-08 19:57:47 -07:00
Michael Bucari-Tovo 068f37319f Add option to adjust minimum file duration
when splitting audiobooks into multiple files by chapter
2026-01-08 18:36:57 -07:00
Michael Bucari-Tovo dc58a101af Add cli export option to specify Asins 2026-01-08 16:54:43 -07:00
Michael Bucari-Tovo 7b68415b02 Add more properties to search engine and library export
- Add `IsAudiblePlus` to search engine
- Add `IsAudiblePlus` and `AbsentFromLastScan` properties to library export
- Refactor library export ToXlsx method
  - Make nullable
  - Improve readability and extensability
  - Use same column header names as CSV
  - Extend export methods to accept optional list of books (future use)
2026-01-08 15:14:20 -07:00
Michael Bucari-Tovo 1514de54da Add menu option to remove Plus books from Audible 2026-01-08 13:00:47 -07:00
Michael Bucari-Tovo 804bac5c4c Add LibraryBook.IsAudiblePlus property 2026-01-07 15:50:23 -07:00
Michael Bucari-Tovo 3fa805d51f Verify correct Serilog parameters used (#1536 ) 2026-01-07 15:10:54 -07:00
rmcrackan 1eff725125 Merge pull request #1541 from rmcrackan/dependabot/npm_and_yarn/preact-10.28.2
Bump preact from 10.28.0 to 10.28.2
2026-01-07 14:35:44 -05:00
dependabot[bot] af1b1a70ae Bump preact from 10.28.0 to 10.28.2
Bumps [preact](https://github.com/preactjs/preact) from 10.28.0 to 10.28.2.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.28.0...10.28.2)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.28.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-07 19:29:23 +00:00
Robert 042f2e135a incr ver 2026-01-06 07:28:33 -05:00
Robert 930fc3da58 Merge branch 'master' of https://github.com/rmcrackan/Libation 2026-01-06 07:26:40 -05:00
Robert 90e8d03590 add github doc contributor 2026-01-06 07:26:35 -05:00
rmcrackan ee908a4f13 Merge pull request #1534 from Mbucari/master
Improve LocateAudiobooksDialog and fix Classic upgrade dialog
2026-01-06 07:26:08 -05:00
rmcrackan 70ec31303b Merge pull request #1535 from niontrix/patch-1
Document Pacstall for program installation on Linux
2026-01-06 07:16:14 -05:00
Tobias Heinlein 319d547aa0 Document Pacstall for program installation on Linux
Added information about Pacstall for easier program installation.
2026-01-06 10:33:20 +01:00
Mbucari a59e42e7c9 Merge branch 'rmcrackan:master' into master 2026-01-05 18:50:51 -07:00
Michael Bucari-Tovo af2e89dd1e Remove UpgradeNotificationDialog form positioning
Hopefully address #1531
2026-01-05 18:50:28 -07:00
rmcrackan b2c5884e11 Merge pull request #1533 from rmcrackan/rmcrackan/log-friendly2
Rmcrackan/log friendly2
2026-01-05 19:30:43 -05:00
Robert 11d9cdefe2 typo 2026-01-05 19:30:03 -05:00
Robert 54485ae150 LogFriendly 2026-01-05 19:28:50 -05:00
Michael Bucari-Tovo 4bd641ee50 Improve LocateAudiobooksDialog
- Move LocatedAudiobooksViewModel to LibationUiBase
- Refactor Avalonia and Classic displays to use same view model.
- Do scan on background task
2026-01-05 15:38:15 -07:00
Robert 6e56297434 LogFriendly 2026-01-05 14:00:43 -05:00
rmcrackan e1f59eadbd Merge pull request #1529 from rmcrackan/rmcrackan/doc-github-sources
Doc re-directs to include pointers to github sources
2026-01-04 09:49:57 -05:00
Robert cfda065219 Doc re-directs to include pointers to github sources 2026-01-04 09:48:14 -05:00
Robert e3ad4a2c32 incr ver 2026-01-02 16:20:46 -05:00
rmcrackan 2b1c772df7 Merge pull request #1527 from Mbucari/master
Add feature to scan library for higher quality books
2026-01-02 16:02:01 -05:00
Mbucari 9b3e4f8762 Merge branch 'rmcrackan:master' into master 2026-01-02 13:13:12 -07:00
MBucari 396d2c8a95 Address rmcrackan comments and refactor 2026-01-02 13:04:35 -07:00
rmcrackan 91090b74ab Merge pull request #1526 from rmcrackan/rmcrackan/fix-export-columns
Bug fix #1524 - Fix export columns
2026-01-02 09:52:33 -05:00
Robert 32979b5905 Bug fix #1524 - Fix export columns 2026-01-02 09:52:17 -05:00
Michael Bucari-Tovo f6b96fc210 Add feature to scan for better quality audiobooks
Add AccessibleDataGridViewColumn which can apply Accessability names and descriptions from the designer.

Create reusable SortBindingList<T> for basic sorting of data-bound items.
2025-12-31 16:31:52 -07:00
Michael Bucari-Tovo 09e610fe08 Sanitize contributor names (#1518) 2025-12-31 13:09:47 -07:00
Michael Bucari-Tovo e50d8c74de Add UseWindowsForms to csproj 2025-12-31 11:28:45 -07:00
Michael Bucari-Tovo 2b1ca13249 Prevent migrations from running more than once 2025-12-31 11:25:43 -07:00
Michael Bucari-Tovo 7d30a3036d Move viewmodel into UiBase 2025-12-30 15:56:47 -07:00
Michael Bucari-Tovo bb8b435810 Improve Find Better Quality Audiobooks scanner
Try to load best audio format from actual liberated audiobook files
Allow re-scanning after completing scanning.
2025-12-30 14:49:58 -07:00
MBucari e850465ec1 Add more null safety
Enable project-wide nullable on LibationUiBase and LibationAvalonia

Explicitly parallelize unit tests
2025-12-30 13:17:11 -07:00
MBucari 29a5c943cb Auto-scroll process queue 2025-12-29 21:52:36 -07:00
Mbucari 31087c0855 Add feature to scan library for higher quality books 2025-12-29 19:30:47 -07:00
rmcrackan c91d359017 Merge pull request #1517 from Mbucari/master
Fix mp3 conversion of liberated AC-4 files and add metadata tags
2025-12-27 22:21:10 -05:00
MBucari 7dfdc0688a Add some more useful tags
AUDIBLE_ACR, AUDIBLE_DRM_TYPE, and AUDIBLE_LOCALE
2025-12-27 15:40:57 -07:00
MBucari c6c36c74f1 Allow converting already downloaded AC-4 to mp3 2025-12-27 14:40:10 -07:00
rmcrackan d932b57853 Merge pull request #1512 from radiorambo/fix-1508
Fix 1508 | auto redirect old docs links to new docs, add new development section to docs
2025-12-27 09:47:04 -05:00
radiorambo a29da7318b remove routex packgage 2025-12-27 13:04:37 +05:30
radiorambo 678c3e6bcd add Documentation folder with old pages but with only link to new docs 2025-12-27 13:03:55 +05:30
rmcrackan a8466e38d4 Merge pull request #1515 from Mbucari/master
Add CFBundleShortVersionString to Info.plist
2025-12-26 16:41:03 -05:00
Mbucari 802ccf25e8 Add recent contributors 2025-12-26 14:08:59 -07:00
Mbucari c243b9c913 Add CFBundleShortVersionString to Info.plist 2025-12-26 13:41:38 -07:00
radiorambo 4d47ab3ebe update old docs links 2025-12-25 15:53:01 +05:30
Umesh 8dea6200ce Merge branch 'master' into fix-1508 2025-12-24 13:38:24 +05:30
radiorambo a578777352 fix broken links 2025-12-24 11:17:43 +05:30
radiorambo e58e5165cf update readme 2025-12-24 11:17:29 +05:30
radiorambo 87c2cb6e19 add separete section for development related 2025-12-24 11:11:48 +05:30
Robert 4f44c26b57 incr ver 2025-12-23 09:52:25 -05:00
rmcrackan 03534773ab Merge pull request #1510 from rmcrackan/dependabot/github_actions/actions/configure-pages-5
Bump actions/configure-pages from 4 to 5
2025-12-23 09:39:59 -05:00
rmcrackan 37f223fb77 Merge pull request #1509 from rmcrackan/dependabot/github_actions/actions/upload-pages-artifact-4
Bump actions/upload-pages-artifact from 3 to 4
2025-12-23 09:31:01 -05:00
radiorambo cf932bd66c redirect old doc links to new doc links 2025-12-23 19:58:14 +05:30
rmcrackan f0dc33a01e Merge pull request #1511 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-12-23 09:11:11 -05:00
dependabot[bot] 315d76e061 Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [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/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:06:01 +00:00
dependabot[bot] 6e78145adc Bump actions/configure-pages from 4 to 5
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:05:51 +00:00
dependabot[bot] 200a334f86 Bump actions/upload-pages-artifact from 3 to 4
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:05:45 +00:00
rmcrackan 4dd4a1495a Update docker.md 2025-12-22 17:46:10 -05:00
rmcrackan b3ce0e0af0 Update docker.md
Add LIBATION_CONNECTION_STRING
2025-12-22 17:45:15 -05:00
Mbucari 1299d91d08 Restrict workflow runs to specific paths 2025-12-22 12:54:58 -07:00
rmcrackan ad3a767057 Merge pull request #1504 from Youssef1313/mtp
Migrate from VSTest to MTP
2025-12-22 14:45:19 -05:00
Michael Bucari-Tovo a59c73caf8 Add win-arm64 release identfier 2025-12-22 12:37:22 -07:00
rmcrackan 442a688b85 Update README.md 2025-12-22 10:28:21 -05:00
rmcrackan 0c85ea4d11 Merge pull request #1498 from radiorambo/new-documentation-website
add new documentation website
2025-12-22 09:50:45 -05:00
Youssef1313 03ed8e6b57 Migrate from VSTest to MTP 2025-12-21 15:03:52 +01:00
rmcrackan 3eca508a26 Merge pull request #1501 from Mbucari/master
Add support for decoding Windows Arm64 and AC-4 audio files
2025-12-19 08:21:26 -05:00
radiorambo 770adf33f3 add gitHub actions workflow for vitePress deployment to gitHub pages. 2025-12-19 14:09:21 +05:30
MBucari 1087ffb150 Add support for converting AC-4 files to mp3 2025-12-19 00:18:06 -07:00
radiorambo f620234e7d add docs overview in homepage and in nav bar links 2025-12-18 14:28:46 +05:30
radiorambo 2b6b5d082e fix nav link increase website width in tablet view 2025-12-18 14:27:17 +05:30
Michael Bucari-Tovo cbbc45c3c5 Add Windows arm64 build 2025-12-17 11:00:29 -07:00
rmcrackan 28de1a6cb6 Merge pull request #1500 from Mbucari/master
Update AAXClean
2025-12-17 06:45:13 -05:00
Michael Bucari-Tovo 1615c6ef77 Update AAXClean 2025-12-16 23:44:04 -07:00
radiorambo 6961bd72fa rename 'report issues' button to 'issues & requests' and simple installation routes 2025-12-16 13:22:56 +05:30
radiorambo 68846a90e5 fix dead links 2025-12-16 13:11:56 +05:30
radiorambo d60ec0702c update nav and homepage buttons 2025-12-16 13:07:55 +05:30
radiorambo 1c55c8533a improve docs 2025-12-16 13:06:44 +05:30
radiorambo 6fa69b603e rename files 2025-12-15 22:08:01 +05:30
radiorambo 3df8a97463 configure config for clean urls 2025-12-15 21:36:39 +05:30
rmcrackan 0bd7bd80b9 Merge pull request #1496 from rmcrackan/dependabot/github_actions/actions/download-artifact-7
Bump actions/download-artifact from 6 to 7
2025-12-15 09:17:03 -05:00
rmcrackan 13bb4238b4 Merge pull request #1497 from rmcrackan/dependabot/github_actions/actions/upload-artifact-6
Bump actions/upload-artifact from 5 to 6
2025-12-15 09:16:49 -05:00
dependabot[bot] d5021e4f74 Bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 14:06:46 +00:00
dependabot[bot] 5e1458cfb4 Bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 14:06:41 +00:00
radiorambo e1d4533887 use github syntax for admonitions in homepage 2025-12-15 18:27:37 +05:30
radiorambo c1bd1d983b make documentation development section in readme concise 2025-12-15 17:50:39 +05:30
radiorambo b567c38a98 update syntax, adjust heading levels, reformat tables, and apply minor text and capitalization fixes across documentation. 2025-12-15 17:45:34 +05:30
radiorambo 348ec22465 update package-lock.json 2025-12-15 17:43:46 +05:30
radiorambo 90bb4d9176 fix favicon and logo not visible 2025-12-15 16:05:02 +05:30
radiorambo 7944154ea6 add icons 2025-12-15 15:37:19 +05:30
radiorambo 01fc7f3fb9 rename documentation files for simple routing paths and reorganise for better navigation 2025-12-15 15:37:07 +05:30
radiorambo b70f973994 update .gitignore for vitepress 2025-12-15 15:34:37 +05:30
radiorambo 98d3f85579 install vitepress and configure 2025-12-15 15:28:05 +05:30
Robert bdae155af6 incr ver 2025-12-11 16:41:36 -05:00
rmcrackan c8b44193ac Merge pull request #1490 from Mbucari/master
Two bugfixes
2025-12-09 13:39:14 -05:00
Mbucari 9545b3a874 Invoke MessageBox on UI thread 2025-12-06 18:55:38 -07:00
Mbucari e932c9fab9 Merge branch 'rmcrackan:master' into master 2025-12-06 18:02:38 -07:00
Robert c8f4c1e751 Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-12-06 14:48:26 -05:00
Robert 0303db153f update audibleapi 2025-12-06 14:48:24 -05:00
Mbucari a7e9479eab Fix file utility modifying file extension using replacement character
The file extension should not be subject to renaming. #1483
2025-12-06 10:40:02 -07:00
Mbucari d339dbc906 Update macOS install instructions. 2025-12-05 21:06:02 -07:00
Robert 5fe6f931ad incr ver 2025-12-04 20:54:08 -05:00
rmcrackan ca9fe9fc32 Merge pull request #1479 from Mbucari/master
Two minor bug fixes
2025-12-04 20:52:19 -05:00
MBucari 986dbd678f Don't throw exceptions from failure to delete db-wal and db-shm files (#1478) 2025-12-03 22:09:35 -07:00
MBucari ea3716f48a Fix books dialog not saving or updating properly (#1477) 2025-12-03 22:03:14 -07:00
rmcrackan 426d5a87b4 Merge pull request #1476 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-6
Bump apple-actions/import-codesign-certs from 5 to 6
2025-12-03 09:48:46 -05:00
dependabot[bot] c893bbe52e Bump apple-actions/import-codesign-certs from 5 to 6
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 5 to 6.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 14:06:28 +00:00
Robert ad5a9874af incr ver 2025-12-02 15:28:59 -05:00
rmcrackan 3b70c08439 Merge pull request #1471 from Mbucari/master
Classic dark mode, customizable downloads, bug fixes
2025-12-02 15:26:24 -05:00
Michael Bucari-Tovo a230605ed5 Improve GetCounts performance. 2025-12-02 12:43:12 -07:00
Michael Bucari-Tovo d48ce39773 Move LibraryCommands.GetCounts() to background thread. 2025-12-02 12:30:05 -07:00
Michael Bucari-Tovo 368e695214 Allow users to shoose whether Libation imports Audible Plus titles. 2025-12-02 12:28:16 -07:00
Michael Bucari-Tovo 9c3881c67d Fix LibationCli reusing content licenses (#1473) 2025-12-02 11:09:58 -07:00
MBucari 4c5fdf05f5 Add "Download split by chapters" context menu item (#1436)
All processables are now created with an instance of Configuration, and they use that instance's settings.

Added Configuration.CreateEphemeralCopy() to clone Configuration without persistence.
2025-12-01 23:23:47 -07:00
MBucari 4bd491f5b9 Make winforms book details and search syntax dialogs nonmodal
Match Chardonnay behavior
2025-12-01 21:24:30 -07:00
MBucari c34b1e752e Update dependencies 2025-12-01 20:40:34 -07:00
MBucari fa30c10435 Fix PDF validation error (#1470 ) 2025-12-01 20:39:52 -07:00
Michael Bucari-Tovo cdb91ae2ca Add dark mode to winforms
- Add dark theme icon variants
- Change all light theme icon fill colors to match Chardonnay

Also fixed #1460  by chaing the directory select control to DirectoryOrCustomSelectControl
2025-12-01 20:39:22 -07:00
Robert 7852067b81 incr ver 2025-11-29 23:22:42 -05:00
rmcrackan 3708515df9 Merge pull request #1467 from Mbucari/master
Fix MessageBox launch error on macOS
2025-11-29 23:21:14 -05:00
Mbucari 530aca4f4d Fix MessageBox launch error on macOS 2025-11-29 15:41:38 -07:00
Robert cf571148bc incr ver 2025-11-25 23:19:49 -05:00
rmcrackan 2c2a720ba9 Merge pull request #1458 from Mbucari/master
Bug fixes in the downloader
2025-11-25 22:47:48 -05:00
Michael Bucari-Tovo b577ef7187 Improve SetBeckupCounts
Change Avalonia's Task-based approach to WinForms' BackgroundWorker approach.
- Reduce number of calls to GetLibrary by adding the Library to the LibraryStats record.
2025-11-25 14:59:48 -07:00
Michael Bucari-Tovo ffbb3c3516 Make namespace name match assembly name 2025-11-25 13:34:12 -07:00
Michael Bucari-Tovo 2a6cf38677 Fix book details dialog not saving 2025-11-25 13:33:40 -07:00
Michael Bucari-Tovo d8104a4d7c Check is stream is disposed before reading position. 2025-11-25 12:34:20 -07:00
Michael Bucari-Tovo af85ea9219 Fix exception being throw in Dispose() 2025-11-25 12:24:07 -07:00
rmcrackan c30e149a36 Merge pull request #1456 from Mbucari/master
Fix database lock from -wal and -whm files
2025-11-24 22:57:12 -05:00
MBucari 050a4867b7 Fix database lock from -wal and -whm files
Delete LibationContext.db-shm and LibationContext.db-wal files as part of startup routine.
2025-11-24 20:45:55 -07:00
Robert 2bf6f7a4f2 incr ver 2025-11-24 15:46:01 -05:00
rmcrackan 788a768271 Merge pull request #1455 from Mbucari/master
Add liberate option `--license` to use license file.
2025-11-24 15:43:18 -05:00
Michael Bucari-Tovo 022a6e979d Add error handling for cookies 2025-11-24 12:01:10 -07:00
Michael Bucari-Tovo 9fc5a7d834 Add liberate option --license to use license file.
Added instructions for liberating using a license file.
2025-11-24 11:36:31 -07:00
Robert b72e5039b1 incr ver 2025-11-24 09:48:22 -05:00
rmcrackan e992b49da2 Merge pull request #1451 from Mbucari/master
Add more liberation logging
2025-11-24 09:14:23 -05:00
MBucari 74afbbf581 Add more liberation logging 2025-11-23 23:40:23 -07:00
rmcrackan d82ffe1467 Merge pull request #1444 from Mbucari/master
Use C# 14 `field` and extension members.
2025-11-23 22:44:57 -05:00
Mbucari 8a84a083d1 Fix removed field 2025-11-23 20:38:20 -07:00
Mbucari 04827f81da Tweak in-app web browser login
- Use private browsing mode
- Use the Android User-Agent
- Use initial signin cookies
2025-11-23 20:35:04 -07:00
Mbucari 805f42b1cc Add warnings for inaccessable InProgress directory (#1446) 2025-11-22 15:29:54 -07:00
Mbucari b9a1709284 Use release version of EntityFrameworkCore.PostgreSQL 2025-11-22 11:46:27 -07:00
Michael Bucari-Tovo b0a40e12b7 Create some extension members
Trying out .NET 10s extension members with some Book extension properties.
2025-11-21 12:31:50 -07:00
Michael Bucari-Tovo dfbc5ec9db Use the new field keyword where appropriate. 2025-11-21 11:50:07 -07:00
rmcrackan 649ef5f864 Merge pull request #1441 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-5
Bump apple-actions/import-codesign-certs from 3 to 5
2025-11-21 11:19:27 -05:00
rmcrackan 4345bf2ee2 Merge pull request #1442 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-11-21 11:19:09 -05:00
rmcrackan 441d430dea Merge pull request #1439 from Mbucari/master
Update to .NET 10
2025-11-21 09:15:00 -05:00
dependabot[bot] 85ee0bcddb Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [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/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 14:06:00 +00:00
dependabot[bot] 0f1fc0f11d Bump apple-actions/import-codesign-certs from 3 to 5
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 3 to 5.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v3...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 14:05:53 +00:00
MBucari 75aa17df11 Fix winforms STAThread on main 2025-11-20 22:35:13 -07:00
MBucari 913019cdfd Revert hack workaround for DataGridView error 2025-11-20 22:19:43 -07:00
MBucari a55da5f187 Refactor DbContext access and disposal
- Remove instance queue. This is a database, after all, and is designed to be accessed and written to concurrently
- Reduce the number of calls to DbContexts.Create()
- Ensure that no LibationContext remains open across an await boundary. Multithread context access is the most likely culprit for past issues.
- Make all Update UserDefinedItem methods asynchronous.
2025-11-20 22:15:54 -07:00
MBucari f9ac0253fb Improve import performance on large batches 2025-11-20 21:37:13 -07:00
MBucari fde78f4167 Update to .NET 10
- Update all project runtime targets
- Update all dependencies
  - NOTE: Using Npgsql.EntityFrameworkCore.PostgreSQL RTM build from MyGet
- Delete unused pubxml files (they were made redundant by recent workflow changes)
- Replace Libation.sln with Libation.slnx
2025-11-20 15:56:47 -07:00
rmcrackan 22159b79d8 Merge pull request #1429 from Mbucari/master
Minor Bugfixes, new Workflows, and signed macOS bundles, refactor LibationFiles, improve libationCLI
2025-11-20 13:45:25 -05:00
MBucari 7fe170acdf Fix CLI help tex being squashed to a single column 2025-11-20 10:42:14 -07:00
MBucari c0898a288b Additional LibationCli documentation
Move CLI examples to the bottom of the page.
2025-11-20 09:55:35 -07:00
MBucari ce2b81036f Add license and settings overrides to LibationCli
- Add `LIBATION_FILES_DIR` environment variable to specify LibationFiles directory instead of appsettings.json
- OptionsBase supports overriding setting
  - Added `EphemeralSettings` which are loaded from Settings.json once and can be modified with the `--override` command parameter
- Added `get-setting` command
  - Prints (editable) settings and their values. Prints specified settings, or all settings if none specified
  - `--listEnumValues` option will list all names for a speficied enum-type settings. If no setting names are specified, prints all enum values for all enum settings.
  - Prints in a text-based table or bare with `-b` switch
- Added `get-license` command which requests a content license and prints it as a json to stdout
- Improved `liberate` command
  - Added `-force` option to force liberation without validation.
  - Added support to download with a license file supplied to stdin
  - Improve startup performance when downloading explicit ASIN(s)
  - Fix long-standing bug where cover art was not being downloading
2025-11-19 23:47:41 -07:00
Michael Bucari-Tovo 65d24ce223 Fix NRE in FileSystemTest when Books dir is invalid (#1435) 2025-11-17 10:54:25 -07:00
MBucari e8c911e603 Improve management and validation of Libation settings
- Move all settings file logic into new LibationFiles class
  - Configuration.LibationFiles is a singleton instance of the LibationFiles class.
  - A LibationFiles instance is bound to a single appsettings.json path. All updates to LibationFiles location are updated in that appsettings.json file
- Unify initial setup and settings validation process
  - Add LibationSetup which handles all startup validation of settings files and prompts users for setup if needed
  - Added a new LibationUiBase.Tests test project with tests for various
2025-11-17 10:49:23 -07:00
Michael Bucari-Tovo 59f66ff480 Fix subdirectory create failing on root drive (#1432)
This appears to be a bug in .NET. Someone started to fix it in a PR, but it went stale and was auto-closed.

https://github.com/dotnet/runtime/pull/117519
2025-11-14 13:44:46 -07:00
Michael Bucari-Tovo e05dcd6f54 Don't overwrite LibationFiles setting in appsettings.json 2025-11-14 13:35:36 -07:00
Michael Bucari-Tovo d1ce9d5a83 Update Mac Workflow
- Add new repo variables
  - `SIGN_MAC_APP_ON_VALIDATE` will force sign/notarize on the validate workflow (normally only done for releases)
  - `WAIT_FOR_NOTARIZE` Causes the build-mac workflow to wait for apple to notarize the bundle so that it can be stapled. This is usually fast (1-2 mis), but can be very long and may cause workflow runners to time out.
2025-11-14 11:19:21 -07:00
Mbucari 2213f5c86a Change msaOS updater to get DMGs
This will break _Automatic_ updates for existing mac users (although I'm not sure it worked all that well to begin with. However, the update notification dialog has had a link to download the bundle manually for a long time now. Old users will still be notified of the new release and be given a direct link to download it.
2025-11-13 23:15:29 -07:00
Mbucari d6b232f342 Update Logging and Error Handling
- Add Configuration.LoggingEnabled property which gets set as soon as Serilog is configured
- Add error handling to InteropFactory
2025-11-13 23:12:57 -07:00
Mbucari f29c19beb8 Update .gitignore 2025-11-13 23:04:33 -07:00
Mbucari 9f6d08fc1f Update Workflows
- Simplify workflows build commands
- Don't build ReadyToRun on validate
- Move get-version into it's own job in build.yml
- Split  macOS into it's own reusable workflow
  - Add app bundle code signing
  - Add notarization
2025-11-13 22:59:26 -07:00
MBucari 717dfcd923 Fix trying to cancel disposed cancellation token source 2025-11-11 14:06:54 -07:00
MBucari a3d181b2ec Fix Primary Screen NRE (#1420) 2025-11-11 03:21:20 -07:00
Mbucari d16eeea56b Improve detection of NativeWebDialog crash 2025-11-10 22:58:05 -07:00
Mbucari 5d2513ec33 Improve button display size uniformity. 2025-11-10 22:21:51 -07:00
Mbucari e5043dcf40 Move button styling to App.xaml 2025-11-10 21:42:34 -07:00
Mbucari c61bfb4134 Fix EditQuickFilters not displaying with no filters 2025-11-10 21:42:06 -07:00
Robert d47a2595b9 incr ver 2025-11-10 22:19:24 -05:00
rmcrackan 55e74db4fb Merge pull request #1419 from Mbucari/master
Bug Fixes and UI Improvement
2025-11-10 22:17:12 -05:00
MBucari 0a171222bc Update Dependency 2025-11-10 19:29:30 -07:00
MBucari c2093157ca Add dolby atmos logo for spatial audiobooks 2025-11-10 19:28:18 -07:00
MBucari 8e073800cd Fix BookDetailsDialog crash when changing error status 2025-11-10 18:25:59 -07:00
MBucari 1daf07b882 Improve logging 2025-11-10 17:58:48 -07:00
MBucari 27a23a16d6 Update AAXClean 2025-11-10 17:34:17 -07:00
Michael Bucari-Tovo c878b9fec0 Detect webview crash and disable webview login 2025-11-10 13:14:23 -07:00
rmcrackan 7a01f075ac Merge pull request #1415 from Mbucari/master
Fix minor UI bugs
2025-11-08 18:04:13 -05:00
Michael Bucari-Tovo 23d391485d Update AboutDialog and add recent contributors 2025-11-07 10:35:33 -07:00
Michael Bucari-Tovo 46be532740 Improve SearchSyntaxDialog
- Double-clicking a tag will paste the tage into the search bar
- SearchSyntaxDialog now modeless
2025-11-06 23:53:57 -07:00
Michael Bucari-Tovo e2fd88d075 Improve ScanAccountsDialog usability 2025-11-06 23:24:17 -07:00
Michael Bucari-Tovo bb0dea3fa9 Improve EditReplacementChars dialog usability 2025-11-06 22:49:09 -07:00
Michael Bucari-Tovo def0b1f611 Prevent crash if watched RootDirectory is deleted 2025-11-06 14:57:54 -07:00
Michael Bucari-Tovo bfee579719 Fix DirectoryOrCustomSelectControl 2025-11-06 13:47:51 -07:00
Mbucari d4139861f3 Only allow mocking lobby bugging 2025-11-06 07:59:55 -07:00
Robert ba15eb1a95 incr ver 2025-11-06 09:43:11 -05:00
rmcrackan 6263fedf84 Merge pull request #1406 from Mbucari/master
Improved Login experience, error messages, and published size.
2025-11-06 08:44:44 -05:00
MBucari 0cbffc3f6c Only allow mocking settings while debugging 2025-11-05 23:52:44 -07:00
MBucari 5f093b06ec Improve Chardonnay setup reliability. 2025-11-05 23:40:20 -07:00
Michael Bucari-Tovo f815c5fd47 Define context menu in XAML, remove need for reflection 2025-11-05 16:20:47 -07:00
Michael Bucari-Tovo 69a8eaad4a Add mock LibraryBook and Configuration capabilities
- Added `MockLibraryBook` which contains factories for easily creating mock LibraryBooks and Books
- Added mock Configuration
  - New `IPersistentDictionary` interface
  - New `MockPersistentDictionary` class which uses a `JObject` as its data store
  - Added `public static Configuration CreateMockInstance()`
    - This method returns a mock Configuration instance **and also sets the `Configuration.Instance` property**
    - Throws an exception if not in debug
- Updated all chardonnay controls to use the mocks in design mode. Previously I was using my actual database and settings file, but that approach is fragile and is unfriendly towards anyone else trying to work on it.
2025-11-05 13:28:49 -07:00
Michael Bucari-Tovo 01b5c18b2b Improve Column Context Menu CheckBox display 2025-11-05 09:39:32 -07:00
Michael Bucari-Tovo 5634fee2aa Add Account column #1398 2025-11-05 08:48:29 -07:00
MBucari e98e4f10bc Ensure FileSystemWatcher is disposed 2025-11-04 22:08:36 -07:00
MBucari ec32ff77b2 Fix theme not being applied when changed by the system (#1368) 2025-11-04 22:07:29 -07:00
Michael Bucari-Tovo 683c984246 Modify script which populates username in webview 2025-11-04 15:06:03 -07:00
Michael Bucari-Tovo 0fa5c4eb1e Request metadata for the audiobook version being downloaded (#1261) 2025-11-04 14:58:27 -07:00
Michael Bucari-Tovo 7507044b82 Improve detection and logging of download capabilities
- Check if the Api supports widevine before trying to download
- Additional logging
2025-11-04 14:32:28 -07:00
Michael Bucari-Tovo 017902ab52 Click to open libation log file 2025-11-04 13:19:40 -07:00
Mbucari dcc5c1c640 Fix quotes in Libation.desktop Exec command 2025-11-03 18:32:05 -07:00
Michael Bucari-Tovo 19efa8c918 Improve error messages for Chardonnay
- A message box alert should be possible regardless of the error
- If crash pre-logging, attempt to write to last used Libation log file
2025-11-03 17:40:38 -07:00
Michael Bucari-Tovo a34efb5e61 Add multiple image sizes in windows folder icons 2025-11-03 14:04:30 -07:00
Michael Bucari-Tovo 9533f80e89 Replace NPOI excel workbook library with ClosedXML
- Reduce build bundles by 30-40 MB
2025-11-03 13:13:13 -07:00
Michael Bucari-Tovo fa238a0915 Use xplat webview control for Audible login
- Use Avalonia-based webview control for Audible login with Chardonnay
- Remove webview interfaces from IInteropFunctions
- Remove Microsoft.Web.WebView2 package from WindowsConfigApp
- Add Microsoft.Web.WebView2 to LibationWinForms
- Remove all other login forms except the external login dialog (fallback in case webview doesn't work). The AudibleApi login with username/password doesn't work anymore. Need to use external browser login method.
2025-11-03 11:29:57 -07:00
Michael Bucari-Tovo f98adef9e9 Update Dependencies
- Remove package references that are already included transitively
- Change Avalonia.ReactiveUI to ReactiveUI.Avalonia
2025-11-03 09:34:23 -07:00
rmcrackan d85e5a0f98 Update Advanced.md
CLI: copy the local sqlite database to postgres
2025-11-03 11:24:02 -05:00
Robert 365ac8167f incr ver 2025-11-03 11:04:55 -05:00
Robert 4720779373 Bug fix. DesignTimeDbContextFactoryBase used to call context.Database.Migrate() . Must now do so ourselves 2025-11-03 11:00:10 -05:00
Robert 0c512162ab Reverting migration notes from #1402 2025-11-03 10:21:13 -05:00
rmcrackan 09ca419faf Merge pull request #1402 from twsouthwick/postgres
Add support for postgres
2025-11-03 09:47:48 -05:00
Taylor Southwick a2b1f13601 remove migration calls 2025-11-02 18:32:20 -08:00
Robert f4e7cf3418 Bugfix #1403 : Trash bin actions result in app crashes 2025-11-01 13:32:19 -04:00
MBucari 8492a7ea3a Properly disposed of LibationContext (#1403) 2025-10-31 13:45:07 -06:00
Taylor Southwick 1b5db9b28f Add support for postgres
Supporting postgres simplifies deployments to environments such as kubernetes. Since sqlite doesn't work well on nfs shares it can be easier for databases to have a dedicated db set up that applications can connect to. Sqlite is easier for most deployments though, so this will default to that if the settings haven't been updated to support it.

This change does the following:

- Separate out SQLite from the DataLayer and adds a Postgres assembly for migrations as well
- Add a configuration setting for a postgres connection string that will be used if it is there, otherwise reverts to the original sqlite string
- Add a copydb command for the cli to bootstrap the postgres db
- A convenience script to update migrations for both dbs at the same time
2025-10-27 16:30:50 -07:00
rmcrackan 5589a6cbd5 Merge pull request #1401 from rmcrackan/dependabot/github_actions/actions/upload-artifact-5
Bump actions/upload-artifact from 4 to 5
2025-10-27 11:22:19 -04:00
dependabot[bot] bfbad988c0 Bump actions/upload-artifact from 4 to 5
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 15:14:03 +00:00
rmcrackan 8db8615713 Merge pull request #1400 from rmcrackan/dependabot/github_actions/actions/download-artifact-6
Bump actions/download-artifact from 5 to 6
2025-10-27 11:07:00 -04:00
dependabot[bot] e8fa3f14b3 Bump actions/download-artifact from 5 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 14:59:43 +00:00
Robert 71552f2417 incr ver 2025-10-23 09:45:55 -04:00
rmcrackan 5b96f96a80 Merge pull request #1390 from Mbucari/master
Bug Fixes and attempts to solve random crashes
2025-10-23 09:43:23 -04:00
Michael Bucari-Tovo afffeb953c Enforce sequential access to DbContext. 2025-10-22 09:22:05 -06:00
Mbucari f4d8685058 Merge branch 'rmcrackan:master' into master 2025-10-22 09:16:49 -06:00
Robert b4cfd18976 incr ver 2025-10-20 21:12:43 -04:00
rmcrackan e12548dacd Merge pull request #1388 from delebash/master
Added IncludedUntil Date
2025-10-20 21:10:46 -04:00
delebash fd95ac7a9c changes per Mbucari 2025-10-20 16:31:06 -04:00
delebash f7cd2b106b fix migration files 2025-10-20 14:12:04 -04:00
delebash 07e51f2191 IncludedUntil migration 2025-10-20 13:09:41 -04:00
delebash fcd79c5561 InludedUntil fixes by Mbucari 2025-10-20 12:55:48 -04:00
delebash ba98820989 move code to LibraryBook 2025-10-18 02:05:56 -04:00
delebash b07e61e6a8 fix UntilDate 2025-10-17 13:49:37 -04:00
delebash 8c3fd19c70 Merge remote-tracking branch 'origin/master' 2025-10-17 13:36:21 -04:00
delebash fa8f761771 Added IncludedUntil Date 2025-10-17 13:36:01 -04:00
rmcrackan 2c882e883d #1378 : allow for invalid beginning-of-time 'purchase_date'. This is the case for an actual user 2025-09-30 16:28:48 -04:00
Mbucari 74c76a7414 Enhance bug report template formatting and instructions
Updated the bug report template to improve clarity and formatting, including default log file locations for various platforms.
2025-09-04 12:46:22 -06:00
Mbucari 17a0c21453 Document xHE-AAC conformance errors for Audible
Added notes on xHE-AAC conformance errors related to Audible files.
2025-09-04 12:24:46 -06:00
rmcrackan fc9c9dfe48 Merge pull request #1360 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-5
Bump actions/setup-dotnet from 4 to 5
2025-09-03 21:43:10 -04:00
dependabot[bot] d5f0e39981 Bump actions/setup-dotnet from 4 to 5
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 5.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-04 00:05:35 +00:00
rmcrackan 0f6493f4af Merge pull request #1353 from rmcrackan/rmcrackan-patch-2
Update NamingTemplates.md
2025-08-27 09:30:09 -04:00
rmcrackan 454b490a06 Update NamingTemplates.md 2025-08-27 09:28:45 -04:00
rmcrackan ffea2648aa Merge pull request #1352 from rmcrackan/rmcrackan-patch-1
Update NamingTemplates.md
2025-08-27 09:22:20 -04:00
rmcrackan 1ac967500c Update NamingTemplates.md
Better documentation for inverted tags
2025-08-27 08:57:20 -04:00
Michael Bucari-Tovo 39e9f675d2 Fix possible race error on startup with autoscan 2025-08-26 10:00:21 -06:00
rmcrackan ed5afe5d0f update dependencies 2025-08-20 12:07:53 -04:00
rmcrackan ab075d0bef Merge pull request #1343 from MajorTanya/patch-1
Place examples in their own line
2025-08-20 11:49:46 -04:00
MajorTanya 7fb1adb41b Place examples in their own line
Markdown collapses single line breaks, so this change makes it so the examples will have their own lines.
2025-08-20 17:26:48 +02:00
rmcrackan 9735a8391c incr ver 2025-08-20 08:39:10 -04:00
rmcrackan dbdfdbc536 Merge pull request #1342 from Mbucari/master
Added new <has PROPERTY-><-has> conditional tag
2025-08-20 08:37:11 -04:00
Michael Bucari-Tovo 3b86fc405f Add <has-> template tag 2025-08-19 18:41:31 -06:00
MBucari 4ea7f04921 Preserve space between series order numbers 2025-08-17 13:40:37 -06:00
MBucari 5b59b442ab Add last downloaded sample rate, bitrate and codec name to search engine. 2025-08-17 13:07:24 -06:00
rmcrackan b5d9c0a27a Incr ver 2025-08-17 10:06:12 -04:00
rmcrackan f5cbf89e13 Merge pull request #1337 from Mbucari/master
Fix linux crpto and series order parsing
2025-08-17 09:57:43 -04:00
rmcrackan 00dc9e020d Update bug_report.md 2025-08-17 09:55:26 -04:00
MBucari bfa0e4d338 Parse floats with invariant culture 2025-08-16 16:39:36 -06:00
Mbucari 5ceda408da Use managed RSASSA-PSS with SHA-1
OpenSSL (the underlying RSA implementation on Linux) has deprecated SHA-1 signing. Used a managed implementation so that it does not error.
2025-08-16 16:28:33 -06:00
Mbucari 716b1923a4 Update FrequentlyAskedQuestions.md 2025-08-15 12:25:03 -06:00
rmcrackan 1148d8125d incr ver 2025-08-15 13:10:05 -04:00
rmcrackan 690fd10e42 Merge pull request #1331 from Mbucari/master
Audio format docs, new audio format options, series order parsing.
2025-08-15 13:08:10 -04:00
Michael Bucari-Tovo 736fbbf82f Improve FilePathCache performance 2025-08-15 10:50:37 -06:00
Michael Bucari-Tovo eda100b7ac Remove FluentAssertions 2025-08-15 10:29:23 -06:00
Michael Bucari-Tovo ceb007500d Update assertions to use ThrowsExactly 2025-08-14 15:58:01 -06:00
Michael Bucari-Tovo 05fad01624 Update dependencies 2025-08-14 15:57:35 -06:00
Michael Bucari-Tovo e1d789ccdc Improve series order parsing and formatting 2025-08-14 15:37:53 -06:00
Michael Bucari-Tovo d0f00f3f1e Add xHE-AAC option 2025-08-14 13:20:01 -06:00
Michael Bucari-Tovo 6ab82dba7b Add audio format info to wiki 2025-08-14 13:16:27 -06:00
rmcrackan 0045202334 update dependencies 2025-08-13 07:52:13 -04:00
rmcrackan 4c80813651 Merge pull request #1330 from rmcrackan/dependabot/github_actions/actions/checkout-5
Bump actions/checkout from 4 to 5
2025-08-11 17:38:40 -04:00
dependabot[bot] 6b637b35ab Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [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/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-11 20:35:21 +00:00
rmcrackan 9b55ffa715 Merge pull request #1329 from rmcrackan/rmcrackan/dolby-atmos
Begin documentation for Dolby Atmos
2025-08-11 09:22:03 -04:00
rmcrackan 65da7890f1 Begin documentation for Dolby Atmos 2025-08-11 09:21:03 -04:00
rmcrackan 72f92ec6c0 update dependencies 2025-08-10 11:21:34 -04:00
rmcrackan 4efc084375 Merge pull request #1324 from rmcrackan/dependabot/github_actions/actions/download-artifact-5
Bump actions/download-artifact from 4 to 5
2025-08-06 10:24:33 -04:00
dependabot[bot] f955daa5ed Bump actions/download-artifact from 4 to 5
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-06 14:09:36 +00:00
rmcrackan 144ab2162a incr ver 2025-08-05 12:58:45 -04:00
rmcrackan 6d0c4a9b3c Merge pull request #1322 from Mbucari/master
Improve Libation's interaction with the file system & other minor fixes
2025-08-05 12:57:03 -04:00
Michael Bucari-Tovo 8a682533c1 Change repo link to rmcrackan 2025-08-05 10:39:03 -06:00
Michael Bucari-Tovo cecabc911e Add "Locate Audiobooks" help text 2025-08-05 10:29:48 -06:00
Michael Bucari-Tovo e35f5209dc Don't change user's replacement character settings
Instead, add the NTFS-only invalid characters to the set of invalid filename characters.
2025-08-05 09:49:22 -06:00
MBucari 4ffe70af0e Fix serilog not logging caller name 2025-08-04 23:24:55 -06:00
MBucari 233ba3184f Add link to naming template wiki 2025-08-04 21:23:50 -06:00
Michael Bucari-Tovo ac4c168725 Allow Libation to start with an invalid Books directory
- Configuration.LibationSettingsAreValid is true if Books property exists and is any non-null, non-empty string.
- If LibationSettingsAreValid is false, Libation will prompt user to set up Libation.
- When the main window is shown, Libation checks if the books directory exists, and if it doesn't, user is notified and prompted to change their setting
- When a user tries to liberate or convert a book, Books directory is validated and user notified if it does not exist.
2025-08-04 19:58:26 -06:00
Michael Bucari-Tovo db588629c0 Null safety and minor UI bugfix
Properly cancel the Locate Audiobooks when the dialog window closes before scanning is finished.
2025-08-04 17:15:37 -06:00
Michael Bucari-Tovo 29be091a4b Fix cross-thread error on AccoundSettings.Saved event 2025-08-04 14:18:04 -06:00
Michael Bucari-Tovo 82a48db57b Fix walkthrough errors on chardonnay. 2025-08-04 10:27:37 -06:00
Mbucari 9f0f32a462 Merge branch 'rmcrackan:master' into master 2025-08-04 10:02:02 -06:00
rmcrackan f64239b5ee Merge pull request #1316 from ajundi/master
Added nix flake and shell files for nixos developers
2025-08-04 09:55:42 -04:00
Ayman Jundi bc8a35aedd Added nix flake and shell files for nixos users who want to work on development. You can use flake method => 'nix develop' or non flake method =>`nix-shell'
The shell.nix file is used for both flake and non-flake invocations. The lock file is also set at a version where the project works.
Note the none-flake method will follow the version of the system and isn't guaranteed to work on older installations if they haven't been updated in a while.
Added Documentation for using Nix package manager for development ./Documentation/LinuxDevelopmentSetupUsingNix.md

Signed-off-by: Ayman Jundi <ajundi@gmail.com>
2025-08-03 16:36:03 -04:00
Ayman Jundi 2fca6b8b91 Added launch.json and task.json entries that allows building and debugging for linux-64 targets without having to modify the csproj files.
Signed-off-by: Ayman Jundi <ajundi@gmail.com>
2025-08-03 15:33:17 -04:00
Mbucari bc2eddd2dd Merge branch 'rmcrackan:master' into master 2025-07-31 10:36:26 -06:00
Michael Bucari-Tovo ae012548bd Smart handling of filename limitations cross platform
Automatically determine if filename lengths in the Books directory are limited to 255 UTF-16 characters (NTFS) or 255 UTF-8 bytes (pretty much every other file system) (#1260)

In non-Windows environments, determine if the Books directory supports filenames containing characters which are illegal in Windows environments (<>|:*?). If it doesn't, then ensure those characters are included in the user's ReplacementCharacters settings (#1258).
2025-07-30 16:04:48 -06:00
rmcrackan 76a59873ea undo dependabot overreach 2025-07-30 09:42:43 -04:00
rmcrackan 3129fdba7b Merge pull request #1319 from rmcrackan/dependabot/nuget/Source/ApplicationServices/multi-ab24e9613a
Bump SixLabors.ImageSharp to 2.1.11, 3.1.11
2025-07-30 09:37:18 -04:00
dependabot[bot] 1771813849 Bump SixLabors.ImageSharp to 2.1.11, 3.1.11
---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-version: 2.1.11
  dependency-type: direct:production
- dependency-name: SixLabors.ImageSharp
  dependency-version: 3.1.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 13:30:26 +00:00
Michael Bucari-Tovo 7024bbf823 Provide NTFS default characters for non-windows users (#1258) 2025-07-29 15:20:52 -06:00
Michael Bucari-Tovo 663f70b8bf Use series Order string instead of parsed float in template tags (#1056) 2025-07-29 12:18:37 -06:00
rmcrackan 7741e3caff incr ver 2025-07-29 07:54:24 -04:00
rmcrackan c82eefa768 Merge pull request #1317 from Mbucari/master
Bug fixes and UI tweak
2025-07-29 07:52:01 -04:00
Michael Bucari-Tovo 0e4231906a Set IsSpatial field instead of only flipping to true (#1273) 2025-07-28 15:28:04 -06:00
Michael Bucari-Tovo 9bca84dca4 Sort columns with null values always at the bottom 2025-07-28 09:29:17 -06:00
MBucari ca30fd41c6 Use proper version string based on build version 2025-07-28 08:59:20 -06:00
MBucari be96f99461 Increment Version 2025-07-27 11:40:35 -06:00
MBucari f017fe419f Fix ID3 tag encoding error (#1315) 2025-07-27 11:38:01 -06:00
rmcrackan ed42916cb2 incr ver 2025-07-26 23:11:51 -04:00
rmcrackan 0bb5bba3c8 Merge pull request #1314 from Mbucari/master
New audio format features, bug fixes, and minor tweaks/improvements.
2025-07-26 23:10:05 -04:00
MBucari a887bf4619 Add "Is Spatial" grid column. 2025-07-26 18:19:19 -06:00
MBucari 53eebcd6ba Use single file downloader/namer if file has only 1 chapter 2025-07-25 16:02:28 -06:00
MBucari a09ae1316d Don't display null file versions 2025-07-25 16:01:48 -06:00
MBucari 7088bd4b8d Check for file existance 2025-07-25 15:49:41 -06:00
MBucari b27325cdcb Improve comvert to mp3 task
- Improve progress reporting and cancellation performance
- Clear current book from queue before queueing single convert to mp3 task
2025-07-25 15:35:03 -06:00
MBucari accedeb1b1 Improve EditQuickFilters dialog reordering behavior 2025-07-25 14:23:14 -06:00
MBucari c98c7c095a Fix quickfilter modification bug (#1313) 2025-07-25 14:22:29 -06:00
MBucari 9b217a4e18 Add audio format data
- Add Book.IsSpatial property and add it to search index
- Read audio format of actual output files and store it in UserDefinedItem. Now works with MP3s.
- Store last downloaded audio file version
- Add IsSpatial, file version, and Audio Format to library exports and to template tags. Updated docs.
- Add last downloaded audio file version and format info to the Last Downloaded tab
- Migrated the DB
- Update AAXClean with some bug fixes
  - Fixed error converting xHE-AAC audio files to mp3 when splitting by chapter (or trimming the audible branding from the beginning of the file)
  - Improve mp3 ID# tags support. Chapter titles are now preserved.
  - Add support for reading EC-3 and AC-4 audio format metadata
2025-07-25 12:18:50 -06:00
Michael Bucari-Tovo a62a9ffc5b Use HttpClient in synchronous mode 2025-07-23 17:00:54 -06:00
Michael Bucari-Tovo 08aebf8ecf Add thread safety 2025-07-23 17:00:36 -06:00
Michael Bucari-Tovo 2f082a9656 Refactor and optimize audiobook download and decrypt process
- Add more null safety
- Fix possible FilePathCache race condition
- Add MoveFilesToBooksDir progress reporting
- All metadata is now downloaded in parallel with other post-success tasks.
- Improve download resuming and file cleanup reliability
- The downloader creates temp files with a UUID filename and does not insert them into the FilePathCache. Created files only receive their final file names when they are moved into the Books folder. This is to prepare for a future plan re naming templates
2025-07-23 16:55:09 -06:00
Michael Bucari-Tovo 1f473039e1 Make search syntax dialog field names scrollable 2025-07-22 15:39:43 -06:00
Michael Bucari-Tovo 0f4197924e Use LibationUiBase.ReactiveObject where applicable
Also tweak the classic process queue control layout
2025-07-22 11:59:34 -06:00
rmcrackan 0f7ffacdf8 incr ver 2025-07-22 10:20:39 -04:00
rmcrackan 829b35c5a8 Merge pull request #1311 from Mbucari/master
Fix serilog dynamic assembly loading issue (#1310)
2025-07-22 10:18:33 -04:00
Michael Bucari-Tovo 614b05d5ff Fix serilog dynamic assembly loading issue (#1310) 2025-07-22 08:00:31 -06:00
rmcrackan 26ccc77b47 incr ver 2025-07-22 07:24:26 -04:00
rmcrackan 64fb2ccf7c Merge pull request #1308 from Mbucari/master
Refactors, bug fixes, and performance improvements.
2025-07-22 07:22:35 -04:00
MBucari 890747a902 Do library scan on background thread 2025-07-22 00:20:16 -06:00
Michael Bucari-Tovo 1fdcea929f Form thread safety 2025-07-21 22:52:17 -06:00
Michael Bucari-Tovo 7848366818 Write logs to text .log file instead of .zip file
The ZipFile sink could cause program hangs. Additionally, the only reason it was ever used was to package verbose AudibleApi account login errors, saving the returned Html page as a file. Otherwise, the zip file only contains a .log text file.

- Removed Serilog.Sinks.ZipFile
- Add Serilog configuration migration
- Added a custom destructure to handle logging files. If any files are logged, they will be written to "LogyyyyMM_AdditionalFiles.zip"
2025-07-21 22:19:55 -06:00
Michael Bucari-Tovo 40b4915b65 Improve download/decrypt cancellation 2025-07-21 15:56:41 -06:00
Michael Bucari-Tovo 80b86086ca Consolidate process queue view models
Remove classic and chardonnay-specific implementations
Refactor TrackedQueue into an IList with INotifyCollectionChanged
2025-07-21 15:56:30 -06:00
Michael Bucari-Tovo bff9b67b72 Remove GridEntry derrived types and interfaces
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images

Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
2025-07-21 10:47:10 -06:00
Mbucari 657a7bb6bc Improve podcast episode GridEntry creation performance.
Tested on a library with ~5000 podcast episodes on an AMD Ryzen 7700X. Startup time decreases by ~400 ms in Release mode.
2025-07-21 09:49:25 -06:00
rmcrackan f0d7a7bf64 incr ver 2025-07-18 07:19:09 -04:00
rmcrackan 8bc098e7bd Merge pull request #1303 from Mbucari/master
Fix upgrade bug when Libation's working dir isn't program files dir
2025-07-18 07:16:46 -04:00
Michael Bucari-Tovo 9280b29512 Fix upgrade bug when Libation's working dir isn't program files dir
Add MockUpgrader for testing the Upgrade process.
Fixes issue #1302
2025-07-17 13:10:42 -06:00
rmcrackan d8e9b9c505 incr ver 2025-07-17 08:07:08 -04:00
rmcrackan 554b308364 Merge pull request #1299 from Mbucari/master
Bugfixes and minor improvements
2025-07-17 08:04:43 -04:00
MBucari 8d7872a376 UI tweak and optimization 2025-07-16 23:31:34 -06:00
MBucari 747451d243 Refactor Classic process queue
The queue is now more MVVM-like.
2025-07-16 22:58:03 -06:00
MBucari 7e79e98771 Fix possible cross-threading errors with MessageBoxBase 2025-07-16 22:57:25 -06:00
Michael Bucari-Tovo 4b7939541a Code cleanup and refactoring for clarity 2025-07-16 22:55:57 -06:00
MBucari a3734c76b1 Use SynchronizeInvoker's Invoke() method. 2025-07-15 23:22:42 -06:00
MBucari ced4ea6c17 Improve sorting by Liberate status by grouping books with PDFs 2025-07-15 22:50:53 -06:00
MBucari 35ca6f2621 Use built-in comparer and ReactiveObject types 2025-07-15 22:50:28 -06:00
MBucari 4dab16837e Move ProcessQueueViewModel logic into LibationUiBase
Fix UI bug in classic when queue is in popped-out mode.
2025-07-15 22:31:17 -06:00
MBucari 1cf889eed7 Move ProcessBookViewModel logic into LiationUiBase 2025-07-15 15:05:33 -06:00
MBucari b65b1e819b Consolidate queue commands into UI base 2025-07-15 13:32:42 -06:00
MBucari 3d50643ab0 Fix visible book counts being incorrect on startup
If quick filters are applied on startup, a race condition was created between the initial library load book counting and the visible books counting. Only display results of the latest book count.
2025-07-15 11:49:20 -06:00
MBucari abd18d74b0 Fix crash when setting drive root as custom directory (#1300) 2025-07-15 11:44:45 -06:00
MBucari 0e49df06b8 Add message box handler to LibationUiBase 2025-07-15 11:40:01 -06:00
MBucari 38cc3e9725 Revert change to release title 2025-07-15 08:54:22 -06:00
MBucari c9af2bba4b Reduce GitHub API calls when no upgrades are available 2025-07-14 14:43:48 -06:00
MBucari 2191c1536d Prepare Libation for win-arm64 releases
Also add support for four-part version numbers in releases.
2025-07-14 14:20:57 -06:00
MBucari 5b9bf2fbb0 Remove duplicate tests 2025-07-14 12:53:47 -06:00
MBucari 9b1ce8c1d7 Update dependencies 2025-07-14 12:43:53 -06:00
MBucari 9f8075041b Only remove a LibraryBook from queue if we are trying to re-download. 2025-07-14 12:42:05 -06:00
MBucari 944645379e Fix message box text truncation when there is no icon (#1294) 2025-07-14 12:19:26 -06:00
Mbucari cc72517284 Merge branch 'rmcrackan:master' into master 2025-07-14 11:45:44 -06:00
rmcrackan 0044820415 Update README.md 2025-07-07 16:31:09 -04:00
rmcrackan 9f24027de1 Update README.md 2025-07-07 16:29:46 -04:00
rmcrackan 24f95cb03d Update GettingStarted.md 2025-07-07 16:27:59 -04:00
rmcrackan 3aeea54615 Update FrequentlyAskedQuestions.md 2025-07-07 16:26:10 -04:00
rmcrackan f511041781 Create a cue sheet: default false 2025-06-25 12:43:50 -04:00
rmcrackan da9dc91469 incr ver for docker enhancement 2025-06-25 06:58:14 -04:00
rmcrackan e04e70d333 Merge pull request #1265 from vipervire/master
Update Books directory to use LIBATION_BOOKS_DIR if populated
2025-06-25 06:57:01 -04:00
rmcrackan e0b566ee60 Merge pull request #1277 from dev-nicolaos/patch-1
Update deb/rpm Installation Instructions
2025-06-24 07:50:35 -04:00
Nicolaos Skimas bf15d7302e Update Deb/RHEL/Fed Installation Instructions 2025-06-23 22:39:45 -07:00
rmcrackan 8f01c644c0 Update bug_report.md 2025-06-19 07:21:21 -04:00
Mbucari ebd2cc96c5 Merge branch 'rmcrackan:master' into master 2025-06-18 12:13:14 -06:00
rmcrackan 0d1cc42ca7 Bugfix #1269 : Chardonnay. Bad filter string causes infinite loop 2025-06-16 13:19:48 -04:00
vipervire e126dd09ce Update Books directory to use LIBATION_BOOKS_DIR if populated 2025-06-05 23:26:52 +00:00
Michael Bucari-Tovo ec497f4f81 Use virtualized list to improve large queue performance 2025-05-19 10:40:41 -06:00
rmcrackan 248fdfd2bc Probably unnecessary paranoid incr ver. Everything looks correct but I've never actually released relying on the ver's 4th part. I'm incrementing just in case 2025-05-10 16:53:04 -04:00
MBucari 35862d619a Increment version 2025-05-09 21:10:38 -06:00
Mbucari ac2c67985d Merge pull request #1253 from Mbucari/master
Fix download error (#1252 )

I'm merging this one and releasing ASAP.
2025-05-09 21:07:59 -06:00
MBucari f8ae303417 Fix download error (#1252 ) 2025-05-09 21:07:01 -06:00
rmcrackan 0d24caeac2 incr ver 2025-05-09 21:10:19 -04:00
rmcrackan 7f1b357c52 Merge pull request #1250 from Mbucari/master
Bug fixes and a change to license request logic
2025-05-09 21:08:19 -04:00
Michael Bucari-Tovo ef67ae9d6a Ask users to clear the accounts when enabling widevine (#1249) 2025-05-09 17:52:14 -06:00
Michael Bucari-Tovo f35c82d59d Change ApiExtended to always allow provide login option
Previously, only some calls to ApiExtended.CreateAsync() would prompt users to login if necessary. Other calls would only work if the account already had a valid identity, and they would throw exceptions otherwise.

Changed ApiExtended so that the UI registers a static ILoginChoiceEager factory delegate that ApiExtended will use in the event that a login is required.
2025-05-09 17:32:12 -06:00
Michael Bucari-Tovo 10c01f4147 Fix occasional error of audio downloads hanging. 2025-05-09 16:32:59 -06:00
Michael Bucari-Tovo 9366b3baca Default to E-AC-3 spatial audio format. 2025-05-09 13:39:59 -06:00
Michael Bucari-Tovo 20e792c589 Always change the last chapter's length to coincide with the end of the audio file. 2025-05-09 13:36:07 -06:00
Michael Bucari-Tovo dfb63d3275 Add contributor 2025-05-09 13:15:18 -06:00
Michael Bucari-Tovo 19db226f5a Use Libation settings to decide which DRM is downloaded. 2025-05-09 13:13:39 -06:00
Mbucari 203ab00865 Merge branch 'rmcrackan:master' into master 2025-05-08 12:15:26 -06:00
MBucari b11a4887d7 Pad final chapter to prevent tuncation from incorrect chapter info (#1246) 2025-05-08 12:13:55 -06:00
rmcrackan e73fc5e1eb Merge pull request #1247 from starry-shivam/patch-2
Small typo fix in DownloadOptions.Factory.cs
2025-05-08 07:12:42 -04:00
Stɑrry Shivɑm 8561a15061 Small typo fix in DownloadOptions.Factory.cs 2025-05-08 10:48:02 +05:30
MBucari 28ba62aead Fix dash files not being saved (#1236) 2025-05-07 23:15:44 -06:00
rmcrackan 176294cc55 Merge pull request #1245 from Mbucari/master
Minor bugfixes
2025-05-07 19:28:45 -04:00
Michael Bucari-Tovo 152b0e362d Update message box icons 2025-05-07 16:10:03 -06:00
Michael Bucari-Tovo 4600d029dc Re-add converter resource inadvertantly removed in 0df17a22 2025-05-07 14:23:58 -06:00
Michael Bucari-Tovo 1a5684799c Update Hangover styles and behaviors 2025-05-07 13:16:44 -06:00
Michael Bucari-Tovo 0df17a2296 Remove retired ItemsRepeater control 2025-05-07 13:12:12 -06:00
Michael Bucari-Tovo 45472abd1f Update dependencies 2025-05-07 11:15:32 -06:00
Mbucari f2ea4539f2 Merge branch 'rmcrackan:master' into master 2025-05-07 11:13:32 -06:00
Michael Bucari-Tovo 52d3b9cb67 Disable warning 2025-05-07 11:13:26 -06:00
rmcrackan 3d87f2cd9b Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-07 12:39:10 -04:00
rmcrackan e4a3d2ac79 better logging for api errors #1240 2025-05-07 12:39:02 -04:00
Michael Bucari-Tovo 8aa157f2f6 Re-add completed audiobooks to queue (#1219) 2025-05-06 15:43:58 -06:00
Michael Bucari-Tovo 5ab6c1fe70 Update AAXClean to fix metadata reader (#1243 ) 2025-05-06 15:33:38 -06:00
Michael Bucari-Tovo b23c46f79f Fix incorrect chapters in some audiobooks (#1210) 2025-05-06 15:32:59 -06:00
Mbucari 0e987eef00 Fix error in download speed throttle (#1242) 2025-05-06 14:48:40 -06:00
rmcrackan ace3d80e41 Merge pull request #1241 from cherez/patch-1
Fixed doubled first name in templates
2025-05-06 16:29:29 -04:00
Mbucari 4bfb4e73ce Fix aax file getting inadvertently deleted (#1236) 2025-05-06 12:45:43 -06:00
Steven Wallace 7805a3ef11 Fixed broken single word name test
This expected the name duplication that the previous commit fixed to be the behavior, changed to expect the single word to be the last name.
2025-05-06 09:58:09 -05:00
Steven Wallace 08ca2a2db3 Fixed doubled first name in templates
v12.3.0 caused a regression with contributors with a single word name, causing the name to be doubled. This was caused by using that name as both the first and last name, so swap the first name with the (blank) last name rather than duplicate them.
2025-05-05 10:37:28 -05:00
rmcrackan 64a85b6aab Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-02 22:15:36 -04:00
rmcrackan 1a38273d5f incr ver 2025-05-02 22:15:32 -04:00
rmcrackan 303dd7c471 Merge pull request #1233 from Mbucari/master
Bugfixes and Feature Requests
2025-05-02 22:14:33 -04:00
MBucari 313e3846c3 Remove AudioFormat from library book exporter (5f455182) 2025-05-02 15:39:47 -06:00
Michael Bucari-Tovo 422c86345e Add logging 2025-05-02 14:50:33 -06:00
Michael Bucari-Tovo ce952417fb Don't replace library properties in queued item with null/empty 2025-05-02 13:07:53 -06:00
Michael Bucari-Tovo 5f4551822b Remove Book.AudioFormat property
This property was set to the highest quality returned by the library scan. Since adding quality option settings, it is no longer guaranteed to reflect the file that is downloaded. Also, the library scan qualities don't contain spatial audio or widevine-specific qualities., only ADRM.
2025-05-02 12:39:12 -06:00
Michael Bucari-Tovo 3aebc7c885 Improve download performance. 2025-05-02 12:19:32 -06:00
Michael Bucari-Tovo 3982edd0f1 Add codec tag and use real bitrate/samplerate (#1227) 2025-05-02 11:20:58 -06:00
Michael Bucari-Tovo f4dafac28f Try to solve #1226 2025-05-01 13:19:03 -06:00
Michael Bucari-Tovo 1090d29f74 Add fine-grained options for downloading widevine content 2025-05-01 13:03:03 -06:00
rmcrackan 1c336e1fe9 bug fix 2025-04-28 18:55:20 -04:00
rmcrackan c7e9e9ac1e incr ver 2025-04-28 13:36:26 -04:00
rmcrackan 8232b2b5e5 Merge pull request #1223 from Mbucari/master
New features, including spatial audio support
2025-04-28 13:34:40 -04:00
MBucari 9ca879cc3d Revert "Allow re-adding completed queued items"
This reverts commit e2aae85fd7.
2025-04-27 14:31:21 -06:00
MBucari ece48eb6d7 Add spatial audio support 2025-04-27 14:31:14 -06:00
MBucari bffaea6026 Add CDM API url list 2025-04-27 13:15:50 -06:00
MBucari e2aae85fd7 Allow re-adding completed queued items 2025-04-25 19:54:19 -06:00
MBucari 1777dc5a7e Update AAXClean.Codecs and dependencies 2025-04-25 19:52:51 -06:00
Mbucari 2dfe00f428 Merge branch 'rmcrackan:master' into master 2025-04-15 00:36:11 -06:00
rmcrackan 2cd0a022ff bug fix #1212 : fix window title 2025-04-03 08:20:31 -04:00
Michael Bucari-Tovo 5d7ac699e6 Mark unreleased books as unavailable (#1079) 2025-03-25 12:35:18 -06:00
Michael Bucari-Tovo 7d806e0f3e Increase tag template options for contributor and series types
- Add template tag support for multiple series
- Add series ID and contributor ID to template tags
- <first author> and <first narrator> are now name types with name formatter support
- Properly import contributor IDs into database
- Updated docs
2025-03-25 09:34:57 -06:00
Michael Bucari-Tovo 0a9e489f48 Move contributors to UI Base 2025-03-24 13:29:02 -06:00
rmcrackan 17612dacd2 incr ver 2025-03-22 06:35:30 -04:00
rmcrackan e61ad41d5a Merge pull request #1202 from Mbucari/master
Add multiselect feature and a bugfix
2025-03-22 06:31:33 -04:00
Michael Bucari-Tovo c77f2e2162 Add multi-select context menu support (rmcrackan/Libation#1195) 2025-03-21 16:49:21 -06:00
Michael Bucari-Tovo bfcd226795 Fix libation hanging on first inport of large libraries 2025-03-21 11:08:36 -06:00
rmcrackan 0af7c4d90a fix ver 2025-03-21 09:19:03 -04:00
rmcrackan e4826388be incr ver 2025-03-21 09:18:48 -04:00
rmcrackan 98a1fa4dda Merge pull request #1193 from Mbucari/master
Add support for custom themes in chardonnay
2025-03-21 09:12:35 -04:00
Michael Bucari-Tovo 81e9ab7fb2 Fix theme not resetting properly
Change button foreground color
2025-03-20 16:30:08 -06:00
Mbucari 9c82d34ba4 Merge branch 'rmcrackan:master' into master 2025-03-20 15:30:18 -06:00
Mbucari a384bceab0 Update Readme for Chardonnay Themes 2025-03-20 15:29:46 -06:00
Michael Bucari-Tovo 545540d9a4 Improve Libation glass icons for use with dark mode. 2025-03-20 15:04:22 -06:00
MBucari f402912a92 Mark resource as dynamic and delete unused resource 2025-03-19 22:43:50 -06:00
Michael Bucari-Tovo aab4f1d9d6 Add theme import and export function 2025-03-19 21:47:24 -06:00
Michael Bucari-Tovo f183b587b8 Revert all changes if window is closed by user. 2025-03-19 16:38:58 -06:00
Michael Bucari-Tovo 733a091ebd Add theme preview dialog 2025-03-19 16:26:14 -06:00
Mbucari 9043ea6334 Merge branch 'rmcrackan:master' into master 2025-03-19 14:21:51 -06:00
Michael Bucari-Tovo 40890f242a Fix spelling errors 2025-03-19 14:16:32 -06:00
rmcrackan 6c03f525bf Update InstallOnMac.md -- minimum OS supported 2025-03-19 16:01:40 -04:00
Michael Bucari-Tovo dcda1a0cc2 Add contributors to about page 2025-03-18 21:18:25 -06:00
Michael Bucari-Tovo e509f842e4 Remove unused windows forms buttons and streamline dialogs 2025-03-18 21:18:25 -06:00
Mbucari faa2e04b9f Merge branch 'rmcrackan:master' into master 2025-03-18 21:17:30 -06:00
Robert McRackan 71afb5c9f4 incr ver 2025-03-18 21:09:27 -04:00
Michael Bucari-Tovo d90ef3f4d4 Mark IconFill as a dynamic resource 2025-03-18 12:33:01 -06:00
Michael Bucari-Tovo f84bb753e9 Revert custom window border on Windows 2025-03-13 16:44:16 -06:00
Michael Bucari-Tovo b34970bd47 Add support for custom themes in chardonnay 2025-03-13 16:05:32 -06:00
Robert McRackan a37eb383cd Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-03-10 19:11:22 -04:00
Robert McRackan 614965e1ab incr ver 2025-03-10 19:09:44 -04:00
rmcrackan 52d611a74c Merge pull request #1189 from Mbucari/master
Minor bug fixes.
2025-03-10 17:30:24 -04:00
Michael Bucari-Tovo 653381b1df Fix Auto download not working sometimes (#1183) 2025-03-10 12:57:52 -06:00
Michael Bucari-Tovo 4e067f5b5b Remove inadvertently committed debugging code 2025-03-10 10:46:36 -06:00
Michael Bucari-Tovo ee05ca4eb2 Handle corrupted LibraryScans.zip file (#1185) 2025-03-10 09:49:22 -06:00
Michael Bucari-Tovo 65e12d9a8f Add default window dimensions 2025-03-10 09:07:35 -06:00
rmcrackan 5dc1bafb94 Update Docker.md 2025-03-07 10:12:05 -05:00
rmcrackan 3010f80834 Merge pull request #1181 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.7
Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
2025-03-06 20:49:34 -05:00
dependabot[bot] 6c20b85200 Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.6...v3.1.7)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-06 22:29:43 +00:00
rmcrackan bf87180fe9 Merge pull request #1179 from Mbucari/master
UI tweak and Linux command updates
2025-03-05 17:41:47 -05:00
Michael Bucari-Tovo ae9aac789f Use polkit (#1176) and dnf5/dnf (#1177) 2025-03-05 14:22:51 -07:00
Mbucari e6cd182872 Merge branch 'rmcrackan:master' into master 2025-03-05 11:25:50 -07:00
Michael Bucari-Tovo 7eeb2dcd7f Move encoding options above to the mp3 settings column (#764)
Additionally, add more tool tips for cryptic options.
2025-03-05 11:17:47 -07:00
rmcrackan 71bb0571d1 Merge pull request #1178 from RokeJulianLockhart/patch-1
Add instructions for Fedora to `InstallOnLinux.md`.
2025-03-05 11:32:10 -05:00
Mr. Beedell, Roke Julian Lockhart 7bb5b2968e Add instructions for Fedora to InstallOnLinux.md
Partially improves upon https://github.com/rmcrackan/Libation/issues/1177#issue-2897597653.
2025-03-05 15:17:49 +00:00
rmcrackan b051283fca Update bug_report.md 2025-03-05 09:30:43 -05:00
rmcrackan 53af2ee39e Update feature_request.md 2025-03-05 09:30:18 -05:00
rmcrackan fab32a1744 Update bug_report.md 2025-03-05 09:29:04 -05:00
rmcrackan e2dabc8a53 Update feature_request.md 2025-03-05 09:26:47 -05:00
rmcrackan fd82f7ae5a Update feature_request.md 2025-03-05 09:26:19 -05:00
rmcrackan df9535a83d Update FrequentlyAskedQuestions.md 2025-03-05 08:20:42 -05:00
rmcrackan 85b6792468 Merge pull request #1175 from Mbucari/master
Additional null safety
2025-03-04 21:32:35 -05:00
Michael Bucari-Tovo e37abbf276 Fix dark theme text color in DataGridTextColumn 2025-03-04 16:18:06 -07:00
Michael Bucari-Tovo c3938c49a9 Additional null safety 2025-03-04 15:41:26 -07:00
Michael Bucari-Tovo 7658f21d7c Fix tags font color in dark mode 2025-03-04 15:07:37 -07:00
Michael Bucari-Tovo c4827fc761 Add error logging 2025-03-04 12:54:05 -07:00
rmcrackan 649b52af1d Merge pull request #1174 from Mbucari/master
Null safety & UI tweak
2025-03-04 13:10:51 -05:00
Michael Bucari-Tovo da06511951 Only show buttons on mouse over 2025-03-04 10:34:09 -07:00
Michael Bucari-Tovo 88d3e5ff0c Null safety checks. 2025-03-04 10:33:29 -07:00
rmcrackan 5f99e594d8 Merge pull request #1172 from Mbucari/master
Thread safety and AccountSettings.json error handling
2025-03-03 19:27:45 -05:00
Michael Bucari-Tovo 981a183992 Add a border around dialogs with CanResize=true 2025-03-03 15:20:58 -07:00
Michael Bucari-Tovo ac036f65f1 Handle and notify users of invalid account settings file 2025-03-03 14:41:56 -07:00
Michael Bucari-Tovo b36e110b49 Add thread safety and comments re threading 2025-03-03 10:11:17 -07:00
Robert McRackan ef3c71a939 Incr ver 2025-03-02 20:34:45 -05:00
rmcrackan b2af93bed9 Merge pull request #1169 from Mbucari/master
12.0.1 Bug Fixes
2025-03-02 20:33:59 -05:00
Mbucari 1f427919e6 Try to solve threadding issues (#1168) 2025-03-02 11:59:35 -07:00
Mbucari c9c5bbb687 Fix errorre rmoving entries from the cache (#1167) 2025-03-02 10:55:26 -07:00
Robert McRackan efbefa2784 Increm ver 2025-03-01 10:44:49 -05:00
rmcrackan 51aabe5dd4 Merge pull request #1161 from Mbucari/master
Performance and UI tweaks
2025-02-28 22:48:49 -05:00
Michael Bucari-Tovo 1c668adff8 Deliminated category names in library exports with semicolon (#1107) 2025-02-28 17:35:58 -07:00
Michael Bucari-Tovo 4170dcc1d5 Chardonnay UI bug fixes and improvements
- Theme changes do not require restart
- Fix some text appearing black in dark mode
- Fix dialog boxes not appearing correctly on Windows
- Fix queue vertical scroll bar overlapping items
2025-02-28 17:34:55 -07:00
Michael Bucari-Tovo 68cfae1d58 Fix ever-widening Form1 when form size is restored. 2025-02-28 12:04:49 -07:00
Michael Bucari-Tovo a790c7535c Changes to default directories for file storage (#1112)
- Add My Music and Local Application Data to known directories
- Make %localappdata%\Libation the default settings folder on *nix machines
- Make %MyMusic%\Libation\Books the default books folder on *nix machines
2025-02-28 12:01:58 -07:00
Michael Bucari-Tovo 3b7d5a354f Re-add books to queue that failed or were cancelled. 2025-02-28 10:47:26 -07:00
MBucari a9375f1520 Improve file cache performance and add migration
LibraryCommands.GetCounts hits the file cache hard. The previous cache implementation was linear list, so finding an entry by ID was (n). When you consider that each book may have many files, the number of cache entries could grow to many multiples of the library size.

The new cache uses a dictionary with the ID as its key, and a CacheEntry list as its value.
2025-02-28 10:07:45 -07:00
Michael Bucari-Tovo 47c9fcb883 Improve LibrarySizeChanged performance 2025-02-27 22:56:30 -07:00
rmcrackan 5f5c9f65ed Merge pull request #1160 from Mbucari/master
Fixed stack overflow crash when movifying large databases
2025-02-27 21:41:33 -05:00
MBucari 1417a4b992 Improve library load performance 2025-02-27 19:16:36 -07:00
Michael Bucari-Tovo 2d6120f0c4 Get full library in LibrarySizeChanged event and pass as EventArgs
There are multiple subscribers to LibraryCommands.LibrarySizeChanged, and each one calls GetLibrary_Flat_NoTracking(). Passing the full library as an event argument speeds up all operations which happen after the library size changes.

Fix initial backup counts
2025-02-27 13:11:28 -07:00
Michael Bucari-Tovo 2a25b7e0ad Load MainWindow before library finishes loading like Classic 2025-02-27 11:17:18 -07:00
Michael Bucari-Tovo 4766ea7372 Improve NRE safety for quick filters 2025-02-27 10:10:26 -07:00
Michael Bucari-Tovo d195dd07dc Remove upload-release-assets package (out of support) 2025-02-27 09:30:08 -07:00
Michael Bucari-Tovo bcbb7610ad Refactor GetAllEntries methods for clarity and maintainability
d
2025-02-27 09:00:26 -07:00
Michael Bucari-Tovo 6c5773df24 Fix stack overflow exception when updating large databases (#1158) 2025-02-26 14:41:59 -07:00
rmcrackan 211f15af25 Update InstallOnMac.md 2025-02-25 10:03:11 -05:00
Robert McRackan e3b0f80016 incr ver: 12.0 2025-02-24 12:05:24 -05:00
Robert McRackan 7b2c7e49e5 fix unit tests 2025-02-23 11:11:25 -05:00
Robert McRackan 6a40d19393 Fix unit tests 2025-02-23 10:28:09 -05:00
Robert McRackan 3167744111 #1151 : default character replacement for colon is _ 2025-02-23 10:13:15 -05:00
Robert McRackan b6a3ba335a Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-22 22:54:12 -05:00
Robert McRackan fa1ddc726a #1151 - unicode colon is causing issues with Windows Chardonnay 2025-02-22 22:54:00 -05:00
rmcrackan b3b0662dec Update InstallOnMac.md
Apple can’t check app for malicious software
2025-02-19 17:09:26 -05:00
Robert McRackan 5cb22cfd24 Update AAXClean.Codecs 2025-02-17 20:07:50 -05:00
Michael Bucari-Tovo e911344850 Workaround for DataGridView filtering internal NullReferenceException 2025-02-10 10:20:18 -07:00
Michael Bucari-Tovo 8ec7e5a9d2 Revert "DataGridView filtering internal NullReferenceException **HACK**"
This reverts commit e1f749c3da.
2025-02-10 10:19:24 -07:00
Robert McRackan e1f749c3da DataGridView filtering internal NullReferenceException **HACK** 2025-02-10 11:23:27 -05:00
Robert McRackan ba060d15aa Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-10 09:12:32 -05:00
Robert McRackan 93fde236c8 format for clarity 2025-02-10 09:12:29 -05:00
Michael Bucari-Tovo 13aad1a7cb Restrict audio sample rate settings to allowed values (#1116) 2025-01-16 10:35:58 -07:00
rmcrackan 65c64c4504 Update Docker.md 2025-01-13 09:28:55 -05:00
rmcrackan 14ba04c28b Update Docker.md 2025-01-13 09:26:52 -05:00
rmcrackan 96e886d207 Update FrequentlyAskedQuestions.md
Brazil login
2025-01-08 10:04:55 -05:00
Robert McRackan c7279574a9 Upgrade avalonia 2025-01-07 08:00:35 -05:00
rmcrackan a522e1ff7e Merge pull request #1072 from shuvashish76/patch-1
Update InstallOnLinux.md with repology badge
2024-12-08 08:56:32 -05:00
shuvashish76 9ebc4444bd Update InstallOnLinux.md 2024-12-07 06:24:34 +00:00
shuvashish76 525afdf050 Update InstallOnLinux.md 2024-12-07 06:03:46 +00:00
shuvashish76 983cdf6ad5 Update InstallOnLinux.md with repology badge 2024-12-07 05:38:35 +00:00
Robert McRackan 09bb32a435 incr ver 2024-12-06 13:10:37 -05:00
rmcrackan 817ef33fbd Merge pull request #1071 from pixil98/master
update docker image to dotnet 9
2024-12-06 13:07:39 -05:00
Aaron Reisman be52b496a6 Update docker image to .net 9 2024-12-06 10:29:47 -06:00
Robert McRackan c0c99db6fa Incr ver 2024-12-05 14:54:16 -05:00
rmcrackan a1c8fb5921 Merge pull request #1066 from Mbucari/master
Fix chardonnay window closing (#1065)
2024-12-05 14:53:11 -05:00
MBucari 4576c0e193 Updata Avalonia to 11.2.2 2024-12-04 19:12:59 -07:00
MBucari d592e9435e Fix main window closing when dialog is closed (#1065) 2024-12-04 19:11:36 -07:00
Robert McRackan 9ce6cb54ab incr ver 2024-11-22 17:18:39 -05:00
Robert McRackan c15d49fc64 publish profiles => .net 2024-11-22 17:04:08 -05:00
Robert McRackan 99be869aa9 yaml => .net9 2024-11-22 16:56:16 -05:00
Robert McRackan a0e875a79c Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-11-22 16:49:09 -05:00
Robert McRackan 6134becc70 Upgrade to .net9 2024-11-22 16:47:59 -05:00
rmcrackan eadf7cff79 Merge pull request #1050 from stickystyle/patch-1
set maxdepth to prevent traversal into subdirectories
2024-11-20 13:13:58 -05:00
Ryan Parrish 87ca76f9cb set maxdepth to prevent traversal into subdirectories 2024-11-20 11:32:02 -05:00
Robert McRackan 43d1019059 * Bug fix #1048: docker: Error when using SLEEP_TIME - "integer expression expected"
Thanks @charltonstanley !
2024-11-19 06:40:51 -05:00
Robert McRackan ed87ded77a Docker deployment fix 2024-11-14 22:51:02 -05:00
rmcrackan 56d4205360 Merge pull request #1042 from pixil98/master
Exclude docker build report artifact from release packing
2024-11-14 22:50:26 -05:00
Aaron Reisman 1f839606ae Add a pattern to exclude docker build artifact from release 2024-11-14 16:33:30 -06:00
Robert McRackan 6eebe652d4 Docker changes => pre-release 2024-11-14 15:22:28 -05:00
rmcrackan 5fff22a0e1 Merge pull request #1011 from pixil98/master
Run docker image as non-root user
2024-11-14 15:19:36 -05:00
Aaron Reisman cd7040cdc7 pretty up the workflows 2024-11-14 11:18:16 -06:00
Aaron Reisman 97b792868f Update documentation with new envvars 2024-11-14 10:54:35 -06:00
pixil98 984f931f67 Merge branch 'rmcrackan:master' into master 2024-11-14 01:04:16 -06:00
Aaron Reisman e0dd9b845a rework database handling 2024-11-14 00:59:28 -06:00
rmcrackan f1c8b320c2 Merge pull request #1039 from jwillikers/fix-metadata
Fix metainfo validation
2024-11-13 21:09:45 -05:00
Jordan Williams 9b7d0cd909 Fix metainfo validation 2024-11-13 15:14:14 -06:00
rmcrackan 99592ff84e Merge pull request #1036 from jwillikers/flatpak-files
Add some necessary files for the Flatpak
2024-11-13 15:36:18 -05:00
Jordan Williams f97cfe77f9 Add CI to validate the desktop file and appstream metainfo 2024-11-10 15:09:56 -06:00
Jordan Williams 2954cb961b Add metainfo and screenshots 2024-11-10 14:05:24 -06:00
Jordan Williams 1e29b98b82 Add categories and keywords to the desktop file 2024-11-10 14:05:24 -06:00
Robert McRackan 8b76da0dbe incr ver 2024-10-25 15:05:08 -04:00
Mbucari 0a749d2d88 Update Bundle_MacOS.sh 2024-10-25 10:51:56 -06:00
Aaron Reisman 9ed6c1fd0d cleanup 2024-10-22 10:07:37 -05:00
Aaron Reisman 9825e2b552 Build both platforms in one action 2024-10-22 09:27:00 -05:00
Aaron Reisman 011efe3676 remove unused configure step 2024-10-22 00:39:35 -05:00
Aaron Reisman 2bdcc221f5 Specify platform(?) 2024-10-22 00:28:27 -05:00
pixil98 21bedca367 Merge branch 'rmcrackan:master' into master 2024-10-21 23:55:31 -05:00
Aaron Reisman 074fe79ded Update docker workflow to try building on validate 2024-10-21 17:13:08 -05:00
Aaron Reisman ac8c090c4c Rework run script to support db mount better 2024-10-21 14:02:52 -05:00
Aaron Reisman ade693bebb Update docker readme 2024-10-19 01:54:37 -05:00
Aaron Reisman 9bc53e45cd large overhaul of docker run script 2024-10-19 01:31:03 -05:00
Aaron Reisman 7d4eaa11e7 Run docker image as non-root user 2024-10-18 00:13:19 -05:00
Robert McRackan 4521c5d5ed incr ver 2024-10-16 12:04:04 -04:00
rmcrackan eb39f994e1 Merge pull request #1006 from cbordeman/Friendly-name-filters-995
Fixed bug in main view quick filter binding.
2024-10-16 08:03:44 -04:00
Chris Bordeman c19833b34e Fixed bug in main view quick filter binding. 2024-10-15 21:46:22 -04:00
Robert McRackan 6dcf456d06 fix ver 2024-10-15 13:20:58 -04:00
rmcrackan 8a87462cf5 Merge pull request #997 from cbordeman/Friendly-name-filters-995
Implemented Name on Quick Filters.
2024-10-15 13:09:38 -04:00
Chris Bordeman 9da2a44eff Small fix 2024-10-15 00:13:40 -04:00
Chris Bordeman 7af8d8aa70 Remove some warnings. 2024-10-15 00:07:57 -04:00
Chris Bordeman 4801f37e7c Moved QuickFilters migration to AppScaffolding. 2024-10-14 23:59:05 -04:00
rmcrackan 4f5df44d40 Merge pull request #999 from cbordeman/Add-default-theme-setting
Implemented "System" option for theme.
2024-10-14 15:09:21 -04:00
Chris Bordeman 63e28b13c1 Implemented "System" option for theme. 2024-10-12 02:17:35 -04:00
Chris Bordeman f92b2b65b2 Implemented Name on Quick Filters. 2024-10-11 05:45:04 -04:00
rmcrackan f7a4a95e3b Merge pull request #994 from cbordeman/master
Disable shrink/expand on mouseover for all scrollbars.
2024-10-10 07:38:24 -04:00
Chris Bordeman 71b8e9e51c Disable shrink/expand on mouseover for all scrollbars. 2024-10-10 02:22:36 -04:00
Robert McRackan c6788ccb48 typo 2024-09-25 10:12:01 -04:00
Robert McRackan 0503ee1404 incr ver 2024-09-18 08:17:35 -04:00
rmcrackan 303192c6c3 Merge pull request #978 from muchtall/allow-UseCoverAsFolderIcon-on-linux
Allow Non-Windows installations to populate Windows folder artwork
2024-09-18 08:15:05 -04:00
muchtall 6e21e96aa2 Allow Non-Windows installations to populate Windows folder artwork (https://github.com/rmcrackan/Libation/issues/977) 2024-09-16 19:42:26 +00:00
Robert McRackan d1d0a7e487 db migration for IsFinished 2024-09-11 07:56:18 -04:00
Robert McRackan 2fd8ea91e1 New search field: Finished/IsFinished.
All work complete except db migration
2024-09-11 07:45:37 -04:00
Robert McRackan 92ee0b2e6d update dependencies 2024-09-11 07:20:54 -04:00
Robert McRackan f0b5ae1cdc New filter option: IsInSeries, InSeries 2024-09-09 14:22:05 -04:00
Robert McRackan eb659cc7d7 typo 2024-09-09 08:31:55 -04:00
Robert McRackan cdd5f229d3 incr ver 2024-09-05 09:12:19 -04:00
Robert McRackan 29edfb7c3f Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-09-05 09:07:54 -04:00
Robert McRackan c0cb454d45 Make recursive file enumerations safer 2024-09-05 09:07:47 -04:00
rmcrackan 970a77c9e9 Merge pull request #946 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.5
Bump SixLabors.ImageSharp from 3.1.4 to 3.1.5 in /Source/LibationUiBase
2024-07-22 14:05:40 -04:00
dependabot[bot] 3488c8e0f5 Bump SixLabors.ImageSharp from 3.1.4 to 3.1.5 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-22 17:45:08 +00:00
rmcrackan 5133720cc8 InstallOnMac.md updated instructions 2024-06-30 07:22:38 -04:00
rmcrackan 4150746f45 InstallOnMac.md typo 2024-06-29 08:08:57 -04:00
Robert McRackan 3a95e1e72f incr ver 2024-06-26 08:00:48 -04:00
rmcrackan c74e26e1af Update release.yml
Release titles: don't include "v" in front of version number
2024-06-26 07:54:11 -04:00
rmcrackan ae54c95d46 Update docker.yml
Rolling back docker build push action to attempt to fix build
2024-06-26 07:51:00 -04:00
Robert McRackan 56e6bd164b By user request, other Serilog.Sink packages are included for experimental use 2024-06-26 07:07:49 -04:00
rmcrackan 9b28bdceaa Merge pull request #923 from rmcrackan/dependabot/github_actions/docker/build-push-action-6
Bump docker/build-push-action from 5 to 6
2024-06-17 16:33:34 -04:00
dependabot[bot] b7f7d9004d Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-17 14:23:33 +00:00
rmcrackan 1be0991e62 Add Audibly to FAQ 2024-06-07 13:27:18 -04:00
rmcrackan ff8a2e59c5 Update FrequentlyAskedQuestions.md 2024-05-27 11:48:48 -04:00
Robert McRackan 453904261b Bug fix. Audible's api acts weird when page count gets to ~400 2024-05-16 07:53:19 -04:00
Robert McRackan f9340db90a update references. audible api bugfix 2024-05-15 06:55:25 -04:00
Robert McRackan 5cd329dd26 refactor 2024-05-13 15:23:37 -04:00
Robert McRackan b2a882b79d Bug fix #904 -- navigation bug with new Accessibility feature 2024-05-13 15:17:17 -04:00
Robert McRackan 75df78a2f7 Add OSVersion to logs 2024-05-13 14:01:34 -04:00
Robert McRackan 3ad52cbecc docker to .net8 2024-05-09 10:01:38 -04:00
Robert McRackan 27b2fe741c Add accessibility 2024-05-07 10:39:30 -04:00
Robert McRackan d19fe2250c Add accessibility text to grid stoplight buttons 2024-05-06 21:56:00 -04:00
Robert McRackan d16d0c8de2 Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-05-05 16:24:07 -04:00
Robert McRackan c213d5d9f6 Attempt to solve networking issue by disabling ipv6 2024-05-05 16:24:00 -04:00
rmcrackan c73a023572 Update FrequentlyAskedQuestions.md 2024-04-30 22:42:34 -04:00
rmcrackan 67389917fd Merge pull request #879 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.4
Bump SixLabors.ImageSharp from 3.1.3 to 3.1.4 in /Source/LibationUiBase
2024-04-15 17:03:13 -04:00
dependabot[bot] b3264d5f42 Bump SixLabors.ImageSharp from 3.1.3 to 3.1.4 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.3...v3.1.4)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-15 20:27:24 +00:00
Robert McRackan 962d9b550f "Unable to load module" should just be a warning, not error 2024-04-15 08:03:49 -04:00
Robert McRackan 91ce7272ae Bug fix #874 : Chardonnay wasn't allowing custom directories ending in "Books" 2024-04-04 08:13:37 -04:00
rmcrackan 2f64ca6856 Merge pull request #870 from ScubyG/patch-1
Update InstallOnLinux.md
2024-03-22 07:37:00 -04:00
Davy Jones cfe5db436c Update InstallOnLinux.md
Fixed typo
2024-03-22 01:23:52 +00:00
rmcrackan 3653fc8094 Merge pull request #863 from rmcrackan/dependabot/github_actions/softprops/action-gh-release-2
Bump softprops/action-gh-release from 1 to 2
2024-03-11 10:45:44 -04:00
dependabot[bot] 662c0ec871 Bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [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/v1...v2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 14:20:12 +00:00
rmcrackan 44d39eabdb Merge pull request #861 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.3
Bump SixLabors.ImageSharp from 3.1.2 to 3.1.3 in /Source/LibationUiBase
2024-03-05 11:45:59 -05:00
dependabot[bot] a0550d5c97 Bump SixLabors.ImageSharp from 3.1.2 to 3.1.3 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.2...v3.1.3)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-05 16:38:00 +00:00
Robert McRackan 14eaca6d45 Update dependency. Security update 2024-03-05 11:36:36 -05:00
Robert McRackan 93ccc206ef Update dependencies 2024-03-04 14:02:42 -05:00
Robert McRackan d3f0fd711e Remove validation check: SeriesName is null . At least 1 exception found in the wild 2024-02-29 14:44:41 -05:00
rmcrackan 3f4604e877 Update FrequentlyAskedQuestions.md
reformat
2024-02-29 10:15:47 -05:00
rmcrackan c316709af8 Update FrequentlyAskedQuestions.md
Classic vs Chardonnay
2024-02-29 10:15:01 -05:00
Robert McRackan 221d5c7f1c paypal link 2024-02-19 10:32:26 -05:00
Robert McRackan 5a86a1a27b Typo 2024-02-13 21:42:24 -05:00
rmcrackan dc5e55de68 Merge pull request #848 from patienttruth/master
Update Docker.md
2024-02-13 07:22:33 -05:00
patienttruth ee37864a42 Update Docker.md
Included an explanation that you may have to manually input the InProgress variable.
2024-02-13 06:49:33 +00:00
Robert McRackan efe347667c Better error reporting in audible api -- incl. inner exceptions 2024-01-20 21:44:29 -05:00
Robert McRackan f27a18bdbb Better error reporting in audible api 2024-01-19 09:30:48 -05:00
Robert McRackan d1834659d9 incr ver 2024-01-12 09:08:14 -05:00
Robert McRackan 7842b521d7 Update dependencies. Notes about upgrading 2024-01-12 07:44:40 -05:00
Robert McRackan 0822f0229d Revert Avalonia. Bug in 11.0.6 2024-01-04 10:03:33 -05:00
Robert McRackan 26aee4d29d Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-01-03 11:43:59 -05:00
Robert McRackan 17a80a23a8 AAXClean upgrade to .net8 2024-01-03 11:43:45 -05:00
rmcrackan e26fc9ca62 Merge pull request #808 from rmcrackan/dependabot/github_actions/actions/download-artifact-4
Bump actions/download-artifact from 3 to 4
2023-12-15 09:50:25 -05:00
rmcrackan a03ccf1143 Merge pull request #807 from rmcrackan/dependabot/github_actions/actions/upload-artifact-4
Bump actions/upload-artifact from 3 to 4
2023-12-15 09:50:11 -05:00
dependabot[bot] bb8dd615db Bump actions/download-artifact from 3 to 4
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-15 14:08:26 +00:00
dependabot[bot] 9022a2889f Bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-15 14:08:20 +00:00
rmcrackan ef049a3b02 Merge pull request #805 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-4
Bump actions/setup-dotnet from 3 to 4
2023-12-05 10:01:34 -05:00
dependabot[bot] 77409750aa Bump actions/setup-dotnet from 3 to 4
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 3 to 4.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-05 14:14:17 +00:00
Robert McRackan 1702130b01 Upgrade to .net8 2023-11-15 19:57:39 -05:00
Robert McRackan b6d1a7e3ba Upgrade to .net8 2023-11-15 19:53:26 -05:00
Robert McRackan 2907ba5c13 Add FAQ 2023-10-20 08:27:20 -04:00
Robert McRackan 6df6c79ac8 New locale: Brazil 2023-10-18 22:33:24 -04:00
rmcrackan 3a9ca5d827 Merge pull request #779 from rmcrackan/dependabot/github_actions/dwenegar/upload-release-assets-2
Bump dwenegar/upload-release-assets from 1 to 2
2023-10-13 14:41:26 -04:00
dependabot[bot] e1e663e327 Bump dwenegar/upload-release-assets from 1 to 2
Bumps [dwenegar/upload-release-assets](https://github.com/dwenegar/upload-release-assets) from 1 to 2.
- [Release notes](https://github.com/dwenegar/upload-release-assets/releases)
- [Commits](https://github.com/dwenegar/upload-release-assets/compare/v1...v2)

---
updated-dependencies:
- dependency-name: dwenegar/upload-release-assets
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-13 14:37:55 +00:00
Robert McRackan 4b00d5fd84 incr ver 2023-09-18 13:58:23 -04:00
rmcrackan 02dbf8aad0 Merge pull request #751 from Mbucari/master
Minor Bug Fixes
2023-09-18 13:56:53 -04:00
rmcrackan 8326389f5c Merge pull request #732 from rmcrackan/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 3 to 4
2023-09-18 13:56:33 -04:00
dependabot[bot] 34535b3ce1 Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [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/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-18 17:33:43 +00:00
rmcrackan 7e5366ab95 Merge pull request #738 from rmcrackan/dependabot/github_actions/docker/setup-buildx-action-3
Bump docker/setup-buildx-action from 2 to 3
2023-09-18 13:30:24 -04:00
dependabot[bot] 690de9bc5c Bump docker/setup-buildx-action from 2 to 3
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-18 17:25:20 +00:00
rmcrackan c976aa2bb2 Merge pull request #739 from rmcrackan/dependabot/github_actions/docker/login-action-3
Bump docker/login-action from 2 to 3
2023-09-18 13:25:07 -04:00
rmcrackan 27f659285d Merge pull request #740 from rmcrackan/dependabot/github_actions/docker/build-push-action-5
Bump docker/build-push-action from 4 to 5
2023-09-18 13:24:55 -04:00
rmcrackan 423a5e7720 Merge pull request #741 from rmcrackan/dependabot/github_actions/docker/setup-qemu-action-3
Bump docker/setup-qemu-action from 2 to 3
2023-09-18 13:24:44 -04:00
Michael Bucari-Tovo 9152e12fe1 Fix #748 2023-09-18 10:14:54 -06:00
Michael Bucari-Tovo f471c53139 Fix #734 2023-09-18 10:08:15 -06:00
dependabot[bot] 66d055bb90 Bump docker/setup-qemu-action from 2 to 3
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:49 +00:00
dependabot[bot] 2bbb35363a Bump docker/build-push-action from 4 to 5
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:46 +00:00
dependabot[bot] 1d3687cf9e Bump docker/login-action from 2 to 3
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:42 +00:00
Mbucari daf925157f Update AppScaffolding.csproj 2023-09-02 07:59:30 -06:00
Mbucari 40eec9e674 Merge pull request #730 from Mbucari/master
Fix #729
2023-09-01 18:46:42 -06:00
MBucari 6f0782053e Restore PDF functionality (#729) 2023-09-01 16:30:35 -06:00
Michael Bucari-Tovo 04ad033ba0 Remove appsettings.json from program files dir 2023-08-31 09:36:38 -06:00
Robert McRackan f12f8ba3ee incr ver 2023-08-30 15:00:20 -04:00
rmcrackan cc6feb21ff Merge pull request #728 from Mbucari/master
Minor Bug fixes
2023-08-30 14:46:16 -04:00
Michael Bucari-Tovo c4f2ec428d Allow Settings form resizing 2023-08-30 12:38:28 -06:00
Michael Bucari-Tovo 59689cb647 Update Dependencies 2023-08-30 11:59:40 -06:00
Michael Bucari-Tovo d7f3758ebc Add support for environment variables and unix shortcuts in appsettings.json path. 2023-08-30 11:47:50 -06:00
Mbucari 49775b019c Update InstallOnMac.md 2023-08-30 09:06:57 -06:00
Michael Bucari-Tovo e55e969349 Fix issue with save button being disabled. 2023-08-30 08:59:20 -06:00
Mbucari 42a93bfac1 Update AAXClean 2023-08-27 19:47:32 -06:00
Mbucari f86c77a546 Create directory before trying to create appsettings.json 2023-08-27 19:47:00 -06:00
MBucari 88c35e2a56 Add scrolling view to settings window panels (#720) 2023-08-23 20:25:30 -06:00
MBucari b405e8b6b2 Remove unised response groups 2023-08-20 11:40:24 -06:00
MBucari 92d283187d Add support for locating mp3 audiobooks 2023-08-20 11:38:43 -06:00
Robert McRackan 51b8cfe71f incr ver 2023-08-17 14:03:40 -04:00
rmcrackan c80da5357b Merge pull request #716 from Mbucari/master
Fix NRE when item has no category ladders (#715)
2023-08-17 14:02:43 -04:00
Michael Bucari-Tovo 736d7c4a5f Fix NRE when item has no category ladders (#715) 2023-08-17 11:55:28 -06:00
Robert McRackan f175b7592e incr major ver 2023-08-12 21:45:11 -04:00
rmcrackan 415e6e7bc6 Merge pull request #710 from Mbucari/master
Batch of Fixxed Issues
2023-08-12 21:20:37 -04:00
MBucari d6a413e8d9 Update Avalonia to 11.0.3 2023-08-12 17:30:08 -06:00
Mbucari 3049de6246 Merge branch 'rmcrackan:master' into master 2023-08-12 17:20:09 -06:00
MBucari fb9b4eb77e Update audio duration on library scan (#707) 2023-08-12 17:19:56 -06:00
MBucari e65b6c76a8 Add ability to preview templates on user's books (#700)
Add template editor menu items to main grid context menu
2023-08-12 17:12:50 -06:00
Mbucari 167a021eb1 Change glass icon shading 2023-08-11 10:55:31 -06:00
Mbucari ff3ac2d6fd Fix MessageBox crash when UI has no Screens (#708) 2023-08-11 09:04:05 -06:00
Robert McRackan f733079a49 remove Moq 2023-08-10 15:24:43 -04:00
Mbucari 893d68190d Lazy load cover to improve startup time 2023-08-10 10:18:28 -06:00
Mbucari 97f94d8782 Add custom column widths to chardonnay 2023-08-07 15:54:50 -06:00
Mbucari 4b2ce0c2d1 Brighter stoplight colors (#702) 2023-08-07 11:31:22 -06:00
Mbucari ee00417c6f Add white glow to libation_glass.svg (#701) 2023-08-07 11:31:03 -06:00
Robert McRackan 768afd8ecd incr ver 2023-08-04 11:57:15 -04:00
rmcrackan 32c3fa85ce Merge pull request #699 from Mbucari/master
Fix broken template editor (#698)
2023-08-04 11:55:30 -04:00
Mbucari 6986c8f018 Fix broken template editor (#698) 2023-08-04 09:26:51 -06:00
Robert McRackan f69c2b1cfc incr ver 2023-08-02 21:55:19 -04:00
rmcrackan b11675c36a Merge pull request #696 from Mbucari/master
Bug fixes
2023-08-02 21:52:57 -04:00
Mbucari 379c2ed62d Fix account nickname retrieval (#629) 2023-08-02 13:54:49 -06:00
Mbucari 7c8489b52f Fix walkthrough causing freeze (#695) 2023-08-02 13:15:58 -06:00
rmcrackan c61a863edd Merge pull request #694 from Mbucari/master
Fix DPI scaling bug (#692)
2023-08-01 15:03:21 -04:00
Mbucari 1d54f32ef3 Fix DPI scaling bug (#692) 2023-08-01 11:55:23 -06:00
rmcrackan fabe4afd94 Merge pull request #691 from Mbucari/master
Fix #686 and enable nullable in FileManager and LibationFileManager
2023-07-30 20:22:55 -04:00
MBucari 61efa3c0c1 Update dependencies 2023-07-30 14:00:12 -06:00
MBucari fe70daf0bc Update avalonia to v11.0.1 2023-07-30 13:54:44 -06:00
MBucari 34033e7947 Enable Nullable 2023-07-30 13:31:57 -06:00
MBucari e8c63e9a6e Fix UI control overlapping label (#686) 2023-07-30 13:15:43 -06:00
Robert McRackan 9315165f80 update dependencies 2023-07-21 21:21:48 -04:00
Robert McRackan ce624399ba incr ver 2023-07-19 07:48:11 -04:00
rmcrackan 63e9700c4a Merge pull request #682 from Mbucari/master
Fix #680 and Add category ladders
2023-07-19 07:46:05 -04:00
Mbucari 914e574bf8 Improve GridView
- Remove LongDescription
- Description has the full description
- Better MyRating updating
2023-07-18 16:18:01 -06:00
Mbucari b94f9bbc15 Fix grid update bug 2023-07-18 16:11:22 -06:00
Mbucari 4e34834c35 Fix category search indexing 2023-07-18 16:00:06 -06:00
Mbucari 3211b2dc85 Improved Category Ladders 2023-07-18 15:51:02 -06:00
Mbucari ea6adeb58f Add category ladders 2023-07-17 16:50:45 -06:00
Mbucari 90eccbf2f6 Fix FilePathCache NRE (#680) 2023-07-17 08:55:55 -06:00
rmcrackan 668cd7dba8 Merge pull request #679 from Mbucari/master
Mp3 embedded cuesheet and raw metadata
2023-07-16 14:32:49 -04:00
MBucari c08b2b575c UI Tweak 2023-07-16 10:57:17 -06:00
MBucari 07eaa48e10 Save raw json metadata 2023-07-16 10:54:05 -06:00
MBucari 3cf5fc1d99 Add mp3 embedded cuesheet (#677) 2023-07-15 10:44:31 -06:00
Robert McRackan 15ad753fa1 update dependencies 2023-07-14 20:58:26 -04:00
rmcrackan 75b984bdb2 Merge pull request #678 from Mbucari/master
Fix quick filter not being applied on startup
2023-07-14 20:53:57 -04:00
Mbucari f586d1d59f Fix quick filter not being applied on startup 2023-07-13 11:00:05 -06:00
Mbucari cb91a591f0 inc ver 2023-07-13 09:58:45 -06:00
Mbucari 0c0c556c6a Merge pull request #674 from Mbucari/master
Fix #673
2023-07-13 09:31:28 -06:00
MBucari ff63b73c09 Fix #673 2023-07-13 09:30:02 -06:00
Mbucari c1d56adbd2 Add groupbox title 2023-07-12 21:29:00 -06:00
rmcrackan bcd99fd208 Merge pull request #670 from Mbucari/master
Add products grid scaling setting
2023-07-12 21:10:51 -04:00
Mbucari d1df10d060 Add products grid scaling setting
- Add Grid Scaling Settings
- Add WinForms DPI migration to remove stored form sizes
- Add textbox clear button
2023-07-12 15:32:37 -06:00
Mbucari 1fa415628f Update ProductsGrid.cs 2023-07-10 11:39:33 -06:00
rmcrackan a83fe9e532 Merge pull request #667 from Mbucari/master
Fix setting Panel2MinSize min width bug (#666)
2023-07-10 11:19:45 -04:00
Mbucari f85462ffec Fix setting Panel2MinSize min width bug (#666) 2023-07-10 09:11:38 -06:00
Robert McRackan 156349c293 incr ver 2023-07-10 09:26:26 -04:00
rmcrackan 5976706e40 Merge pull request #664 from Mbucari/startup-2
New settings, context menu, and performance improvements
2023-07-10 09:25:13 -04:00
Mbucari 1e40180f0c Fix unit test 2023-07-09 16:42:08 -06:00
Mbucari 7d09728e6b Add Re-download context menu item 2023-07-09 16:26:58 -06:00
Mbucari 4899ef3007 Add new settings and settings dialog help tips
Add CombineNestedChapterTitles setting (#663)
Add SaveMetadataToFile setting
Add extended setting descriptions for select options
2023-07-09 16:07:13 -06:00
Mbucari 296c2b43eb Remove extra library load and move comments to Main 2023-07-09 10:10:00 -06:00
Mbucari 932472cb91 Add full context menu to call columns 2023-07-09 09:53:28 -06:00
Mbucari 1bf86b05ec Download high quality cover art 2023-07-09 09:35:40 -06:00
Mbucari 5d5e3a6671 improve startup time 2023-07-09 09:23:58 -06:00
Robert McRackan 9720a573c7 incr ver 2023-07-07 20:27:57 -04:00
rmcrackan 1cf01aa92a Merge pull request #660 from Mbucari/master
Crash logging to chardonnay
2023-07-07 20:27:09 -04:00
Mbucari 4df9e5abbf Add unhandled error handling and crash logging to chardonnay 2023-07-07 14:14:12 -06:00
Mbucari 9243aa47e7 Upgrade Avalonia to v11.0.0 2023-07-07 14:13:54 -06:00
rmcrackan c69f41a2a6 Merge pull request #659 from Mbucari/master
Fix classic scaling on high dpi displays
2023-07-07 08:06:22 -04:00
Mbucari 27c74e52ca Fix classic scaling on high dpi displays 2023-07-06 21:34:29 -06:00
Robert McRackan bfa7f5cca9 Bug fix #657 : Settings dialog size was recently changed. Save and Cancel buttons were pushed outside of the dialog's bounds 2023-07-06 09:27:52 -04:00
rmcrackan 22a3dcbc1f Merge pull request #656 from Mbucari/master
Fix query parsing tags with underscores (#655)
2023-07-06 09:16:20 -04:00
Mbucari ec9d11cf52 Fix query parsing tags with underscores (#655) 2023-07-05 15:47:37 -06:00
Mbucari fbc29dfb0a Set Variety correctly 2023-07-04 09:58:39 -06:00
Robert McRackan 03d30ff6af incr. ver. 2023-07-03 22:06:00 -04:00
rmcrackan ecfe0dc033 Merge pull request #651 from Mbucari/master
Overhaul LibationCli and add Download Quality Option
2023-07-03 21:57:04 -04:00
Mbucari f2d475a9b0 Add audiobookshelf tags for m4b and mp3
Fix the following tag fields so they are correctly parsed and displayed in audiobookshelf:
Language
Publisher
Series name and number
ASIN
2023-07-03 15:57:11 -06:00
Mbucari 86124fc609 Address comments 2023-07-03 10:01:25 -06:00
Mbucari db2b10d2a4 Performance improvement 2023-07-03 07:04:29 -06:00
Mbucari 83402028fd Update Avalonia 2023-07-02 19:27:58 -06:00
Mbucari 423b5312f7 Add setting to choose downloaded audio quality ((#648) 2023-07-02 19:19:28 -06:00
Mbucari 3be7d8e825 Minor cli edits and fix potential deadlock 2023-07-02 18:29:36 -06:00
Mbucari 29803c6ba0 Overhaul LibationCli
Add version verb with option to check for upgrade
Add Search verb to search the library
Add export file type inference
Add more set-status options
Add console progress bar and ETA
Add processable option to liberate specific book IDs
Scan accounts by nickname or account ID
Improve startup performance for halp and on parsing error
More useful error messages
2023-07-02 15:01:10 -06:00
Mbucari bb05847b25 Improve finding audio file by ID 2023-07-02 14:08:27 -06:00
Robert McRackan 5219ad53e1 incr ver 2023-07-01 21:34:36 -04:00
Mbucari 30aa691aae Merge pull request #646 from Alanoll/feat-add-book-subtitles
feat: add Book subtitle capturing so TitleShort reflects titles better
2023-07-01 12:47:03 -05:00
Mbucari 83fa73cef5 Integrate new Title and Subtitle properties into Libation 2023-06-29 21:06:54 -06:00
Alanoll 2195574422 feat: add Book subtitle capturing so TitleShort reflects titles better 2023-06-26 12:18:15 -05:00
Robert McRackan 74ce408c8b incr ver 2023-06-25 21:27:59 -04:00
rmcrackan 85be15b843 Merge pull request #642 from Mbucari/master
Bug fixes and minor features
2023-06-25 21:26:24 -04:00
MBucari b4b85cd485 Change the default file timestamp source 2023-06-25 17:28:26 -06:00
Mbucari 0093968537 Merge branch 'rmcrackan:master' into master 2023-06-25 15:25:52 -06:00
MBucari 1b09b1fd48 Remove multispace instances from template filenames (#637) 2023-06-25 15:14:10 -06:00
MBucari ac87d70613 Add options to set file created/modified timestamps (#638) 2023-06-25 14:07:39 -06:00
MBucari a5d98364fa Enable auto-downloading (#636) 2023-06-25 11:12:52 -06:00
MBucari ca0e639a19 Commit account edits before saving (#639) 2023-06-25 11:11:58 -06:00
Robert McRackan b0e3022988 incr ver 2023-06-15 21:40:35 -04:00
rmcrackan 6765c2bfa7 Merge pull request #633 from Mbucari/master
User series order float (#632)
2023-06-15 21:38:02 -04:00
Mbucari 94d3742317 Update NamingTemplates.md 2023-06-15 12:33:58 -06:00
Mbucari bd3e833dc1 Use series order float (#632)
Add decimal formatter to number tag types
2023-06-15 10:42:36 -06:00
rmcrackan a386ace0e6 Update NamingTemplates.md
Add \<account nickname\>
2023-06-14 14:06:21 -04:00
rmcrackan 8221d7e202 Merge pull request #631 from Mbucari/master
Add features #626 and #627 and Fix #628
2023-06-14 14:03:24 -04:00
Robert McRackan fa92946d20 incr ver 2023-06-14 14:02:50 -04:00
Mbucari 6d13325c4f Add <account nickname> tag (#629) 2023-06-14 11:56:38 -06:00
Mbucari 7a9c6720c7 Fix Stupid 2023-06-14 11:35:11 -06:00
Mbucari 697f797509 Remove debug code 2023-06-14 11:16:53 -06:00
Mbucari ec9854212a Write error info to StdErr (#626) 2023-06-14 10:58:37 -06:00
Mbucari 46f6ba1710 Add feature #627 and fix bug #628
- Feature: Option to overwrite existing audio files when moving to Books
- Bugfix: Do not set liberated status if moving files fails.
2023-06-14 10:51:43 -06:00
rmcrackan 7347244f0a Merge pull request #630 from CLHatch/patch-1
Update Advanced.md
2023-06-14 07:17:45 -04:00
CLHatch c29c4c470c Update Advanced.md
M4B files use the `@wrt` instead of `TCOM` tag for "composer".
2023-06-14 02:33:49 -05:00
rmcrackan ee51fd9da6 Merge pull request #625 from Mbucari/master
Refactor LibationSearchEngine
2023-06-13 12:39:42 -04:00
Mbucari 2c4705de6e Address #625 comments and refactor 2023-06-13 09:05:17 -06:00
Mbucari b4aa220051 Refactor LibationSearchEngine 2023-06-12 14:02:55 -06:00
Robert McRackan 4ab6da132b Bug fix #621 2023-06-12 10:13:40 -04:00
Mbucari b006429a53 Fix #621 (#624) 2023-06-11 21:05:42 -06:00
Robert McRackan 54d157d244 another tag fail. incr ver 2023-06-11 17:07:03 -04:00
Robert McRackan a4dfdf80e4 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-06-11 17:03:55 -04:00
Robert McRackan d8c90bc745 incr ver 2023-06-11 17:03:35 -04:00
Mbucari 46accddd2d Merge pull request #623 from Mbucari/master
Redesign query sanitizer (#618)
2023-06-11 11:51:13 -06:00
Mbucari f40ecbc07e Merge branch 'rmcrackan:master' into master 2023-06-11 11:33:28 -06:00
Mbucari 536982cb5f Remove obsolete code 2023-06-11 09:44:30 -06:00
Mbucari ea3d96329b Add query sanitization unit tests 2023-06-11 09:44:21 -06:00
rmcrackan e87fcbb16f Update Settings documentation 2023-06-11 10:04:00 -04:00
Mbucari 541cf79b6f Redesign query sanitizer (#618) 2023-06-10 15:08:50 -06:00
Robert McRackan 55fa82f92e New incr ver. Previous Tag attempt did generate builds; did not draft a new release 2023-06-09 11:49:59 -04:00
Robert McRackan 4a0c2b2180 Bug fix #618 2023-06-09 11:27:40 -04:00
Mbucari c77fe5d561 Add Asin query tokenizer 2023-06-08 14:23:39 -06:00
Robert McRackan 359d082ffd incr ver 2023-06-03 15:06:12 -04:00
rmcrackan 017bdba404 Merge pull request #616 from Mbucari/master
Fix #612 and update Avalonia to v11-rc1
2023-06-03 15:04:56 -04:00
Mbucari d4bf13b3fd Update Hangover Avalonia to v11-rc1 2023-06-03 00:30:02 -06:00
Mbucari 87b695b2de Merge branch 'rmcrackan:master' into master 2023-06-03 00:01:10 -06:00
Mbucari 222b16113e Update NamingTemplates.md 2023-06-03 00:00:01 -06:00
Mbucari 75c07c3209 Fix SavePodcastsToParentFolder setting (#612) 2023-06-02 23:54:32 -06:00
Mbucari e640edee7f Use proper key name 2023-06-02 23:53:48 -06:00
Mbucari 6c48fc1f5e Update avalonia ro v11-RC1 2023-06-02 23:39:16 -06:00
Mbucari e5708a382b Use new synchronous UI invoker 2023-06-02 23:21:55 -06:00
Robert McRackan da9cb3371f incr ver 2023-05-23 13:06:09 -04:00
rmcrackan 91d0f8020e Merge pull request #606 from Mbucari/master
Corectly read and write locales
2023-05-23 13:04:35 -04:00
Mbucari 156726ca95 Corectly read and write locales 2023-05-23 09:41:28 -06:00
rmcrackan 3dad4c194b Update README.md 2023-05-20 23:12:23 -04:00
Mbucari 6025a7538a Merge pull request #604 from Mbucari/master
Fix rpm upgrade
2023-05-19 16:39:04 -06:00
Mbucari 824f65baae Fix rpm upgrade 2023-05-19 16:37:00 -06:00
Mbucari 9372a7318b inc ver 2023-05-19 13:46:26 -06:00
Mbucari ddd032c16d Fix null string in integer fields 2023-05-19 13:36:22 -06:00
Mbucari 9aaf523240 Update InstallOnMac.md 2023-05-19 13:07:33 -06:00
Mbucari 8cbdeb38fa Update InstallOnLinux.md 2023-05-19 13:05:29 -06:00
Mbucari a9258a1811 Update GettingStarted.md 2023-05-19 13:01:25 -06:00
Robert McRackan 0dbc42c407 incr ver 2023-05-19 14:43:17 -04:00
rmcrackan 2c91de1b3b Merge pull request #603 from Mbucari/master
New searches, new linux release, new Avalonia build
2023-05-19 14:23:05 -04:00
Mbucari 607cd07b74 Change IInteropFunctions.ReleaseIdentifier to ReleaseIdString 2023-05-19 12:08:22 -06:00
Mbucari 64d080336c Use correct package manager 2023-05-19 11:30:09 -06:00
Mbucari fd510861c6 Add AbsentFromLastScan (#601) LastDownloaded (#602) search 2023-05-19 11:09:57 -06:00
Mbucari 3fdfbb9e26 Improve episode sequence detection (#600) 2023-05-19 11:07:42 -06:00
Mbucari 3e74898dac Merge branch 'rmcrackan:master' into master 2023-05-19 09:31:47 -06:00
Mbucari d6fe3013ab RPM build 2023-05-19 09:30:11 -06:00
Robert McRackan 265794bae0 update dependencies 2023-05-19 11:24:47 -04:00
Mbucari 7586f7a159 Upgrade Avalonia to v11.0.0-preview8 2023-05-15 12:58:45 -06:00
Mbucari 5dfddfb549 Use avres DataGrid theme and only replace DataGridColumnHeader 2023-05-15 12:51:46 -06:00
Mbucari 98bb06378a Update Avalonia to v11.0.0-preview8 2023-05-15 10:54:56 -06:00
Robert McRackan 429367d21c incr ver 2023-04-17 21:39:18 -04:00
Mbucari ea9e36fd76 Merge pull request #588 from Mbucari/master
Use old activation bytes if present.
2023-04-17 19:34:26 -06:00
MBucari fe534b335b Update dependencies 2023-04-17 19:32:52 -06:00
MBucari 6db3a8fbf3 Use old activation bytes if present. 2023-04-17 16:09:47 -06:00
Robert McRackan 48c69a1339 Can log to zip files with new ZipFile sink 2023-04-15 15:55:53 -04:00
rmcrackan 1ab882f327 Merge pull request #587 from Mbucari/master
Serilog log to zip file
2023-04-15 15:53:41 -04:00
MBucari 019b110a8a Fix #585 2023-04-15 13:43:50 -06:00
MBucari 9e14169e15 Update dependencies 2023-04-15 13:39:43 -06:00
MBucari e08a68219d Add Serilog.Sinks.ZipFile to write logs into a zip file 2023-04-15 12:45:20 -06:00
Mbucari af24c6e07b Merge branch 'rmcrackan:master' into master 2023-04-15 10:58:06 -06:00
Robert McRackan e31847e669 Incr. ver. 2023-04-14 14:38:45 -04:00
Mbucari c4f55d2ad1 Change "Click here" link verbiage 2023-04-14 11:37:22 -06:00
rmcrackan 1439e38cb0 Merge pull request #584 from Mbucari/master
Web Browser Login for Windows
2023-04-14 13:33:23 -04:00
Mbucari 4456432116 Add WebLoginDialog for Windows Chardonnay 2023-04-13 19:16:32 -06:00
Mbucari df2936e0b6 Use WebLoginDialog as primary login method on Win10+ 2023-04-13 09:10:13 -06:00
Mbucari 53b5c1b902 Fix rare bug where episode may not sort beneath its parent 2023-04-11 14:43:01 -06:00
Mbucari 82fba7e752 Grid refresh performance and behavior improvements 2023-04-11 14:33:45 -06:00
rmcrackan 1a95f2923b Merge pull request #579 from Mbucari/master
Bug fixes and more shared code moved to UI base
2023-04-10 22:47:24 -04:00
Mbucari 1939aae81c Simplify and comment 2023-04-10 19:50:30 -06:00
Mbucari 9a663fda15 Filtering bugfix 2023-04-10 17:08:09 -06:00
Mbucari 84b2996102 Merge branch 'rmcrackan:master' into master 2023-04-10 16:17:24 -06:00
Mbucari af8e1cd5ef Change episode default sorting to SeriesOrder descending 2023-04-10 16:17:10 -06:00
Mbucari 8a1b375f0d Fix #574 (for realsies this time) 2023-04-10 15:00:32 -06:00
Mbucari 6800986f25 Update GridEntryBindingList to behave move like Chardonnay 2023-04-10 14:10:50 -06:00
Mbucari 6110b08d16 Fix typo 2023-04-10 13:05:50 -06:00
Mbucari 666b5d83df Move filter query and RowComparer into UI base 2023-04-10 13:05:38 -06:00
rmcrackan 7db5a34f1b Merge pull request #577 from Mbucari/master
Fixed your issues
2023-04-10 13:14:05 -04:00
Mbucari e52772826a Merge branch 'rmcrackan:master' into master 2023-04-09 17:45:38 -06:00
Mbucari 8ea9b2abc6 Fix #574 2023-04-09 17:41:24 -06:00
Mbucari c10bb276f5 Fix #575 2023-04-09 17:41:10 -06:00
Mbucari 9dcb3b3a25 Slight chardonnay refactor and UI tweak 2023-04-09 17:39:31 -06:00
Robert McRackan d857882220 Bug fix: logins 2023-04-07 19:58:13 -04:00
rmcrackan d731db4036 Update Docker.md 2023-04-05 08:02:42 -04:00
rmcrackan ca5b40b176 Merge pull request #571 from Mbucari/master
Chardonnay UI Refinements and Refactor
2023-04-05 08:00:44 -04:00
MBucari b29ec26f63 Remove useless interface 2023-04-04 22:38:02 -06:00
MBucari 7569b01bd0 MacOS Compatibility 2023-04-04 22:26:13 -06:00
MBucari 6465b0a885 Fix possible NRE 2023-04-04 19:17:43 -06:00
Mbucari 5e99cb6f02 Refine dialog layouts and presentation 2023-04-04 19:08:52 -06:00
Mbucari d737cd2199 Improve LinkLabel control 2023-04-04 12:10:23 -06:00
Mbucari 2d2907e076 Refactor settings dialog 2023-04-04 11:18:28 -06:00
Mbucari 05c454dce4 Fix directory select controls 2023-04-04 10:49:27 -06:00
rmcrackan e64a9d2adf Merge pull request #566 from Mbucari/master
Reattach event handlers
2023-04-03 16:23:10 -04:00
Mbucari 6252f015b3 Reattach event handlers 2023-04-03 14:09:22 -06:00
rmcrackan 7ada0082a9 Merge pull request #565 from Mbucari/master
About Dialog, mac menus, and hotkeys
2023-04-03 15:54:40 -04:00
Mbucari 826e53c9cb Remove assemblies add acknowledgements to About 2023-04-03 13:34:20 -06:00
Mbucari 2248d7b24e Sort episodes by column beneath their parents 2023-04-02 21:28:55 -06:00
Mbucari 69918c2587 Cleanup 2023-04-02 21:28:37 -06:00
Michael Bucari-Tovo 1991bf5b4d Add more info to About dialog 2023-04-02 18:16:01 -06:00
MBucari 756d387238 UI Tweaks and new application hotkeys 2023-04-02 15:08:03 -06:00
MBucari 8d73f5cc7e Add About dialog 2023-04-02 13:27:51 -06:00
MBucari 4a65d6bbd3 Add native menu for mac and refactor MainWindow 2023-04-01 23:58:22 -06:00
Robert McRackan 10a1b56b3c incr ver 2023-03-31 16:39:13 -04:00
Robert McRackan 66fb392b7f Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-03-31 16:16:23 -04:00
Robert McRackan 49ef96055c update dependencies 2023-03-31 16:16:21 -04:00
rmcrackan cb4a209f69 Merge pull request #564 from Mbucari/master
Fix #563 and probably fix #534
2023-03-31 14:27:43 -04:00
Mbucari 255e18eb5e Fix external login failure error (#563) 2023-03-31 12:00:20 -06:00
Mbucari 7e1ec47b46 Tweak AccessKeyHandler 2023-03-31 11:59:48 -06:00
MBucari 40c725b8c2 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-30 19:58:19 -06:00
MBucari 5d0937dc48 Add support for custom access keys 2023-03-30 19:57:39 -06:00
Robert McRackan bff81bfc4b update paypal links 2023-03-30 09:44:20 -04:00
MBucari aa7c159985 Define window dimensions 2023-03-29 19:44:33 -06:00
Robert McRackan 012d94a146 incr ver 2023-03-29 18:02:33 -04:00
Mbucari 22bd1ed121 Fix autoscan bug 2023-03-29 15:54:46 -06:00
Mbucari c832f26b08 Merge pull request #561 from Mbucari/master
Try fix #560
2023-03-29 15:40:52 -06:00
Mbucari efd73d334e inv ver 2023-03-29 15:39:25 -06:00
Mbucari 0db3ee6fd7 Fix library scan bug 2023-03-29 15:38:57 -06:00
Robert McRackan 6aaf4f63d1 incr major ver 2023-03-29 15:58:57 -04:00
rmcrackan ab392a9285 Merge pull request #558 from Mbucari/master
Refined Walkthrough
2023-03-29 15:54:15 -04:00
Mbucari efc9ff4bd8 Disable buttons on new row 2023-03-29 13:31:39 -06:00
Mbucari a52b466c85 Fix QuickFilter Walkthrough 2023-03-29 13:17:31 -06:00
Mbucari 5611431abf Quick Filters display moveup and movedown buttons appropriately 2023-03-29 13:06:18 -06:00
Mbucari a75932d1f4 Refine Walkthrough 2023-03-29 11:35:17 -06:00
Mbucari 6c8464b650 Use HashSet 2023-03-29 11:32:07 -06:00
rmcrackan ba4a1c5a51 Merge pull request #554 from Mbucari/master
Bug fixes and guided tour
2023-03-28 16:49:49 -04:00
Mbucari 3681c0f18f Final walkthrough tweaks 2023-03-28 14:08:51 -06:00
Mbucari e365ba7296 Use AvaloniaList properties 2023-03-28 13:29:07 -06:00
Mbucari 2afb5365dd Add search and quick filters to walkthrough 2023-03-28 12:30:05 -06:00
Mbucari 00cf7693d5 Add code comments 2023-03-28 10:02:22 -06:00
MBucari dac6877a06 Fix #556 2023-03-28 07:09:46 -06:00
MBucari 36005508a1 Allow users to cancel walkthrough 2023-03-27 20:24:15 -06:00
MBucari d9e27fd32e Bring cover viewer to front 2023-03-27 19:56:50 -06:00
MBucari d86bcbb414 Add usings 2023-03-27 19:52:26 -06:00
MBucari 00cbab5b58 Update window title 2023-03-27 19:51:10 -06:00
MBucari 807725f6ff Replace editable DataGridTextColumn with TextBox (#552) 2023-03-27 19:40:23 -06:00
MBucari ec9356b36e Do not import orphaned episodes (#553) 2023-03-27 18:58:43 -06:00
MBucari add31024da Improve book availability detection (#551) 2023-03-27 17:53:25 -06:00
MBucari 27d2ada5a4 Don't warn for blank password with external login 2023-03-27 17:23:46 -06:00
Mbucari 702219ee69 Add guided walkthrough 2023-03-27 16:18:21 -06:00
Mbucari cdf1a01457 Do not launch settings dialog after installation 2023-03-27 13:18:37 -06:00
Mbucari a71ccbac6e Add Series Order column 2023-03-27 12:13:56 -06:00
Mbucari f8c6b836c3 Merge branch 'rmcrackan:master' into master 2023-03-27 11:15:19 -06:00
Michael Bucari-Tovo 090871f50d More migrations to Avalonia 11.0.0-preview6 2023-03-27 11:14:54 -06:00
Robert McRackan e62f01d2a3 Incr ver so bug fixes can be released. New features will also be announced in upcoming new major ver 2023-03-27 08:48:53 -04:00
Mbucari 68af6a5ebb Merge branch 'rmcrackan:master' into master 2023-03-26 21:04:53 -06:00
Michael Bucari-Tovo 8bba8538d5 Recheck for partially downloaded files. 2023-03-26 20:54:29 -06:00
rmcrackan 2cd9b86930 Merge pull request #549 from Mbucari/master
Lots of Bug Fixes and 2 New Features.
2023-03-26 22:54:08 -04:00
MBucari b876d90964 Remove AudibleApi from solution 2023-03-26 08:43:33 -06:00
Mbucari 49c91c273b Merge branch 'rmcrackan:master' into master 2023-03-26 08:08:36 -06:00
MBucari c07bc88493 Update AudibleApi 2023-03-25 21:18:38 -06:00
MBucari 397a516dc1 Fix (#548) 2023-03-25 21:18:38 -06:00
Robert McRackan 1c2b51aa83 update dependencies 2023-03-25 22:25:37 -04:00
Mbucari fc6f494f0d Add dark mode support 2023-03-25 16:33:11 -06:00
Mbucari 7289459170 Migrate to Avalonia 11.0.0-preview6 2023-03-22 13:44:25 -06:00
Mbucari ed6f741a65 Fix SettingsFileIsValid 2023-03-22 11:46:11 -06:00
Mbucari 1783da3e2d Ensure series and episode DateAdded is never default (#543) 2023-03-22 11:02:57 -06:00
Mbucari e7eac7bed3 Log DTO items even if validation fails 2023-03-22 11:00:51 -06:00
MBucari 9ae1f0399b Add SeriesViewDialog 2023-03-22 08:28:20 -06:00
MBucari 784ab73a36 Add context menu to Series grid entries (#536) 2023-03-19 12:04:51 -06:00
MBucari 99687e968e Create books directory if not found (#542) 2023-03-19 10:19:38 -06:00
MBucari 565c84c4ab Add series # to grid display (#529) 2023-03-17 22:11:04 -06:00
MBucari 18cf20ecad All books that pass the filter are counted as "visible" (#536) 2023-03-17 19:52:47 -06:00
MBucari 2725340994 Suppress VisibleCountChanged firing when updating grid 2023-03-17 17:51:39 -06:00
MBucari 56de1e7659 Preserve "expanded" status when updating library 2023-03-17 17:47:27 -06:00
Mbucari fd16e97632 When book is unavailable, check other accounts (#535) 2023-03-17 14:06:02 -06:00
Robert McRackan 36076242a7 Bug fix #532 : Possible rull ref exception for pre-amazon germany 2023-03-15 13:49:11 -04:00
Robert McRackan 718e6c14d0 update dependency 2023-03-14 22:10:55 -04:00
rmcrackan eb61ba3d69 Merge pull request #531 from Mbucari/master
Bug fixes and performance improvements
2023-03-14 07:53:25 -04:00
MBucari defabf7356 Use new AudibleApi methods 2023-03-13 21:00:25 -06:00
Mbucari 1149c10cf1 Merge branch 'rmcrackan:master' into master 2023-03-13 20:49:56 -06:00
MBucari ec7dd1b54a Use new AudibleApi methods 2023-03-13 20:47:32 -06:00
Robert McRackan bb900b31ef update dependencies 2023-03-13 22:28:46 -04:00
MBucari eed42bd108 Improve grid update performance 2023-03-11 21:50:30 -07:00
MBucari 3f0e6b9ee5 Fix window restore maximize statate on secondary monitor. 2023-03-11 21:35:33 -07:00
MBucari 5ec01913d5 Fix bug where book with corrupt image cannot be queued. 2023-03-11 20:58:06 -07:00
rmcrackan 245e55782e Merge pull request #527 from Mbucari/master
Improve Library Display performance and Refactor grid viewmodels
2023-03-11 16:44:31 -05:00
MBucari cc306e0e19 Fix expand/collapse button icon in Avalonia 2023-03-11 12:28:34 -07:00
MBucari 26a9bc6bbf Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-11 11:12:05 -07:00
MBucari fb9d062545 WinForms and Avalonia now share all GridEntry view models 2023-03-11 11:10:58 -07:00
MBucari 49c6b391fd WinForms and Avalonia now share all GridEntry view models 2023-03-10 20:00:25 -07:00
MBucari e1cd8b8f94 Improve Library load and refresh performance 2023-03-10 19:01:49 -07:00
Robert McRackan ef1edf1136 AYCL bug fix: US and Italy 2023-03-10 15:37:51 -05:00
rmcrackan 0def1b426a Merge pull request #526 from Mbucari/master
Add better AYCL detection and add verbose library scan logging
2023-03-10 15:26:41 -05:00
Mbucari 230e014bb1 Add better AYCL detection and add verbose library scan logging 2023-03-10 13:09:59 -07:00
Robert McRackan 34f56d2fd7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-03-08 14:07:51 -05:00
Robert McRackan c45ffaf4a6 Incr ver 2023-03-08 14:07:47 -05:00
rmcrackan ae43ab103e Merge pull request #524 from Mbucari/master
Improve library scan speed and Track and display book availability
2023-03-08 14:06:45 -05:00
Mbucari 559977ce0b Add 'Unavailable' book and pdf counts. 2023-03-08 11:26:07 -07:00
Mbucari ccd4d3e26d Check for null Plan array 2023-03-08 11:21:47 -07:00
MBucari e76f99ff28 Fix rmcrackan/Libation#523 2023-03-07 22:34:36 -07:00
MBucari d3607583ab Tweak episode scan 2023-03-07 20:32:50 -07:00
MBucari 3ebd4ce243 Show AbsentFromLastScan book status in grid 2023-03-07 20:02:22 -07:00
Mbucari f6dcc0db1d Add AbsentFromLastScan 2023-03-07 18:58:18 -07:00
MBucari bd49db83e4 Improve library scan performance 2023-03-07 15:30:22 -07:00
Mbucari 4140722a6d Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-06 16:56:57 -07:00
Mbucari da36f9414d Improve library scan performance 2023-03-06 16:49:52 -07:00
Mbucari 1510f71ca6 Merge branch 'rmcrackan:master' into master 2023-03-03 16:33:36 -07:00
Mbucari cdb27ef712 Add last downloaded info to exports 2023-03-03 15:06:06 -07:00
Robert McRackan 790319ed98 incr ver 2023-03-03 15:58:05 -05:00
rmcrackan 1b0fb2b316 Merge pull request #522 from Mbucari/master
Resolved Several Issues (It's not as bad as  2,453 lines suggests)
2023-03-03 15:56:59 -05:00
Mbucari 02371f2221 Deleting folders with custom icons no longer triggers system file warning 2023-03-03 10:56:31 -07:00
Mbucari 2b672f86be Fatten up the chevrons 2023-03-02 19:57:43 -07:00
Mbucari 36176bff33 Update ImageSharp 2023-03-02 19:41:59 -07:00
Mbucari 174b0c26b8 Update fileicon to latest version 2023-03-02 19:40:38 -07:00
Mbucari 26c60e8e79 Convert queue expand/collapse button text to images (rmcrackan/Libation#339) 2023-03-02 19:23:03 -07:00
Mbucari d94759d868 Add Last Download column to grid (rmcrackan/Libation#498) 2023-03-02 18:52:45 -07:00
Mbucari bd7e45ca3c Add last download into to database 2023-03-02 15:09:10 -07:00
Mbucari 52a863c62a Add audiobook Trash Bin 2023-03-02 13:12:32 -07:00
Mbucari fe55b90ee3 Fix rmcrackan/Libation#511 2023-03-01 22:14:57 -07:00
Mbucari df224cc7f3 Move TrackedQueue to LibationUiBase 2023-03-01 09:33:17 -07:00
Mbucari 2a59329350 Merge branch 'rmcrackan:master' into master 2023-02-28 16:41:14 -07:00
Mbucari abdf0e7261 Parallelize post-liberation tasks 2023-02-28 16:40:53 -07:00
Mbucari b9c2a1cce3 Add folder icon support to MacOS 2023-02-28 15:57:27 -07:00
rmcrackan aa86fca08f Update InstallOnMac.md 2023-02-28 15:46:51 -05:00
Robert McRackan cf9ec9facf did last tag incorrect. New version 2023-02-28 10:13:26 -05:00
Robert McRackan f6084ef10c v9.4.1 2023-02-28 10:04:47 -05:00
rmcrackan 740b73beb7 Merge pull request #518 from Mbucari/master
Improve Audible login and Libation Upgrade
2023-02-28 09:51:08 -05:00
Mbucari 5c45802391 Fixed review comments 2023-02-28 07:42:26 -07:00
MBucari 429aa603f5 Update workflows 2023-02-27 21:41:59 -07:00
MBucari 80ea394934 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-02-27 16:33:16 -07:00
Mbucari bce4437c79 Change workflows 2023-02-27 16:18:48 -07:00
Mbucari b6ad1a289b Remove windows desktop runtime dependency from chardonnay 2023-02-27 16:13:40 -07:00
Mbucari 2a22d05f37 Remove windows desktop runtime dependency from chardonnay 2023-02-27 15:08:54 -07:00
Mbucari d787843fd2 Unify upgrade process and add update progress bar 2023-02-27 14:08:15 -07:00
Mbucari ded58f687d Update 2FA and Captcha controls 2023-02-27 14:08:14 -07:00
Mbucari 1f1f34b6ce Merge branch 'rmcrackan:master' into master 2023-02-27 09:36:53 -07:00
Mbucari ffadf90f4f Fix MFA and 2FA 2023-02-27 09:36:19 -07:00
rmcrackan 67807efacf Merge pull request #515 from Mbucari/patch-4
Update InstallOnMac.md
2023-02-26 15:29:55 -05:00
Mbucari 980f5afa54 Update InstallOnMac.md 2023-02-25 19:42:45 -07:00
Robert McRackan b2f68760b2 New audible api login 2023-02-24 15:52:14 -05:00
rmcrackan faf86711a5 Merge pull request #509 from Mbucari/master
Add More MP3 Options and improved AAXClean
2023-02-24 15:35:38 -05:00
Mbucari 4a78b9d28f Revert workflow change 2023-02-24 12:38:29 -07:00
Michael Bucari-Tovo 1b0a7f5062 New mp3 options and improved encoding performance 2023-02-24 12:12:41 -07:00
Mbucari 49982043e0 Merge branch 'rmcrackan:master' into master 2023-02-24 11:15:14 -07:00
Robert McRackan 378cf7057e updated to AudibleApi v8 2023-02-24 13:12:18 -05:00
Mbucari abdc0f018e Update build-linux.yml 2023-02-22 09:23:15 -07:00
Robert McRackan c65f61b92e Fix paypal links 2023-02-22 07:33:58 -05:00
Robert McRackan c12805c8ce incr ver for release 2023-02-19 14:55:55 -05:00
rmcrackan 67f9a6db78 Merge pull request #503 from Mbucari/master
Mac and Linux Arm64 releases and Fixed #502
2023-02-19 14:52:09 -05:00
Mbucari bb6336ce2a Update .releaseindex.json 2023-02-19 11:27:23 -07:00
Michael Bucari-Tovo af7a4a6acf Add comments 2023-02-19 11:11:28 -07:00
Michael Bucari-Tovo 21d18aa565 Final edits 2023-02-19 10:59:42 -07:00
Michael Bucari-Tovo c96875ba5d Add '-chardonnay' to build assets name 2023-02-19 10:23:49 -07:00
Michael Bucari-Tovo 6ebbfb8e59 Refactor SetReleaseIdentifier() 2023-02-19 10:20:01 -07:00
Michael Bucari-Tovo 1e6e28cd57 Start downloading asynchronously 2023-02-18 22:38:26 -07:00
Michael Bucari-Tovo defed72862 Force garbage collection after completing a Processable 2023-02-18 22:16:46 -07:00
Michael Bucari-Tovo 71503b34b5 Fix macOS crash 2023-02-18 20:29:10 -07:00
Michael Bucari-Tovo a00849fb6f Refactor InteropFactory 2023-02-18 13:57:00 -07:00
Michael Bucari-Tovo 14b63c0883 Add apple UUTYPEs 2023-02-18 10:27:37 -07:00
Michael Bucari-Tovo 59d556733e Edit Mac Build Script 2023-02-17 23:46:28 -07:00
Michael Bucari-Tovo a99a175683 Update AAXClean to fix #502 2023-02-17 23:20:35 -07:00
Michael Bucari-Tovo 26fedcfb60 Fix DirectorySelectControl not displaying known dir 2023-02-17 22:58:24 -07:00
Michael Bucari-Tovo dde8024506 More thread safety to address #492 2023-02-17 22:57:43 -07:00
Michael Bucari-Tovo 25f7c29380 New linux build workflows 2023-02-17 18:04:34 -07:00
Robert McRackan 2f347e83e8 fix linux 'can update'. upgrade aaxclean 2023-02-16 07:57:36 -05:00
rmcrackan 080a74884d Update InstallOnMac.md
new mac setup video
2023-02-16 07:44:09 -05:00
Robert McRackan 2dbeb64c38 incr ver. updates for mac and linux 2023-02-15 08:38:13 -05:00
rmcrackan bb508c0718 Merge pull request #489 from Mbucari/master
Mac App Bundle and added mp3 conversion support on mac
2023-02-15 08:33:06 -05:00
Michael Bucari-Tovo 9a450b0d63 add 'macOS' to mac bundle name 2023-02-15 06:31:09 -07:00
Michael Bucari-Tovo c1de0e60d2 Hopefully fix #492 2023-02-14 23:07:40 -07:00
Mbucari dc7c03661d Add auto update to linux and macos 2023-02-14 23:06:14 -07:00
Mbucari 952eee6d32 Merge branch 'rmcrackan:master' into master 2023-02-13 21:42:11 -07:00
Michael Bucari-Tovo 472a0f30b9 Launch hangover from Libation app bundle for mac 2023-02-13 21:40:53 -07:00
Robert McRackan 73533c58a8 update dependencies 2023-02-13 21:14:56 -05:00
Mbucari 65ef018719 Move NameListFormatter to its own class 2023-02-13 10:09:13 -07:00
Mbucari f0ca349539 Update UNSAFE_MigrationHelper with new appsettings.json getter 2023-02-13 09:03:03 -07:00
Mbucari 500b287721 Fix #490 2023-02-13 08:08:10 -07:00
Mbucari 21f3ae45d3 Delete deb.yml 2023-02-12 22:25:39 -07:00
Michael Bucari-Tovo d496564f0d Edit Mac and Linux bundle build workflows 2023-02-12 21:50:33 -07:00
Michael Bucari-Tovo 6fdd6293ce Ensure appsettings.json is created in a writable location. 2023-02-12 15:32:51 -07:00
Michael Bucari-Tovo 3bca495521 Add MacOS app bundle workflow 2023-02-11 23:38:17 -07:00
Michael Bucari-Tovo 0fb580f1a5 Ensure appsettings.json is created in a writable location. 2023-02-11 20:06:04 -07:00
Michael Bucari-Tovo a7cd47e0b1 Update AAXClean 2023-02-11 18:34:07 -07:00
Robert McRackan 30aecedfae incr ver 2023-02-10 23:16:22 -05:00
rmcrackan e72799efe5 Merge pull request #487 from Mbucari/master
Custom author and narrator names formatting and batch locate books
2023-02-10 23:14:31 -05:00
Michael Bucari-Tovo ee8c0ae27b Use new .NET regular expression source generators 2023-02-10 19:45:52 -07:00
Mbucari 5b4a4341ad More agressive garbage collection 2023-02-10 15:03:43 -07:00
Mbucari 56823c1105 Move FindAudiobooks() to AudioFileStorage 2023-02-10 14:54:29 -07:00
Mbucari 1f4ada604a Make suggested changes 2023-02-10 14:37:28 -07:00
Mbucari 3a4ab80892 Add human name parsing and formatting to naming templates 2023-02-10 12:53:12 -07:00
Mbucari bba9c2ba7b Add Locate Audiobooks function (#485) 2023-02-10 09:35:21 -07:00
Robert McRackan c4acd5d208 incr ver 2023-02-08 13:56:13 -05:00
rmcrackan 381440db4c Merge pull request #479 from Mbucari/master
Fix #478 and other stuff I'd already worked on
2023-02-08 13:43:56 -05:00
Michael Bucari-Tovo 00c8be1f7e Create LibationUiBase for shared UI code 2023-02-08 09:30:13 -07:00
rmcrackan d665122aa2 Update GettingStarted.md
Classic vs Chardonnay
2023-02-08 07:49:18 -05:00
Michael Bucari-Tovo bb40df5fa3 Fix #478 2023-02-07 22:58:29 -07:00
Mbucari e3c9f70dff Move shared GUI code into AppScaffolding 2023-02-06 16:04:58 -07:00
Mbucari b351033cec Improve download and convert speed estimate 2023-02-06 15:54:12 -07:00
Mbucari 18f69bc73d Refactor Naming Template 2023-02-06 15:24:18 -07:00
Robert McRackan 39fe7b79d2 Bug fix #474 2023-02-06 08:35:24 -05:00
rmcrackan 85769d797b Merge pull request #473 from Mbucari/master
Made changes discussed in previous PR and fixed #472
2023-02-04 17:32:53 -05:00
Michael Bucari-Tovo 9a80f18e1c Rename ConditionalTagClass to ConditionalTagCollection 2023-02-04 15:19:47 -07:00
Michael Bucari-Tovo aec8305e52 Fix #472 2023-02-04 12:49:59 -07:00
Michael Bucari-Tovo a672174a9b Refactor Naming Templates 2023-02-04 12:49:48 -07:00
Mbucari 6f490b4491 Rename TagClass to TagCollection 2023-02-03 17:14:04 -07:00
Mbucari 5917d059e4 Add Enumerable initializers 2023-02-03 17:03:15 -07:00
Robert McRackan 40602c7626 incr ver 2023-02-03 17:26:17 -05:00
rmcrackan 7d5ee2afa8 Merge pull request #471 from Mbucari/master
Add a more general NamingTemplate
2023-02-03 17:24:37 -05:00
Mbucari 08b6f8fa11 Add test 2023-02-03 14:46:48 -07:00
Mbucari 5f9699aa3b Merge branch 'master' of https://github.com/Mbucari/Libation 2023-02-03 14:43:42 -07:00
Mbucari 70607aaaf4 Documentation 2023-02-03 14:43:35 -07:00
Mbucari 1d96d39af7 Add Naming Template Documentation 2023-02-03 14:33:11 -07:00
Mbucari 5557772957 Add conditional negation 2023-02-03 11:47:55 -07:00
Mbucari 5c7db6cd23 Add <series#> tag zero padding (#466) 2023-02-03 11:31:15 -07:00
Mbucari c72b64d74c Properly truncate filenames 2023-02-03 09:53:40 -07:00
Mbucari 20474e0b3c Add a more general NamingTemplate 2023-02-02 22:48:27 -07:00
Robert McRackan 867085600c New feature #469 - <language> and <language short> template options 2023-02-01 12:12:50 -05:00
rmcrackan 74290ec609 Merge pull request #468 from rmcrackan/dependabot/github_actions/docker/build-push-action-4
Bump docker/build-push-action from 3 to 4
2023-01-31 10:18:40 -05:00
dependabot[bot] 5ee555e60c Bump docker/build-push-action from 3 to 4
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-31 14:11:36 +00:00
Robert McRackan a36c28d48f incr. ver. 2023-01-30 21:24:54 -05:00
rmcrackan 0877f2c042 Merge pull request #464 from Mbucari/master
Fix #463
2023-01-25 20:48:58 -05:00
Mbucari 2baf5243ea Fix #463 2023-01-25 14:23:02 -07:00
rmcrackan b7e71f5812 Merge pull request #462 from Mbucari/master
Refactor AaxDecrypter
2023-01-25 07:06:37 -05:00
Michael Bucari-Tovo 2ed1076fab Cleanup 2023-01-24 23:35:05 -07:00
Michael Bucari-Tovo 0b20aa751f Only Dispose of NFS on disposing 2023-01-24 23:00:07 -07:00
Michael Bucari-Tovo 05a4ece8d1 Merged 2023-01-24 22:27:31 -07:00
Michael Bucari-Tovo 25b37c6266 Refactor AaxDecrypter 2023-01-24 22:25:55 -07:00
Robert McRackan b668cff0ac Update dependenciesd. Build is broken until the ambiguous ref.s moved into Dinah.Core are resolved 2023-01-24 22:40:08 -05:00
Robert McRackan 4d6c742ae9 Bug fix #459 , New feature/setting #366 2023-01-23 22:50:37 -05:00
rmcrackan 933f663d22 Merge pull request #460 from Mbucari/master
Upgrade AAXClean.Codecs to 0.5.12, add moov relocation, and fix #459
2023-01-23 22:46:20 -05:00
Michael Bucari-Tovo 0c55f278a4 Revert Solution Changes 2023-01-23 20:32:27 -07:00
Michael Bucari-Tovo 3f567ee82e Merged 2023-01-23 20:13:19 -07:00
Michael Bucari-Tovo 8dc912c11d Add option to move the moov atom to the beginning of the file. 2023-01-23 20:11:00 -07:00
Michael Bucari-Tovo f1b4e2a17d Upgrade AAXClean.Codecs to 0.5.11 2023-01-23 19:04:15 -07:00
Michael Bucari-Tovo 630cfdeab3 Upgrade AAXClean.Codecs to 0.5.11 2023-01-23 17:39:08 -07:00
Michael Bucari-Tovo 7029409792 Upgrade AAXClean.Codecs to 0.5.10 and fix #459 2023-01-23 16:30:17 -07:00
rmcrackan d0727b5a85 Update InstallOnMac.md
Added @Mbucari 's awesome video
2023-01-23 08:17:28 -05:00
rmcrackan 9f52ad5e0a Update README.md
link to docker readme
2023-01-23 08:07:37 -05:00
rmcrackan 501ae643f7 Merge pull request #458 from pixil98/master
Deb build tweaks, Docker read me
2023-01-23 08:06:51 -05:00
Aaron Reisman 400074170e Add docker readme 2023-01-22 17:09:25 -06:00
Aaron Reisman 17103ed066 Get release id correctly 2023-01-22 15:33:27 -06:00
Aaron Reisman b6b29309c9 Go back to the old way of uploading assets 2023-01-22 15:15:56 -06:00
Aaron Reisman a04538710f Try with a different glob 2023-01-22 14:53:05 -06:00
Aaron Reisman 01f6f5c137 Add name to release 2023-01-22 14:17:08 -06:00
Aaron Reisman b1a37cbd8c Switch to a still maintained release action 2023-01-22 13:56:36 -06:00
Aaron Reisman 8c59e1280b Wait for deb to be finished before releasing 2023-01-22 13:20:59 -06:00
Aaron Reisman 00339127aa reference correct deb yaml 2023-01-22 13:15:51 -06:00
Aaron Reisman 5935b40b60 Remove output version 2023-01-22 13:03:23 -06:00
Aaron Reisman 6cfd2dea96 Move deb building out of the build pipeline 2023-01-22 13:01:12 -06:00
rmcrackan 7d3a39c693 Merge pull request #456 from Mbucari/master
Add file creation DateTime to naming templates
2023-01-20 15:18:11 -05:00
Mbucari 6e7a4ea475 Update date format regex and tests 2023-01-20 10:03:55 -07:00
Michael Bucari-Tovo 3479dbc3f0 Date format naming templates 2023-01-20 01:00:22 -07:00
Mbucari 9309aea6d9 Add file creation DateTime to naming templates
typo
2023-01-19 17:12:28 -07:00
Robert McRackan f72551fa9a incr ver 2023-01-19 08:06:18 -05:00
rmcrackan e3b237b75f Merge pull request #454 from Mbucari/master
Fixed #453
2023-01-18 22:23:55 -05:00
Mbucari b1ddf18f73 Merge branch 'rmcrackan:master' into master 2023-01-18 15:47:18 -07:00
Michael Bucari-Tovo 13f522abb8 Fix file extension detection error (#453) 2023-01-18 15:47:05 -07:00
Robert McRackan 3c3d956bf3 remove linux and mac from beta 2023-01-16 13:16:49 -05:00
Robert McRackan 8160547c11 incr ver 2023-01-16 13:06:19 -05:00
rmcrackan ef71d36dee Merge pull request #451 from Mbucari/patch-3
Updated for new deb package builds
2023-01-16 12:59:23 -05:00
Mbucari b0d8434455 Updated for new deb package builds 2023-01-16 10:41:21 -07:00
rmcrackan 9be0d58461 Merge pull request #450 from Mbucari/master
Update workflows and AAXClean
2023-01-16 10:38:18 -05:00
Michael Bucari-Tovo 1addcc8211 Update AAXClean and add better error handling 2023-01-15 21:42:03 -07:00
Mbucari 38c75dc8c5 Update workflows 2023-01-12 15:47:01 -07:00
rmcrackan 89c3ea8311 Merge pull request #444 from Mbucari/master
Build and attach deb package
2023-01-12 08:45:14 -05:00
Michael Bucari-Tovo 18ff799fb1 Update build scripts 2023-01-11 21:14:26 -07:00
Mbucari 67b6aaed99 Fix #447 2023-01-11 16:08:01 -07:00
Mbucari 08bb463560 Invoke observables directly instead of through event handler 2023-01-11 14:38:12 -07:00
Mbucari 97767dcabb GLOB pattern 2023-01-11 14:37:12 -07:00
Mbucari fb18940a5c Use DataGridMyRatingColumn for both user and product ratings. 2023-01-11 14:36:43 -07:00
Mbucari b823f5fa00 Import average rating 2023-01-11 14:35:14 -07:00
Mbucari d64fb081a0 Build and attach deb package 2023-01-11 14:15:07 -07:00
Robert McRackan 09118b1ddf nvm. I guess this has been default : true for a long time. Reverting 2023-01-10 10:13:47 -05:00
Robert McRackan de20590fd5 do not enable AutoScan by default 2023-01-10 10:04:53 -05:00
rmcrackan 1050ffdb24 Merge pull request #443 from Mbucari/master
Chardonnay Bugfix + Moved Default Settings into Configuration
2023-01-10 09:54:49 -05:00
Michael Bucari-Tovo 2e49c7f697 Commit edits before refresh 2023-01-09 18:57:16 -07:00
Mbucari 708cdcc24c Merge branch 'master' of https://github.com/Mbucari/Libation 2023-01-09 16:30:59 -07:00
Mbucari c89eafd568 Fix rating edits updating search results. 2023-01-09 16:27:19 -07:00
Michael Bucari-Tovo 10de241d53 Cache default values 2023-01-09 16:11:30 -07:00
Mbucari e58952035f Spaces 2023-01-09 16:06:37 -07:00
Mbucari 50a8c7508a Cache default values 2023-01-09 16:05:55 -07:00
Michael Bucari-Tovo 2b243a6934 Remove testing code 2023-01-09 15:28:36 -07:00
Michael Bucari-Tovo ece93cb4d7 Finish migrating default Configuration values into Configuration 2023-01-09 15:26:06 -07:00
rmcrackan 5b3ca0ed32 Merge pull request #442 from wtanksleyjr/master
Add a version parameter to DEB creation, do sanity checks, use it.
2023-01-09 16:36:48 -05:00
Robert McRackan 7474f1221a bug fix #441 -- new code with empty setting 2023-01-09 16:30:12 -05:00
William Tanksley d023a943c1 Add a version parameter to DEB creation, do sanity checks, use it. 2023-01-09 13:12:12 -08:00
Michael Bucari-Tovo 4e80af5c53 Bind MyRatingCellEditor background color to the cell's background 2023-01-09 14:06:06 -07:00
Michael Bucari-Tovo eee785377f Add default values to Configuration 2023-01-09 14:05:33 -07:00
Robert McRackan 915906e6ed test push ver 2023-01-09 11:59:24 -05:00
Robert McRackan 358c8b577e increment version 2023-01-09 11:53:44 -05:00
rmcrackan 5c450a01a4 Merge pull request #440 from Mbucari/master
Configuration Change Tracking and Bookk Records
2023-01-09 11:41:34 -05:00
Michael Bucari-Tovo 36264c6c6e Add back a check that was removed for testing 2023-01-08 13:19:21 -07:00
Michael Bucari-Tovo fca946bf15 Fix bug with long folder templates 2023-01-08 10:01:30 -07:00
Michael Bucari-Tovo 452ceef285 Tweak 2023-01-07 23:27:28 -07:00
Michael Bucari-Tovo 7fafee804d Double clicking on template item adds it to the template. 2023-01-07 23:15:38 -07:00
Michael Bucari-Tovo 3a48479435 Typos and formatting 2023-01-07 18:41:34 -07:00
Michael Bucari-Tovo cab8555ab5 Make UpgradeNotificationDialog a DialogWindow 2023-01-07 18:18:14 -07:00
Michael Bucari-Tovo e3b7cbcc2a Add proper Upgrade form 2023-01-07 18:09:37 -07:00
Michael Bucari-Tovo ed15614288 Improve character display in EditTemplateDialog 2023-01-07 14:33:49 -07:00
Michael Bucari-Tovo acb6d1b335 Use new Inline controls to selectively style TextBlock text 2023-01-07 12:05:38 -07:00
Michael Bucari-Tovo fe804796ab Use ContinueWith to set Rating changed value 2023-01-06 23:46:06 -07:00
Michael Bucari-Tovo 4725fe36d1 Add property changed filtering events to Configuration 2023-01-06 22:56:00 -07:00
Michael Bucari-Tovo 5c73beff4b Add PropertyChanged detection for Dictionary type settings 2023-01-06 19:46:55 -07:00
Michael Bucari-Tovo 1f7000c2c9 Add Configurations property change notifications 2023-01-06 16:50:20 -07:00
Mbucari f09baa1318 Merge branch 'rmcrackan:master' into master 2023-01-05 23:41:27 -07:00
Michael Bucari-Tovo 7eaa03e43c Add clip and bookmark viewer and exporter 2023-01-05 23:40:39 -07:00
Robert McRackan 26099303fa update dependencies 2023-01-05 21:44:34 -05:00
Michael Bucari-Tovo 6417aee780 Add book records dialog 2023-01-05 17:02:39 -07:00
rmcrackan f9deaba4c5 Merge pull request #438 from Mbucari/master
Linux and mac upgrade notification
2023-01-03 21:26:04 -05:00
Michael Bucari-Tovo ddd6a3b279 Change args 2023-01-03 15:17:57 -07:00
Michael Bucari-Tovo 9359950666 Formatting 2023-01-03 15:00:29 -07:00
Michael Bucari-Tovo d31b2a1b65 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-01-03 14:56:09 -07:00
Michael Bucari-Tovo b89b4e0af4 Linux and mac upgrade notification 2023-01-03 14:55:58 -07:00
rmcrackan cbcde027b3 Merge pull request #436 from Mbucari/master
Add download speed limit
2023-01-02 13:07:03 -05:00
Mbucari d306e6bd22 Merge branch 'rmcrackan:master' into master 2023-01-02 02:54:09 -07:00
Michael Bucari-Tovo 9ec877999e Add download speed limit 2023-01-02 02:46:46 -07:00
rmcrackan f4189bf409 Merge pull request #432 from Mbucari/master
Add ability for users to edit book ratings from the main grid
2023-01-01 22:34:06 -05:00
Michael Bucari-Tovo 0ed5062683 Cancel rating edit on escape 2023-01-01 11:23:22 -07:00
Michael Bucari-Tovo 7ef666dc91 Add TCOM tag for narrator 2022-12-31 23:31:24 -07:00
Michael Bucari-Tovo 1ac825919a Remove old migrations 2022-12-31 22:41:11 -07:00
Michael Bucari-Tovo a7bf30954d Fix null file bug and add context menu to my ratings column 2022-12-31 21:09:30 -07:00
Michael Bucari-Tovo 613cfdd903 Automatic refiltering now works on chardonnay 2022-12-31 19:50:49 -07:00
Michael Bucari-Tovo 28802c8279 Refilter on search update 2022-12-31 18:41:55 -07:00
Michael Bucari-Tovo 6d7b3bd5f0 Improve star display on classic 2022-12-31 15:39:46 -07:00
Michael Bucari-Tovo b97d8e9403 Add ratings cell tool tips 2022-12-31 11:12:04 -07:00
Michael Bucari-Tovo b4838d364e Only show hollow stars in editing mode 2022-12-31 10:33:18 -07:00
Michael Bucari-Tovo 05ac5c63e1 Formatting 2022-12-31 10:16:54 -07:00
Michael Bucari-Tovo 874bf9e7c0 Improve classic and chardonnay rating editor simmilarity 2022-12-31 10:02:30 -07:00
Michael Bucari-Tovo c9497ef39e Make my rating column sortable 2022-12-30 19:44:16 -07:00
Michael Bucari-Tovo 496830d01d Fix cell editor to work with desktop scaling 2022-12-30 19:25:11 -07:00
Mbucari ccebcdd4c7 Merge branch 'rmcrackan:master' into master 2022-12-30 17:01:22 -07:00
Michael Bucari-Tovo c900fe8461 Add user rating editing to grid 2022-12-30 17:00:40 -07:00
rmcrackan a0158db37e Merge pull request #431 from Mbucari/master
Fix file naming template on unix systems (#430)
2022-12-30 11:05:09 -05:00
Robert McRackan b8c26b01ad update dependencies 2022-12-30 09:58:51 -05:00
Mbucari 3a44bef0d9 Lowercase command is more linux-ey 2022-12-29 16:24:41 -07:00
Mbucari 57a4ee781b .deb package build script (#390) 2022-12-29 16:04:20 -07:00
= e12f475850 Refactor 2022-12-29 15:39:48 -07:00
= f822a23daa Linux and OSX directory length limits 2022-12-29 15:29:18 -07:00
= 6901b8be35 Fix file naming template on unix systems 2022-12-29 14:12:46 -07:00
Robert McRackan 83fb2cd1d0 New feature #430 : bulk set pdf-downloaded status 2022-12-29 09:33:32 -05:00
Robert McRackan c98664d584 Bugfix #423 : Chardonnay updater fails when windows username has a space 2022-12-22 09:45:32 -05:00
Robert McRackan d098be8b03 EF: move seed data into corresponding config class 2022-12-22 09:37:21 -05:00
Robert McRackan 3f6689d032 Test release before v9 2022-12-20 12:43:04 -05:00
rmcrackan b4206fc203 Merge pull request #416 from pixil98/master
Linux runners and Docker image
2022-12-19 21:58:38 -05:00
rmcrackan cfa4a0c07f Merge pull request #417 from Mbucari/master
Improve download cancellation
2022-12-19 13:42:24 -05:00
Michael Bucari-Tovo 357b220ace Suppress warnings 2022-12-19 09:48:18 -07:00
Michael Bucari-Tovo 47968304c9 Return Download to new background thread 2022-12-19 09:15:36 -07:00
Michael Bucari-Tovo 2024d5e116 Improve download cancellation. 2022-12-18 21:52:51 -07:00
pixil98 5ae2a99c14 Docker workflow (#7)
* Refactored workflows
* Added docker build to release
* Linux and MacOS now build on Linux
2022-12-18 17:57:37 -06:00
rmcrackan 7fd002d2c9 Merge pull request #413 from Mbucari/master
Update obsolete code and fix #347
2022-12-18 09:41:31 -05:00
Michael Bucari-Tovo b7b7038244 Delete partially decrypted files from previous Libation instances 2022-12-17 12:30:26 -07:00
Michael Bucari-Tovo b5519c4875 Add option for user to choose custom temp folder 2022-12-17 12:25:13 -07:00
Michael Bucari-Tovo 44feab9eb2 Update comments 2022-12-17 11:39:37 -07:00
Michael Bucari-Tovo 96c45c33e5 Refactor NetworkFileStream replace obsolete WebRequest 2022-12-17 11:31:51 -07:00
Michael Bucari-Tovo 36efbcb812 Replace deprecated file dialogs 2022-12-16 21:08:11 -07:00
Michael Bucari-Tovo 03f44b4e9c Fix IDE class grouping 2022-12-16 19:54:31 -07:00
rmcrackan 19860e9f09 Merge pull request #412 from Mbucari/master
Fix Character Replacements and Add More Useful Error Messages
2022-12-16 21:28:36 -05:00
Michael Bucari-Tovo 0701cb3970 Reorder tabs 2022-12-16 16:45:51 -07:00
Michael Bucari-Tovo 7d6000e3b6 Bring Hangover Chardonnay into feature parity with Classic (#409) 2022-12-16 16:41:24 -07:00
Mbucari ef973ac56a Merge branch 'rmcrackan:master' into master 2022-12-16 08:55:08 -07:00
Robert McRackan 91a1033c52 makes slashes more clear 2022-12-16 09:48:00 -05:00
Robert McRackan 4197db6af9 Fix unit tests failing because of windows newlines 2022-12-16 09:27:44 -05:00
MBucari 210ab065c2 Make tests xplat 2022-12-15 23:04:27 -07:00
Mbucari 9cd10eca58 Merge branch 'rmcrackan:master' into master 2022-12-15 19:25:27 -07:00
Robert McRackan ba676be46d update dependency 2022-12-15 21:06:26 -05:00
Mbucari 665a2e1866 Merge branch 'rmcrackan:master' into master 2022-12-15 16:22:43 -07:00
Michael Bucari-Tovo 94469cae3d Add better error messages for license denial #352 2022-12-15 16:22:25 -07:00
Michael Bucari-Tovo a0dd2ccad6 Make filename character replacement more xplat and allow replacing any char, not just illegal. 2022-12-15 15:50:48 -07:00
Robert McRackan b2cf837de7 Hangover. WinForms. Restore deleted books 2022-12-15 14:11:27 -05:00
Mbucari 80bcf60b5b Merge branch 'rmcrackan:master' into master 2022-12-14 15:40:42 -07:00
Robert McRackan 7ad0ab566a New feature: 'Remove' now removes forever. Removed books won't be re-added on next scan 2022-12-14 16:19:55 -05:00
Michael Bucari-Tovo 2b16e86c7b Fix character replacement for non-windows platforms. 2022-12-13 16:33:37 -07:00
Robert McRackan f2ea02ae0b bugfix. file extension 2022-12-13 16:09:24 -05:00
Robert McRackan f65cd39040 bug fix: keyboard shortcuts 2022-12-13 15:58:54 -05:00
Robert McRackan 5ca0d2a399 New feature #406 : Right Click Menu for Stop-Light Icon (Chardonnay UI) 2022-12-13 15:32:33 -05:00
Robert McRackan d1528a095b New feature #406 : Right Click Menu for Stop-Light Icon (Classic UI) 2022-12-13 13:48:28 -05:00
rmcrackan 749173a463 Merge pull request #407 from Mbucari/master
Add dynamic context menus to products grid
2022-12-13 08:33:03 -05:00
Michael Bucari-Tovo 6fbd90a6b3 Fix hidden tag 2022-12-13 02:42:53 -07:00
Michael Bucari-Tovo f39d272e6a Make reused ContextMenu static 2022-12-12 18:23:12 -07:00
Michael Bucari-Tovo bb3854f512 Finishing touch 2022-12-12 17:21:41 -07:00
Michael Bucari-Tovo e40daecfb8 Remove old static context menu 2022-12-12 17:12:44 -07:00
Michael Bucari-Tovo 3716ab9cb5 Merged 2022-12-12 17:11:40 -07:00
Michael Bucari-Tovo 0cc6d6337a Add dynamic context menus to main grid 2022-12-12 17:10:18 -07:00
Robert McRackan ce711a36ba #398 - new feature: right-click, copy 2022-12-12 15:03:20 -05:00
rmcrackan 451af7bea9 Merge pull request #405 from Mbucari/master
Upgraded to Avalonia 11-Preview4
2022-12-12 13:18:06 -05:00
Michael Bucari-Tovo 63200592bf Ensure mandatory character replacements remain marked mandatory 2022-12-12 08:34:10 -07:00
Michael Bucari-Tovo d165dfbeb5 Fix NRE 2022-12-12 08:24:18 -07:00
Michael Bucari-Tovo eed3d84517 Add context menu 2022-12-11 19:21:49 -07:00
Michael Bucari-Tovo ba7d890966 Update hangover 2022-12-11 18:33:57 -07:00
Michael Bucari-Tovo 5140fc63d9 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-12-11 18:30:56 -07:00
Michael Bucari-Tovo 78509c07e0 Remove unused files 2022-12-11 18:30:48 -07:00
Mbucari 5084141215 Delete Source/Visual Studio 2022 directory 2022-12-11 18:23:00 -07:00
Michael Bucari-Tovo 3f2ac83474 Fix copying grid contents to clipboard 2022-12-11 17:50:15 -07:00
Michael Bucari-Tovo 58a0468728 Tidy up 2022-12-11 16:58:51 -07:00
Mbucari 8e13aa7513 Merge branch 'rmcrackan:master' into master 2022-12-11 15:47:24 -07:00
Michael Bucari-Tovo 48e2d91fc8 Implement Illegal Char Replace dialog in Avalonia 2022-12-11 15:47:04 -07:00
rmcrackan a7f119217f Merge pull request #404 from pixil98/master
Fix Auto Updating
2022-12-11 09:12:10 -05:00
pixil98 865f2261fe Fix Auto Updating (#8)
Release workflow no longer includes the parent folder in the zip.
2022-12-10 00:30:08 -06:00
Michael Bucari-Tovo dfedb23efd Refactor ProductsDisplay 2022-12-09 12:27:52 -07:00
Mbucari c01e1c3e4b Merge branch 'rmcrackan:master' into master 2022-12-09 12:27:14 -07:00
Robert McRackan ad8dac5fb0 more forgiving releaseindex windows regex 2022-12-08 08:17:17 -05:00
Mbucari 84e81b6218 Merge branch 'rmcrackan:master' into master 2022-12-07 19:13:22 -07:00
Robert McRackan 86efe631fe restore yaml 2022-12-07 13:49:05 -05:00
Robert McRackan f5f1dc483b publish debugging. create new version 2022-12-07 13:34:30 -05:00
Robert McRackan 8aa4328c6c update dependencies 2022-12-07 13:09:02 -05:00
Michael Bucari-Tovo a01a8c4b19 Update Avalonia to v.11-Preview-4 2022-12-07 10:15:12 -07:00
Robert McRackan 4b2387b621 update dependencies 2022-12-07 11:53:10 -05:00
Robert McRackan 74d16d8ef9 yaml releases don't run. comment out for now 2022-12-07 07:42:11 -05:00
rmcrackan b1ea8f9fa7 Update GettingStarted.md
disclaimer: don't install in Program Files
2022-12-06 15:59:11 -05:00
rmcrackan c666fdeaff document CLI set-status 2022-12-06 15:02:26 -05:00
Robert McRackan 7068782975 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-12-06 14:58:30 -05:00
Robert McRackan c4cebbebe7 * #396 New feature : match download status to files
* UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
  * CLI: Full library. No prompt
2022-12-06 14:58:22 -05:00
rmcrackan 53d43d9fa9 Merge pull request #401 from pixil98/master
Add validate and release workflows
2022-12-05 14:54:39 -05:00
Aaron Reisman 11d59beeed Rename happens before zipping 2022-11-28 13:08:57 -06:00
Aaron Reisman ef71e297f4 Add special handling for classic build 2022-11-28 12:54:17 -06:00
Aaron Reisman 1e4d1d1973 Lowercase OS names in releaseindex 2022-11-28 12:44:48 -06:00
pixil98 893d99854b Merge branch 'master' into master 2022-11-25 00:20:51 -06:00
Aaron Reisman db93980cd5 Rename publish to release 2022-11-24 23:54:49 -06:00
pixil98 34fac30b2b Merge official updates (#6)
Pull latest Libation updates, fix move to net7
2022-11-24 23:53:00 -06:00
pixil98 2fa0bcb765 Near final workflows
Updated workflows to release zips with the correct file names.
2022-11-23 15:48:37 -06:00
pixil98 78fd09aa91 Proper build
Builds all packages properly
2022-11-22 10:45:33 -06:00
Robert McRackan a54516b4f5 Fix pubxml hierarchy 2022-11-21 13:51:30 -05:00
Robert McRackan f193d6f376 AudibleApi fixed 2022-11-17 17:05:58 -05:00
pixil98andAaron Reisman 8a82c294a1 Fix publish workflow (#2)
* Add dotnet test workflow

* main -> master

* Try a different workflow

* Add working-directory

* use windows runner

* use env var

* Fix build and test order

* Specify configuration

* Specify sln instead of working dir

* Specify that DOTNET_SLN is an env var

* Add publish workflow

* Add env.DOTNET_SLN to publish workflow

* Add publish job

* Combine publish into one job

* Just create an artifact

* Remove unused nuget lines

* Add Publish job back

Co-authored-by: Aaron Reisman <areisman@epic.com>
2022-11-16 13:40:57 -06:00
Robert McRackan 9392cf4bf0 update dependencies 2022-11-16 13:36:23 -05:00
Robert McRackan ec4deb9099 update db dependencies 2022-11-16 12:48:22 -05:00
Robert McRackan cf0548aab9 update dependencies 2022-11-16 12:46:12 -05:00
pixil98 064801380b Add workflows (#1)
Adds basic workflows
2022-11-16 11:44:15 -06:00
Robert McRackan 9dc2a7424a DataLayer => .net7 2022-11-16 11:49:07 -05:00
Robert McRackan 4c8a56a5b9 Core, Utilities => .net7 2022-11-16 11:47:34 -05:00
Robert McRackan 9aad263996 Domain Internal Utilities => .net7 2022-11-16 11:46:26 -05:00
Robert McRackan ce1ab7c20d Domain Utilities => .net7 2022-11-16 11:45:04 -05:00
Robert McRackan c9217990cd applications => .net7 2022-11-16 11:43:35 -05:00
Robert McRackan 90cbf3b7a6 tests => .net7 2022-11-16 11:27:51 -05:00
Robert McRackan c4f1b22ddf demos => .net7 2022-11-16 11:24:30 -05:00
Robert McRackan fb612ea6ab Bug fix #394 : Scanning dir.s containing symlinks causes errors. Thanks @CharlieRussel 2022-11-14 16:22:29 -05:00
Robert McRackan bce44b6f6d Fix string interpolation bugs 2022-11-14 15:16:22 -05:00
Robert McRackan 7575736991 Bugfix #389 : Handle corrupt cache file 2022-11-08 07:22:08 -05:00
Robert McRackan 06f8d055fc update dependencies 2022-11-02 16:24:14 -04:00
Robert McRackan d64e043fe8 #367 : New template option "year": year published to audible 2022-10-21 13:41:44 -04:00
Robert McRackan 99564d9c25 update dependencies 2022-10-18 08:33:43 -04:00
rmcrackan 29bccd3e33 Update InstallOnMac.md
Gatekeeper instructions
2022-10-05 22:25:40 -04:00
Robert McRackan 20f65f6534 Fix description of poorly named AutoDownloadEpisodes 2022-09-28 15:49:16 -04:00
Robert McRackan 8ca72b2e2d incr ver 2022-09-28 13:27:04 -04:00
Robert McRackan 75429f288f update dependencies 2022-09-28 13:25:41 -04:00
Robert McRackan d1bb921346 Cache assembly fetches/resolution so that repeat errors aren't clogging the log 2022-09-24 09:48:44 -04:00
rmcrackan b979b6ddad Update README.md 2022-09-24 07:54:48 -04:00
rmcrackan 4eba41ddbb Update GettingStarted.md 2022-09-23 21:48:16 -04:00
rmcrackan 418f5062ff Update README.md
remove unofficial linux instructions
2022-09-23 21:46:45 -04:00
rmcrackan f736f7f909 Update GettingStarted.md
remove unofficial linux instructions
2022-09-23 21:46:06 -04:00
rmcrackan 96ead28246 Update Advanced.md
remove unofficial linux instructions
2022-09-23 21:45:28 -04:00
Robert McRackan 34bad7a53d fix string template 2022-09-19 14:08:46 -04:00
Robert McRackan 7ac1fff3a0 update dependencies 2022-09-09 11:53:59 -04:00
Robert McRackan a4c5c53df3 incr ver 2022-09-09 11:10:56 -04:00
Robert McRackan 87db5cfd94 revert accidental re-name of button text 2022-09-09 11:04:14 -04:00
Robert McRackan 85e7bbf366 Chardonnay readonly textboxes should be grey (as they are in Classic) 2022-09-07 13:29:41 -04:00
Robert McRackan c55c5fac23 typo 2022-09-06 12:47:44 -04:00
Robert McRackan e25e2f7211 Update documentaion for macos 2022-08-31 15:44:12 -04:00
Robert McRackan f310d583d8 Bug fix #364 - app was crashing on attempt to download PDF to which the user no longer had ownership. Eg: returned or Plus catalog 2022-08-29 15:05:56 -04:00
Robert McRackan f05465b29b incr ver 2022-08-18 13:38:23 -04:00
rmcrackan 959e31972e Merge pull request #363 from Mbucari/master
Change assembly loadig
2022-08-18 13:36:08 -04:00
Michael Bucari-Tovo 17181811f0 Remove assembly hot loading 2022-08-18 11:21:40 -06:00
Michael Bucari-Tovo 6d2624d52b Fix comment 2022-08-18 10:59:37 -06:00
Michael Bucari-Tovo 9dd5940c8c Remove trailing wild 2022-08-18 10:59:00 -06:00
Michael Bucari-Tovo 1927d19961 comments 2022-08-18 10:47:53 -06:00
Michael Bucari-Tovo 09cc838bb4 Checks 2022-08-18 10:45:07 -06:00
Michael Bucari-Tovo 8af4c71101 Change assembly loadig 2022-08-18 10:29:30 -06:00
Robert McRackan 7ffdf45164 Bug fix #361 : import would break when audible erroneous duplicates a name in the author list or a name in the narrator list. (Note: the same name as both author and narrator has always been ok.) 2022-08-17 20:05:47 -04:00
Robert McRackan e0999dc9ae Bug fix #358 : pdf downoad errors in CLI were crashing the rest of the loop 2022-08-16 15:41:12 -04:00
Robert McRackan a0f3d44e97 revert changes to DownloadDecryptBook. This is not the correct fix 2022-08-16 14:54:12 -04:00
Robert McRackan 1510a86579 Bug fix #350 : support old style of large multi-part books 2022-08-16 10:14:13 -04:00
Robert McRackan b3581455d2 incr ver. Don't re-use previously bad build number 2022-08-15 22:03:51 -04:00
Robert McRackan 8ee1019fa5 ConfigApp.s need PublishReadyToRun and RuntimeIdentifies 2022-08-15 22:01:38 -04:00
Robert McRackan 285b10a95f bug fix: WindowsConfigApp must explicitly load a type from the Dinah.Core.WindowsDesktop asm since avalonia doesn't reference it 2022-08-15 10:20:41 -04:00
Robert McRackan 0ca33f864b oops: recent bug fix introduced an infinite loop. fixed 2022-08-15 09:41:33 -04:00
Robert McRackan a0823fa26c Bug fix. Book details dialog save button should also close the form 2022-08-14 21:53:34 -04:00
Robert McRackan aa9040da5d Fix release pragma OS var.s 2022-08-14 19:35:01 -04:00
Robert McRackan 222031ecc5 avalonia ui: add new setting 2022-08-14 11:12:52 -04:00
Robert McRackan dda8f5a974 publish profiles should point to Publish dir 2022-08-14 09:32:50 -04:00
Robert McRackan e9b484df04 * LoadByOS build profiles
* incr ver
2022-08-14 09:19:14 -04:00
rmcrackan d505264e86 Merge pull request #356 from Mbucari/master
Add useCoverAsFolderIconCb setting to avalonia
2022-08-13 18:33:38 -04:00
Michael Bucari-Tovo c0b1f1dc0a Add useCoverAsFolderIconCb setting to avalonia 2022-08-12 18:37:02 -06:00
Robert McRackan 1524d558a4 * Feature #307 : New windows setting to use cover art as folder's icon. Incomplete. Need to add to avalonia settings
* Interop refactor
2022-08-12 17:55:15 -04:00
Robert McRackan aea8c11dc4 Add OS-specific interop 2022-08-12 13:49:51 -04:00
Robert McRackan 86c7f89788 update dependencies 2022-08-08 11:47:08 -04:00
1205 changed files with 126176 additions and 32720 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
{
"CdmUrls": [
"https://ollj0gz40d.execute-api.us-west-2.amazonaws.com/default/AudibleCdm"
]
}
+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
+22 -8
View File
@@ -6,10 +6,14 @@ labels: bug
assignees: ''
---
**Describe the bug**
PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking an attached log file may get limited or delayed help.
___
## Describe the bug
A clear and concise description of what the bug is.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
@@ -17,15 +21,25 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Screenshots**
## Screenshots
If applicable, add screenshots to help explain your problem.
**Platform**
## Platform
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here.
## Log Files
Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
**Default Log File Locations**
|Platform|Folder|
|-|-|
|Windows|`%userprofile%\Libation`|
|macOS|`~/Library/Application Support/Libation`|
|Linux|`~/.local/share/Libation`|
**macOS:** If that folder does not exist because Libation never starts, try launching from Terminal and say whether it works: `/Applications/Libation.app/Contents/MacOS/Libation` (see [Troubleshooting for macOS](https://github.com/rmcrackan/Libation/blob/main/docs/advanced/troubleshoot.md#macos)).
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+16 -4
View File
@@ -6,14 +6,26 @@ labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
**No-go ideas**
There are lots of great ideas and many are beyond what we intend to do for Libation. Some good ideas which we do not intend to pursue:
* comprehensive api/cli
* aax/audiobook import
* bulk rename of existing files
* general metadata/tag editor
* playback features
* web gui
* supporting non-audible vendors
* official docker support
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
**Additional context**
Add any other context or screenshots about the feature request here.
+8
View File
@@ -0,0 +1,8 @@
---
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

+10
View File
@@ -0,0 +1,10 @@
# Labels required by exempt-creators.yml and stale.yml. Sync with sync-labels workflow.
# (Other repo labels are left unchanged when prune: false.)
---
- name: stale
color: 'eeeeee'
description: 'No activity for an extended period'
- name: exempt
color: '0e8a16'
description: 'Excluded from automatic stale closure (e.g. maintainer-opened)'
+83
View File
@@ -0,0 +1,83 @@
# build-linux.yml
# Reusable workflow that builds the Linux and MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
OS:
type: string
description: >
The operating system targeted by the build.
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
required: true
jobs:
build:
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
runs-on: ubuntu-latest
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
run: |
SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
chmod +rx ${SCRIPT}
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}
+113
View File
@@ -0,0 +1,113 @@
# build-mac.yml
# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
sign-app:
type: boolean
description: "Wheather to sign an notorize the app bundle and dmg."
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
jobs:
build:
name: "macOS-${{ inputs.architecture }}"
runs-on: macos-latest
env:
RUNTIME_ID: "osx-${{ inputs.architecture }}"
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
steps:
- 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@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
run: |
SCRIPT=./Scripts/Bundle_MacOS.sh
chmod +rx ${SCRIPT}
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Notarize bundle
if: ${{ inputs.sign-app }}
run: |
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
WAIT="--wait"
fi
echo "::debug::Submitting the disk image for notarization"
# Capture stdout+stderr (2>&1). Use || true so that when notarytool fails (e.g. Apple TOS
# agreement required), the script does not exit before we can print RESPONSE—otherwise the
# job would fail with no visible error message in the workflow log.
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1) || true
echo "$RESPONSE"
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
echo "::notice::Notary Submission Id: $SUBMISSION_ID"
# Re-fail the step if submit failed (e.g. no submission id). The job still fails, but the
# output above is now visible in the log so we can see the real error (e.g. sign agreement).
if [ -z "$SUBMISSION_ID" ]; then
echo "::error::Notarization submit failed. See output above for details (e.g. Apple TOS agreement)."
exit 1
fi
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
echo "::debug::Stapling the notarization ticket to the disk image"
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
fi
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}
+145
View File
@@ -0,0 +1,145 @@
# build-windows.yml
# Reusable workflow that builds the Windows versions of Libation.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
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
jobs:
build:
name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})"
runs-on: windows-latest
strategy:
matrix:
ui: [Avalonia]
architecture: [x64]
release_name: [chardonnay]
artifact_stem: [Libation]
include:
- architecture: x64
ui: WinForms
release_name: classic
artifact_stem: Libation-Classic
- architecture: arm64
ui: Avalonia
release_name: chardonnay
artifact_stem: Libation
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
$PUBLISH_ARGS=@(
"--runtime", "win-${{ matrix.architecture }}",
"--configuration", "Release",
"--output", "../bin",
"-p:PublishProtocol=FileSystem",
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
"-p:SelfContained=true")
# 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
working-directory: ./bin
run: |
$delfiles = @(
"WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json")
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
# Both UIs publish as Libation.exe. upload-artifact's if-no-files-found only proves the zip exists,
# which it does whether or not the app is inside it, so say outright that it has to be.
if (-not (Test-Path "Libation.exe")) { throw "Libation.exe is missing from the publish output" }
$artifact="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path * -DestinationPath "$artifact"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.zip.outputs.artifact }}
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 }}
+72
View File
@@ -0,0 +1,72 @@
# build.yml
# Reusable workflow that builds Libation for all platforms.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
description: "Libation version number"
required: true
dotnet-version:
type: string
default: "10.x"
description: ".NET version to target"
run-unit-tests:
type: boolean
description: "Whether to run unit tests prior to publishing."
publish-r2r:
type: boolean
description: "Whether to publish assemblies as ReadyToRun."
release:
type: boolean
description: "Whether this workflow is being called as a release"
retention-days:
type: number
description: "Number of days the artifacts are to be retained."
jobs:
windows:
uses: ./.github/workflows/build-windows.yml
with:
libation-version: ${{ inputs.libation-version }}
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:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-mac.yml
with:
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }}
secrets: inherit
linux:
strategy:
matrix:
OS: [Redhat, Debian]
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
OS: ${{ matrix.OS }}
+71
View File
@@ -0,0 +1,71 @@
name: Deploy VitePress site to Pages
on:
# Runs on pushes targeting the `main` branch. Change this to `master` if you're
# using the `master` branch as the default branch.
push:
branches: [master]
paths:
- .github/workflows/deploy.yml
- docs/**
- .vitepress/**
- index.md
- package.json
- package-lock.json
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: pages
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
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
# with:
# 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@v7
with:
node-version: 24
cache: npm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Install dependencies
run: npm ci # or pnpm install / yarn install / bun install
- 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@v5
with:
path: .vitepress/dist
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+67
View File
@@ -0,0 +1,67 @@
# docker.yml
# Reusable workflow that builds a docker image for Libation.
---
name: docker
on:
workflow_call:
inputs:
version:
type: string
description: "Version number"
required: true
release:
type: boolean
description: "Is this a release build?"
required: true
secrets:
docker_username:
required: true
docker_token:
required: true
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
if: ${{ inputs.release }}
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.docker_username }}
password: ${{ secrets.docker_token }}
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v6
with:
flavor: |
latest=true
images: |
name=${{ secrets.docker_username }}/libation
tags: |
type=raw,value=${{ inputs.version }},enable=${{ inputs.release }}
- 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: ${{ inputs.release && 'type=gha,mode=max,ignore-error=true' || '' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
@@ -0,0 +1,67 @@
# One-time or occasional backfill: add `exempt` to open issues/PRs opened by
# rmcrackan or Mbucari that predate exempt-creators.yml. Run from Actions →
# "Exempt creators backfill" → Run workflow.
---
name: Exempt creators backfill
on:
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
backfill:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const exemptLabel = 'exempt';
const creators = new Set(['rmcrackan', 'mbucari']);
const owner = context.repo.owner;
const repo = context.repo.repo;
const items = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
per_page: 100,
});
let labeled = 0;
let notCreator = 0;
let alreadyExempt = 0;
for (const item of items) {
const login = item.user && item.user.login;
if (!login || !creators.has(login.toLowerCase())) {
notCreator++;
continue;
}
const names = item.labels.map((l) => l.name);
if (names.some((n) => n.toLowerCase() === exemptLabel)) {
alreadyExempt++;
continue;
}
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: item.number,
labels: [exemptLabel],
});
labeled++;
} catch (e) {
core.warning(`#${item.number}: ${e.message}`);
}
}
core.info(
`Backfill done: added exempt to ${labeled} item(s); ` +
`${alreadyExempt} already had exempt; ${notCreator} not from rmcrackan/Mbucari.`
);
+39
View File
@@ -0,0 +1,39 @@
# Assigns the `exempt` label to issues and PRs opened by maintainer accounts so
# stale.yml does not mark them stale. See issue #1532.
# For issues/PRs opened before this existed, run workflow "Exempt creators backfill".
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/exempt-creators.yml
---
name: Exempt specific creators
on:
issues:
types: [opened]
pull_request:
types: [opened]
permissions:
issues: write
pull-requests: write
jobs:
exempt:
runs-on: ubuntu-latest
steps:
- name: Add exempt label
if: >-
(github.event_name == 'issues' &&
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@v9
with:
script: |
const number = context.issue?.number || context.payload.pull_request?.number;
const repo = context.repo;
if (number) {
await github.rest.issues.addLabels({
...repo,
issue_number: number,
labels: ['exempt'],
});
}
+68
View File
@@ -0,0 +1,68 @@
# release.yml
# Builds and creates the release on any tags starting with a `v`
---
name: release
on:
push:
tags:
- "v*"
jobs:
prerelease:
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get tag version
id: get_version
run: |
export TAG="${{ github.ref_name }}"
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
docker:
needs: [prerelease]
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prerelease.outputs.version }}
release: true
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
build:
needs: [prerelease]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
libation-version: ${{ needs.prerelease.outputs.version }}
publish-r2r: true
release: true
release:
needs: [prerelease, build]
runs-on: ubuntu-latest
steps:
- name: Download Chardonnay, Linux, and Mac artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
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@v3
with:
name: Libation ${{ needs.prerelease.outputs.version }}
body: "<Put a body here>"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: false
files: |
artifacts/*
+66
View File
@@ -0,0 +1,66 @@
# Marks inactive issues/PRs stale and closes them after a further quiet period.
# Exempt labels include `enhancement`, `exempt` (from exempt-creators.yml), and others below.
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/stale.yml
---
name: Close stale issues and PRs
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v11
with:
days-before-stale: 30
days-before-close: 14
operations-per-run: 100
stale-issue-message: |
This issue has been automatically marked as stale because it has not had any activity for 30 days.
It will be closed in 14 days if no further activity occurs.
If this issue is still relevant, please leave a comment to keep it open.
Thank you for your contributions to Libation.
close-issue-message: |
This issue has been automatically closed due to inactivity.
If you believe this issue is still relevant, please reopen it or create a new issue with updated information.
Thank you for your understanding.
stale-issue-label: stale
stale-pr-message: |
This pull request has been automatically marked as stale because it has not had any activity for 30 days.
It will be closed in 14 days if no further activity occurs.
If you're still working on this PR, please leave a comment to keep it open.
Thank you for your contribution.
close-pr-message: |
This pull request has been automatically closed due to inactivity.
If you'd like to continue working on this, please reopen the PR or submit a new one.
Thank you for your contribution.
stale-pr-label: stale
exempt-issue-labels: 'priority: critical,priority: high,security,help wanted,good first issue,needs-discussion,enhancement,exempt'
exempt-pr-labels: 'priority: critical,priority: high,security,enhancement,exempt'
exempt-all-milestones: true
exempt-all-assignees: false
exempt-draft-pr: true
ascending: true
remove-stale-when-updated: true
debug-only: false
+29
View File
@@ -0,0 +1,29 @@
# Ensures labels referenced by exempt-creators / stale workflows exist.
# Optional per #1532 — run workflow_dispatch if labels are missing.
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/sync-labels.yml
---
name: Sync labels
on:
push:
branches: [master]
paths:
- '.github/labels.yml'
workflow_dispatch:
permissions:
issues: write
jobs:
sync-labels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Sync labels
uses: micnncim/action-label-syncer@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
manifest: .github/labels.yml
prune: false
@@ -0,0 +1,22 @@
name: Validate MetaInfo
"on":
pull_request:
branches: ["master"]
paths:
- .github/workflows/validate-appstream-metainfo.yml
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
push:
branches: ["master"]
paths:
- .github/workflows/validate-appstream-metainfo.yml
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
jobs:
validate-appstream-metainfo:
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub/flatpak-builder-lint:latest
steps:
- uses: actions/checkout@v7
- name: Check the MetaInfo file
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
@@ -0,0 +1,21 @@
name: Check desktop file
"on":
pull_request:
branches: ["master"]
paths:
- .github/workflows/validate-desktop-file.yml
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
push:
branches: ["master"]
paths:
- .github/workflows/validate-desktop-file.yml
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
jobs:
validate-desktop-file:
runs-on: ubuntu-latest
steps:
- 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
+47
View File
@@ -0,0 +1,47 @@
# validate.yml
# Validates that Libation will build on a pull request or push to master.
---
name: validate
on:
push:
branches: [master]
paths:
- Source/**
- .github/workflows/**
pull_request:
branches: [master]
paths:
- Source/**
- .github/workflows/**
jobs:
get_version:
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get version
id: get_version
run: |
wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj"
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
build:
needs: [get_version]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
libation-version: ${{ needs.get_version.outputs.version }}
retention-days: 14
run-unit-tests: true
docker:
needs: [get_version]
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.get_version.outputs.version }}
release: false
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
+12 -1
View File
@@ -370,4 +370,15 @@ FodyWeavers.xsd
/__TODO.txt
/DataLayer/LibationContext.db
*/bin-Avalonia
*/bin-Avalonia
# macOS Directory Info
.DS_Store
# JetBrains Rider Settings
**/.idea/
# VitePress
node_modules
.vitepress/cache
.vitepress/dist
+10 -4
View File
@@ -1,6 +1,12 @@
{
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win-classic\\.zip",
"WindowsAvalonia":"Libation\\.\\d+\\.\\d+\\.\\d+-win-chardonnay\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
"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",
"LinuxAvalonia_DEB": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
}
+162
View File
@@ -0,0 +1,162 @@
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",
head: [["link", { rel: "icon", href: "/favicon.ico" }]],
cleanUrls: true,
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
logo: {
light: "/libation_logo_light.svg",
dark: "/libation_logo_dark.svg",
},
footer: {
message: "Released under the GPLv3 License",
},
editLink: {
pattern: "https://github.com/rmcrackan/Libation/edit/master/:path",
},
lastUpdated: true,
nav: [
{ text: "Getting Started", link: "/docs/getting-started" },
{ text: "Docs", link: "/docs/index" },
{
text: "Download",
link: "https://github.com/rmcrackan/Libation/releases/latest",
},
{
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "/donate" },
],
sidebar: [
{
items: [
{ text: "Overview", link: "/docs/index" },
{ text: "Getting Started", link: "/docs/getting-started" },
{ text: "FAQ", link: "/docs/frequently-asked-questions" },
{
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "/donate" },
],
},
{
text: "Installation",
collapsed: false,
items: [
{ text: "Linux", link: "/docs/installation/linux" },
{ text: "Mac", link: "/docs/installation/mac" },
{ text: "Docker", link: "/docs/installation/docker" },
],
},
{
text: "Features",
collapsed: false,
items: [
{
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",
},
{
text: "Easy guide to searching",
link: "/docs/features/lucene",
},
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
],
},
{
text: "Advanced",
collapsed: false,
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: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
],
},
{
text: "Development",
collapsed: false,
items: [
{
text: "Getting Started",
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" },
],
},
],
outline: {
level: "deep",
},
socialLinks: [
{ icon: "github", link: "https://github.com/rmcrackan/Libation" },
],
search: {
provider: "local",
},
// Show the external-link arrow on outbound links in markdown (nav/sidebar already do).
externalLinkIcon: true,
},
});
@@ -0,0 +1,259 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { getDetectOS } from './download-detection.js'
const PREVIEW_OPTIONS = [
{ value: '', label: 'Current system' },
{ value: 'windows|x64', label: 'Windows (x64)' },
{ value: 'windows|arm64', label: 'Windows (arm64)' },
{ value: 'mac|x64', label: 'Mac (x64)' },
{ value: 'mac|arm64', label: 'Mac (arm64)' },
{ value: 'linux|x64|', label: 'Linux (x64)' },
{ value: 'linux|x64|Ubuntu', label: 'Linux Ubuntu (x64)' },
{ value: 'linux|x64|Fedora', label: 'Linux Fedora (x64)' },
{ value: 'linux|arm64|', label: 'Linux (arm64)' },
{ value: 'linux|arm64|Ubuntu', label: 'Linux Ubuntu (arm64)' },
{ value: 'linux|arm64|Fedora', label: 'Linux Fedora (arm64)' },
]
const INSTALL_MAC_URL = 'https://getlibation.com/docs/installation/mac'
const INSTALL_LINUX_URL = 'https://getlibation.com/docs/installation/linux'
const status = ref('loading') // 'loading' | 'ready' | 'error'
const errorMessage = ref('')
const cachedAssets = ref([])
const rec = ref(null)
const blockHead = ref('')
const previewValue = ref('')
const installGuideUrl = ref(null)
const installGuideLabel = ref('')
const d = ref(null)
function getPreviewOverrides() {
if (!previewValue.value) return null
const parts = previewValue.value.split('|')
const overrides = { os: parts[0], architecture: parts[1] }
if (parts.length > 2 && parts[2]) overrides.linuxFlavor = parts[2]
return overrides
}
function recommendedForLabel(overrides) {
if (!d.value) return 'Recommended download for your system'
const os = (overrides && overrides.os) || d.value.os
const ver = (overrides && overrides.osVersion) || d.value.osVersion
const arch = (overrides && overrides.architecture) || d.value.architecture
const flavor = (overrides && overrides.linuxFlavor !== undefined) ? overrides.linuxFlavor : d.value.linuxFlavor
if (os === 'windows') return 'Recommended download for Windows (' + arch + ')'
if (os === 'mac') return 'Recommended download for Mac ' + (ver ? ver + ' ' : '') + '(' + arch + ')'
if (os === 'linux') return 'Recommended download for ' + (flavor ? flavor + ' ' : '') + 'Linux (' + arch + ')'
return 'Recommended download for your system'
}
function updateRecommendation() {
if (!cachedAssets.value.length || !d.value) return
const overrides = getPreviewOverrides()
rec.value = d.value.recommendDownload(cachedAssets.value, overrides)
blockHead.value = recommendedForLabel(overrides)
const os = (overrides && overrides.os) || d.value.os
if (os === 'mac') {
installGuideUrl.value = INSTALL_MAC_URL
installGuideLabel.value = 'Install on macOS'
} else if (os === 'linux') {
installGuideUrl.value = INSTALL_LINUX_URL
installGuideLabel.value = 'Install on Linux'
} else {
installGuideUrl.value = null
installGuideLabel.value = ''
}
}
const showReasonWarn = computed(() => {
return rec.value?.reason && rec.value.reason !== 'Recommended for your system.'
})
const showAssets = computed(() => {
if (!rec.value || !d.value || status.value !== 'ready') return []
const libationAssets = cachedAssets.value
const overrides = getPreviewOverrides()
const os = (overrides && overrides.os) || d.value.os
const arch = (overrides && overrides.architecture) || d.value.architecture
return libationAssets.filter((a) => {
const p = d.value.parseAssetFilename(a.name)
if (!p) return false
if (os === 'windows' || os === 'mac' || os === 'linux') {
if (p.platform !== os) return false
}
if (arch === 'x64' || arch === 'arm64') {
if (p.arch !== arch) return false
}
return true
})
})
watch(previewValue, () => updateRecommendation())
onMounted(() => {
d.value = getDetectOS()
fetch('https://api.github.com/repos/rmcrackan/Libation/releases/latest')
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Failed to load release'))))
.then((release) => {
cachedAssets.value = (release.assets || []).map((a) => ({
name: a.name,
url: a.browser_download_url,
}))
status.value = 'ready'
updateRecommendation()
})
.catch((err) => {
status.value = 'error'
errorMessage.value = err.message || 'Could not load latest release.'
})
})
</script>
<template>
<div class="download-widget-wrapper">
<div v-if="status === 'loading'" class="download-widget recommended">
Loading latest release
</div>
<div v-else-if="status === 'error'" class="download-widget recommended">
<p class="warn">{{ errorMessage }}</p>
</div>
<div v-else class="download-widget recommended">
<div class="block-head">
<svg class="icon-download" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span>{{ blockHead }}{{ rec?.windowsX64Choice ? ':' : rec?.recommended ? ':' : rec ? '.' : '' }}</span>
<select v-model="previewValue" aria-label="Find recommended download for" class="preview-select">
<option v-for="o in PREVIEW_OPTIONS" :key="o.value || 'current'" :value="o.value">
{{ o.label }}
</option>
</select>
</div>
<!-- Windows x64: Chardonnay + Classic -->
<template v-if="rec?.windowsX64Choice">
<ul class="widget-list">
<li><strong>Chardonnay</strong> (modern UI): <a :href="rec.windowsX64Choice.chardonnay.url" download>{{ rec.windowsX64Choice.chardonnay.name }}</a></li>
<li><strong>Classic</strong> (compact, screenreader-friendly): <a :href="rec.windowsX64Choice.classic.url" download>{{ rec.windowsX64Choice.classic.name }}</a></li>
</ul>
<p class="widget-link"><a :href="rec.classicVsChardonnayFaqUrl" target="_blank" rel="noopener">What's the difference between Classic and Chardonnay?</a></p>
<p v-if="showReasonWarn" class="warn">{{ rec.reason }}</p>
</template>
<!-- Single recommended -->
<template v-else-if="rec?.recommended">
<p class="widget-download"><a :href="rec.recommended.url" download>{{ rec.recommended.name }}</a></p>
<p v-if="installGuideUrl" class="widget-link">
<a :href="installGuideUrl" target="_blank" rel="noopener">{{ installGuideLabel }}</a>
</p>
<p v-if="showReasonWarn" class="warn">{{ rec.reason }}</p>
<div v-if="rec.alternatives?.length" class="alternatives">
{{ rec.alternatives.length === 1 ? 'Another option:' : 'Other options:' }}
<ul>
<li v-for="a in rec.alternatives" :key="a.name">
<a :href="a.url" download>{{ a.name }}</a>
</li>
</ul>
</div>
</template>
<!-- No match -->
<template v-else-if="rec">
<p class="warn">{{ rec.reason || 'Could not recommend a download.' }}</p>
<div class="alternatives">
Available:
<ul>
<li v-for="a in showAssets" :key="a.name">
<a :href="a.url" download>{{ a.name }}</a>
</li>
</ul>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.download-widget-wrapper {
margin: 1.5rem auto;
max-width: 42rem;
}
.download-widget.recommended {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
padding: 0.75rem 1rem;
border-radius: 8px;
font-weight: 600;
}
.download-widget.recommended a {
color: var(--vp-c-brand-1);
text-decoration: underline;
}
.download-widget.recommended a:hover {
text-decoration: underline;
opacity: 0.8;
}
.block-head {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.block-head .icon-download {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
color: var(--vp-c-brand-1);
}
.preview-select {
margin-left: auto;
font-size: 0.85rem;
padding-right: 1.75rem;
padding-left: 0.5rem;
appearance: none;
background-color: var(--vp-c-bg-soft);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.35rem center;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
cursor: pointer;
}
.widget-list {
margin: 0 0 0 1rem;
padding-left: 0.5rem;
}
.widget-list li strong {
font-weight: 700;
}
.widget-download {
margin: 0.25rem 0;
}
.widget-link {
margin: 0.5rem 0 0;
}
.warn {
background: var(--vp-custom-block-warning-bg);
color: var(--vp-custom-block-warning-text);
padding: 0.5rem 0.75rem;
border-radius: 6px;
margin: 0.5rem 0 0;
font-size: 0.9rem;
font-weight: normal;
}
.alternatives {
font-size: 0.9rem;
font-weight: normal;
color: var(--vp-c-text-2);
margin-top: 0.5rem;
}
.alternatives ul {
margin: 0.25rem 0 0 1rem;
padding-left: 0.5rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup>
import { ref, onMounted, defineAsyncComponent } from 'vue'
import DefaultTheme from 'vitepress/theme'
import { useData } from 'vitepress'
const { frontmatter } = useData()
const { Layout: DefaultLayout } = DefaultTheme
const mounted = ref(false)
onMounted(() => { mounted.value = true })
// Load widget only on client so download-detection.js (navigator) never runs during SSR
const DownloadSuggestionWidget = defineAsyncComponent(() =>
import('./DownloadSuggestionWidget.vue')
)
</script>
<template>
<DefaultLayout>
<template #home-hero-after>
<DownloadSuggestionWidget v-if="frontmatter.layout === 'home' && mounted" />
</template>
</DefaultLayout>
</template>
+69
View File
@@ -0,0 +1,69 @@
/* 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 */
/* Hide "Issues & Requests" and "Donate" links on tablet */
.VPNav .VPNavBar .nav .VPNavBarMenu .VPMenu:nth-child(3) {
display: none;
}
/* Alternative: Use a more specific selector if needed */
.VPNavBarMenuLink[href*="issues"] {
display: none;
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Client-side OS and system detection for download recommendation.
* Uses navigator.userAgent and (when available) navigator.userAgentData.
* Call getDetectOS() in the browser only (e.g. in Vue onMounted).
*/
export function getDetectOS() {
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
const uaData = typeof navigator !== 'undefined' && navigator.userAgentData
function getOS() {
if (uaData?.platform) {
const p = uaData.platform.toLowerCase()
if (p === 'windows') return 'windows'
if (p === 'macos') return 'mac'
if (p === 'linux') return 'linux'
if (p === 'android') return 'android'
if (p === 'ios') return 'ios'
}
if (/\bWindows\b/i.test(ua)) return 'windows'
if (/\bMac\b/i.test(ua) || /\bMacintosh\b/i.test(ua)) return 'mac'
if (/\bLinux\b/i.test(ua) && !/\bAndroid\b/i.test(ua)) return 'linux'
if (/\bAndroid\b/i.test(ua)) return 'android'
if (/\b(iPhone|iPad|iPod)\b/i.test(ua)) return 'ios'
return 'other'
}
function getOSVersion() {
const os = getOS()
if (os === 'windows') {
const ntMatch = ua.match(/Windows NT (\d+\.\d+)/)
if (ntMatch) {
const nt = ntMatch[1]
if (nt === '10.0') return '10 or 11 (NT 10.0)'
if (nt === '6.3') return '8.1'
if (nt === '6.2') return '8'
if (nt === '6.1') return '7'
return `NT ${nt}`
}
return null
}
if (os === 'mac') {
const macMatch = ua.match(/Mac OS X (\d+[._]\d+(?:[._]\d+)?)/)
return macMatch ? macMatch[1].replace(/_/g, '.') : null
}
if (os === 'linux') {
const linuxMatch = ua.match(/Linux ([^\s;)]+)/)
return linuxMatch ? linuxMatch[1] : null
}
if (os === 'android') {
const m = ua.match(/Android (\d+(?:\.\d+)*)/)
return m ? m[1] : null
}
if (os === 'ios') {
const m = ua.match(/OS (\d+[._]\d+(?:[._]\d+)?)/)
return m ? m[1].replace(/_/g, '.') : null
}
return null
}
function getArchitecture() {
if (uaData?.architecture) {
const a = uaData.architecture.toLowerCase()
if (a === 'x86' || a === 'amd64') return a === 'amd64' ? 'x64' : 'x86'
if (a === 'arm' || a === 'arm64' || a === 'aarch64') return a === 'aarch64' ? 'arm64' : a
return a
}
if (/\b(aarch64|arm64|ARM64)\b/i.test(ua)) return 'arm64'
if (/\b(arm|aarch32)\b/i.test(ua) && !/arm64/i.test(ua)) return 'arm'
if (/\b(Wow64|Win64|x64|x86_64|amd64)\b/i.test(ua)) return 'x64'
if (/\b(WOW32|Win32|x86)\b/i.test(ua)) return 'x86'
return 'other'
}
function getLinuxFlavor() {
if (getOS() !== 'linux') return null
if (/\bUbuntu\b/i.test(ua)) return 'Ubuntu'
if (/\bFedora\b/i.test(ua)) return 'Fedora'
if (/\bDebian\b/i.test(ua)) return 'Debian'
if (/\bLinux Mint\b/i.test(ua)) return 'Linux Mint'
if (/\bOpenSUSE\b/i.test(ua)) return 'OpenSUSE'
if (/\bArch\b/i.test(ua)) return 'Arch'
if (/\bCentOS\b/i.test(ua)) return 'CentOS'
if (/\bRed Hat\b/i.test(ua)) return 'Red Hat'
if (/\bChrome OS\b/i.test(ua) || /\bCrOS\b/i.test(ua)) return 'Chrome OS'
return null
}
function parseAssetFilename(name) {
if (!name || typeof name !== 'string') return null
const lower = name.toLowerCase()
let platform = null
if (lower.includes('windows')) platform = 'windows'
else if (lower.includes('macos') || (lower.includes('mac') && !lower.includes('macos'))) platform = 'mac'
else if (lower.includes('linux')) platform = 'linux'
if (!platform) return null
let arch = null
if (lower.includes('arm64') || lower.includes('aarch64')) arch = 'arm64'
else if (lower.includes('amd64') || lower.includes('x64')) arch = 'x64'
else if (lower.includes('x86') || lower.includes('i386')) arch = 'x86'
if (!arch) return null
let packageType = null
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 }
}
function recommendDownload(assets, overrides) {
const os = (overrides && overrides.os != null) ? overrides.os : getOS()
const arch = (overrides && overrides.architecture != null) ? overrides.architecture : getArchitecture()
const linuxFlavor = (overrides && overrides.linuxFlavor !== undefined) ? overrides.linuxFlavor : getLinuxFlavor()
let reason = ''
if (!Array.isArray(assets) || assets.length === 0) {
return { recommended: null, alternatives: [], reason: reason || 'No assets provided.' }
}
const wantArch = (arch === 'x64' || arch === 'x86') ? 'x64' : (arch === 'arm64' || arch === 'arm') ? 'arm64' : null
const wantOs = os === 'windows' ? 'windows' : os === 'mac' ? 'mac' : os === 'linux' ? 'linux' : null
const preferDeb = linuxFlavor === 'Ubuntu' || linuxFlavor === 'Debian' || linuxFlavor === 'Linux Mint' || linuxFlavor === 'Chrome OS'
const preferRpm = linuxFlavor === 'Fedora' || linuxFlavor === 'Red Hat' || linuxFlavor === 'CentOS' || linuxFlavor === 'OpenSUSE'
const parsed = []
for (let i = 0; i < assets.length; i++) {
const a = assets[i]
const name = a && (a.name != null ? a.name : a)
const url = a && a.url
const p = parseAssetFilename(name)
if (!p) continue
parsed.push({ name, url, parsed: p })
}
if (parsed.length === 0) {
return { recommended: null, alternatives: [], reason: reason || 'No recognizable asset filenames.' }
}
function score(entry) {
const p = entry.parsed
const nameLower = (entry.name || '').toLowerCase()
let s = 0
if (wantOs && p.platform === wantOs) s += 100
else if (wantOs) s -= 50
if (wantArch && p.arch === wantArch) s += 50
else if (wantArch) s -= 30
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
}
parsed.sort((a, b) => score(b) - score(a))
const best = parsed[0]
const bestScore = score(best)
const recommended = bestScore >= 50 ? { name: best.name, url: best.url } : null
let altCandidates = parsed.slice(1, 4)
if (wantOs) altCandidates = altCandidates.filter(e => e.parsed.platform === wantOs)
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
let winClassic = null
for (let j = 0; j < parsed.length; j++) {
const e = parsed[j]
if (e.parsed.platform !== 'windows' || e.parsed.arch !== 'x64') continue
const n = (e.name || '').toLowerCase()
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 (!recommended && wantOs) {
reason = 'No asset matched your system (OS: ' + os + ', arch: ' + arch + '). Try choosing a download manually.'
} else if (recommended && wantOs && !wantArch) {
reason = 'Matched your OS; architecture could not be detected. If this download is wrong, pick another (e.g. arm64 vs x64).'
}
return {
recommended,
alternatives,
reason: reason || (recommended ? 'Recommended for your system.' : ''),
windowsX64Choice,
classicVsChardonnayFaqUrl: windowsX64Choice ? classicVsChardonnayFaqUrl : null,
}
}
return {
os: getOS(),
osVersion: getOSVersion(),
architecture: getArchitecture(),
linuxFlavor: getLinuxFlavor(),
getOS,
getOSVersion,
getArchitecture,
getLinuxFlavor,
parseAssetFilename,
recommendDownload,
}
}
+8
View File
@@ -0,0 +1,8 @@
import DefaultTheme from 'vitepress/theme'
import Layout from './Layout.vue'
import './custom.css'
export default {
extends: DefaultTheme,
Layout,
}
+32
View File
@@ -0,0 +1,32 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console) Windows",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": ".NET Core Launch (console) Linux",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build_linux",
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
}
]
}
+59
View File
@@ -0,0 +1,59 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "build",
"dependsOn": [
"build_libation",
"build_linuxconfigapp"
]
},
{
"label": "build_libation",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
"label": "build_linuxconfigapp",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
"label": "build_linux",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj",
"-p:TargetFramework=net9.0",
"-p:TargetFrameworks=net9.0",
"-p:RuntimeIdentifier=linux-x64"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
}
]
}
+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
+13
View File
@@ -0,0 +1,13 @@
<Project>
<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>
+3
View File
@@ -0,0 +1,3 @@
{
"LibationFiles": "/config-internal"
}
+233
View File
@@ -0,0 +1,233 @@
#!/bin/bash
error() {
log "ERROR" "$1"
}
warn() {
log "WARNING" "$1"
}
info() {
log "info" "$1"
}
debug() {
if [ "${LOG_LEVEL}" = "debug" ]; then
log "debug" "$1"
fi
}
log() {
LEVEL=$1
MESSAGE=$2
printf "$(date '+%F %T') %s: %s\n" "${LEVEL}" "${MESSAGE}"
}
init_config_file() {
FILE=$1
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
if [ -f ${FULLPATH} ]; then
info "loading ${FILE}"
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
return 0
else
warn "${FULLPATH} not found, creating empty file"
echo "{}" > ${LIBATION_CONFIG_INTERNAL}/${FILE}
return 1
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
VALUE=$3
info "setting ${KEY} to ${VALUE}"
echo $(jq --arg k "${KEY}" --arg v "${VALUE}" '.[$k] = $v' ${LIBATION_CONFIG_INTERNAL}/${FILE}) > ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp
mv ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp ${LIBATION_CONFIG_INTERNAL}/${FILE}
}
is_mounted() {
DIR=$1
if grep -qs "${DIR} " /proc/mounts;
then
return 0
else
return 1
fi
}
create_db() {
DBFILE=$1
if [ -f "${DBFILE}" ]; then
warn "prexisting database found when creating"
return 0
else
if ! touch "${DBFILE}"; then
error "unable to create database, check permissions on host"
exit 1
fi
return 1
fi
}
setup_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 ]]
then
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}'"
if [[ -f $FILE ]]
then
info "database found at ${FILE}"
elif [[ $LIBATION_CREATE_DB == "true" ]]
then
warn "database not found, creating one at ${FILE}"
create_db "${FILE}"
else
error "database not found and creation is disabled"
exit 1
fi
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
}
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
info "loading database"
# If user provides a separate database mount, use that
if is_mounted "${LIBATION_DB_DIR}";
then
DB_LOCATION=${LIBATION_DB_DIR}
# Otherwise, use the config directory
else
DB_LOCATION=${LIBATION_CONFIG_DIR}
fi
setup_db ${DB_LOCATION}
# Try to warn if books dir wasn't mounted in
if ! is_mounted "${LIBATION_BOOKS_DIR}";
then
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
fi
if [ "${SLEEP_TIME}" == -1 ]; then
info "running once"
else
info "running every ${SLEEP_TIME}"
fi
# loop
while true
do
run
# Liberate only once if SLEEP_TIME was set to -1
if [ "${SLEEP_TIME}" == -1 ]; then
break
fi
sleep "${SLEEP_TIME}"
done
info "exiting"
}
main "$@"
+44
View File
@@ -0,0 +1,44 @@
# Dockerfile
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
COPY Source /Source
RUN dotnet publish \
/Source/LibationCli/LibationCli.csproj \
--os linux \
--arch ${TARGETARCH} \
--configuration Release \
--output /Source/bin/Publish/Linux-chardonnay \
-p:PublishProtocol=FileSystem \
-p:PublishReadyToRun=true \
-p:SelfContained=true
FROM mcr.microsoft.com/dotnet/runtime:10.0
ARG USER_UID=1001
ARG USER_GID=1001
# Set the character set that will be used for folder and filenames when liberating
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV SLEEP_TIME=-1
ENV LIBATION_CONFIG_INTERNAL=/config-internal
ENV LIBATION_CONFIG_DIR=/config
ENV LIBATION_DB_DIR=/db
ENV LIBATION_DB_FILE=
ENV LIBATION_CREATE_DB=true
ENV LIBATION_BOOKS_DIR=/data
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y jq && \
mkdir -m777 ${LIBATION_CONFIG_INTERNAL} ${LIBATION_BOOKS_DIR}
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}
ENTRYPOINT ["/libation/liberate.sh"]
+2 -78
View File
@@ -1,79 +1,3 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
# This page has been moved to https://getlibation.com/docs/advanced/advanced
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Advanced: Table of Contents
- [Files and folders](#files-and-folders)
- [Linux and Mac (unofficial)](#linux-and-mac)
- [Settings](#settings)
- [Custom File Naming](#custom-file-naming)
- [Command Line Interface](#command-line-interface)
### Files and folders
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
* In Libation's initial folder are the files that make up the program. Since nothing else is here, just copy new files here to upgrade the program. Delete this folder to delete Libation.
* In a separate folder, Libation keeps track of all of the files it creates like settings and downloaded images. After an upgrade, Libation might think that's its being run for the first time. Just click ADVANCED SETUP and point to this folder. Libation will reload your library and settings.
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Linux and Mac
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592), [Linux and WINE](https://github.com/rmcrackan/Libation/issues/28#issuecomment-1161111014))
### Settings
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
### Custom File Naming
In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
These templates apply to GUI and CLI.
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
```
help
libationcli --help
verb-specific help
libationcli scan --help
scan all libraries
libationcli scan
scan only libraries for specific accounts
libationcli scan nickname1 nickname2
convert all m4b files to mp3
libationcli convert
liberate all books and pdfs
libationcli liberate
liberate pdfs only
libationcli liberate --pdf
libationcli liberate -p
export library to file
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
```
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/advanced/advanced.md))
+3
View File
@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/features/audio-file-formats
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/audio-file-formats.md))
+3
View File
@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/installation/docker
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/docker.md))
@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/frequently-asked-questions
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/frequently-asked-questions.md))
+2 -137
View File
@@ -1,138 +1,3 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
# This page has been moved to https://getlibation.com/docs/getting-started
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Getting started: Table of Contents
- [Download Libation](#download-libation-1)
- [Installation](#installation)
- [Create Accounts](#create-accounts)
- [Import your library](#import-your-library)
- [Download your books -- DRM-free!](#download-your-books----drm-free)
- [Download PDF attachments](#download-pdf-attachments)
- [Details of downloaded files](#details-of-downloaded-files)
- [Export your library](#export-your-library)
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
### Installation
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
### Create Accounts
Create your account(s):
![Create your accounts, menu](images/v40_accounts.png)
New locale options include many more regions including old audible accounts which pre-date the amazon acquisition
![Choose your account locales](images/v40_locales.png)
### Import your library
Be default, Libation will periodically scan the accounts you added above with a checkbox next to them. Nothing for you to do. You can also scan manually.
Select Import > Scan Library:
![Import step 1](images/Import1.png)
Or if you have multiple accounts, you'll get to choose whether to scan all accounts or just the ones you select:
![Import which accounts](images/v40_import.png)
If this is a new installation, or you're scanning an account you haven't scanned before, you'll be prompted to enter your password for the Audible account.
![Login password](images/alt-login1.png)
Enter the password and click Submit. Audible will prompt you with a CAPTCHA image.
![Login captcha](images/alt-login2.png)
Enter the CAPTCHA answer characters and click Submit. If all has gone well, Libation will start scanning the account.
In rare instances, the Captcha image/response will fail in an endless loop. If this happens, delete the problem account, and then click Save. Re-add the account and click Save again. Now try to scan the account again. This time, instead of typing your password, click the link that says "Or click here". This will open the Audible External Login dialog shown below.
![Login alternative setup](images/alt-login3.png)
You can either copy the URL shown and paste it into your browser or launch the browser directly by clicking Launch in Browser. Audible will display its standard login page. Login, including answering the CAPTCHA on the next page. In some cases, you might have to approve the login from the email account associated with that login, but once the login is successful, you'll see an error message.
![Login alternative login result](images/alt-login4.png)
This actually means you've successfully logged in. Copy the entire URL shown in your browser and return to Libation. Paste that URL into the text box at the bottom of the Audible External Login window and click Submit.
You'll see this window while it's scanning:
![Import step 2](images/Import2.png)
Success! We see how many new titles are imported:
![Import step 3](images/Import3.png)
### Download your books -- DRM-free!
Automatically download some or all of your audible books. This shows you how much of your library is not yet downloaded and decrypted:
The stoplights will tell you a title's status:
* Green: downloaded and decrypted
* Yellow: downloaded but still encrypted with DRM
* Red: not downloaded
* PDF icon without arrow: downloaded
* PDF with arrow: not downloaded
Or hover over the button to see the status.
![Liberate book step 1](images/LiberateBook1.png)
Select Liberate > Begin Book Backups
You can also click on the stop light to download only that title and its PDF
![Liberate book step 2](images/LiberateBook2.png)
First the original book with DRM is downloaded
![Liberate book step 3](images/LiberateBook3.png)
Then it's decrypted so you can use it on any device you choose. The very first time you decrypt a book, this step will take a while. Every other book will go much faster. The first time, Libation has to figure out the special decryption key which allows your personal books to be unlocked.
![Liberate book step 4](images/LiberateBook4.png)
And voila! If you have multiple books not yet liberated, Libation will automatically move on to the next.
![Liberate book step 5](images/LiberateBook5.png)
The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Download PDF attachments
For books which include PDF downloads, Libation can download these for you as well and will attempt to store them with the book. "Book backup" will already download an available PDF. This additional option is useful when Audible adds a PDF to your book after you've already backed it up.
Select Liberate > Begin PDF Backups
![PDF download step 2](images/PdfDownload2.png)
The downloads work just like with books, only with no additional decryption needed.
![PDF download step 3](images/PdfDownload3.png)
### Details of downloaded files
![Post download](images/PostDownload.png)
When you set up Libation, you'll specify a Books directory. Libation looks inside that directory and all subdirectories to look for files or folders with each library book's audible id. This way, organization is completely up to you. When you download + decrypt a book, you get several files
* .m4b: your audiobook in m4b format. This is the most pure version of your audiobook and retains the highest quality. Now that it's decrypted, you can play it on any audio player and put it on any device. If you'd like, you can also use 3rd party tools to turn it into an mp3. The freedom to do what you want with your files was the original inspiration for Libation.
* .cue: this is a file which logs where chapter breaks occur. Many tools are able to use this if you want to split your book into files along chapter lines.
### Export your library
![Export](images/Export.png)
Export your library to Excel, CSV, or JSON
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/getting-started.md))
+3
View File
@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/installation/linux
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/linux.md))
+3
View File
@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/installation/mac
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/mac.md))
@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/development/nix-linux-setup
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/development/nix-linux-setup.md))
+3
View File
@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/features/naming-templates
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/naming-templates.md))
+2 -78
View File
@@ -1,79 +1,3 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
# This page has been moved to https://getlibation.com/docs/features/searching-and-filtering
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Searching and filtering: Table of Contents
- [Tags](#tags)
- [Searches](#searches)
- [Search examples](#search-examples)
- [Filters](#filters)
### Tags
To add tags to a title, click the tags button
![Tags step 1](images/Tags1.png)
Add as many tags as you'd like. Tags are separated by a space. Each tag can contain letters, numbers, and underscores
![Tags step 2](images/Tags2.png)
Tags are saved non-case specific for easy search. There is one special tag "hidden" which will also grey-out the book
![Tags step 3](images/Tags3.png)
To edit tags, just click the button again.
### Searches
Libation's advanced searching is built on the powerful Lucene search engine. Simple searches are effortless and powerful searches are simple. To search, just type and click Filter or press enter
* Type anything in the search box to search common fields: title, authors, narrators, and the book's audible id
* Use Lucene's "Query Parser Syntax" for advanced searching.
* Easy tutorial: http://www.lucenetutorial.com/lucene-query-syntax.html
* Full official guide: https://lucene.apache.org/core/2_9_4/queryparsersyntax.html
* Tons of search fields, specific to audiobooks
* Synonyms so you don't have to memorize magic words. Eg: author and author**s** will both work
* Click [?] button for a full list of search fields and synonyms ![Filter options](images/FilterOptionsButton.png)
* Search by tag like \[this\]
* When tags have an underscore you can use part of the tag. This is useful for quick categories. The below examples make this more clear.
### Search examples
Search for anything with the word potter
![Search example: potter](images/SearchExamplePotter.png)
If you only want to see Harry Potter
![Search example: "harry potter"](images/SearchExampleHarryPotter.png)
If you only want to see potter except for Harry Potter. You can also use "-" instead of "NOT"
![Search example: "potter NOT harry"](images/SearchExamplePotterNotHarry.png)
![Search example: "potter -harry"](images/SearchExamplePotterNotHarry2.png)
To see only books written by Neil Gaiman where he also narrates his own book. (If you don't include AND, you'll see everything written by Neil Gaiman and also all books in your library which are self-narrated.)
![Search example: author:gaiman AND authornarrated](images/SearchExampleGaimanAuthorNarrated.png)
I tagged autobiographies as auto_bio and biographies written by someone else as bio. I can get only autobiographies with \[auto_bio\] or get both by searching \[bio\]
![Search example: \[bio\]](images/SearchExampleBio.png)
![Search example: \[auto_bio\]](images/SearchExampleAutoBio.png)
### Filters
If you have a search you want to save, click Add To Quick Filters to save it in your Quick Filters list. To use it again, select it from the Quick Filters list.
To edit this list go to Quick Filters > Edit quick filters. Here you can re-order the list, delete filters, double-click a filter to edit it, or double-click the bottom blank box to add a new filter.
Check "Quick Filters > Start Libation with 1st filter Default" to have your top filter automatically applied when Libation starts. In this top example, I want to always start without these: at books I've tagged hidden, books I've tagged as free_audible_originals, and books which I have rated.
![default filters](images/FiltersDefault.png)
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/searching-and-filtering.md))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512px" enable-background="new 0 0 512 512">
<path id="slosh" transform=
"translate(-50 23)
scale(0.7, 0.7)
rotate(12 256,256)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
<use href="#slosh" transform="translate(512 0) scale(-1 1)" />
</svg>

After

Width:  |  Height:  |  Size: 736 B

+41
View File
@@ -0,0 +1,41 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<defs>
<g id="glass">
<path transform="translate(16 16)" fill-rule="evenodd" d=
"M177,16
H79
A 32.0781 63.7932 -1.5106 0 0 66 80
A 158.789 471.1259 41.9466 0 0 90 131
A 81.7197 122.0515 35.3745 0 0 128 143.3484
A 81.7197 122.0515 -35.3745 0 0 166 131
A 158.789 471.1259 -41.9466 0 0 190 80
A 32.0781 63.7932 1.5106 0 0 177 16
L 184 0
A 44.7901 78.5247 1.1521 0 1 194 122
A 97.0039 135.3148 -36.2124 0 1 136 159
V 240
H 176
A 8 8 0 0 1 176 256
H 80
A 8 8 0 0 1 80 240
H 120
V 159
A 97.0039 135.3148 36.2124 0 1 62 122
A 44.7901 78.5247 -1.1521 0 1 72 0
H184
z"/>
</g>
<g transform="translate(16 16)" id="wine-level">
<path d=
"M182,64
H 74
A 115.9979 308.8033 38.9474 0 0 128 134.4277
A 115.9979 308.8033 -38.9474 0 0 182,64
z"/>
</g>
</defs>
<use href="#glass" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#wine-level" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#glass" fill="Black" />
<use href="#wine-level" fill="Black" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<g>
<path transform="rotate(90 128,128) translate(60 -16)" fill-rule="evenodd" d=
"M177,16
H79
A 32.0781 63.7932 -1.5106 0 0 66 80
A 158.789 471.1259 41.9466 0 0 90 131
A 81.7197 122.0515 35.3745 0 0 128 143.3484
A 81.7197 122.0515 -35.3745 0 0 166 131
A 158.789 471.1259 -41.9466 0 0 190 80
A 32.0781 63.7932 1.5106 0 0 177 16
L 184 0
A 44.7901 78.5247 1.1521 0 1 194 122
A 97.0039 135.3148 -36.2124 0 1 136 159
V 240
H 176
A 8 8 0 0 1 176 256
H 80
A 8 8 0 0 1 80 240
H 120
V 159
A 97.0039 135.3148 36.2124 0 1 62 122
A 44.7901 78.5247 -1.1521 0 1 72 0
H184
M170,115
V24
A 19.5181 45.9183 -3.3549 0 1 182.4322 69.5
A 19.5181 45.9183 3.3549 0 1 170 115
z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 936 B

+33
View File
@@ -0,0 +1,33 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
<path
transform=
"rotate(15 256,256)
translate(0 25)
scale(0.93, 0.93)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
</svg>

After

Width:  |  Height:  |  Size: 649 B

+41 -52
View File
@@ -1,70 +1,59 @@
# Libation: Liberate your Library
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
**Libation** is a free, open-source application for downloading and managing your Audible audiobooks. It decrypts your library, removes DRM, and lets you own your audiobooks forever.
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
> <a href="https://getlibation.com"><img src=".github/download-icon.svg" width="20" height="20" alt="" /></a> **[Which version should I download?](https://getlibation.com)** — get a recommended download for your system on our site.
# Table of Contents
## Features
- [Audible audiobook manager](#audible-audiobook-manager)
- [The good](#the-good)
- [The bad](#the-bad)
- [The ugly](#the-ugly)
- [Getting started](Documentation/GettingStarted.md)
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
- [Installation](Documentation/GettingStarted.md#installation)
- [Installation on Ubuntu](Source/LibationAvalonia/README.md)
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
- [Import your library](Documentation/GettingStarted.md#import-your-library)
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
- [Export your library](Documentation/GettingStarted.md#export-your-library)
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
- [Tags](Documentation/SearchingAndFiltering.md#tags)
- [Searches](Documentation/SearchingAndFiltering.md#searches)
- [Search examples](Documentation/SearchingAndFiltering.md#search-examples)
- [Filters](Documentation/SearchingAndFiltering.md#filters)
- [Advanced](Documentation/Advanced.md)
- [Files and folders](Documentation/Advanced.md#files-and-folders)
- [Linux and Mac (unofficial)](Documentation/Advanced.md#linux-and-mac)
- [Settings](Documentation/Advanced.md#settings)
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- **Unlock Your Library**: Download and remove DRM from your audiobooks.
- **Cross-Platform**: Fully supported on Windows, macOS, and Linux.
- **Region Support**: Works with Audible regions US, UK, Canada, Germany, France, Australia, Japan, India, and Spain.
- **Advanced Organization**: Search, filter, and tag your books.
- **Fast & Efficient**: Powered by AAXClean for fast decryption without heavy dependencies like ffmpeg.
- **Import**: Easily import your existing library, including cover art.
## Getting started
## Getting started with Libation
All documentation has been moved to our new site: [getlibation.com](https://getlibation.com). Or jump to the important bits:
* [Getting Started](https://getlibation.com/docs/getting-started)
* [Download](https://github.com/rmcrackan/Libation/releases/latest)
* [Step-by-step walk-through](Documentation/GettingStarted.md)
* [Issues, bugs, and requests](https://github.com/rmcrackan/Libation/issues)
* [Documentation](https://getlibation.com/docs/index)
## Audible audiobook manager
## Development
### The good
Grab the latest release for your platform from the [Releases Page](https://github.com/rmcrackan/Libation/releases/latest).
* Import library from audible, including cover art
* Download and remove DRM from all books
* Download accompanying PDFs
* Add tags to books for better organization
* Powerful advanced search built on the Lucene search engine
* Customizable saved filters for common searches
* Open source
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
## Documentation
<a name="theBad"/>
Comprehensive documentation is available in the `docs` directory and on our [Documentation Site](https://getlibation.com/docs).
### The bad
- [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)
* Windows only
* Large file size
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
## Development & Contributing
### The ugly
We welcome contributions!
* Documentation? Yer lookin' at it
* This is a single-developer personal passion project. Support, response, updates, enhancements, bug fixes etc are as my free time allows
* I have a full-time job, a life, and a finite attention span. Therefore a lot of time can potentially go by with no improvements of any kind
- **[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.
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
## Community & Support
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
- **[Issues](https://github.com/rmcrackan/Libation/issues)**: Report bugs or request features.
- **[Donate](https://getlibation.com/donate)**: Support the project if you find it useful.
## License
Libation is released under the GPL-3.0 License
---
If you found this useful, tell a friend. If you found this REALLY useful, you can [donate here](https://getlibation.com/donate)
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation Linux bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z "$ARCH" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb
FOLDER_EXEC=$DEB_DIR/usr/lib/libation
echo "Exec dir: $FOLDER_EXEC"
mkdir -p $FOLDER_EXEC
echo "Moving bins from $BIN_DIR to $FOLDER_EXEC"
mv "${BIN_DIR}/"* $FOLDER_EXEC
if [ $? -ne 0 ]
then echo "Error moving ${BIN_DIR} files"
exit
fi
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $FOLDER_EXEC/$n
done
FOLDER_ICON=$DEB_DIR/usr/share/icons/hicolor/scalable/apps/
echo "Icon dir: $FOLDER_ICON"
FOLDER_DESKTOP=$DEB_DIR/usr/share/applications
echo "Desktop dir: $FOLDER_DESKTOP"
FOLDER_DEBIAN=$DEB_DIR/DEBIAN
echo "Debian dir: $FOLDER_DEBIAN"
mkdir -p $FOLDER_ICON
mkdir -p $FOLDER_DESKTOP
mkdir -p $FOLDER_DEBIAN
echo "Copying icon..."
cp $FOLDER_EXEC/libation_glass.svg $FOLDER_ICON/libation.svg
echo "Copying desktop file..."
cp $FOLDER_EXEC/Libation.desktop $FOLDER_DESKTOP/Libation.desktop
echo "Creating pre-install file..."
echo "#!/bin/bash
# Pre-install script, removes previous installation program files and sym links
echo \"Removing previously created symlinks...\"
rm /usr/bin/libation
rm /usr/bin/hangover
rm /usr/bin/libationcli
echo \"Removing previously installed Libation files...\"
rm -r /usr/lib/libation
# making sure it won't stop installation
exit 0
" >> $FOLDER_DEBIAN/preinst
echo "Creating post-install file..."
echo "#!/bin/bash
gtk-update-icon-cache -f /usr/share/icons/hicolor/
ln -s /usr/lib/libation/Libation /usr/bin/libation
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
# Increase the maximum number of inotify instances
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
" >> $FOLDER_DEBIAN/postinst
echo "Creating control file..."
echo "Package: Libation
Version: $VERSION
Architecture: $ARCH
Essential: no
Priority: optional
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
Recommends: libgtk-3-0, libwebkit2gtk-4.1-0
" >> $FOLDER_DEBIAN/control
echo "Changing permissions for pre- and post-install files..."
chmod +x "$FOLDER_DEBIAN/preinst"
chmod +x "$FOLDER_DEBIAN/postinst"
if [ "$(uname -s)" == "Darwin" ]; then
echo "macOS detected, installing dpkg"
brew install dpkg
fi
DEB_FILE=Libation.${VERSION}-linux-chardonnay-${ARCH}.deb
echo "Creating $DEB_FILE"
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
echo "moving to ./bundle/$DEB_FILE"
mkdir bundle
mv $DEB_FILE ./bundle/$DEB_FILE
rm -r "$BIN_DIR"
echo "Done!"
+174
View File
@@ -0,0 +1,174 @@
#!/bin/bash
set -euo pipefail
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
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 1
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit 1
fi
if [ -z "${VERSION:-}" ]
then
echo "This script must be called with the Libation version number as an argument."
exit 1
fi
if [ -z "${ARCH:-}" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit 1
fi
if [ "$SIGN_WITH_KEY" != "true" ]
then
echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
fi
BUNDLE=./Libation.app
echo "Bundle dir: $BUNDLE"
if [[ -d $BUNDLE ]]
then
echo "$BUNDLE directory already exists, aborting."
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"
BUNDLE_RESOURCES=$BUNDLE_CONTENTS/Resources
echo "Resources dir: $BUNDLE_RESOURCES"
BUNDLE_MACOS=$BUNDLE_CONTENTS/MacOS
echo "MacOS dir: $BUNDLE_MACOS"
mkdir -p $BUNDLE_CONTENTS
mkdir -p $BUNDLE_RESOURCES
mkdir -p $BUNDLE_MACOS
mv "${BIN_DIR}/"* $BUNDLE_MACOS
if [ $? -ne 0 ]; then
echo "Error moving ${BIN_DIR} files"
exit 1
fi
echo "Make fileicon executable..."
chmod +x $BUNDLE_MACOS/fileicon
echo "Moving icon..."
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
echo "Moving background.png file..."
mv $BUNDLE_MACOS/background.png ./background.png
echo "Moving background.png file..."
mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
echo "Set LSArchitecturePriority to $PLIST_ARCH"
# Portable sed -i (BSD sed on macOS requires backup arg; use .bak then remove)
sed -i.bak "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak
echo "Set CFBundleVersion to $VERSION"
sed -i.bak "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $BUNDLE_MACOS/$n
done
DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
all_identities=$(security find-identity -v -p codesigning)
identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing executables in: $BUNDLE"
codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE"
codesign --verify --verbose "$BUNDLE"
else
echo "Signing with empty key: $BUNDLE"
codesign --force --deep -s - $BUNDLE
fi
echo "Creating app disk image: $DMG_FILE"
mkdir Libation
mv $BUNDLE ./Libation/$BUNDLE
mv Libation_DS_Store Libation/.DS_Store
mkdir Libation/.background
mv background.png Libation/.background/
ln -s /Applications "./Libation/ "
mkdir ./bundle
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.
# - Copy the .DS_Store from the directory and save it to Libation_DS_Store
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing $DMG_FILE"
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
fi
echo "Done!"
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation Linux bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z "$ARCH" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
BASEDIR=$(pwd)
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
if [[ "$ARCH" == "x64" ]]
then
ARCH_RPM="x86_64"
ARCH="amd64"
else
ARCH_RPM="aarch64"
fi
notinstalled=('libcoreclrtraceptprovider.so' 'libation_glass.svg' 'Libation.desktop')
mkdir -p ~/rpmbuild/SPECS
mkdir ~/rpmbuild/BUILD
mkdir ~/rpmbuild/RPMS
echo "Name: libation
Version: ${VERSION}
Release: 1
Summary: Liberate your Audible Library
License: GPLv3+
URL: https://github.com/rmcrackan/Libation
Source0: https://github.com/rmcrackan/Libation
Requires: bash gtk3 webkit2gtk4.1
%define __os_install_post %{nil}
%description
Liberate your Audible Library
%install
mkdir -p %{buildroot}%{_libdir}/%{name}
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/scalable/apps
mkdir -p %{buildroot}%{_datadir}/applications
if test -f 'libcoreclrtraceptprovider.so'; then
rm 'libcoreclrtraceptprovider.so'
fi
install -m 666 libation_glass.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/libation.svg
install -m 666 Libation.desktop %{buildroot}%{_datadir}/applications/Libation.desktop
rm libation_glass.svg
rm Libation.desktop
install * %{buildroot}%{_libdir}/%{name}/
%post
if [ \$1 -eq 1 ] ; then
# Initial installation
ln -s %{_libdir}/%{name}/Libation %{_bindir}/libation
ln -s %{_libdir}/%{name}/Hangover %{_bindir}/hangover
ln -s %{_libdir}/%{name}/LibationCli %{_bindir}/libationcli
gtk-update-icon-cache -f %{_datadir}/icons/hicolor/
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
fi
%postun
if [ \$1 -eq 0 ] ; then
# Uninstall
rm %{_bindir}/libation
rm %{_bindir}/hangover
rm %{_bindir}/libationcli
fi
%files
%{_datadir}/icons/hicolor/scalable/apps/libation.svg
%{_datadir}/applications/Libation.desktop" >> ~/rpmbuild/SPECS/libation.spec
cd "$BIN_DIR"
for f in *; do
if [[ " ${delfiles[*]} " =~ " ${f} " ]]; then
echo "Deleting $f"
elif [[ ! " ${notinstalled[*]} " =~ " ${f} " ]]; then
echo "%{_libdir}/%{name}/${f}" >> ~/rpmbuild/SPECS/libation.spec
cp $f ~/rpmbuild/BUILD/
else
cp $f ~/rpmbuild/BUILD/
fi
done
cd ~/rpmbuild/SPECS/
rpmbuild -bb --target $ARCH_RPM libation.spec
cd $BASEDIR
RPM_FILE=$(ls ~/rpmbuild/RPMS/${ARCH_RPM})
mkdir bundle
mv ~/rpmbuild/RPMS/${ARCH_RPM}/$RPM_FILE "./bundle/Libation.${VERSION}-linux-chardonnay-${ARCH}.rpm"
+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")));
}
+7 -2
View File
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -13,11 +14,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="0.2.12" />
<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>
+133 -90
View File
@@ -1,107 +1,150 @@
using System;
using AAXClean;
using Mpeg4Lib;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AAXClean;
using Dinah.Core.Net.Http;
namespace AaxDecrypter
namespace AaxDecrypter;
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
{
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
public event EventHandler<MetadataItems>? RetrievedMetadata;
public Mp4File? AaxFile { get; private set; }
protected Mp4Operation? AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions) { }
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt)
{
public event EventHandler<AppleTags> RetrievedMetadata;
base.SetCoverArt(coverArt);
if (coverArt is not null && AaxFile?.MetadataItems is not null)
AaxFile.MetadataItems.Cover = coverArt;
}
protected AaxFile AaxFile;
public override async Task CancelAsync()
{
await base.CancelAsync();
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
}
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt)
private Mp4File Open()
{
if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0)
throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file.");
else if (DownloadOptions.InputType is FileType.Dash)
{
base.SetCoverArt(coverArt);
if (coverArt is not null && AaxFile?.AppleTags is not null)
AaxFile.AppleTags.Cover = coverArt;
//We may have multiple keys , so use the key whose key ID matches
//the dash files default Key ID.
var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
var dash = new DashFile(InputFileStream);
if (dash.Tenc is null)
throw new InvalidOperationException("The DASH file does not contain 'tenc' box, indicating that it is unencrypted.");
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
if (kidIndex == -1)
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
keys[0] = keys[kidIndex];
var keyId = keys[kidIndex].KeyPart1;
var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
dash.SetDecryptionKey(keyId, key);
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
//Remove meta box containing DRM info
if (DownloadOptions.FixupFile && dash.Moov.GetChild<Mpeg4Lib.Boxes.MetaBox>() is { } meta)
dash.Moov.Children.Remove(meta);
return dash;
}
protected bool Step_GetMetadata()
else if (DownloadOptions.InputType is FileType.Aax)
{
AaxFile = new AaxFile(InputFileStream);
if (DownloadOptions.StripUnabridged)
{
AaxFile.AppleTags.Title = AaxFile.AppleTags.TitleSansUnabridged;
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
}
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate);
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
return !IsCanceled;
var aax = new AaxFile(InputFileStream);
var key = keys[0].KeyPart1;
aax.SetDecryptionKey(keys[0].KeyPart1);
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
return aax;
}
protected DownloadProgress Step_DownloadAudiobook_Start()
else if (DownloadOptions.InputType is FileType.Aaxc)
{
var zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = InputFileStream.Length
};
OnDecryptProgressUpdate(zeroProgress);
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
return zeroProgress;
var aax = new AaxFile(InputFileStream);
var key = keys[0].KeyPart1;
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
return aax;
}
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
void WriteKeyFile(string contents)
{
AaxFile.Close();
CloseInputFileStream();
OnDecryptProgressUpdate(zeroProgress);
}
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = AaxFile.Duration;
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = (e.ProcessPosition / e.TotalDuration);
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
}
public override async Task CancelAsync()
{
IsCanceled = true;
if (AaxFile != null)
await AaxFile.CancelAsync();
AaxFile?.Dispose();
CloseInputFileStream();
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
File.WriteAllText(keyFile, contents + Environment.NewLine);
OnTempFileCreated(new(keyFile));
}
}
protected bool Step_GetMetadata()
{
AaxFile = Open();
RetrievedMetadata?.Invoke(this, AaxFile.MetadataItems);
if (DownloadOptions.StripUnabridged)
{
AaxFile.MetadataItems.Title = AaxFile.MetadataItems.TitleSansUnabridged;
AaxFile.MetadataItems.Album = AaxFile.MetadataItems.Album?.Replace(" (Unabridged)", "");
}
if (DownloadOptions.FixupFile)
{
if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Narrator))
AaxFile.MetadataItems.AppleListBox.EditOrAddTag("©wrt", AaxFile.MetadataItems.Narrator);
if (!string.IsNullOrWhiteSpace(AaxFile.MetadataItems.Copyright))
AaxFile.MetadataItems.Copyright = AaxFile.MetadataItems.Copyright.Replace("(P)", "℗").Replace("&#169;", "©");
//Add audiobook shelf tags
//https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
const string tagDomain = "com.pilabor.tone";
AaxFile.MetadataItems.Title = DownloadOptions.Title;
if (DownloadOptions.Subtitle is string subtitle)
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
if (DownloadOptions.Publisher is string publisher)
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
if (DownloadOptions.Language is string language)
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
if (DownloadOptions.AudibleProductId is string asin)
{
AaxFile.MetadataItems.Asin = asin;
AaxFile.MetadataItems.AppleListBox.EditOrAddTag("asin", asin);
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
}
if (DownloadOptions.SeriesName is string series)
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
if (DownloadOptions.SeriesNumber is string part)
AaxFile.MetadataItems.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
}
OnRetrievedTitle(AaxFile.MetadataItems.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.MetadataItems.FirstAuthor);
OnRetrievedNarrators(AaxFile.MetadataItems.Narrator);
OnRetrievedCoverArt(AaxFile.MetadataItems.Cover);
OnInitialized();
return !IsCanceled;
}
protected virtual void OnInitialized() { }
}
+108 -178
View File
@@ -1,189 +1,119 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AAXClean;
using AAXClean;
using AAXClean.Codecs;
using FileManager;
using Mpeg4Lib;
using System;
using System.IO;
using System.Threading.Tasks;
namespace AaxDecrypter
namespace AaxDecrypter;
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
{
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
private FileStream? workingFileStream;
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions)
{
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
private List<string> multiPartFilePaths { get; } = new List<string>();
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
}
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
public override async Task<bool> RunAsync()
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
if (AaxFile is null)
throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}.");
if (DownloadOptions.LameConfig is null)
throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}.");
//Step 1
Serilog.Log.Information("Begin Get Aaxc Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Get Aaxc Metadata");
else
{
Serilog.Log.Information("Failed to Complete Get Aaxc Metadata");
return false;
}
//Step 2
Serilog.Log.Information("Begin Download Decrypted Audiobook");
if (await Step_DownloadAudiobookAsMultipleFilesPerChapter())
Serilog.Log.Information("Completed Download Decrypted Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Download Decrypted Audiobook");
return false;
}
//Step 3
Serilog.Log.Information("Begin Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Cleanup");
return false;
}
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
00:00:00 - 00:00:02 | Part 1
00:00:02 - 00:35:00 | Chapter 1
00:35:02 - 01:02:00 | Chapter 2
01:02:00 - 01:02:02 | Part 2
01:02:02 - 01:41:00 | Chapter 3
01:41:00 - 02:05:00 | Chapter 4
The book will be split into the following files:
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
*/
private async Task<bool> Step_DownloadAudiobookAsMultipleFilesPerChapter()
{
var zeroProgress = Step_DownloadAudiobook_Start();
var chapters = DownloadOptions.ChapterInfo.Chapters;
// Ensure split files are at least minChapterLength in duration.
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
var runningTotal = TimeSpan.Zero;
string title = "";
for (int i = 0; i < chapters.Count; i++)
{
if (runningTotal == TimeSpan.Zero)
title = chapters[i].Title;
runningTotal += chapters[i].Duration;
if (runningTotal >= minChapterLength)
{
splitChapters.AddChapter(title, runningTotal);
runningTotal = TimeSpan.Zero;
}
}
// reset, just in case
multiPartFilePaths.Clear();
ConversionResult result;
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
result = await ConvertToMultiMp4a(splitChapters);
else
result = await ConvertToMultiMp3(splitChapters);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
Step_DownloadAudiobook_End(zeroProgress);
return result == ConversionResult.NoErrorsDetected;
}
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp4aAsync
(
splitChapters,
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.TrimOutputToChapterLength
);
}
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp3Async
(
splitChapters,
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.LameConfig,
DownloadOptions.TrimOutputToChapterLength
);
}
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
=> Callback(currentChapter, splitChapters, newSplitCallback as NewSplitCallback);
private void Callback(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
{
MultiConvertFileProperties props = new()
{
OutputFileName = OutputFileName,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
Title = newSplitCallback?.Chapter?.Title,
};
newSplitCallback.OutputFile = createOutputFileStream(props);
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
newSplitCallback.TrackNumber = currentChapter;
newSplitCallback.TrackCount = splitChapters.Count;
}
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
{
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters);
multiPartFilePaths.Add(fileName);
FileUtility.SaferDelete(fileName);
var file = File.Open(fileName, FileMode.OpenOrCreate);
OnFileCreated(fileName);
return file;
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
chapters: null);
}
}
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
if (AaxFile is null) return false;
try
{
await (AaxConversion = decryptMultiAsync(AaxFile, DownloadOptions.ChapterInfo));
if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
return AaxConversion.IsCompletedSuccessfully;
}
finally
{
workingFileStream?.Dispose();
FinalizeDownload();
}
}
private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters)
{
var chapterCount = 0;
return
DownloadOptions.OutputFormat == OutputFormat.M4b
? aaxFile.ConvertToMultiMp4aAsync
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
)
: aaxFile.ConvertToMultiMp3Async
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.LameConfig
);
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
{
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
MultiConvertFileProperties props = new()
{
OutputFileName = newTempFile.FilePath,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
Title = newSplitCallback.Chapter?.Title,
};
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
newSplitCallback.TrackNumber = currentChapter;
newSplitCallback.TrackCount = splitChapters.Count;
OnTempFileCreated(newTempFile with { PartProperties = props });
}
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
{
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
}
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
{
if (DownloadOptions.OutputFormat is OutputFormat.M4b
&& DownloadOptions.MoveMoovToBeginning
&& filename is not null
&& File.Exists(filename))
{
return Mp4File.RelocateMoovAsync(filename);
}
else return Mp4Operation.FromCompleted(aaxFile);
}
}
+105 -105
View File
@@ -1,113 +1,113 @@
using System;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core.Net.Http;
using FileManager;
using System;
using System.IO;
using System.Threading.Tasks;
using AAXClean;
using AAXClean.Codecs;
using FileManager;
namespace AaxDecrypter
namespace AaxDecrypter;
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
private readonly AverageSpeed averageSpeed = new();
private TempFile? outputTempFile;
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions)
{
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
var step = 1;
public override async Task<bool> RunAsync()
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
//Step 1
Serilog.Log.Information("Begin Step 1: Get Aaxc Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Step 1: Get Aaxc Metadata");
else
{
Serilog.Log.Information("Failed to Complete Step 1: Get Aaxc Metadata");
return false;
}
//Step 2
Serilog.Log.Information("Begin Step 2: Download Decrypted Audiobook");
if (await Step_DownloadAudiobookAsSingleFile())
Serilog.Log.Information("Completed Step 2: Download Decrypted Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Step 2: Download Decrypted Audiobook");
return false;
}
//Step 3
Serilog.Log.Information("Begin Step 3: Create Cue");
if (await Task.Run(Step_CreateCue))
Serilog.Log.Information("Completed Step 3: Create Cue");
else
{
Serilog.Log.Information("Failed to Complete Step 3: Create Cue");
return false;
}
//Step 4
Serilog.Log.Information("Begin Step 4: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 4: Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Step 4: Cleanup");
return false;
}
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
}
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
{
var zeroProgress = Step_DownloadAudiobook_Start();
FileUtility.SaferDelete(OutputFileName);
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
ConversionResult decryptionResult = await decryptAsync(outputFile);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
Step_DownloadAudiobook_End(zeroProgress);
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
return success;
}
private Task<ConversionResult> decryptAsync(Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 ?
AaxFile.ConvertToMp3Async
(
outputFile,
DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
)
: DownloadOptions.FixupFile ?
AaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
)
: AaxFile.ConvertToMp4aAsync(outputFile);
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
}
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
{
if (AaxFile is null)
throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}.");
if (DownloadOptions.LameConfig is null)
throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}.");
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
DownloadOptions.ChapterInfo);
}
}
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
if (AaxFile is null) return false;
outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
FileUtility.SaferDelete(outputTempFile.FilePath);
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnTempFileCreated(outputTempFile);
try
{
await (AaxConversion = decryptAsync(AaxFile, outputFile));
return AaxConversion.IsCompletedSuccessfully;
}
finally
{
FinalizeDownload();
}
}
private async Task<bool> Step_MoveMoov()
{
if (outputTempFile is null) return false;
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
return AaxConversion.IsCompletedSuccessfully;
}
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * e.FractionCompleted,
BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted),
TotalBytesToReceive = InputFileStream.Length
});
}
private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
? aaxFile.ConvertToMp3Async
(
outputFile,
DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo
)
: DownloadOptions.FixupFile
? aaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo
)
: aaxFile.ConvertToMp4aAsync(outputFile);
}
+193 -135
View File
@@ -1,167 +1,225 @@
using System;
using System.Collections.Generic;
using Dinah.Core;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
using System;
using System.IO;
using System.Threading.Tasks;
using Dinah.Core;
using Dinah.Core.Net.Http;
using FileManager;
namespace AaxDecrypter
namespace AaxDecrypter;
public enum OutputFormat { M4b, Mp3 }
public abstract class AudiobookDownloadBase
{
public enum OutputFormat { M4b, Mp3 }
public event EventHandler<string?>? RetrievedTitle;
public event EventHandler<string?>? RetrievedAuthors;
public event EventHandler<string?>? RetrievedNarrators;
public event EventHandler<byte[]?>? RetrievedCoverArt;
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
public event EventHandler<TempFile>? TempFileCreated;
public abstract class AudiobookDownloadBase
public bool IsCanceled { get; protected set; }
protected AsyncStepSequence AsyncSteps { get; } = new();
protected string OutputDirectory { get; }
public IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
protected virtual long InputFilePosition
{
public event EventHandler<string> RetrievedTitle;
public event EventHandler<string> RetrievedAuthors;
public event EventHandler<string> RetrievedNarrators;
public event EventHandler<byte[]> RetrievedCoverArt;
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public event EventHandler<string> FileCreated;
public bool IsCanceled { get; set; }
public string TempFilePath { get; }
protected string OutputFileName { get; private set; }
protected IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
private NetworkFileStreamPersister nfsPersister;
private string jsonDownloadState { get; }
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
get
{
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
var outDir = Path.GetDirectoryName(OutputFileName);
if (!Directory.Exists(outDir))
Directory.CreateDirectory(outDir);
if (!Directory.Exists(cacheDirectory))
Directory.CreateDirectory(cacheDirectory);
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
}
public abstract Task CancelAsync();
public virtual void SetCoverArt(byte[] coverArt)
{
if (coverArt is not null)
OnRetrievedCoverArt(coverArt);
//Use try/catch instread of checking CanRead to avoid
//a race with the background download completing
//between the check and the Position call.
try { return InputFileStream.Position; }
catch { return InputFileStream.Length; }
}
}
private bool downloadFinished;
public abstract Task<bool> RunAsync();
private NetworkFileStreamPersister? m_nfsPersister;
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
private readonly DownloadProgress zeroProgress;
private readonly string jsonDownloadState;
private readonly string tempFilePath;
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string authors)
=> RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string narrators)
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
protected void OnFileCreated(string path)
=> FileCreated?.Invoke(this, path);
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
{
OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
protected void CloseInputFileStream()
if (!Directory.Exists(OutputDirectory))
Directory.CreateDirectory(OutputDirectory);
if (!Directory.Exists(cacheDirectory))
Directory.CreateDirectory(cacheDirectory);
jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
zeroProgress = new DownloadProgress
{
nfsPersister?.NetworkFileStream?.Close();
nfsPersister?.Dispose();
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = 0
};
OnDecryptProgressUpdate(zeroProgress);
}
protected TempFile GetNewTempFilePath(string extension)
{
extension = FileUtility.GetStandardizedExtension(extension);
var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension);
return new(path, extension);
}
public async Task<bool> RunAsync()
{
await InputFileStream.BeginDownloadingAsync();
var progressTask = Task.Run(reportProgress);
(bool success, var elapsed) = await AsyncSteps.RunAsync();
//Stop the downloader so it doesn't keep running in the background.
if (!success)
NfsPersister.Dispose();
await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
NfsPersister.Dispose();
return success;
async Task reportProgress()
{
AverageSpeed averageSpeed = new();
while (
InputFileStream.CanRead
&& InputFileStream.Length > InputFilePosition
&& !InputFileStream.IsCancelled
&& !downloadFinished)
{
averageSpeed.AddPosition(InputFilePosition);
var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average;
if (double.IsNormal(estSecsRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
var progressPercent = 100d * InputFilePosition / InputFileStream.Length;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = InputFilePosition,
TotalBytesToReceive = InputFileStream.Length
});
await Task.Delay(200);
}
OnDecryptTimeRemaining(TimeSpan.Zero);
OnDecryptProgressUpdate(zeroProgress);
}
}
protected bool Step_CreateCue()
public virtual Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt) { }
protected void OnRetrievedTitle(string? title)
=> RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string? authors)
=> RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string? narrators)
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[]? coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
public void OnTempFileCreated(TempFile path)
=> TempFileCreated?.Invoke(this, path);
protected virtual void FinalizeDownload()
{
NfsPersister.Dispose();
downloadFinished = true;
}
protected async Task<bool> Step_CreateCueAsync()
{
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
if (DownloadOptions.ChapterInfo.Count <= 1)
{
if (!DownloadOptions.CreateCueSheet) return true;
// not a critical step. its failure should not prevent future steps from running
try
{
var path = Path.ChangeExtension(OutputFileName, ".cue");
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
OnFileCreated(path);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
}
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
return !IsCanceled;
}
protected bool Step_Cleanup()
// not a critical step. its failure should not prevent future steps from running
try
{
bool success = !IsCanceled;
if (success)
{
FileUtility.SaferDelete(jsonDownloadState);
if (DownloadOptions.AudibleKey is not null &&
DownloadOptions.AudibleIV is not null &&
DownloadOptions.RetainEncryptedFile)
{
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
FileUtility.SaferMove(TempFilePath, aaxPath);
//Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key");
FileUtility.SaferDelete(keyPath);
File.WriteAllText(keyPath, $"Key={DownloadOptions.AudibleKey}\r\nIV={DownloadOptions.AudibleIV}");
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(TempFilePath);
}
return success;
var tempFile = GetNewTempFilePath(".cue");
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
OnTempFileCreated(tempFile);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
}
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream()
private NetworkFileStreamPersister OpenNetworkFileStream()
{
NetworkFileStreamPersister? nfsp = default;
try
{
if (!File.Exists(jsonDownloadState))
return NewNetworkFilePersister();
return nfsp = newNetworkFilePersister();
try
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// The download url expires after 1 hour.
// The new url points to the same file.
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
return nfsp;
}
catch
{
nfsp?.Target?.Dispose();
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(tempFilePath);
return nfsp = newNetworkFilePersister();
}
finally
{
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
if (nfsp is not null)
{
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// If More than ~1 hour has elapsed since getting the download url, it will expire.
// The new url will be to the same file.
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
return nfsp;
}
catch
{
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(TempFilePath);
return NewNetworkFilePersister();
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString()));
OnTempFileCreated(new(jsonDownloadState));
}
}
private NetworkFileStreamPersister NewNetworkFilePersister()
NetworkFileStreamPersister newNetworkFilePersister()
{
var headers = new System.Net.WebHeaderCollection
{
{ "User-Agent", DownloadOptions.UserAgent }
};
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, headers);
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
}
+171
View File
@@ -0,0 +1,171 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
namespace AaxDecrypter;
public static class LinqStats
{
public static (double mean, double stdDev) BasicStatisticsBy<T>(this IEnumerable<T> values, Func<T, double> selector)
{
var count = values.Count();
var mean = values.Average(selector);
return (mean, Math.Sqrt(values.Sum(s => Math.Pow(selector(s) - mean, 2)) / (count - 1)));
}
public static bool T_Test_2By<T>(this IEnumerable<T> values, Func<T, double> selector, IEnumerable<T> secondGroup, Significance confidence)
{
var n1 = values.Count();
var n2 = secondGroup.Count();
var n = n1 + n2;
if (n1 < 3 || n2 < 3) return false;
(var mean1, var stdDev1) = values.BasicStatisticsBy(selector);
(var mean2, var stdDev2) = secondGroup.BasicStatisticsBy(selector);
var pooledStdDev = Math.Sqrt((((n1 - 1) * (stdDev1 * stdDev1)) + ((n2 - 1) * (stdDev2 * stdDev2))) / (n1 + n2 - 2));
var testStat = Math.Abs(mean1 - mean2) / (pooledStdDev * Math.Sqrt(1d / n1 + 1d / n2));
var crit = T_Stat(Math.Min(n - 2, MAX_DEGREES_FREEDOM), confidence);
return testStat > crit;
}
public static bool T_Test_1By<T>(this IEnumerable<T> values, Func<T, double> selector, double testMean, Significance confidence)
{
var n = values.Count();
if (n < 2) return false;
(var sampleMean, var sampleStdDev) = values.BasicStatisticsBy(selector);
var testStat = Math.Abs(sampleMean - testMean) / (sampleStdDev / Math.Sqrt(n));
var crit = T_Stat(Math.Min(n - 1, MAX_DEGREES_FREEDOM), confidence);
return testStat > crit;
}
private static double T_Stat(int degreesFreedom, Significance confidence)
{
ArgumentValidator.EnsureBetweenInclusive(degreesFreedom, nameof(degreesFreedom), MIN_DEGREES_FREEDOM, MAX_DEGREES_FREEDOM);
return T_TABLE[(int)confidence][degreesFreedom - MIN_DEGREES_FREEDOM];
}
static LinqStats()
{
T_TABLE = new double[][] { T_Table_01, T_Table_05, T_Table_10, T_Table_15, T_Table_20, T_Table_25 };
}
private const int MIN_DEGREES_FREEDOM = 1;
private const int MAX_DEGREES_FREEDOM = 201;
/// <summary>
/// 2-tailed t-Distribution critical values at 75%, 80%, 85%,
/// 90%, 95%, and 99% confidence for 1 - 201 degrees of freedom.
/// </summary>
private static readonly double[][] T_TABLE;
private static readonly double[] T_Table_25 = { 2.414213562, 1.603567451, 1.422625281, 1.344397556, 1.300949037, 1.273349309, 1.254278682, 1.240318261, 1.229659173, 1.221255395, 1.214460246, 1.208852542, 1.204146242, 1.200140298, 1.196689284, 1.193685414, 1.191047107, 1.188711483, 1.186629298, 1.184761434, 1.183076432, 1.181548697, 1.180157199, 1.178884497, 1.177716003, 1.176639425, 1.175644329, 1.174721803, 1.173864189, 1.173064871, 1.1723181, 1.17161886, 1.170962753, 1.17034591, 1.169764906, 1.169216709, 1.168698615, 1.168208212, 1.167743338, 1.167302049, 1.166882595, 1.166483396, 1.166103019, 1.165740162, 1.165393644, 1.165062385, 1.164745398, 1.164441782, 1.164150707, 1.163871412, 1.163603196, 1.163345413, 1.163097467, 1.162858803, 1.162628911, 1.162407316, 1.162193577, 1.161987283, 1.161788052, 1.161595527, 1.161409375, 1.161229286, 1.161054967, 1.160886145, 1.160722566, 1.160563987, 1.160410184, 1.160260944, 1.160116066, 1.159975363, 1.159838656, 1.159705777, 1.159576569, 1.15945088, 1.15932857, 1.159209503, 1.159093552, 1.158980598, 1.158870524, 1.158763222, 1.158658589, 1.158556526, 1.15845694, 1.158359742, 1.158264847, 1.158172173, 1.158081645, 1.157993188, 1.157906731, 1.157822209, 1.157739556, 1.157658712, 1.157579617, 1.157502216, 1.157426454, 1.157352281, 1.157279646, 1.157208502, 1.157138804, 1.157070509, 1.157003573, 1.156937958, 1.156873624, 1.156810534, 1.156748653, 1.156687945, 1.156628379, 1.156569922, 1.156512543, 1.156456213, 1.156400904, 1.156346587, 1.156293237, 1.156240827, 1.156189334, 1.156138733, 1.156089001, 1.156040117, 1.155992058, 1.155944804, 1.155898335, 1.155852631, 1.155807674, 1.155763446, 1.155719928, 1.155677105, 1.155634959, 1.155593475, 1.155552637, 1.15551243, 1.155472839, 1.155433851, 1.155395452, 1.155357629, 1.155320368, 1.155283658, 1.155247486, 1.155211841, 1.15517671, 1.155142084, 1.15510795, 1.1550743, 1.155041122, 1.155008406, 1.154976144, 1.154944326, 1.154912942, 1.154881984, 1.154851443, 1.154821311, 1.15479158, 1.154762241, 1.154733287, 1.154704711, 1.154676505, 1.154648662, 1.154621175, 1.154594037, 1.154567242, 1.154540783, 1.154514654, 1.154488849, 1.154463361, 1.154438185, 1.154413316, 1.154388747, 1.154364474, 1.15434049, 1.154316792, 1.154293373, 1.154270229, 1.154247355, 1.154224746, 1.154202398, 1.154180307, 1.154158467, 1.154136875, 1.154115526, 1.154094417, 1.154073543, 1.1540529, 1.154032485, 1.154012294, 1.153992323, 1.153972568, 1.153953027, 1.153933695, 1.15391457, 1.153895647, 1.153876925, 1.153858399, 1.153840066, 1.153821925, 1.15380397, 1.153786201, 1.153768613, 1.153751204, 1.153733972, 1.153716914, 1.153700026 };
private static readonly double[] T_Table_20 = { 3.077683537, 1.885618083, 1.637744354, 1.533206274, 1.475884049, 1.439755747, 1.414923928, 1.39681531, 1.383028738, 1.372183641, 1.363430318, 1.356217334, 1.350171289, 1.345030374, 1.340605608, 1.336757167, 1.33337939, 1.330390944, 1.327728209, 1.325340707, 1.323187874, 1.321236742, 1.31946024, 1.317835934, 1.316345073, 1.314971864, 1.313702913, 1.312526782, 1.311433647, 1.310415025, 1.309463549, 1.308572793, 1.307737124, 1.306951587, 1.306211802, 1.305513886, 1.304854381, 1.304230204, 1.303638589, 1.303077053, 1.302543359, 1.302035487, 1.301551608, 1.30109006, 1.300649332, 1.300228048, 1.299824947, 1.299438879, 1.299068785, 1.298713694, 1.298372713, 1.298045016, 1.297729843, 1.297426488, 1.2971343, 1.296852673, 1.296581044, 1.29631889, 1.296065725, 1.295821094, 1.295584571, 1.295355762, 1.295134294, 1.29491982, 1.294712013, 1.294510568, 1.294315197, 1.294125629, 1.293941609, 1.293762898, 1.293589269, 1.293420507, 1.293256413, 1.293096793, 1.292941469, 1.292790268, 1.292643029, 1.292499597, 1.292359828, 1.292223583, 1.29209073, 1.291961144, 1.291834705, 1.291711301, 1.291590824, 1.291473171, 1.291358243, 1.291245948, 1.291136195, 1.291028899, 1.290923979, 1.290821356, 1.290720956, 1.290622708, 1.290526543, 1.290432395, 1.290340202, 1.290249904, 1.290161442, 1.290074761, 1.289989809, 1.289906533, 1.289824884, 1.289744816, 1.289666283, 1.289589241, 1.289513648, 1.289439464, 1.289366649, 1.289295166, 1.289224979, 1.289156054, 1.289088355, 1.289021851, 1.28895651, 1.288892302, 1.288829199, 1.288767171, 1.288706191, 1.288646234, 1.288587273, 1.288529284, 1.288472243, 1.288416127, 1.288360913, 1.288306581, 1.288253109, 1.288200477, 1.288148665, 1.288097654, 1.288047427, 1.287997964, 1.287949248, 1.287901264, 1.287853994, 1.287807422, 1.287761534, 1.287716314, 1.287671748, 1.287627821, 1.287584521, 1.287541833, 1.287499745, 1.287458245, 1.287417319, 1.287376957, 1.287337146, 1.287297876, 1.287259135, 1.287220914, 1.2871832, 1.287145985, 1.287109259, 1.287073012, 1.287037235, 1.287001918, 1.286967053, 1.286932631, 1.286898644, 1.286865084, 1.286831942, 1.286799212, 1.286766884, 1.286734952, 1.286703409, 1.286672248, 1.286641461, 1.286611042, 1.286580985, 1.286551283, 1.286521929, 1.286492918, 1.286464244, 1.286435901, 1.286407882, 1.286380184, 1.286352799, 1.286325724, 1.286298952, 1.286272479, 1.286246299, 1.286220408, 1.286194801, 1.286169474, 1.286144421, 1.286119638, 1.286095122, 1.286070867, 1.28604687, 1.286023127, 1.285999633, 1.285976384, 1.285953377, 1.285930609, 1.285908074, 1.285885771, 1.285863694, 1.285841842, 1.285820209, 1.285798794 };
private static readonly double[] T_Table_15 = { 4.16529977, 2.281930588, 1.924319657, 1.778192164, 1.699362566, 1.650173154, 1.616591737, 1.59222144, 1.573735785, 1.559235933, 1.547559766, 1.537956495, 1.529919606, 1.523095061, 1.517227969, 1.51213017, 1.507659754, 1.503707672, 1.500188756, 1.497035518, 1.494193795, 1.491619612, 1.489276897, 1.487135783, 1.485171326, 1.483362535, 1.481691617, 1.48014339, 1.478704821, 1.477364662, 1.47611315, 1.474941772, 1.473843072, 1.47281049, 1.471838233, 1.470921166, 1.470054719, 1.469234815, 1.468457801, 1.467720399, 1.467019655, 1.466352901, 1.465717725, 1.465111933, 1.464533534, 1.463980712, 1.463451805, 1.462945295, 1.46245979, 1.461994009, 1.461546775, 1.461117, 1.460703683, 1.460305896, 1.45992278, 1.459553538, 1.45919743, 1.458853767, 1.458521908, 1.458201256, 1.457891251, 1.457591373, 1.457301133, 1.457020074, 1.456747768, 1.45648381, 1.456227824, 1.455979454, 1.455738365, 1.455504241, 1.455276784, 1.455055715, 1.454840767, 1.45463169, 1.454428246, 1.454230212, 1.454037373, 1.453849529, 1.453666487, 1.453488066, 1.453314093, 1.453144404, 1.452978842, 1.452817259, 1.452659513, 1.452505469, 1.452354998, 1.452207977, 1.452064289, 1.451923821, 1.451786468, 1.451652126, 1.451520697, 1.451392088, 1.451266209, 1.451142973, 1.451022299, 1.450904108, 1.450788323, 1.450674871, 1.450563684, 1.450454694, 1.450347836, 1.450243048, 1.450140271, 1.450039448, 1.449940523, 1.449843444, 1.449748158, 1.449654617, 1.449562773, 1.449472581, 1.449383997, 1.449296977, 1.449211481, 1.449127468, 1.449044902, 1.448963744, 1.448883959, 1.448805513, 1.448728372, 1.448652503, 1.448577876, 1.44850446, 1.448432226, 1.448361146, 1.448291192, 1.448222337, 1.448154557, 1.448087826, 1.44802212, 1.447957415, 1.447893688, 1.447830919, 1.447769085, 1.447708165, 1.44764814, 1.44758899, 1.447530695, 1.447473238, 1.447416601, 1.447360765, 1.447305715, 1.447251433, 1.447197905, 1.447145113, 1.447093044, 1.447041682, 1.446991013, 1.446941023, 1.446891698, 1.446843026, 1.446794994, 1.446747588, 1.446700797, 1.446654609, 1.446609012, 1.446563996, 1.446519548, 1.446475659, 1.446432318, 1.446389514, 1.446347238, 1.44630548, 1.44626423, 1.44622348, 1.44618322, 1.446143442, 1.446104137, 1.446065296, 1.446026911, 1.445988975, 1.44595148, 1.445914417, 1.44587778, 1.445841561, 1.445805753, 1.445770349, 1.445735343, 1.445700727, 1.445666495, 1.445632641, 1.445599159, 1.445566042, 1.445533284, 1.445500881, 1.445468825, 1.445437112, 1.445405736, 1.445374691, 1.445343973, 1.445313576, 1.445283495, 1.445253726, 1.445224264, 1.445195103, 1.445166239, 1.445137668, 1.445109385, 1.445081387 };
private static readonly double[] T_Table_10 = { 6.313751515, 2.91998558, 2.353363435, 2.131846786, 2.015048373, 1.943180281, 1.894578605, 1.859548038, 1.833112933, 1.812461123, 1.795884819, 1.782287556, 1.770933396, 1.761310136, 1.753050356, 1.745883676, 1.739606726, 1.734063607, 1.729132812, 1.724718243, 1.720742903, 1.717144374, 1.713871528, 1.71088208, 1.708140761, 1.70561792, 1.703288446, 1.701130934, 1.699127027, 1.697260887, 1.695518783, 1.693888748, 1.692360309, 1.690924255, 1.689572458, 1.688297714, 1.68709362, 1.68595446, 1.684875122, 1.683851013, 1.682878002, 1.681952357, 1.681070703, 1.680229977, 1.679427393, 1.678660414, 1.677926722, 1.677224196, 1.676550893, 1.675905025, 1.67528495, 1.674689154, 1.674116237, 1.673564906, 1.673033965, 1.672522303, 1.672028888, 1.671552762, 1.671093032, 1.670648865, 1.670219484, 1.669804163, 1.669402222, 1.669013025, 1.668635976, 1.668270514, 1.667916114, 1.667572281, 1.667238549, 1.666914479, 1.666599658, 1.666293696, 1.665996224, 1.665706893, 1.665425373, 1.665151353, 1.664884537, 1.664624645, 1.664371409, 1.664124579, 1.663883913, 1.663649184, 1.663420175, 1.663196679, 1.6629785, 1.662765449, 1.662557349, 1.662354029, 1.662155326, 1.661961084, 1.661771155, 1.661585397, 1.661403674, 1.661225855, 1.661051817, 1.66088144, 1.66071461, 1.660551217, 1.660391156, 1.660234326, 1.66008063, 1.659929976, 1.659782273, 1.659637437, 1.659495383, 1.659356034, 1.659219312, 1.659085144, 1.658953458, 1.658824187, 1.658697265, 1.658572629, 1.658450216, 1.658329969, 1.65821183, 1.658095744, 1.657981659, 1.657869522, 1.657759285, 1.657650899, 1.657544319, 1.657439499, 1.657336397, 1.65723497, 1.657135178, 1.657036982, 1.656940344, 1.656845226, 1.656751594, 1.656659413, 1.656568649, 1.65647927, 1.656391244, 1.656304542, 1.656219133, 1.656134988, 1.65605208, 1.655970382, 1.655889868, 1.655810511, 1.655732287, 1.655655173, 1.655579143, 1.655504177, 1.655430251, 1.655357345, 1.655285437, 1.655214506, 1.655144534, 1.6550755, 1.655007387, 1.654940175, 1.654873847, 1.654808385, 1.654743774, 1.654679996, 1.654617035, 1.654554875, 1.654493503, 1.654432901, 1.654373057, 1.654313957, 1.654255585, 1.654197929, 1.654140976, 1.654084713, 1.654029128, 1.653974208, 1.653919942, 1.653866317, 1.653813324, 1.653760949, 1.653709184, 1.653658017, 1.653607437, 1.653557435, 1.653508002, 1.653459126, 1.6534108, 1.653363013, 1.653315758, 1.653269024, 1.653222803, 1.653177088, 1.653131869, 1.653087138, 1.653042889, 1.652999113, 1.652955802, 1.652912949, 1.652870547, 1.652828589, 1.652787068, 1.652745977, 1.65270531, 1.652665059, 1.652625219, 1.652585784, 1.652546746, 1.652508101 };
private static readonly double[] T_Table_05 = { 12.70620474, 4.30265273, 3.182446305, 2.776445105, 2.570581836, 2.446911851, 2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.20098516, 2.17881283, 2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.10092204, 2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.06865761, 2.063898562, 2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456, 2.039513446, 2.036933343, 2.034515297, 2.032244509, 2.030107928, 2.028094001, 2.026192463, 2.024394164, 2.02269092, 2.02107539, 2.01954097, 2.018081703, 2.016692199, 2.015367574, 2.014103389, 2.012895599, 2.011740514, 2.010634758, 2.009575237, 2.008559112, 2.00758377, 2.006646805, 2.005745995, 2.004879288, 2.004044783, 2.003240719, 2.002465459, 2.001717484, 2.000995378, 2.000297822, 1.999623585, 1.998971517, 1.998340543, 1.997729654, 1.997137908, 1.996564419, 1.996008354, 1.995468931, 1.994945415, 1.994437112, 1.993943368, 1.993463567, 1.992997126, 1.992543495, 1.992102154, 1.99167261, 1.991254395, 1.990847069, 1.99045021, 1.990063421, 1.989686323, 1.989318557, 1.98895978, 1.988609667, 1.988267907, 1.987934206, 1.987608282, 1.987289865, 1.9869787, 1.986674541, 1.986377154, 1.986086317, 1.985801814, 1.985523442, 1.985251004, 1.984984312, 1.984723186, 1.984467455, 1.984216952, 1.983971519, 1.983731003, 1.983495259, 1.983264145, 1.983037526, 1.982815274, 1.982597262, 1.98238337, 1.982173483, 1.98196749, 1.981765282, 1.981566757, 1.981371815, 1.981180359, 1.980992298, 1.980807541, 1.980626002, 1.980447599, 1.980272249, 1.980099876, 1.979930405, 1.979763763, 1.979599878, 1.979438685, 1.979280117, 1.979124109, 1.978970602, 1.978819535, 1.97867085, 1.978524491, 1.978380405, 1.978238539, 1.978098842, 1.977961264, 1.977825758, 1.977692277, 1.977560777, 1.977431212, 1.977303542, 1.977177724, 1.97705372, 1.976931489, 1.976810994, 1.976692198, 1.976575066, 1.976459563, 1.976345655, 1.976233309, 1.976122494, 1.976013178, 1.975905331, 1.975798924, 1.975693928, 1.975590315, 1.975488058, 1.975387131, 1.975287508, 1.975189163, 1.975092073, 1.974996213, 1.97490156, 1.974808092, 1.974715786, 1.974624621, 1.974534576, 1.97444563, 1.974357764, 1.974270957, 1.974185191, 1.974100447, 1.974016708, 1.973933954, 1.973852169, 1.973771337, 1.97369144, 1.973612462, 1.973534388, 1.973457202, 1.973380889, 1.973305434, 1.973230823, 1.973157042, 1.973084077, 1.973011915, 1.972940542, 1.972869946, 1.972800114, 1.972731033, 1.972662692, 1.972595079, 1.972528182, 1.97246199, 1.972396491, 1.972331676, 1.972267533, 1.972204051, 1.972141222, 1.972079034, 1.972017478, 1.971956544, 1.971896224 };
private static readonly double[] T_Table_01 = { 63.65674116, 9.924843201, 5.84090931, 4.604094871, 4.032142984, 3.707428021, 3.499483297, 3.355387331, 3.249835542, 3.169272673, 3.105806516, 3.054539589, 3.012275839, 2.976842734, 2.946712883, 2.920781622, 2.89823052, 2.878440473, 2.860934606, 2.84533971, 2.831359558, 2.818756061, 2.807335684, 2.796939505, 2.787435814, 2.778714533, 2.770682957, 2.763262455, 2.756385904, 2.749995654, 2.744041919, 2.738481482, 2.733276642, 2.728394367, 2.723805589, 2.71948463, 2.715408722, 2.711557602, 2.707913184, 2.704459267, 2.701181304, 2.698066186, 2.695102079, 2.692278266, 2.689585019, 2.687013492, 2.684555618, 2.682204027, 2.679951974, 2.677793271, 2.675722234, 2.673733631, 2.671822636, 2.669984796, 2.668215988, 2.666512398, 2.664870482, 2.663286954, 2.661758752, 2.660283029, 2.658857127, 2.657478565, 2.656145025, 2.654854337, 2.653604469, 2.652393515, 2.651219685, 2.650081299, 2.648976774, 2.647904624, 2.646863444, 2.645851913, 2.644868782, 2.643912872, 2.642983067, 2.642078313, 2.641197611, 2.640340015, 2.639504627, 2.638690596, 2.637897113, 2.63712341, 2.636368757, 2.635632458, 2.634913852, 2.634212309, 2.633527229, 2.632858038, 2.632204191, 2.631565166, 2.630940463, 2.630329608, 2.629732145, 2.629147638, 2.628575671, 2.628015844, 2.627467774, 2.626931096, 2.626405457, 2.625890521, 2.625385965, 2.624891476, 2.624406758, 2.623931523, 2.623465496, 2.623008411, 2.622560015, 2.622120061, 2.621688313, 2.621264543, 2.620848534, 2.620440073, 2.620038957, 2.619644989, 2.619257981, 2.618877749, 2.618504116, 2.618136914, 2.617775976, 2.617421145, 2.617072266, 2.616729191, 2.616391776, 2.616059883, 2.615733377, 2.615412127, 2.615096008, 2.614784899, 2.61447868, 2.614177238, 2.613880461, 2.613588242, 2.613300477, 2.613017065, 2.612737908, 2.61246291, 2.61219198, 2.611925028, 2.611661966, 2.611402711, 2.611147181, 2.610895295, 2.610646976, 2.61040215, 2.610160742, 2.609922682, 2.609687901, 2.609456331, 2.609227907, 2.609002566, 2.608780245, 2.608560883, 2.608344423, 2.608130807, 2.60791998, 2.607711886, 2.607506474, 2.607303692, 2.607103489, 2.606905817, 2.606710628, 2.606517876, 2.606327515, 2.606139501, 2.605953791, 2.605770342, 2.605589114, 2.605410067, 2.605233162, 2.605058359, 2.604885623, 2.604714916, 2.604546204, 2.60437945, 2.604214622, 2.604051686, 2.60389061, 2.603731363, 2.603573912, 2.603418229, 2.603264282, 2.603112045, 2.602961487, 2.602812582, 2.602665303, 2.602519622, 2.602375515, 2.602232955, 2.602091918, 2.60195238, 2.601814317, 2.601677705, 2.601542523, 2.601408747, 2.601276355, 2.601145327, 2.601015642, 2.600887278, 2.600760216, 2.600634436 };
}
public enum Significance
{
P01,
P05,
P10,
P15,
P20,
P25
}
public class AverageSpeed
{
/// <summary>Average speed in units per second</summary>
public double Average { get; private set; }
public TimeSpan SlowWindow { get; }
public TimeSpan FastWindow { get; }
public Significance SlowSignificance { get; }
public Significance FastSignificance { get; }
private DateTime start;
private TimeSpan lastTime;
private double lastPosition = double.NaN;
private readonly record struct Point(TimeSpan Time, double Velocity);
private readonly LinkedList<Point> speeds = new();
private const int MAX_SPEEDS = 200;
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
/// <param name="slowWindow">Total moving average time window</param>
/// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
{
SlowWindow = ArgumentValidator.EnsureGreaterThan(slowWindow, nameof(slowWindow), fastWindow);
FastWindow = ArgumentValidator.EnsureGreaterThan(fastWindow, nameof(fastWindow), TimeSpan.Zero);
SlowSignificance = slowSignificance;
FastSignificance = fastSignificance;
}
/// <summary>Add a new position to the moving average</summary>
public void AddPosition(double position)
{
var now = DateTime.UtcNow;
if (start == default)
start = now;
var time = now - start;
while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First!.Value.Time > SlowWindow))
speeds.RemoveFirst();
if (!double.IsNaN(lastPosition))
{
var newSpeed = (position - lastPosition) / (time - lastTime).TotalSeconds;
speeds.AddLast(new Point(time, newSpeed));
}
lastTime = time;
lastPosition = position;
Average = ComputeNextAverage();
}
private double ComputeNextAverage()
{
if (speeds.Count == 0)
return 0;
else if (speeds.Count == 1)
return speeds.Last!.Value.Velocity;
else
{
var n_newest = speeds.Count(s => s.Time > lastTime.Subtract(FastWindow));
var n_oldest = speeds.Count - n_newest;
if (speeds.Take(n_oldest).T_Test_2By(s => s.Velocity, speeds.TakeLast(n_newest), FastSignificance))
{
//Speeds in FastWindow are significantly different from reset of speeds in SlowWindow.
//Discard older speeds and keep only speeds in FastWindow
for (; n_oldest > 0; n_oldest--)
speeds.RemoveFirst();
return speeds.Average(s => s.Velocity);
}
else
return
speeds.T_Test_1By(s => s.Velocity, Average, SlowSignificance)
? speeds.Average(s => s.Velocity)
: Average;
}
}
}
+41 -44
View File
@@ -1,62 +1,59 @@
using System;
using Dinah.Core;
using Mpeg4Lib;
using System.IO;
using System.Text;
using AAXClean;
using Dinah.Core;
namespace AaxDecrypter
namespace AaxDecrypter;
public static class Cue
{
public static class Cue
{
public static string CreateContents(string filePath, ChapterInfo chapters)
{
var stringBuilder = new StringBuilder();
public static string CreateContents(string filePath, ChapterInfo chapters)
{
var stringBuilder = new StringBuilder();
stringBuilder.AppendLine(GetFileLine(filePath, "MP3"));
stringBuilder.AppendLine(GetFileLine(filePath, "MP3"));
var startOffset = chapters.StartOffset;
var startOffset = chapters.StartOffset;
var trackCount = 0;
foreach (var c in chapters.Chapters)
{
var startTime = c.StartOffset - startOffset;
trackCount++;
var trackCount = 1;
foreach (var c in chapters.Chapters)
{
var startTime = c.StartOffset - startOffset;
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds / 1000d * 75)}");
}
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
}
return stringBuilder.ToString();
}
return stringBuilder.ToString();
}
public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath)
=> UpdateFileName(cueFileInfo.FullName, audioFilePath);
public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath)
=> UpdateFileName(cueFileInfo.FullName, audioFilePath);
public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo)
=> UpdateFileName(cueFilePath, audioFileInfo.FullName);
public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo)
=> UpdateFileName(cueFilePath, audioFileInfo.FullName);
public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo)
=> UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName);
public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo)
=> UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName);
public static void UpdateFileName(string cueFilePath, string audioFilePath)
{
var cueContents = File.ReadAllLines(cueFilePath);
public static void UpdateFileName(string cueFilePath, string audioFilePath)
{
var cueContents = File.ReadAllLines(cueFilePath);
for (var i = 0; i < cueContents.Length; i++)
{
var line = cueContents[i];
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
continue;
for (var i = 0; i < cueContents.Length; i++)
{
var line = cueContents[i];
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
continue;
var fileTypeBegins = line.LastIndexOf(" ") + 1;
cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]);
break;
}
var fileTypeBegins = line.LastIndexOf(" ") + 1;
cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]);
break;
}
File.WriteAllLines(cueFilePath, cueContents);
}
File.WriteAllLines(cueFilePath, cueContents);
}
private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}";
}
private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}";
}
+49 -20
View File
@@ -1,25 +1,54 @@
using AAXClean;
using Mpeg4Lib;
using System;
namespace AaxDecrypter
namespace AaxDecrypter;
public class KeyData
{
public interface IDownloadOptions
public byte[] KeyPart1 { get; }
public byte[]? KeyPart2 { get; }
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
{
FileManager.ReplacementCharacters ReplacementCharacters { get; }
string DownloadUrl { get; }
string UserAgent { get; }
string AudibleKey { get; }
string AudibleIV { get; }
OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; }
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
NAudio.Lame.LameConfig LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitleName(MultiConvertFileProperties props);
}
KeyPart1 = keyPart1;
KeyPart2 = keyPart2;
}
[Newtonsoft.Json.JsonConstructor]
public KeyData(string keyPart1, string? keyPart2 = null)
{
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
KeyPart1 = Convert.FromHexString(keyPart1);
if (keyPart2 != null)
KeyPart2 = Convert.FromHexString(keyPart2);
}
}
public interface IDownloadOptions
{
event EventHandler<long> DownloadSpeedChanged;
string DownloadUrl { get; }
string UserAgent { get; }
KeyData[]? DecryptionKeys { get; }
TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
string? AudibleProductId { get; }
string? Title { get; }
string? Subtitle { get; }
string? Publisher { get; }
string? Language { get; }
string? SeriesName { get; }
string? SeriesNumber { get; }
NAudio.Lame.LameConfig? LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; }
string GetMultipartTitle(MultiConvertFileProperties props);
public FileType? InputType { get; }
}
+58 -20
View File
@@ -1,33 +1,71 @@
using AAXClean;
using AAXClean.Codecs;
using Mpeg4Lib;
using NAudio.Lame;
using System;
using System.Linq;
namespace AaxDecrypter
namespace AaxDecrypter;
public static class MpegUtil
{
public static class MpegUtil
private const string TagDomain = "com.pilabor.tone";
public static void ConfigureLameOptions(
Mpeg4File mp4File,
LameConfig lameConfig,
bool downsample,
bool matchSourceBitrate,
ChapterInfo? chapters)
{
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
double bitrateMultiple = 1;
if (mp4File.TimeScale < lameConfig.OutputSampleRate)
{
double bitrateMultiple = 1;
lameConfig.OutputSampleRate = mp4File.TimeScale;
}
else if (mp4File.TimeScale > lameConfig.OutputSampleRate)
{
bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale;
}
if (mp4File.AudioChannels == 2)
{
if (downsample)
bitrateMultiple = 0.5;
else
lameConfig.Mode = MPEGMode.Stereo;
}
if (mp4File.AudioChannels == 2)
{
if (downsample)
bitrateMultiple /= 2;
else
lameConfig.Mode = MPEGMode.Stereo;
}
if (matchSourceBitrate)
{
int kbps = (int)(mp4File.AverageBitrate * bitrateMultiple / 1024);
if (matchSourceBitrate)
{
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
if (lameConfig.VBR is null)
lameConfig.BitRate = kbps;
else if (lameConfig.VBR == VBRMode.ABR)
lameConfig.ABRRateKbps = kbps;
}
if (lameConfig.VBR is null)
lameConfig.BitRate = kbps;
else if (lameConfig.VBR == VBRMode.ABR)
lameConfig.ABRRateKbps = kbps;
}
//Setup metadata tags
lameConfig.ID3 = mp4File.MetadataItems.ToIDTags();
if (mp4File.MetadataItems.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
lameConfig.ID3.Subtitle = subtitle;
if (chapters?.Count > 0)
{
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
}
//Copy over all other freeform tags
foreach (var t in mp4File.MetadataItems.AppleListBox.Tags.OfType<Mpeg4Lib.Boxes.FreeformTagBox>())
{
if (t.Name?.Name is string name &&
t.Mean?.ReverseDnsDomain is string domain &&
!lameConfig.ID3.UserDefinedText.ContainsKey(name) &&
mp4File.MetadataItems.AppleListBox.GetFreeformTagString(domain, name) is string tagStr &&
!string.IsNullOrWhiteSpace(tagStr))
lameConfig.ID3.UserDefinedText.Add(name, tagStr);
}
}
}
@@ -1,15 +1,12 @@
using System;
using System.IO;
using FileManager;
namespace AaxDecrypter
namespace AaxDecrypter;
public class MultiConvertFileProperties
{
public class MultiConvertFileProperties
{
public string OutputFileName { get; set; }
public int PartsPosition { get; set; }
public int PartsTotal { get; set; }
public string Title { get; set; }
}
public required string OutputFileName { get; set; }
public int PartsPosition { get; set; }
public int PartsTotal { get; set; }
public string? Title { get; set; }
public DateTime FileDate { get; } = DateTime.Now;
}
+405 -426
View File
@@ -1,465 +1,444 @@
using Dinah.Core;
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.Threading;
using System.Threading.Tasks;
namespace AaxDecrypter
namespace AaxDecrypter;
/// <summary>A resumable, simultaneous file downloader and reader. </summary>
public class NetworkFileStream : Stream, IUpdatable
{
/// <summary>
/// A <see cref="CookieContainer"/> for a single Uri.
/// </summary>
public class SingleUriCookieContainer : CookieContainer
public event EventHandler? Updated;
#region Public Properties
/// <summary> Location to save the downloaded data. </summary>
[JsonProperty(Required = Required.Always)]
public string SaveFilePath { get; }
/// <summary> Http(s) address of the file to download. </summary>
[JsonProperty(Required = Required.Always)]
public Uri Uri { get; private set; }
/// <summary> Http headers to be sent to the server with the request. </summary>
[JsonProperty(Required = Required.Always)]
public Dictionary<string, string> RequestHeaders { get; private set; }
/// <summary> The position in <see cref="SaveFilePath"/> that has been written and flushed to disk. </summary>
[JsonProperty(Required = Required.Always)]
public long WritePosition { get; private set; }
/// <summary> The total length of the <see cref="Uri"/> file to download. </summary>
[JsonProperty(Required = Required.Always)]
public long ContentLength { get; private set; }
[JsonIgnore]
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
[JsonIgnore]
public Task? DownloadTask { get; private set; }
private long _speedLimit = 0;
/// <summary>bytes per second</summary>
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
#endregion
#region Private Properties
private FileStream _writeFile { get; }
private FileStream _readFile { get; }
private CancellationTokenSource _cancellationSource { get; } = new();
private EventWaitHandle? _downloadedPiece { get; set; }
private DateTime NextUpdateTime { get; set; }
#endregion
#region Constants
//Download memory buffer size
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
//NetworkFileStream will flush all data in _writeFile to disk after every
//DATA_FLUSH_SZ bytes are written to the file stream.
private const int DATA_FLUSH_SZ = 1024 * 1024;
//Number of times per second the download rate is checked and throttled
private const int THROTTLE_FREQUENCY = 8;
//Minimum throttle rate. The minimum amount of data that can be throttled
//on each iteration of the download loop is DOWNLOAD_BUFF_SZ.
public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
#endregion
#region Constructor
/// <summary> A resumable, simultaneous file downloader and reader. </summary>
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
/// <param name="uri">Http(s) address of the file to download.</param>
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string>? requestHeaders = null)
{
private Uri baseAddress;
public Uri Uri
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
RequestHeaders = requestHeaders ?? new();
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
{
get => baseAddress;
set
{
baseAddress = new UriBuilder(value.Scheme, value.Host).Uri;
}
Position = WritePosition
};
if (_writeFile.Length < WritePosition)
{
_writeFile.Dispose();
throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}");
}
public CookieCollection GetCookies()
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
SetUriForSameFile(uri);
}
#endregion
#region Downloader
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
private void OnUpdate(bool waitForWrite = false)
{
try
{
return GetCookies(Uri);
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
{
Updated?.Invoke(this, EventArgs.Empty);
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
//If an update is called less than 100 ms since the last update, persister will
//sleep the thread until 100 ms has elapsed.
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
}
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
}
}
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
public class NetworkFileStream : Stream, IUpdatable
/// <summary> Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/> </summary>
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
public void SetUriForSameFile(Uri uriToSameFile)
{
public event EventHandler Updated;
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
#region Public Properties
if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath))
throw new ArgumentException($"New uri to the same file must have the same file name.");
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
if (DownloadTask is not null)
throw new InvalidOperationException("Cannot change Uri after download has started.");
/// <summary>
/// Location to save the downloaded data.
/// </summary>
[JsonProperty(Required = Required.Always)]
public string SaveFilePath { get; }
Uri = uriToSameFile;
}
/// <summary>
/// Http(s) address of the file to download.
/// </summary>
[JsonProperty(Required = Required.Always)]
public Uri Uri { get; private set; }
/// <summary>
/// All cookies set by caller or by the remote server.
/// </summary>
[JsonProperty(Required = Required.Always)]
public SingleUriCookieContainer CookieContainer { get; }
/// <summary>
/// Http headers to be sent to the server with the request.
/// </summary>
[JsonProperty(Required = Required.Always)]
public WebHeaderCollection RequestHeaders { get; private set; }
/// <summary>
/// The position in <see cref="SaveFilePath"/> that has been written and flushed to disk.
/// </summary>
[JsonProperty(Required = Required.Always)]
public long WritePosition { get; private set; }
/// <summary>
/// The total length of the <see cref="Uri"/> file to download.
/// </summary>
[JsonProperty(Required = Required.Always)]
public long ContentLength { get; private set; }
#endregion
#region Private Properties
private HttpWebRequest HttpRequest { get; set; }
private FileStream _writeFile { get; }
private FileStream _readFile { get; }
private Stream _networkStream { get; set; }
private bool hasBegunDownloading { get; set; }
public bool IsCancelled { get; private set; }
private EventWaitHandle downloadEnded { get; set; }
private EventWaitHandle downloadedPiece { get; set; }
#endregion
#region Constants
//Download buffer size
private const int DOWNLOAD_BUFF_SZ = 32 * 1024;
//NetworkFileStream will flush all data in _writeFile to disk after every
//DATA_FLUSH_SZ bytes are written to the file stream.
private const int DATA_FLUSH_SZ = 1024 * 1024;
#endregion
#region Constructor
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
/// <param name="uri">Http(s) address of the file to download.</param>
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
/// <param name="cookies">A <see cref="SingleUriCookieContainer"/> with cookies to send with the <see cref="HttpWebRequest"/>. It will also be populated with any cookies set by the server. </param>
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null)
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
/// <returns>The downloader <see cref="Task"/></returns>
public async Task BeginDownloadingAsync()
{
if (ContentLength != 0 && WritePosition == ContentLength)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
SaveFilePath = saveFilePath;
Uri = uri;
WritePosition = writePosition;
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri };
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
{
Position = WritePosition
};
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
SetUriForSameFile(uri);
}
#endregion
#region Downloader
/// <summary>
/// Update the <see cref="JsonFilePersister"/>.
/// </summary>
private void Update()
{
RequestHeaders = HttpRequest.Headers;
try
{
Updated?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
}
}
/// <summary>
/// Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/>
/// </summary>
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
public void SetUriForSameFile(Uri uriToSameFile)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
if (hasBegunDownloading)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
HttpRequest = WebRequest.CreateHttp(Uri);
HttpRequest.CookieContainer = CookieContainer;
HttpRequest.Headers = RequestHeaders;
//If NetworkFileStream is resuming, Header will already contain a range.
HttpRequest.Headers.Remove("Range");
HttpRequest.AddRange(WritePosition);
}
/// <summary>
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
/// </summary>
private void BeginDownloading()
{
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
if (ContentLength != 0 && WritePosition == ContentLength)
{
hasBegunDownloading = true;
downloadEnded.Set();
return;
}
if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
var response = HttpRequest.GetResponse() as HttpWebResponse;
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
//Content length is the length of the range request, and it is only equal
//to the complete file length if requesting Range: bytes=0-
if (WritePosition == 0)
ContentLength = response.ContentLength;
_networkStream = response.GetResponseStream();
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
new Thread(() => DownloadFile())
{ IsBackground = true }
.Start();
hasBegunDownloading = true;
DownloadTask = Task.CompletedTask;
return;
}
/// <summary>
/// Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.
/// </summary>
private void DownloadFile()
if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
//Initiate connection with the first request block and
//get the total content length before returning.
var client = new HttpClient();
var response = await RequestNextByteRangeAsync(client);
if (ContentLength != 0 && ContentLength != response.FileSize)
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
ContentLength = response.FileSize;
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Hand off the client and the open request to the downloader to download and write data to file.
DownloadTask = Task.Run(() => DownloadLoopInternal(client, response), _cancellationSource.Token);
}
// 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
{
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
var buff = new byte[DOWNLOAD_BUFF_SZ];
var startPosition = WritePosition;
try
var tlsRetryCount = 0;
while (WritePosition < ContentLength && !IsCancelled)
{
int bytesRead;
do
try
{
bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
downloadPosition += bytesRead;
if (downloadPosition > nextFlush)
{
_writeFile.Flush();
WritePosition = downloadPosition;
Update();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
downloadedPiece.Set();
}
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
_writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition;
Update();
if (!IsCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "An error was encountered while downloading {Uri}", Uri);
}
finally
{
downloadedPiece.Set();
downloadEnded.Set();
}
}
#endregion
#region Json Connverters
public static JsonSerializerSettings GetJsonSerializerSettings()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(new CookieContainerConverter());
settings.Converters.Add(new WebHeaderCollectionConverter());
return settings;
}
internal class CookieContainerConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(SingleUriCookieContainer);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var result = new SingleUriCookieContainer()
{
Uri = new Uri(jObj["Uri"].Value<string>()),
Capacity = jObj["Capacity"].Value<int>(),
MaxCookieSize = jObj["MaxCookieSize"].Value<int>(),
PerDomainCapacity = jObj["PerDomainCapacity"].Value<int>()
};
var cookieList = jObj["Cookies"].ToList();
foreach (var cookie in cookieList)
{
result.Add(
new Cookie
{
Comment = cookie["Comment"].Value<string>(),
HttpOnly = cookie["HttpOnly"].Value<bool>(),
Discard = cookie["Discard"].Value<bool>(),
Domain = cookie["Domain"].Value<string>(),
Expired = cookie["Expired"].Value<bool>(),
Expires = cookie["Expires"].Value<DateTime>(),
Name = cookie["Name"].Value<string>(),
Path = cookie["Path"].Value<string>(),
Port = cookie["Port"].Value<string>(),
Secure = cookie["Secure"].Value<bool>(),
Value = cookie["Value"].Value<string>(),
Version = cookie["Version"].Value<int>(),
});
await DownloadToFile(blockResponse);
}
catch (HttpIOException e)
when (e.HttpRequestError is HttpRequestError.ResponseEnded
&& WritePosition != startPosition
&& WritePosition < ContentLength && !IsCancelled)
{
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
return result;
}
//the download made *some* progress since the last attempt.
//Try again to complete the download from where it left off.
//Make sure to rewind file to last flush position.
_writeFile.Position = startPosition = WritePosition;
blockResponse.Dispose();
blockResponse = await RequestNextByteRangeAsync(client);
public override bool CanWrite => true;
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.");
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var cookies = value as SingleUriCookieContainer;
var obj = (JObject)JToken.FromObject(value);
var container = cookies.GetCookies();
var propertyNames = container.Select(c => JToken.FromObject(c));
obj.AddFirst(new JProperty("Cookies", new JArray(propertyNames)));
obj.WriteTo(writer);
}
_writeFile.Position = startPosition = WritePosition;
blockResponse.Dispose();
blockResponse = await RequestNextByteRangeAsync(client);
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
}
}
}
internal class WebHeaderCollectionConverter : JsonConverter
catch (Exception ex)
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(WebHeaderCollection);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var result = new WebHeaderCollection();
foreach (var kvp in jObj)
result.Add(kvp.Key, kvp.Value.Value<string>());
return result;
}
public override bool CanWrite => true;
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var jObj = new JObject();
var type = value.GetType();
var headers = value as WebHeaderCollection;
var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k]));
jObj.Add(jHeaders);
jObj.WriteTo(writer);
}
//Don't throw from DownloadTask.
//This task gets awaited in Dispose() and we don't want to have an unhandled exception there.
Serilog.Log.Error(ex, "An error was encountered during the download process.");
}
#endregion
#region Download Stream Reader
[JsonIgnore]
public override bool CanRead => true;
[JsonIgnore]
public override bool CanSeek => true;
[JsonIgnore]
public override bool CanWrite => false;
[JsonIgnore]
public override long Length
finally
{
get
{
if (!hasBegunDownloading)
BeginDownloading();
return ContentLength;
}
}
[JsonIgnore]
public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); }
[JsonIgnore]
public override bool CanTimeout => false;
[JsonIgnore]
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
[JsonIgnore]
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
public override void Flush() => throw new NotImplementedException();
public override void SetLength(long value) => throw new NotImplementedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
public override int Read(byte[] buffer, int offset, int count)
{
if (!hasBegunDownloading)
BeginDownloading();
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
return _readFile.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
var newPosition = origin switch
{
SeekOrigin.Current => Position + offset,
SeekOrigin.End => ContentLength + offset,
_ => offset,
};
WaitToPosition(newPosition);
return _readFile.Position = newPosition;
}
/// <summary>
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
/// </summary>
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition)
{
while (WritePosition < requiredPosition
&& hasBegunDownloading
&& !IsCancelled
&& !downloadEnded.WaitOne(0))
{
downloadedPiece.WaitOne(100);
}
}
public override void Close()
{
IsCancelled = true;
while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ;
_readFile.Close();
_writeFile.Close();
_networkStream?.Close();
Update();
}
#endregion
~NetworkFileStream()
{
downloadEnded?.Close();
downloadedPiece?.Close();
_writeFile.Dispose();
blockResponse.Dispose();
client.Dispose();
}
}
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
{
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
//Just in case it snuck in the saved json (Issue #1232)
RequestHeaders.Remove("Range");
foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value);
request.Headers.Add("Range", $"bytes={WritePosition}-");
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
var totalSize = response.Content.Headers.ContentRange?.Length ??
throw new WebException("The response did not contain a total content length.");
var rangeSize = response.Content.Headers.ContentLength ??
throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
return new BlockResponse(response, rangeSize, totalSize);
}
private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
{
public void Dispose() => Response?.Dispose();
}
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
private async Task DownloadToFile(BlockResponse block)
{
var endPosition = WritePosition + block.BlockSize;
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
var buff = new byte[DOWNLOAD_BUFF_SZ];
try
{
DateTime startTime = DateTime.UtcNow;
long bytesReadSinceThrottle = 0;
int bytesRead;
do
{
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
downloadPosition += bytesRead;
if (downloadPosition > nextFlush)
{
await _writeFile.FlushAsync(_cancellationSource.Token);
WritePosition = downloadPosition;
OnUpdate();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
_downloadedPiece?.Set();
}
#region throttle
bytesReadSinceThrottle += bytesRead;
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
if (delayMS > 0)
await Task.Delay(delayMS, _cancellationSource.Token);
startTime = DateTime.UtcNow;
bytesReadSinceThrottle = 0;
}
#endregion
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
await _writeFile.FlushAsync(_cancellationSource.Token);
WritePosition = downloadPosition;
if (!IsCancelled && WritePosition < endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
catch (OperationCanceledException)
{
Serilog.Log.Information("Download was cancelled");
}
finally
{
_downloadedPiece?.Set();
OnUpdate(waitForWrite: true);
}
}
#endregion
#region Download Stream Reader
[JsonIgnore]
public override bool CanRead => _readFile.CanRead;
[JsonIgnore]
public override bool CanSeek => _readFile.CanSeek;
[JsonIgnore]
public override bool CanWrite => false;
[JsonIgnore]
public override long Length
{
get
{
if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
return ContentLength;
}
}
[JsonIgnore]
public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); }
[JsonIgnore]
public override bool CanTimeout => false;
[JsonIgnore]
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
[JsonIgnore]
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
public override void Flush() => throw new InvalidOperationException();
public override void SetLength(long value) => throw new InvalidOperationException();
public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException();
public override int Read(byte[] buffer, int offset, int count)
{
if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
return IsCancelled ? 0 : _readFile.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
var newPosition = origin switch
{
SeekOrigin.Current => Position + offset,
SeekOrigin.End => ContentLength + offset,
_ => offset,
};
WaitToPosition(newPosition);
return _readFile.Position = newPosition;
}
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition)
{
while (WritePosition < requiredPosition
&& DownloadTask?.IsCompleted is false
&& !IsCancelled)
{
_downloadedPiece?.WaitOne(50);
}
}
private bool disposed = false;
/*
* https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0
*
* In derived classes, do not override the Close() method, instead, put all of the
* Stream cleanup logic in the Dispose(Boolean) method.
*/
protected override void Dispose(bool disposing)
{
if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
{
_cancellationSource.Cancel();
DownloadTask?.GetAwaiter().GetResult();
_downloadedPiece?.Dispose();
_cancellationSource?.Dispose();
_readFile.Dispose();
_writeFile.Dispose();
OnUpdate(waitForWrite: true);
}
base.Dispose(disposing);
}
#endregion
}
@@ -1,23 +1,24 @@
using Dinah.Core.IO;
using Newtonsoft.Json;
namespace AaxDecrypter
namespace AaxDecrypter;
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
{
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
{
/// <summary>Alias for Target </summary>
public NetworkFileStream NetworkFileStream => Target;
/// <summary>Alias for Target </summary>
public NetworkFileStream NetworkFileStream => Target;
/// <summary>uses path. create file if doesn't yet exist</summary>
public NetworkFileStreamPersister(NetworkFileStream networkFileStream, string path, string? jsonPath = null)
: base(networkFileStream, path, jsonPath) { }
/// <summary>uses path. create file if doesn't yet exist</summary>
public NetworkFileStreamPersister(NetworkFileStream networkFileStream, string path, string jsonPath = null)
: base(networkFileStream, path, jsonPath) { }
/// <summary>load from existing file</summary>
public NetworkFileStreamPersister(string path, string? jsonPath = null)
: base(path, jsonPath) { }
/// <summary>load from existing file</summary>
public NetworkFileStreamPersister(string path, string jsonPath = null)
: base(path, jsonPath) { }
protected override JsonSerializerSettings GetSerializerSettings() => NetworkFileStream.GetJsonSerializerSettings();
}
protected override void Dispose(bool disposing)
{
if (disposing)
NetworkFileStream?.Dispose();
base.Dispose(disposing);
}
}
+16
View File
@@ -0,0 +1,16 @@
using FileManager;
namespace AaxDecrypter;
public record TempFile
{
public LongPath FilePath { get; init; }
public string Extension { get; }
public MultiConvertFileProperties? PartProperties { get; init; }
public TempFile(LongPath filePath, string? extension = null)
{
FilePath = filePath;
extension ??= System.IO.Path.GetExtension(filePath);
Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant();
}
}
@@ -1,112 +1,33 @@
using System;
using System.Threading;
using FileManager;
using System.Threading.Tasks;
using Dinah.Core.Net.Http;
using FileManager;
namespace AaxDecrypter
namespace AaxDecrypter;
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
{
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
protected override long InputFilePosition => InputFileStream.WritePosition;
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
: base(outDirectory, cacheDirectory, dlLic)
{
AsyncSteps.Name = "Download Unencrypted Audiobook";
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
}
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic) { }
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
await (InputFileStream.DownloadTask ?? Task.CompletedTask);
public override async Task<bool> RunAsync()
if (IsCanceled)
return false;
else
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
//Step 1
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Step 1: Get Mp3 Metadata");
else
{
Serilog.Log.Information("Failed to Complete Step 1: Get Mp3 Metadata");
return false;
}
//Step 2
Serilog.Log.Information("Begin Step 2: Download Audiobook");
if (await Task.Run(Step_DownloadAudiobookAsSingleFile))
Serilog.Log.Information("Completed Step 2: Download Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Step 2: Download Audiobook");
return false;
}
//Step 3
Serilog.Log.Information("Begin Step 3: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 3: Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Step 3: Cleanup");
return false;
}
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
}
public override Task CancelAsync()
{
IsCanceled = true;
CloseInputFileStream();
return Task.CompletedTask;
}
protected bool Step_GetMetadata()
{
OnRetrievedCoverArt(null);
return !IsCanceled;
}
private bool Step_DownloadAudiobookAsSingleFile()
{
DateTime startTime = DateTime.Now;
// MUST put InputFileStream.Length first, because it starts background downloader.
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
{
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
Thread.Sleep(200);
}
CloseInputFileStream();
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
SetOutputFileName(realOutputFileName);
OnFileCreated(realOutputFileName);
return !IsCanceled;
FinalizeDownload();
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
OnTempFileCreated(tempFile);
return true;
}
}
}
+12 -4
View File
@@ -1,16 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>8.3.6.1</Version>
<TargetFramework>net10.0</TargetFramework>
<Version>14.0.2</Version>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="1.0.1" />
<PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks -->
<!-- Only File sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<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>
@@ -18,4 +26,4 @@
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>
</Project>
File diff suppressed because it is too large. Load diff
+244 -250
View File
@@ -1,273 +1,267 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;
using Dinah.Core;
using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
namespace AppScaffolding
namespace AppScaffolding;
/// <summary>
///
///
/// directly manipulates settings files without going through domain logic.
///
/// for migrations only. use with caution.
///
///
/// </summary>
public static class UNSAFE_MigrationHelper
{
/// <summary>
///
///
/// directly manipulates settings files without going through domain logic.
///
/// for migrations only. use with caution.
///
///
/// </summary>
public static class UNSAFE_MigrationHelper
public static string? SettingsDirectory
=> !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null
? null
: value;
#region appsettings.json
public static bool APPSETTINGS_TryGet(string key, out string? value)
{
public static string SettingsDirectory
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
? null
: value;
bool success = false;
JToken? val = null;
#region appsettings.json
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json");
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
value = success ? val?.Value<string>() : null;
return success;
}
public static bool APPSETTINGS_TryGet(string key, out string value)
/// <summary>only insert if not exists</summary>
public static void APPSETTINGS_Insert(string key, string value)
=> process_APPSETTINGS_Json(jObj => jObj.TryAdd(key, value));
/// <summary>only update if exists</summary>
public static void APPSETTINGS_Update(string key, string value)
=> process_APPSETTINGS_Json(jObj =>
{
bool success = false;
JToken val = null;
if (jObj.ContainsKey(key))
jObj[key] = value;
});
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
/// <summary>only delete if exists</summary>
public static void APPSETTINGS_Delete(string key)
=> process_APPSETTINGS_Json(jObj =>
{
if (jObj.ContainsKey(key))
jObj.Remove(key);
});
value = success ? val.Value<string>() : null;
return success;
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
{
if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile)
return;
var startingContents = File.ReadAllText(appSettingsFile);
JObject jObj;
try
{
jObj = JObject.Parse(startingContents);
}
catch
{
return;
}
/// <summary>only insert if not exists</summary>
public static void APPSETTINGS_Insert(string key, string value)
=> process_APPSETTINGS_Json(jObj => jObj.TryAdd(key, value));
action(jObj);
/// <summary>only update if exists</summary>
public static void APPSETTINGS_Update(string key, string value)
=> process_APPSETTINGS_Json(jObj => {
if (jObj.ContainsKey(key))
jObj[key] = value;
});
if (!save)
return;
/// <summary>only delete if exists</summary>
public static void APPSETTINGS_Delete(string key)
=> process_APPSETTINGS_Json(jObj => {
if (jObj.ContainsKey(key))
jObj.Remove(key);
});
// only save if different
var endingContents_indented = jObj.ToString(Formatting.Indented);
var endingContents_compact = jObj.ToString(Formatting.None);
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON);
public static bool Settings_TryGet(string key, out string? value)
{
bool success = false;
JToken? val = null;
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val?.Value<string>() : null;
return success;
}
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType;
}
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value)
{
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
if (val?.Type == JTokenType.String)
{
// only insert if not exists
if (!APPSETTINGS_Json_Exists)
return;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
JObject jObj;
try
{
jObj = JObject.Parse(startingContents);
}
catch
{
return;
}
action(jObj);
if (!save)
return;
// only save if different
var endingContents_indented = jObj.ToString(Formatting.Indented);
var endingContents_compact = jObj.ToString(Formatting.None);
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(APPSETTINGS_JSON, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static bool Settings_TryGet(string key, out string value)
{
bool success = false;
JToken val = null;
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
return success;
}
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{
JToken val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType;
}
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
{
JToken val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
if (val?.Type == JTokenType.String)
{
value = val.Value<string>();
return true;
}
else
{
value = null;
return false;
}
}
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
{
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
return;
process_SettingsJson(jObj =>
{
var token = jObj.SelectToken(jsonPath);
if (token is null
|| token is not JObject o
|| o[propertyName] is null)
return;
var oldValue = token.Value<string>(propertyName);
if (oldValue != newValue)
token[propertyName] = newValue;
});
}
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
{
length = 0;
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false;
JArray array = null;
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
length = array.Count;
value = val.Value<string>();
return true;
}
public static void Settings_AddToArray(string jsonPath, string newValue)
else
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
array.Add(newValue);
});
value = null;
return false;
}
/// <summary>Do not add if already exists</summary>
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
{
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
return;
for (var i = 0; i < qty; i++)
{
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
if (exists && value == newValue)
return;
}
Settings_AddToArray(arrayPath, newValue);
}
/// <summary>only remove if not exists</summary>
public static void Settings_RemoveFromArray(string jsonPath, int position)
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
if (position < array.Count)
array.RemoveAt(position);
});
}
/// <summary>only insert if not exists</summary>
public static void Settings_Insert(string key, string value)
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
/// <summary>only update if exists</summary>
public static void Settings_Update(string key, string value)
=> process_SettingsJson(jObj => {
if (jObj.ContainsKey(key))
jObj[key] = value;
});
/// <summary>only delete if exists</summary>
public static void Settings_Delete(string key)
=> process_SettingsJson(jObj => {
if (jObj.ContainsKey(key))
jObj.Remove(key);
});
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_SettingsJson(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!SettingsJson_Exists)
return;
var startingContents = File.ReadAllText(SettingsJsonPath);
JObject jObj;
try
{
jObj = JObject.Parse(startingContents);
}
catch
{
return;
}
action(jObj);
if (!save)
return;
// only save if different
var endingContents_indented = jObj.ToString(Formatting.Indented);
var endingContents_compact = jObj.ToString(Formatting.None);
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(SettingsJsonPath, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db";
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion
}
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
{
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
return;
process_SettingsJson(jObj =>
{
var token = jObj.SelectToken(jsonPath);
if (token is null
|| token is not JObject o
|| o[propertyName] is null)
return;
var oldValue = token.Value<string>(propertyName);
if (oldValue != newValue)
token[propertyName] = newValue;
});
}
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
{
length = 0;
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false;
JArray? array = null;
process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray);
length = array?.Count ?? 0;
return true;
}
public static void Settings_AddToArray(string jsonPath, string newValue)
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
(jObj.SelectToken(jsonPath) as JArray)?.Add(newValue);
});
}
/// <summary>Do not add if already exists</summary>
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
{
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
return;
for (var i = 0; i < qty; i++)
{
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
if (exists && value == newValue)
return;
}
Settings_AddToArray(arrayPath, newValue);
}
/// <summary>only remove if not exists</summary>
public static void Settings_RemoveFromArray(string jsonPath, int position)
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count)
array.RemoveAt(position);
});
}
/// <summary>only insert if not exists</summary>
public static void Settings_Insert(string key, string value)
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
/// <summary>only update if exists</summary>
public static void Settings_Update(string key, string value)
=> process_SettingsJson(jObj =>
{
if (jObj.ContainsKey(key))
jObj[key] = value;
});
/// <summary>only delete if exists</summary>
public static void Settings_Delete(string key)
=> process_SettingsJson(jObj =>
{
if (jObj.ContainsKey(key))
jObj.Remove(key);
});
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_SettingsJson(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!File.Exists(SettingsJsonPath))
return;
var startingContents = File.ReadAllText(SettingsJsonPath);
JObject jObj;
try
{
jObj = JObject.Parse(startingContents);
}
catch
{
return;
}
action(jObj);
if (!save)
return;
// only save if different
var endingContents_indented = jObj.ToString(Formatting.Indented);
var endingContents_compact = jObj.ToString(Formatting.None);
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(SettingsJsonPath, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db";
public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion
}
+39 -3
View File
@@ -1,6 +1,42 @@
using System;
using System;
using System.Text.RegularExpressions;
namespace AppScaffolding
namespace AppScaffolding;
/// <summary>Result of a version check: definitive up-to-date, update available, or unable to determine.</summary>
public enum VersionCheckOutcome
{
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
UpToDate,
UpdateAvailable,
UnableToDetermine,
}
/// <summary>Result of checking for a new version. Use <see cref="Outcome"/> to show the right message; <see cref="UpgradeProperties"/> is set only when <see cref="Outcome"/> is <see cref="VersionCheckOutcome.UpdateAvailable"/>.</summary>
public record VersionCheckResult(VersionCheckOutcome Outcome, UpgradeProperties? UpgradeProperties = null);
public partial record UpgradeProperties
{
public string ZipUrl { get; }
public string HtmlUrl { get; }
public string ZipName { get; }
public Version LatestRelease { get; }
public string Notes { get; }
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
{
ZipName = zipName;
HtmlUrl = htmlUrl;
ZipUrl = zipUrl;
LatestRelease = latestRelease;
var text = NoAppBlockRegex().Replace(notes, "");
text = LinkStripRegex().Replace(text, "$1");
Notes = text.Trim();
}
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
private static partial Regex LinkStripRegex();
[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.";
}
@@ -1,17 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="28.0.1" />
<PackageReference Include="NPOI" Version="2.5.6" />
<PackageReference Include="ClosedXML" Version="0.105.1">
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
<ProjectReference Include="..\DataLayer.Postgres\DataLayer.Postgres.csproj" />
<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'">
@@ -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;
}
}
}
@@ -0,0 +1,76 @@
using DataLayer;
using Dinah.Core;
using LibationFileManager;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ApplicationServices;
public class BulkSetDownloadStatus
{
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
public int Count => actionSets.Count;
public IEnumerable<string> Messages => actionSets.Select(a => a.message);
public string AggregateMessage => $"Are you sure you want to set {Messages.Aggregate((a, b) => $"{a} and {b}")}?";
private readonly List<LibraryBook> _libraryBooks;
private readonly bool _setDownloaded;
private readonly bool _setNotDownloaded;
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
{
_libraryBooks = libraryBooks;
_setDownloaded = setDownloaded;
_setNotDownloaded = setNotDownloaded;
}
public int Discover()
{
var bookExistsList = _libraryBooks
.Select(libraryBook => new
{
LibraryBook = libraryBook,
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
})
.ToList();
if (_setDownloaded)
{
var books2change = bookExistsList
.Where(a => a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
.Select(a => a.LibraryBook)
.ToList();
if (books2change.Any())
actionSets.Add((
$"{"book".PluralizeWithCount(books2change.Count)} to 'Downloaded'",
LiberatedStatus.Liberated,
books2change));
}
if (_setNotDownloaded)
{
var books2change = bookExistsList
.Where(a => !a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
.Select(a => a.LibraryBook)
.ToList();
if (books2change.Any())
actionSets.Add((
$"{"book".PluralizeWithCount(books2change.Count)} to 'Download Pending'",
LiberatedStatus.NotLiberated,
books2change));
}
return Count;
}
public async Task ExecuteAsync()
{
foreach (var a in actionSets)
await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
}
}
+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;
}
}
+149 -13
View File
@@ -1,21 +1,157 @@
using System;
using System.Collections.Generic;
using DataLayer;
using DataLayer;
using LibationFileManager;
using Serilog;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
namespace ApplicationServices
namespace ApplicationServices;
public static class DbContexts
{
public static class DbContexts
{
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString);
private static bool _sqliteDbValidated;
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
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);
try
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents);
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))
{
EssentialFileValidator.ValidateCreatedAndReport(SqliteStorage.DatabasePath);
_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)
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents);
}
public static List<LibraryBook> GetUnliberated_Flat_NoTracking()
{
using var context = GetContext();
return context.GetUnLiberated_Flat_NoTracking();
}
public static List<LibraryBook> GetDeletedLibraryBooks()
{
using var context = GetContext();
return context.GetDeletedLibraryBooks();
}
/// <summary>How many books belong in the search index. A row count only; no entities are loaded.</summary>
public static int GetIndexableBookCount()
{
using var context = GetContext();
return context.GetIndexableBookCount();
}
/// <summary>
/// How many <see cref="LibraryBook"/> rows are in the trash. A row count only; no entities are loaded,
/// so this is cheap enough to refresh whenever the library changes.
/// </summary>
public static int GetTrashedBookCount()
{
using var context = GetContext();
return context.GetLibraryBookCountsByTrashFlag().InTrash;
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
{
using var context = GetContext();
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);
}
+140
View File
@@ -0,0 +1,140 @@
using DataLayer;
using Newtonsoft.Json;
using System;
using System.Linq;
namespace ApplicationServices;
internal class ExportDto(LibraryBook libBook)
{
[Name("Account")]
public string Account { get; } = libBook.Account;
[Name("Date Added to library")]
public DateTime DateAdded { get; } = libBook.DateAdded;
[Name("Is Audible Plus?")]
public bool IsAudiblePlus { get; } = libBook.IsAudiblePlus;
[Name("Absent from last scan?")]
public bool AbsentFromLastScan { get; } = libBook.AbsentFromLastScan;
[Name("Audible Product Id")]
public string AudibleProductId { get; } = libBook.Book.AudibleProductId;
[Name("Locale")]
public string Locale { get; } = libBook.Book.Locale;
[Name("Title")]
public string Title { get; } = libBook.Book.Title;
[Name("Subtitle")]
public string Subtitle { get; } = libBook.Book.Subtitle;
[Name("Authors")]
public string AuthorNames { get; } = libBook.Book.AuthorNames;
[Name("Narrators")]
public string NarratorNames { get; } = libBook.Book.NarratorNames;
[Name("Length In Minutes")]
public int LengthInMinutes { get; } = libBook.Book.LengthInMinutes;
[Name("Description")]
public string Description { get; } = libBook.Book.Description;
[Name("Publisher")]
public string? Publisher { get; } = libBook.Book.Publisher;
[Name("Has PDF")]
public bool HasPdf { get; } = libBook.Book.HasPdf;
[Name("Series Names")]
public string SeriesNames { get; } = libBook.Book.SeriesNames();
[Name("Series Order")]
public string SeriesOrder { get; } = libBook.Book.SeriesLink?.Any() is true ? string.Join(", ", libBook.Book.SeriesLink.Select(sl => $"{sl.Order} : {sl.Series.Name}")) : "";
[Name("Community Rating: Overall")]
public float? CommunityRatingOverall { get; } = ZeroIsNull(libBook.Book.Rating?.OverallRating);
[Name("Community Rating: Performance")]
public float? CommunityRatingPerformance { get; } = ZeroIsNull(libBook.Book.Rating?.PerformanceRating);
[Name("Community Rating: Story")]
public float? CommunityRatingStory { get; } = ZeroIsNull(libBook.Book.Rating?.StoryRating);
[Name("Cover Id")]
public string? PictureId { get; } = libBook.Book.PictureId;
[Name("Cover Id Large")]
public string? PictureLarge { get; } = libBook.Book.PictureLarge;
[Name("Is Abridged?")]
public bool IsAbridged { get; } = libBook.Book.IsAbridged;
[Name("Date Published")]
public DateTime? DatePublished { get; } = libBook.Book.DatePublished;
[Name("Categories")]
public string CategoriesNames { get; } = string.Join("; ", libBook.Book.LowestCategoryNames());
[Name("My Rating: Overall")]
public float? MyRatingOverall { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.OverallRating);
[Name("My Rating: Performance")]
public float? MyRatingPerformance { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.PerformanceRating);
[Name("My Rating: Story")]
public float? MyRatingStory { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.StoryRating);
[Name("My Libation Tags")]
public string MyLibationTags { get; } = libBook.Book.UserDefinedItem.Tags;
[Name("Book Liberated Status")]
public string BookStatus { get; } = libBook.Book.UserDefinedItem.BookStatus.ToString();
[Name("PDF Liberated Status")]
public string? PdfStatus { get; } = libBook.Book.UserDefinedItem.PdfStatus.ToString();
[Name("Content Type")]
public string ContentType { get; } = libBook.Book.ContentType.ToString();
[Name("Language")]
public string? Language { get; } = libBook.Book.Language;
[Name("Last Downloaded")]
public DateTime? LastDownloaded { get; } = libBook.Book.UserDefinedItem.LastDownloaded;
[Name("Last Downloaded Version")]
public string? LastDownloadedVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString();
[Name("Is Finished?")]
public bool IsFinished { get; } = libBook.Book.UserDefinedItem.IsFinished;
[Name("Is Spatial?")]
public bool IsSpatial { get; } = libBook.Book.IsSpatial;
[Name("Included Until")]
public DateTime? IncludedUntil { get; } = libBook.IncludedUntil;
[Name("Last Downloaded File Version")]
public string? LastDownloadedFileVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedFileVersion;
[Ignore /* csv ignore */]
public AudioFormat? LastDownloadedFormat { get; } = libBook.Book.UserDefinedItem.LastDownloadedFormat;
[Name("Last Downloaded Codec"), JsonIgnore]
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
[Name("Last Downloaded Sample rate"), JsonIgnore]
public int? SampleRate => LastDownloadedFormat?.SampleRate;
[Name("Last Downloaded Audio Channels"), JsonIgnore]
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
[Name("Last Downloaded Bitrate"), JsonIgnore]
public int? BitRate => LastDownloadedFormat?.BitRate;
private static float? ZeroIsNull(float? value) => value is 0 ? null : value;
}
Loaded 100 of 1205 files, more files were not shown because too many files have changed in this diff. Show more