From cd8f3df939ca5d4408b860c9266f19510c7102e3 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Mon, 14 Sep 2026 23:19:55 -0400 Subject: [PATCH 1/2] #2044: Add optional PDF inclusion in ABS uploads --- .../FileLiberator/UploadToAudiobookshelf.cs | 27 ++++-- .../Controls/Settings/Audiobookshelf.axaml | 5 ++ .../Settings/AudiobookshelfSettingsVM.cs | 10 +++ .../Configuration.PersistentSettings.cs | 3 + .../Dialogs/SettingsDialog.Audiobookshelf.cs | 4 + .../Dialogs/SettingsDialog.Designer.cs | 58 +++++++------ .../UploadToAudiobookshelfTests.cs | 82 ++++++++++++++++++- docs/advanced/command-line-interface.md | 12 ++- docs/features/audiobookshelf.md | 19 ++++- 9 files changed, 184 insertions(+), 36 deletions(-) diff --git a/Source/FileLiberator/UploadToAudiobookshelf.cs b/Source/FileLiberator/UploadToAudiobookshelf.cs index 2dadaab9..da140d07 100644 --- a/Source/FileLiberator/UploadToAudiobookshelf.cs +++ b/Source/FileLiberator/UploadToAudiobookshelf.cs @@ -136,15 +136,19 @@ public class UploadToAudiobookshelf : Processable, IProcessable /// Composes the final upload payload from one preferred audio format, in deterministic order, - /// followed by cover art at most once. + /// followed by cover art and optional PDFs at most once. /// - internal static List BuildUploadFileList(IEnumerable audioPaths, string? coverPath) + internal static List BuildUploadFileList(IEnumerable audioPaths, string? coverPath, IEnumerable? pdfPaths = null) { var audioFiles = audioPaths .Where(p => !string.IsNullOrWhiteSpace(p)) + .Where(p => FileTypes.GetFileTypeFromPath(p) == FileType.Audio) .Distinct(StringComparer.Ordinal) .ToList(); + if (audioFiles.Count == 0) + return []; + var m4bFiles = audioFiles .Where(p => p.EndsWith(".m4b", StringComparison.OrdinalIgnoreCase)) .ToList(); @@ -162,14 +166,27 @@ public class UploadToAudiobookshelf : Processable, IProcessable !string.IsNullOrWhiteSpace(p) && FileTypes.GetFileTypeFromPath(p) == FileType.PDF) + .OrderBy(p => p, StringComparer.Ordinal)); + + return files.Distinct(StringComparer.Ordinal).ToList(); } - internal static List GetFilesToUpload(LibraryBook libraryBook) + internal List GetFilesToUpload(LibraryBook libraryBook) { var audioFiles = GetAudioFilesOnDisk(libraryBook.Book.AudibleProductId); + if (audioFiles.Count == 0) + return []; - return BuildUploadFileList(audioFiles, GetCoverArtPath(libraryBook, audioFiles.FirstOrDefault())); + var pdfFiles = Configuration.AudiobookshelfIncludePdfs + ? FilePathCache.GetFiles(libraryBook.Book.AudibleProductId) + .Where(f => f.fileType == FileType.PDF) + .Select(f => (string)f.path) + .Where(File.Exists) + : Enumerable.Empty(); + + return BuildUploadFileList(audioFiles, GetCoverArtPath(libraryBook, audioFiles.FirstOrDefault()), pdfFiles); } /// Libation's known cover art output path. Same logic as DownloadDecryptBook.DownloadCoverArt. diff --git a/Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml b/Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml index f937df3b..e6baf5fc 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml @@ -12,6 +12,11 @@ Content="{Binding EnabledText}" Margin="0,0,0,10" /> + + includePdfs; + set => this.RaiseAndSetIfChanged(ref includePdfs, value); + } + public string ServerUrl { get => serverUrl; @@ -137,6 +145,7 @@ public class AudiobookshelfSettingsVM : ViewModelBase // Labels from Configuration descriptions public string EnabledText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfEnabled)); + public string IncludePdfsText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfIncludePdfs)); public string ServerUrlText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfServerUrl)); public string ApiTokenText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfApiToken)); public string LibraryText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfLibraryId)); @@ -224,6 +233,7 @@ public class AudiobookshelfSettingsVM : ViewModelBase public void SaveSettings(Configuration config) { config.AudiobookshelfEnabled = Enabled; + config.AudiobookshelfIncludePdfs = IncludePdfs; config.AudiobookshelfServerUrl = AudiobookshelfApiService.TryNormalizeServerUrlForSave(ServerUrl); ServerUrl = config.AudiobookshelfServerUrl ?? ""; config.AudiobookshelfApiToken = AudiobookshelfTokenStorage.EncryptToken(ApiToken.Trim()); diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index e0b96015..3781c933 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -376,6 +376,9 @@ public partial class Configuration [Description("Automatically upload downloaded books")] public bool AudiobookshelfEnabled { get => GetNonString(defaultValue: false); set => SetNonString(value); } + [Description("Include PDFs when uploading to Audiobookshelf")] + public bool AudiobookshelfIncludePdfs { get => GetNonString(defaultValue: false); set => SetNonString(value); } + [Description("Server URL (base address only)")] public string? AudiobookshelfServerUrl { get => GetString(); set => SetString(value); } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Audiobookshelf.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Audiobookshelf.cs index 2cfe6a20..de161d14 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Audiobookshelf.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Audiobookshelf.cs @@ -17,6 +17,7 @@ public partial class SettingsDialog private void Load_Audiobookshelf(Configuration config) { absEnabledCb.Text = desc(nameof(config.AudiobookshelfEnabled)); + absIncludePdfsCb.Text = desc(nameof(config.AudiobookshelfIncludePdfs)); absUrlLbl.Text = desc(nameof(config.AudiobookshelfServerUrl)); absTokenLbl.Text = desc(nameof(config.AudiobookshelfApiToken)); absLibraryLbl.Text = desc(nameof(config.AudiobookshelfLibraryId)); @@ -25,6 +26,7 @@ public partial class SettingsDialog absStatusLbl.Text = ""; absEnabledCb.Checked = config.AudiobookshelfEnabled; + absIncludePdfsCb.Checked = config.AudiobookshelfIncludePdfs; absUrlTb.Text = config.AudiobookshelfServerUrl ?? ""; absTokenTb.Text = AudiobookshelfTokenStorage.DecryptToken(config.AudiobookshelfApiToken) ?? ""; absTokenTb.PasswordChar = '*'; @@ -42,6 +44,7 @@ public partial class SettingsDialog private void ToggleAudiobookshelfControls(bool enabled) { + absIncludePdfsCb.Enabled = enabled; absUrlTb.Enabled = enabled; absTokenTb.Enabled = enabled; absConnectBtn.Enabled = enabled; @@ -137,6 +140,7 @@ public partial class SettingsDialog private void Save_Audiobookshelf(Configuration config) { config.AudiobookshelfEnabled = absEnabledCb.Checked; + config.AudiobookshelfIncludePdfs = absIncludePdfsCb.Checked; config.AudiobookshelfServerUrl = AudiobookshelfApiService.TryNormalizeServerUrlForSave(absUrlTb.Text); absUrlTb.Text = config.AudiobookshelfServerUrl ?? ""; config.AudiobookshelfApiToken = AudiobookshelfTokenStorage.EncryptToken(absTokenTb.Text.Trim()); diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs index a2db7a46..25f3d592 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs @@ -1,4 +1,4 @@ -namespace LibationWinForms.Dialogs +namespace LibationWinForms.Dialogs { partial class SettingsDialog { @@ -57,6 +57,7 @@ tabControl = new System.Windows.Forms.TabControl(); tab5Audiobookshelf = new System.Windows.Forms.TabPage(); absEnabledCb = new System.Windows.Forms.CheckBox(); + absIncludePdfsCb = new System.Windows.Forms.CheckBox(); absPlaintextWarningLbl = new System.Windows.Forms.Label(); absUrlLbl = new System.Windows.Forms.Label(); absUrlTb = new System.Windows.Forms.TextBox(); @@ -1072,6 +1073,7 @@ tab5Audiobookshelf.AutoScroll = true; tab5Audiobookshelf.BackColor = System.Drawing.SystemColors.Window; tab5Audiobookshelf.Controls.Add(absEnabledCb); + tab5Audiobookshelf.Controls.Add(absIncludePdfsCb); tab5Audiobookshelf.Controls.Add(absPlaintextWarningLbl); tab5Audiobookshelf.Controls.Add(absUrlLbl); tab5Audiobookshelf.Controls.Add(absUrlTb); @@ -1707,58 +1709,65 @@ absEnabledCb.Text = "[AudiobookshelfEnabled desc]"; absEnabledCb.UseVisualStyleBackColor = true; absEnabledCb.CheckedChanged += absEnabledCb_CheckedChanged; + absIncludePdfsCb.AutoSize = true; + absIncludePdfsCb.Location = new System.Drawing.Point(6, 32); + absIncludePdfsCb.Name = "absIncludePdfsCb"; + absIncludePdfsCb.Size = new System.Drawing.Size(350, 19); + absIncludePdfsCb.TabIndex = 1; + absIncludePdfsCb.Text = "[AudiobookshelfIncludePdfs desc]"; + absIncludePdfsCb.UseVisualStyleBackColor = true; // // absPlaintextWarningLbl // absPlaintextWarningLbl.AutoSize = true; absPlaintextWarningLbl.ForeColor = System.Drawing.Color.DarkOrange; - absPlaintextWarningLbl.Location = new System.Drawing.Point(6, 30); + absPlaintextWarningLbl.Location = new System.Drawing.Point(6, 56); absPlaintextWarningLbl.Name = "absPlaintextWarningLbl"; absPlaintextWarningLbl.Size = new System.Drawing.Size(480, 15); - absPlaintextWarningLbl.TabIndex = 1; + absPlaintextWarningLbl.TabIndex = 2; absPlaintextWarningLbl.Text = "Warning: The API token is stored as plaintext in Settings.json."; // // absUrlLbl // absUrlLbl.AutoSize = true; - absUrlLbl.Location = new System.Drawing.Point(6, 55); + absUrlLbl.Location = new System.Drawing.Point(6, 81); absUrlLbl.Name = "absUrlLbl"; absUrlLbl.Size = new System.Drawing.Size(70, 15); - absUrlLbl.TabIndex = 2; + absUrlLbl.TabIndex = 3; absUrlLbl.Text = "[AudiobookshelfServerUrl desc]"; // // absUrlTb // absUrlTb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; - absUrlTb.Location = new System.Drawing.Point(90, 52); + absUrlTb.Location = new System.Drawing.Point(90, 78); absUrlTb.Name = "absUrlTb"; absUrlTb.Size = new System.Drawing.Size(550, 23); - absUrlTb.TabIndex = 3; + absUrlTb.TabIndex = 4; // // absTokenLbl // absTokenLbl.AutoSize = true; - absTokenLbl.Location = new System.Drawing.Point(6, 84); + absTokenLbl.Location = new System.Drawing.Point(6, 110); absTokenLbl.Name = "absTokenLbl"; absTokenLbl.Size = new System.Drawing.Size(65, 15); - absTokenLbl.TabIndex = 4; + absTokenLbl.TabIndex = 5; absTokenLbl.Text = "[AudiobookshelfApiToken desc]"; // // absTokenTb // absTokenTb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; - absTokenTb.Location = new System.Drawing.Point(90, 81); + absTokenTb.Location = new System.Drawing.Point(90, 107); absTokenTb.Name = "absTokenTb"; absTokenTb.Size = new System.Drawing.Size(550, 23); - absTokenTb.TabIndex = 5; + absTokenTb.TabIndex = 6; // // absConnectBtn // absConnectBtn.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; - absConnectBtn.Location = new System.Drawing.Point(646, 52); + absConnectBtn.Location = new System.Drawing.Point(646, 78); absConnectBtn.Name = "absConnectBtn"; absConnectBtn.Size = new System.Drawing.Size(130, 52); - absConnectBtn.TabIndex = 6; + absConnectBtn.TabIndex = 7; absConnectBtn.Text = "Connect / Refresh"; absConnectBtn.UseVisualStyleBackColor = true; absConnectBtn.Click += absConnectBtn_Click; @@ -1766,19 +1775,19 @@ // absStatusLbl // absStatusLbl.AutoSize = true; - absStatusLbl.Location = new System.Drawing.Point(6, 114); + absStatusLbl.Location = new System.Drawing.Point(6, 140); absStatusLbl.Name = "absStatusLbl"; absStatusLbl.Size = new System.Drawing.Size(0, 15); - absStatusLbl.TabIndex = 7; + absStatusLbl.TabIndex = 8; absStatusLbl.Text = ""; // // absLibraryLbl // absLibraryLbl.AutoSize = true; - absLibraryLbl.Location = new System.Drawing.Point(6, 140); + absLibraryLbl.Location = new System.Drawing.Point(6, 166); absLibraryLbl.Name = "absLibraryLbl"; absLibraryLbl.Size = new System.Drawing.Size(50, 15); - absLibraryLbl.TabIndex = 8; + absLibraryLbl.TabIndex = 9; absLibraryLbl.Text = "[AudiobookshelfLibraryId desc]"; // // absLibraryCb @@ -1786,19 +1795,19 @@ absLibraryCb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; absLibraryCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; absLibraryCb.FormattingEnabled = true; - absLibraryCb.Location = new System.Drawing.Point(90, 137); + absLibraryCb.Location = new System.Drawing.Point(90, 163); absLibraryCb.Name = "absLibraryCb"; absLibraryCb.Size = new System.Drawing.Size(686, 23); - absLibraryCb.TabIndex = 9; + absLibraryCb.TabIndex = 10; absLibraryCb.SelectedIndexChanged += absLibraryCb_SelectedIndexChanged; // // absFolderLbl // absFolderLbl.AutoSize = true; - absFolderLbl.Location = new System.Drawing.Point(6, 170); + absFolderLbl.Location = new System.Drawing.Point(6, 196); absFolderLbl.Name = "absFolderLbl"; absFolderLbl.Size = new System.Drawing.Size(45, 15); - absFolderLbl.TabIndex = 10; + absFolderLbl.TabIndex = 11; absFolderLbl.Text = "[AudiobookshelfFolderId desc]"; // // absFolderCb @@ -1806,10 +1815,10 @@ absFolderCb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; absFolderCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; absFolderCb.FormattingEnabled = true; - absFolderCb.Location = new System.Drawing.Point(90, 167); + absFolderCb.Location = new System.Drawing.Point(90, 193); absFolderCb.Name = "absFolderCb"; absFolderCb.Size = new System.Drawing.Size(686, 23); - absFolderCb.TabIndex = 11; + absFolderCb.TabIndex = 12; // // SettingsDialog // @@ -2007,6 +2016,7 @@ private System.Windows.Forms.NumericUpDown minFileDurationNud; private System.Windows.Forms.TabPage tab5Audiobookshelf; private System.Windows.Forms.CheckBox absEnabledCb; + private System.Windows.Forms.CheckBox absIncludePdfsCb; private System.Windows.Forms.Label absPlaintextWarningLbl; private System.Windows.Forms.Label absUrlLbl; private System.Windows.Forms.TextBox absUrlTb; @@ -2019,4 +2029,4 @@ private System.Windows.Forms.Label absFolderLbl; private System.Windows.Forms.ComboBox absFolderCb; } -} \ No newline at end of file +} diff --git a/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs b/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs index 9decbf8e..993afc1a 100644 --- a/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs +++ b/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs @@ -18,8 +18,24 @@ namespace FileLiberator.Tests; [DoNotParallelize] public class UploadToAudiobookshelfTests { + private string testFilesDirectory = string.Empty; + private string? previousFilesDirectory; + + [TestInitialize] + public void IsolateFileCache() + { + previousFilesDirectory = Environment.GetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR); + testFilesDirectory = CreateEmptyBooksDirectory(); + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, testFilesDirectory); + } + [TestCleanup] - public void RestoreConfiguration() => Configuration.RestoreSingletonInstance(); + public void RestoreConfiguration() + { + Configuration.RestoreSingletonInstance(); + Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, previousFilesDirectory); + Directory.Delete(testFilesDirectory, recursive: true); + } private static Configuration ConfiguredForAudiobookshelf() { @@ -253,6 +269,70 @@ public class UploadToAudiobookshelfTests return booksDirectory; } + [TestMethod] + public void Pdf_setting_defaults_off_and_round_trips() + { + var config = ConfiguredForAudiobookshelf(); + Assert.IsFalse(config.AudiobookshelfIncludePdfs); + foreach (var enabled in new[] { true, false }) + { + config.AudiobookshelfIncludePdfs = enabled; + Assert.AreEqual(enabled, config.CreateEphemeralCopy().AudiobookshelfIncludePdfs); + } + } + + [TestMethod] + public void BuildUploadFileList_appends_distinct_pdfs_after_cover_and_excludes_zip() + { + var files = UploadToAudiobookshelf.BuildUploadFileList( + ["book.m4b"], "cover.jpg", ["b.pdf", "a.PDF", "b.pdf", "supplement.zip"]); + CollectionAssert.AreEqual(new[] { "book.m4b", "cover.jpg", "a.PDF", "b.pdf" }, files); + } + + [TestMethod] + public void BuildUploadFileList_requires_audio_even_with_attachments() + { + Assert.AreEqual(0, UploadToAudiobookshelf.BuildUploadFileList( + ["cover.jpg", "book.pdf"], "cover.jpg", ["book.pdf"]).Count); + } + + [TestMethod] + public void GetFilesToUpload_includes_only_existing_pdfs_for_this_book_when_enabled() + { + var directory = CreateEmptyBooksDirectory(); + try + { + var config = ConfiguredForAudiobookshelf(); + config.Books = directory; + var book = LibraryBookWith(LiberatedStatus.Liberated); + var audio = Path.Combine(directory, "B0TEST0001.m4b"); + var pdf = Path.Combine(directory, "supplement.pdf"); + var secondPdf = Path.Combine(directory, "second.PDF"); + var otherPdf = Path.Combine(directory, "other.pdf"); + var untrackedPdf = Path.Combine(directory, "untracked.pdf"); + var zip = Path.Combine(directory, "supplement.zip"); + foreach (var path in new[] { audio, pdf, secondPdf, otherPdf, untrackedPdf, zip }) + File.WriteAllText(path, "test content"); + FilePathCache.Insert(book.Book.AudibleProductId, audio, pdf, secondPdf, zip, Path.Combine(directory, "missing.pdf")); + FilePathCache.Insert("B0OTHER001", otherPdf); + var sut = UploadToAudiobookshelf.Create(config); + string[] Paths() => sut.GetFilesToUpload(book) + .Select(p => ((LongPath)p).PathWithoutPrefix).ToArray(); + + CollectionAssert.AreEquivalent(new[] { audio }, Paths()); + config.AudiobookshelfIncludePdfs = true; + CollectionAssert.AreEquivalent(new[] { audio, pdf, secondPdf }, Paths()); + File.Delete(pdf); + CollectionAssert.AreEquivalent(new[] { audio, secondPdf }, Paths()); + File.Delete(audio); + Assert.AreEqual(0, Paths().Length); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + /// /// Books liberated before the path cache existed - or whose cache was lost - have no /// entry. Backfill must still find them by scanning the Books diff --git a/docs/advanced/command-line-interface.md b/docs/advanced/command-line-interface.md index 859526e1..08a284ae 100644 --- a/docs/advanced/command-line-interface.md +++ b/docs/advanced/command-line-interface.md @@ -172,7 +172,7 @@ The run covers both halves of "book and pdf backups": titles that need downloadi A title's PDF comes from the same license as its audiobook, so a run that fetches both asks Audible for one license, not two. -Audiobookshelf auto-upload is not part of that second half. It runs when a title is liberated, so a run that only back-fills a PDF does not upload; use `abs upload` to send titles liberated earlier. +Audiobookshelf auto-upload is not part of that second half. It runs when a title is liberated, so a run that only back-fills a PDF does not upload; use `abs upload` to send titles liberated earlier. Titles already on Audiobookshelf remain skipped, including those missing PDFs. ## Upload Already-Liberated Books to Audiobookshelf @@ -195,7 +195,15 @@ Requires Audiobookshelf to be enabled and fully configured; otherwise the comman libationcli abs upload -o AudiobookshelfServerUrl="https://abs.example.com" -o AudiobookshelfApiToken="..." ``` -Titles already on the server are skipped. The run ends with a summary of uploaded, already-on-server, no-files-found, failed, and skipped counts. Failures are also written to stderr; the command exits 0 either way. See [Audiobookshelf Auto-Upload](/docs/features/audiobookshelf#uploading-books-you-already-liberated). +To include known local PDFs with future audiobook uploads, enable **Include PDFs when uploading to Audiobookshelf** in Settings (off by default), or override it for one run: + +```console +libationcli abs upload -o AudiobookshelfIncludePdfs=true +``` + +The same setting applies to `liberate`. It includes existing PDFs recorded for the book, regardless of when they were downloaded; missing or untracked PDFs and ZIP supplements are omitted. Audio must be available, and PDFs are never uploaded on their own. + +Titles already on the server are skipped, even if they are missing PDFs. The run ends with a summary of uploaded, already-on-server, no-files-found, failed, and skipped counts. Failures are also written to stderr; the command exits 0 either way. See [Audiobookshelf Auto-Upload](/docs/features/audiobookshelf#uploading-books-you-already-liberated). ## Liberate Pdfs Only diff --git a/docs/features/audiobookshelf.md b/docs/features/audiobookshelf.md index af3a88d8..ce4c95a2 100644 --- a/docs/features/audiobookshelf.md +++ b/docs/features/audiobookshelf.md @@ -8,9 +8,10 @@ Available in Classic (WinForms), Chardonnay (Avalonia), and the CLI `liberate` c 1. Open **Settings** -> **Audiobookshelf**. 2. Enable **Automatically upload downloaded books**. -3. Enter your Audiobookshelf **Server URL** and **API Token** (see below). -4. Click **Connect / Refresh**. Libation loads your book libraries and folders. -5. Choose the target **Library** and **Folder**, then save settings. +3. Optionally enable **Include PDFs when uploading to Audiobookshelf** (off by default). +4. Enter your Audiobookshelf **Server URL** and **API Token** (see below). +5. Click **Connect / Refresh**. Libation loads your book libraries and folders. +6. Choose the target **Library** and **Folder**, then save settings. Only libraries with media type `book` are listed (podcast libraries are excluded). @@ -54,12 +55,21 @@ When auto-upload is enabled and configured: 1. Libation liberates the book as usual (download and decrypt, then PDF if any). 2. Libation uploads the liberated audio file(s) to the selected Audiobookshelf library and folder. 3. If Libation saved cover art for that book, the cover image is included in the upload. -4. Title, author, and series from Libation's library data are sent as upload metadata. +4. If **Include PDFs when uploading to Audiobookshelf** is enabled, known local PDFs for that book are included. +5. Title, author, and series from Libation's library data are sent as upload metadata. Upload runs for GUI download/decrypt and for CLI `liberate`. It does **not** run on the separate **Convert to MP3** queue or `libationcli convert` - those paths only convert local files. Auto-upload only ever fires at the moment a book is liberated. To send books you liberated earlier, see [Uploading books you already liberated](#uploading-books-you-already-liberated). +### Including PDFs + +**Include PDFs when uploading to Audiobookshelf** applies to future uploads from both the GUI and CLI, including `abs upload`. PDFs can have been downloaded earlier or during the current run. Turning the checkbox on does not itself download or upload anything, and turning it off does not change PDF downloads to your computer. + +Libation includes only existing PDF files it has recorded for that book. Missing or untracked PDFs and ZIP supplements are omitted; Libation does not search nearby folders for PDFs. At least one audio file must be available: PDFs are never uploaded on their own. + +Books already present on Audiobookshelf are still skipped, even if they are missing PDFs. This option does not add PDFs to existing server entries. The checkbox is disabled while Audiobookshelf integration is off, but its saved value is retained. + ## Uploading books you already liberated Books liberated before Audiobookshelf was set up - or while it was turned off - are never sent by auto-upload. The `abs upload` command backfills them from the copies already on disk. Nothing is re-downloaded from Audible, and local files are never deleted. @@ -81,6 +91,7 @@ The command: - Considers only books Libation has marked as **liberated**. Books whose liberation errored are skipped, because an errored liberation may have left partial files. - Finds audio using both Libation's file-path cache **and** a scan of your Books directory, so books whose cache entry was lost are still found. +- Includes known local PDFs when **Include PDFs when uploading to Audiobookshelf** is enabled, provided audio is available. See [Including PDFs](#including-pdfs). - Checks the server before each upload and skips titles already present - see [Duplicate handling](#duplicate-handling). - Prints a summary at the end: uploaded, already on server, no files found, failed, skipped. `skipped` counts books you named by ASIN that were not eligible - most often because they are not marked as liberated. From cc819b4bf98bd63baaf4f43259a24dad28e495ec Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Mon, 14 Sep 2026 23:54:21 -0400 Subject: [PATCH 2/2] Fix Audiobookshelf PDF upload test setup Create temporary AccountsSettings.json required by cover-art filename generation, fixing the missing-file failure in CI. Refs #2044 --- .../_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs b/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs index 993afc1a..a224f003 100644 --- a/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs +++ b/Source/_Tests/FileLiberator.Tests/UploadToAudiobookshelfTests.cs @@ -304,6 +304,8 @@ public class UploadToAudiobookshelfTests { var config = ConfiguredForAudiobookshelf(); config.Books = directory; + // Cover-art naming reads account nicknames, even when no cover exists. + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); var book = LibraryBookWith(LiberatedStatus.Liberated); var audio = Path.Combine(directory, "B0TEST0001.m4b"); var pdf = Path.Combine(directory, "supplement.pdf");