Commit Graph
692 Commits
Author SHA1 Message Date
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
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
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 819193fe03 Merge pull request #1958 from dmatlock171/parallel-downloads-rebased
Parallel downloads rebased
2026-08-24 13:41:58 -04: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
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 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
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 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 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 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 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
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
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 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 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 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 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 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
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
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
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
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 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 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
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 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
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 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 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
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
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
Robert McRackan 636b85f8e4 UI: add Settings export for the portable encryption master key 2026-08-01 12:09:13 -04:00
Robert McRackan 5f4a75aade Detect identity decrypt failures by exception type 2026-08-01 11:25:56 -04:00
Robert McRackan 79b6521a4e surfaced encrypted-token decrypt failure for Docker and CLI 2026-08-01 09:38:50 -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
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
Robert McRackan 20150cc4ed update dependencies 2026-07-25 22:03:23 -04:00