Merge pull request #2068 from rmcrackan/rmcrackan/abs-pdf

#2044: Add optional PDF inclusion in ABS uploads
This commit is contained in:
rmcrackan authored and GitHub committed 2026-09-15 00:09:44 -04:00
commit a0842f1b3b
9 files changed
+186 -36

No files matched your search

+22 -5
View File
@@ -136,15 +136,19 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
/// <summary>
/// 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.
/// </summary>
internal static List<string> BuildUploadFileList(IEnumerable<string> audioPaths, string? coverPath)
internal static List<string> BuildUploadFileList(IEnumerable<string> audioPaths, string? coverPath, IEnumerable<string>? 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<UploadToAudioboo
if (!string.IsNullOrWhiteSpace(coverPath))
files.Add(coverPath);
return files;
files.AddRange((pdfPaths ?? [])
.Where(p => !string.IsNullOrWhiteSpace(p) && FileTypes.GetFileTypeFromPath(p) == FileType.PDF)
.OrderBy(p => p, StringComparer.Ordinal));
return files.Distinct(StringComparer.Ordinal).ToList();
}
internal static List<string> GetFilesToUpload(LibraryBook libraryBook)
internal List<string> 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<string>();
return BuildUploadFileList(audioFiles, GetCoverArtPath(libraryBook, audioFiles.FirstOrDefault()), pdfFiles);
}
/// <summary>Libation's known cover art output path. Same logic as DownloadDecryptBook.DownloadCoverArt.</summary>
@@ -12,6 +12,11 @@
Content="{Binding EnabledText}"
Margin="0,0,0,10" />
<CheckBox IsChecked="{Binding IncludePdfs, Mode=TwoWay}"
Content="{Binding IncludePdfsText}"
IsEnabled="{Binding Enabled}"
Margin="0,0,0,10" />
<Grid ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Server URL -->
<TextBlock Grid.Row="0" Grid.Column="0"
@@ -16,6 +16,7 @@ public class AudiobookshelfSettingsVM : ViewModelBase
{
private readonly Configuration config;
private bool enabled;
private bool includePdfs;
private string serverUrl = "";
private string apiToken = "";
private string statusText = "";
@@ -32,6 +33,7 @@ public class AudiobookshelfSettingsVM : ViewModelBase
{
this.config = config;
enabled = config.AudiobookshelfEnabled;
includePdfs = config.AudiobookshelfIncludePdfs;
serverUrl = config.AudiobookshelfServerUrl ?? "";
apiToken = AudiobookshelfTokenStorage.DecryptToken(config.AudiobookshelfApiToken) ?? "";
@@ -53,6 +55,12 @@ public class AudiobookshelfSettingsVM : ViewModelBase
}
}
public bool IncludePdfs
{
get => 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());
@@ -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); }
@@ -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());
+34 -24
View File
@@ -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;
}
}
}
@@ -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,72 @@ 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;
// 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");
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);
}
}
/// <summary>
/// Books liberated before the path cache existed - or whose cache was lost - have no
/// <see cref="FilePathCache"/> entry. Backfill must still find them by scanning the Books
+10 -2
View File
@@ -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
+15 -4
View File
@@ -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.