Add CLI upload verb to send already-liberated books to Audiobookshelf - #1918
Add CLI upload verb to send already-liberated books to Audiobookshelf#1918caiowilson wants to merge 2 commits into
upload verb to send already-liberated books to Audiobookshelf#1918Conversation
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>
|
Thanks for this - clear write-up, and the backfill gap is real. The 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
|
|
Oh yeah, you're also welcome to do the honors of adding yourself to this :) Libation/Source/LibationUiBase/LibationContributor.cs Lines 30 to 56 in 7914256 Thanks for helping to improve Libation! |
What
Adds a
uploadverb to LibationCli: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
UploadToAudiobookshelfthat this verb exposes, and narrows which books qualify for upload.Why
UploadToAudiobookshelfalready exists and works, but it has exactly one entry point:LiberateOptions.CreateBackupBookchains it ontoDownloadDecryptBook.Completed. Upload only ever happens as a side effect of a fresh download.That leaves no path to the server for:
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
--idtargeting,--override,--libationFiles, the console progress bar, and stderr error printing.UploadToAudiobookshelf.Validatealready 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
Validaterejects every book and the run looks like a silent no-op. All five settings carry[Description], so they can be supplied per-run with--overridefor headless or Docker use.File-lookup defect
ValidateandGetFilesToUploadconsulted independent sources of truth:ValidateBook.AudioExists(EntityExtensions.cs:23)GetFilesToUploadFilePathCache.GetFilesA book liberated long ago has
BookStatus == Liberatedand passesValidate. If the path cache has no entry for it — cache lost, files moved, or liberated by a version predating the cache —GetFilesToUploadreturned an empty list, andProcessAsyncreturned an emptyStatusHandler, 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-FileTypefrom that type's extensions, so the disk scan cannot pull in PDFs or cover art.Candidate filter
Validatenow requiresLiberatedStatus.Liberated. It previously usedBook.AudioExists, which also acceptsLiberatedStatus.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:Raised once per processed book. The verb subscribes, writes failures to stderr, and prints an end-of-run summary:
Exit code stays 0 in all cases, matching every other processable verb.
Design notes
ProcessAsyncstill always returns a successfulStatusHandler. This was the one non-obvious call. It would be natural for a command whose only job is uploading to return real errors, butUploadToAudiobookshelfis not CLI-only —ProcessBookViewModel.cs:272adds it to the GUI process queue. There, a non-successStatusHandlerfalls through toProcessBookResult.None, which invokesGetFailureActionAsync(the per-book Abort/Retry/Ignore dialog), breaks the step loop, and marks the bookFailed. A failed Audiobookshelf upload would have failed a GUI liberation, contradicting the documented behavior indocs/features/audiobookshelf.md. Failures therefore travel onOutcomeDetermined, which is why that event carries a message.LiberateOptionsand the GUI queue do not subscribe and are unaffected. A regression test pins this.skippedexists because the event cannot see every book. On a targeted run,ProcessSingleAsyncreturns"Validation failed"without ever callingProcessAsync, so no outcome is raised. Without a skipped count,libationcli upload B0NOTLIBERATEDwould print a summary of all zeros. The verb counts candidates throughRunAsync'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:
UploadBookAsynccallsBookExistsAsyncbefore every upload and returnsAlreadyExistson 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. ALastUploadedToAudiobookshelffield onUserDefinedItemwould 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 coveringValidate, the pure upload-file-list composition, the disk-scan lookup, and the soft-fail invariant.The defect fix was verified by reproducing it first:
GetAudioFilesOnDiskwas implemented with the old cache-only lookup and the test failed withExpected:<1>. Actual:<0>against a file sitting on disk, then the lookup was switched.FileLiberatorgains a_InternalsVisible.cs, matching the existing pattern inLibationSearchEngineandAudibleUtilities.Locally: 1012 tests across all 7 test projects pass.
FileLiberator,LibationUiBase,LibationAvalonia, andLibationClibuild. WinForms was not built locally (macOS); CI covers it.Notes
LibationCli.Testsproject exists, so the verb itself has no unit tests. It is verified by build and by--helpoutput. Say the word if you would like a test project added.UploadBookAsyncround trip is unchanged by this PR but the new entry point into it has not been run against real hardware.docs/features/audiobookshelf.mdgains a backfill section, anddocs/advanced/command-line-interface.mdno longer claims upload happens only duringliberate.UploadBookAsync(decrypts, then passes the decrypted value toBookExistsAsync, which decrypts again) was checked and is harmless —DecryptTokenreturns its input unchanged without theenc:prefix. Left alone. Mentioning it only so it is not re-investigated.🤖 Generated with Claude Code