Skip to content

Add CLI upload verb to send already-liberated books to Audiobookshelf - #1918

Open
caiowilson wants to merge 2 commits into
rmcrackan:masterfrom
caiowilson:caiowilson/cli-audiobookshelf-upload
Open

Add CLI upload verb to send already-liberated books to Audiobookshelf#1918
caiowilson wants to merge 2 commits into
rmcrackan:masterfrom
caiowilson:caiowilson/cli-audiobookshelf-upload

Conversation

@caiowilson

Copy link
Copy Markdown

What

Adds a upload verb to LibationCli:

libationcli upload                 # every liberated book
libationcli upload B017V4IM1G      # a specific title
libationcli upload --id B017V4IM1G

It uploads books to Audiobookshelf using the audio files already on disk. Nothing is re-downloaded from Audible. No local file is moved or deleted.

Also fixes a file-lookup defect in UploadToAudiobookshelf that this verb exposes, and narrows which books qualify for upload.

Why

UploadToAudiobookshelf already exists and works, but it has exactly one entry point: LiberateOptions.CreateBackupBook chains it onto DownloadDecryptBook.Completed. Upload only ever happens as a side effect of a fresh download.

That leaves no path to the server for:

  • books liberated before Audiobookshelf was configured
  • books liberated while the integration was disabled
  • books whose upload failed once and was never retried

The only existing workaround is liberate --force, which re-downloads the entire library and re-consumes Audible bandwidth to move files that are already on disk.

How

The verb

UploadOptions : ProcessableOptionsBase (Source/LibationCli/Options/UploadOptions.cs). Setup.LoadVerbs() discovers verbs by reflection, so it self-registers.

The base class already provides everything the verb needs: positional ASIN and --id targeting, --override, --libationFiles, the console progress bar, and stderr error printing. UploadToAudiobookshelf.Validate already selects the right candidate set. The verb is mostly plumbing an existing strategy into an existing runner.

Before iterating, it checks that Audiobookshelf is enabled and that server URL, token, library, and folder are all set. Without that check Validate rejects every book and the run looks like a silent no-op. All five settings carry [Description], so they can be supplied per-run with --override for headless or Docker use.

File-lookup defect

Validate and GetFilesToUpload consulted independent sources of truth:

Method Source Meaning
Validate Book.AudioExists (EntityExtensions.cs:23) a DB status flag
GetFilesToUpload FilePathCache.GetFiles a JSON cache of paths

A book liberated long ago has BookStatus == Liberated and passes Validate. If the path cache has no entry for it — cache lost, files moved, or liberated by a version predating the cache — GetFilesToUpload returned an empty list, and ProcessAsync returned an empty StatusHandler, which means success. The book was silently skipped and the run reported done.

The existing chained call site never hits this, because it runs seconds after the download that populated the cache. Bulk backfill hits it constantly.

Lookup now goes through AudibleFileStorage.Audio.GetPaths, which unions the path cache with a live scan of the Books directory. That method's regex is built per-FileType from that type's extensions, so the disk scan cannot pull in PDFs or cover art.

Candidate filter

Validate now requires LiberatedStatus.Liberated. It previously used Book.AudioExists, which also accepts LiberatedStatus.Error. An errored liberation may have left partial files, and uploading those is worse than skipping them. Those books were only ever reachable here by accident.

Outcome reporting

New event on UploadToAudiobookshelf:

public enum UploadOutcome { Uploaded, AlreadyExists, NoFilesFound, Failed }
public event EventHandler<UploadOutcomeEventArgs>? OutcomeDetermined;

Raised once per processed book. The verb subscribes, writes failures to stderr, and prints an end-of-run summary:

Audiobookshelf upload summary:
  uploaded:          12
  already on server:  3
  no files found:     1
  failed:             2
  skipped:            0

Exit code stays 0 in all cases, matching every other processable verb.

Design notes

ProcessAsync still always returns a successful StatusHandler. This was the one non-obvious call. It would be natural for a command whose only job is uploading to return real errors, but UploadToAudiobookshelf is not CLI-only — ProcessBookViewModel.cs:272 adds it to the GUI process queue. There, a non-success StatusHandler falls through to ProcessBookResult.None, which invokes GetFailureActionAsync (the per-book Abort/Retry/Ignore dialog), breaks the step loop, and marks the book Failed. A failed Audiobookshelf upload would have failed a GUI liberation, contradicting the documented behavior in docs/features/audiobookshelf.md. Failures therefore travel on OutcomeDetermined, which is why that event carries a message. LiberateOptions and the GUI queue do not subscribe and are unaffected. A regression test pins this.

skipped exists because the event cannot see every book. On a targeted run, ProcessSingleAsync returns "Validation failed" without ever calling ProcessAsync, so no outcome is raised. Without a skipped count, libationcli upload B0NOTLIBERATED would print a summary of all zeros. The verb counts candidates through RunAsync's per-book callback and derives the difference.

No database migration, deliberately. Libation records nothing about what it has already uploaded. Deduplication already runs server-side: UploadBookAsync calls BookExistsAsync before every upload and returns AlreadyExists on a match. Backfill is correct without local state. The cost is speed on repeat runs — roughly two library-search calls per candidate, sequential. That seemed the right trade for a command intended to run once. A LastUploadedToAudiobookshelf field on UserDefinedItem would make repeat runs fast and would also give the existing auto-upload a memory it currently lacks, but it did not seem worth a migration across both SQLite and Postgres for this. Happy to add it if you would rather have it.

No GUI change. The verb is CLI-only.

Testing

New Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs, 20 tests covering Validate, the pure upload-file-list composition, the disk-scan lookup, and the soft-fail invariant.

The defect fix was verified by reproducing it first: GetAudioFilesOnDisk was implemented with the old cache-only lookup and the test failed with Expected:<1>. Actual:<0> against a file sitting on disk, then the lookup was switched.

FileLiberator gains a _InternalsVisible.cs, matching the existing pattern in LibationSearchEngine and AudibleUtilities.

Locally: 1012 tests across all 7 test projects pass. FileLiberator, LibationUiBase, LibationAvalonia, and LibationCli build. WinForms was not built locally (macOS); CI covers it.

Notes

  • No LibationCli.Tests project exists, so the verb itself has no unit tests. It is verified by build and by --help output. Say the word if you would like a test project added.
  • End-to-end upload was not exercised against a live Audiobookshelf server. The offline paths are tested; the actual UploadBookAsync round trip is unchanged by this PR but the new entry point into it has not been run against real hardware.
  • Docs updated: docs/features/audiobookshelf.md gains a backfill section, and docs/advanced/command-line-interface.md no longer claims upload happens only during liberate.
  • The token double-decrypt in UploadBookAsync (decrypts, then passes the decrypted value to BookExistsAsync, which decrypts again) was checked and is harmless — DecryptToken returns its input unchanged without the enc: prefix. Left alone. Mentioning it only so it is not re-investigated.

🤖 Generated with Claude Code

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>
@rmcrackan

Copy link
Copy Markdown
Owner

Thanks for this - clear write-up, and the backfill gap is real. The GetPaths fix and keeping upload soft-fail via OutcomeDetermined (so the GUI liberate path stays clean) look right.

Please treat the items below as actionable follow-ups on this PR (or a tight follow-up PR if you prefer). Another AI agent reading this may be able to implement them directly.

Requested change: nest under an abs command group

Please do not ship a top-level upload verb. Audiobookshelf should get its own CLI namespace so we can add more ABS commands later without cluttering the root verb list.

Desired UX:

libationcli abs upload
libationcli abs upload B017V4IM1G
libationcli abs upload --id B017V4IM1G

Today LibationCli only has flat CommandLineParser verbs (see [Verb("liberate", ...)], [Verb("import-account", ...)], etc. in Source/LibationCli/Options/). There is no nested subcommand infrastructure yet, so this will need a small CLI design addition, not just renaming the attribute.

If nested verbs are awkward with the current CommandLineParser setup, an acceptable interim that matches existing kebab-case style would be:

[Verb("abs-upload", HelpText = "Upload already-liberated books to Audiobookshelf. Default: all liberated titles.\n"
	+ "Optional: specify product id(s) via --id or positional ASIN(s).\n"
	+ "Books are never re-downloaded and local files are never deleted.")]
public class AbsUploadOptions : ProcessableOptionsBase
libationcli abs-upload
libationcli abs-upload B017V4IM1G
libationcli abs-upload --id B017V4IM1G

Prefer true nesting (abs upload) if you can get it working cleanly with help text (libationcli help abs, libationcli abs upload --help). If you go interim abs-upload, note that in the PR so we can migrate to abs ... later. Update docs (docs/advanced/command-line-interface.md, docs/features/audiobookshelf.md) to match whichever form you ship.

Keep using ProcessableOptionsBase so positional ASINs and --id stay consistent with liberate / convert.

Other follow-ups

  1. skipped count vs ASIN not in library
    candidates++ only runs when the book exists, so ... upload B0NOTINLIBRARY can print a stderr skip but show skipped: 0. Count "ASIN not found" as skipped, or document that skipped means only "found but Validate failed."

  2. HelpText accuracy
    Do not claim "whose audio is on disk" unless Validate actually checks disk. Today Validate is LiberatedStatus.Liberated; missing files become NoFilesFound. Align help text with that.

  3. m4b + mp3 dual upload
    AudibleFileStorage.Audio.GetPaths can return both after a convert. Prefer one audio payload (e.g. keep .m4b if present, else .mp3) before calling ABS.

  4. Nits (nice to have)

    • Sort multipart audio paths before upload.
    • Log NoFilesFound at Warning, not Error.
    • Optional: non-zero exit when ABS config / Books dir is missing (per-book soft failures can still exit 0).

What already looks good (please keep)

  • GetPaths instead of cache-only lookup
  • Validate narrowed to Liberated (not Error)
  • ProcessAsync always returns success StatusHandler; failures via OutcomeDetermined
  • Early ABS config check before the run
  • No DB migration for upload state in v1
  • Unit tests for Validate / file-list / disk-scan / soft-fail

Happy to re-review once the verb naming/abs nesting and the skipped/help/dual-file items are addressed.

@rmcrackan

Copy link
Copy Markdown
Owner

Oh yeah, you're also welcome to do the honors of adding yourself to this :)

GitHubUser("pixil98"),
GitHubUser("hutattedonmyarm"),
GitHubUser("seanke"),
GitHubUser("wtanksleyjr"),
GitHubUser("Dr.Blank"),
GitHubUser("CharlieRussel"),
GitHubUser("cbordeman"),
GitHubUser("jwillikers"),
GitHubUser("Jo-Be-Co"),
GitHubUser("Shuvashish76"),
GitHubUser("RokeJulianLockhart"),
GitHubUser("maaximal"),
GitHubUser("matalvernaz"),
GitHubUser("muchtall"),
GitHubUser("ScubyG"),
GitHubUser("patienttruth"),
GitHubUser("stickystyle"),
GitHubUser("cherez"),
GitHubUser("delebash"),
GitHubUser("twsouthwick"),
GitHubUser("radiorambo"),
GitHubUser("Youssef1313"),
GitHubUser("niontrix"),
GitHubUser("CryptoJones"),
GitHubUser("m-j-r"),
GitHubUser("Demoniskk"),
GitHubUser("oxivanisher"),

Thanks for helping to improve Libation!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants