From ad61618cc92c5977617c8190b3a2bb129dae4516 Mon Sep 17 00:00:00 2001 From: John Doe <69580846+Demoniskk@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:58:12 +0200 Subject: [PATCH] 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 --- .../AudiobookshelfApiService.cs | 131 ++++++++ .../FileLiberator/UploadToAudiobookshelf.cs | 117 +++++++ .../Controls/Settings/Audiobookshelf.axaml | 79 +++++ .../Controls/Settings/Audiobookshelf.axaml.cs | 18 + .../Dialogs/SettingsDialog.axaml | 8 + .../Settings/AudiobookshelfSettingsVM.cs | 227 +++++++++++++ .../ViewModels/Settings/SettingsVM.cs | 3 + .../Configuration.PersistentSettings.cs | 17 + .../ProcessQueue/ProcessBookViewModel.cs | 1 + .../ProcessQueue/ProcessQueueViewModel.cs | 2 +- .../Dialogs/SettingsDialog.Audiobookshelf.cs | 317 ++++++++++++++++++ .../Dialogs/SettingsDialog.cs | 2 + 12 files changed, 921 insertions(+), 1 deletion(-) create mode 100644 Source/ApplicationServices/AudiobookshelfApiService.cs create mode 100644 Source/FileLiberator/UploadToAudiobookshelf.cs create mode 100644 Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml create mode 100644 Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml.cs create mode 100644 Source/LibationAvalonia/ViewModels/Settings/AudiobookshelfSettingsVM.cs create mode 100644 Source/LibationWinForms/Dialogs/SettingsDialog.Audiobookshelf.cs diff --git a/Source/ApplicationServices/AudiobookshelfApiService.cs b/Source/ApplicationServices/AudiobookshelfApiService.cs new file mode 100644 index 00000000..c8ac5776 --- /dev/null +++ b/Source/ApplicationServices/AudiobookshelfApiService.cs @@ -0,0 +1,131 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; + +namespace ApplicationServices; + +public static class AudiobookshelfApiService +{ + public record Library(string Id, string Name, List Folders); + public record Folder(string Id, string FullPath); + public record LoginResponse(string Token); + + private static HttpClient CreateClient(string serverUrl) + { + var client = new HttpClient(); + client.BaseAddress = new Uri(serverUrl.TrimEnd('/') + "/"); + client.Timeout = TimeSpan.FromSeconds(30); + return client; + } + + public static async Task LoginAsync(string serverUrl, string username, string password) + { + using var client = CreateClient(serverUrl); + var content = new StringContent( + $"{{\"username\":\"{JsonEscape(username)}\",\"password\":\"{JsonEscape(password)}\"}}", + Encoding.UTF8, + "application/json"); + + var response = await client.PostAsync("login", content); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + var obj = JObject.Parse(json); + var token = obj["user"]?["token"]?.Value(); + + return token is null ? null : new LoginResponse(token); + } + + public static async Task> GetLibrariesAsync(string serverUrl, string apiToken) + { + using var client = CreateClient(serverUrl); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); + + var response = await client.GetAsync("api/libraries"); + if (!response.IsSuccessStatusCode) + return []; + + var json = await response.Content.ReadAsStringAsync(); + var obj = JObject.Parse(json); + var libraries = obj["libraries"] as JArray ?? new JArray(); + + return libraries.Select(l => new Library( + l["id"]?.Value() ?? "", + l["name"]?.Value() ?? "", + (l["folders"] as JArray ?? new JArray()) + .Select(f => new Folder( + f["id"]?.Value() ?? "", + f["fullPath"]?.Value() ?? "")) + .ToList())) + .ToList(); + } + + public static async Task TestConnectionAsync(string serverUrl, string apiToken) + { + try + { + using var client = CreateClient(serverUrl); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); + client.Timeout = TimeSpan.FromSeconds(10); + + var response = await client.GetAsync("api/libraries"); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public static async Task UploadBookAsync( + string serverUrl, + string apiToken, + string libraryId, + string folderId, + string title, + string? author, + string? series, + IEnumerable filePaths) + { + using var client = CreateClient(serverUrl); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); + client.Timeout = TimeSpan.FromMinutes(30); + + using var form = new MultipartFormDataContent(); + form.Add(new StringContent(title), "title"); + + if (!string.IsNullOrWhiteSpace(author)) + form.Add(new StringContent(author), "author"); + if (!string.IsNullOrWhiteSpace(series)) + form.Add(new StringContent(series), "series"); + + form.Add(new StringContent(libraryId), "library"); + form.Add(new StringContent(folderId), "folder"); + + int fileIndex = 0; + foreach (var path in filePaths.Where(File.Exists)) + { + var fileContent = new StreamContent(File.OpenRead(path)); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + form.Add(fileContent, fileIndex.ToString(), Path.GetFileName(path)); + fileIndex++; + } + + if (fileIndex == 0) + return false; + + var response = await client.PostAsync("api/upload", form); + return response.IsSuccessStatusCode; + } + + private static string JsonEscape(string s) + => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); +} diff --git a/Source/FileLiberator/UploadToAudiobookshelf.cs b/Source/FileLiberator/UploadToAudiobookshelf.cs new file mode 100644 index 00000000..2a0ca60c --- /dev/null +++ b/Source/FileLiberator/UploadToAudiobookshelf.cs @@ -0,0 +1,117 @@ +using ApplicationServices; +using DataLayer; +using Dinah.Core.ErrorHandling; +using FileManager; +using LibationFileManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace FileLiberator; + +public class UploadToAudiobookshelf : Processable, IProcessable +{ + public override string Name => "Upload to Audiobookshelf"; + + public override bool Validate(LibraryBook libraryBook) + { + if (!Configuration.AudiobookshelfEnabled) + return false; + + if (string.IsNullOrWhiteSpace(Configuration.AudiobookshelfServerUrl) + || string.IsNullOrWhiteSpace(Configuration.AudiobookshelfApiToken) + || string.IsNullOrWhiteSpace(Configuration.AudiobookshelfLibraryId) + || string.IsNullOrWhiteSpace(Configuration.AudiobookshelfFolderId)) + return false; + + return libraryBook.Book.AudioExists; + } + + public override async Task ProcessAsync(LibraryBook libraryBook) + { + OnBegin(libraryBook); + try + { + var files = GetFilesToUpload(libraryBook); + if (files.Count == 0) + { + OnStatusUpdate("No audio files found to upload"); + return new StatusHandler { "No audio files found to upload" }; + } + + OnStatusUpdate($"Uploading {files.Count} file(s) to Audiobookshelf..."); + + var title = libraryBook.Book.TitleWithSubtitle; + var author = libraryBook.Book.AuthorNames; + var series = libraryBook.Book.SeriesNames(); + + var success = await AudiobookshelfApiService.UploadBookAsync( + Configuration.AudiobookshelfServerUrl!, + Configuration.AudiobookshelfApiToken!, + Configuration.AudiobookshelfLibraryId!, + Configuration.AudiobookshelfFolderId!, + title, + author, + series, + files); + + if (success) + { + OnStatusUpdate("Upload to Audiobookshelf completed successfully"); + return new StatusHandler(); + } + else + { + OnStatusUpdate("Upload to Audiobookshelf failed"); + return new StatusHandler { "Upload to Audiobookshelf failed" }; + } + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error uploading {Book} to Audiobookshelf", libraryBook.LogFriendly()); + return new StatusHandler { $"Audiobookshelf upload error: {ex.Message}" }; + } + finally + { + OnCompleted(libraryBook); + } + } + + private static List GetFilesToUpload(LibraryBook libraryBook) + { + var files = new List(); + var productId = libraryBook.Book.AudibleProductId; + + // Get audio files from cache + var audioFiles = FilePathCache.GetFiles(productId) + .Where(f => f.fileType == FileType.Audio && File.Exists(f.path)) + .Select(f => (string)f.path) + .Distinct() + .ToList(); + + files.AddRange(audioFiles); + + // Also look for cover art in the same directory as the first audio file + if (audioFiles.FirstOrDefault() is { } firstAudioFile) + { + var dir = Path.GetDirectoryName(firstAudioFile); + if (dir is not null) + { + var coverFile = Directory.EnumerateFiles(dir, "*.jpg", SearchOption.TopDirectoryOnly) + .Concat(Directory.EnumerateFiles(dir, "*.jpeg", SearchOption.TopDirectoryOnly)) + .Concat(Directory.EnumerateFiles(dir, "*.png", SearchOption.TopDirectoryOnly)) + .FirstOrDefault(); + + if (coverFile is not null) + files.Add(coverFile); + } + } + + return files; + } + + public static UploadToAudiobookshelf Create(Configuration config) => new() { Configuration = config }; + private UploadToAudiobookshelf() { } +} diff --git a/Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml b/Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml new file mode 100644 index 00000000..62100610 --- /dev/null +++ b/Source/LibationAvalonia/Controls/Settings/Audiobookshelf.axaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + +