fix: Address review feedback - UI parity, soft-fail, token encryption, pagination, CLI wiring

This commit is contained in:
John Doe
2026-07-26 19:12:31 +02:00
parent ad61618cc9
commit b5e87847ff
10 changed files with 540 additions and 274 deletions

View File

@@ -13,9 +13,9 @@ namespace ApplicationServices;
public static class AudiobookshelfApiService
{
public record Library(string Id, string Name, List<Folder> Folders);
public record Library(string Id, string Name, string MediaType, List<Folder> Folders);
public record Folder(string Id, string FullPath);
public record LoginResponse(string Token);
public enum UploadResult { Success, AlreadyExists, Failed }
private static HttpClient CreateClient(string serverUrl)
{
@@ -25,67 +25,155 @@ public static class AudiobookshelfApiService
return client;
}
public static async Task<LoginResponse?> 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<string>();
return token is null ? null : new LoginResponse(token);
}
public static async Task<List<Library>> GetLibrariesAsync(string serverUrl, string apiToken)
{
apiToken = AudiobookshelfTokenStorage.DecryptToken(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 errorBody = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Audiobookshelf API returned {(int)response.StatusCode} ({response.StatusCode}) when fetching libraries. Response: {errorBody}");
}
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(
var allLibraries = libraries.Select(l => new Library(
l["id"]?.Value<string>() ?? "",
l["name"]?.Value<string>() ?? "",
l["mediaType"]?.Value<string>() ?? "book",
(l["folders"] as JArray ?? new JArray())
.Select(f => new Folder(
f["id"]?.Value<string>() ?? "",
f["fullPath"]?.Value<string>() ?? ""))
.ToList()))
.ToList();
// Only return libraries with book media type
return allLibraries.Where(l => string.Equals(l.MediaType, "book", StringComparison.OrdinalIgnoreCase)).ToList();
}
public static async Task<bool> TestConnectionAsync(string serverUrl, string apiToken)
public static async Task<bool> BookExistsAsync(string serverUrl, string apiToken, string libraryId, string title)
{
try
{
using var client = CreateClient(serverUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.Timeout = TimeSpan.FromSeconds(10);
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
using var client = CreateClient(serverUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.Timeout = TimeSpan.FromMinutes(2);
var response = await client.GetAsync("api/libraries");
return response.IsSuccessStatusCode;
}
catch
var normalizedTitle = NormalizeTitle(title);
var baseTitle = GetBaseTitle(normalizedTitle);
int page = 0;
const int limit = 500;
const int maxPages = 200;
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: looking for '{Title}' (base: '{BaseTitle}') in library {LibraryId}", normalizedTitle, baseTitle, libraryId);
while (page < maxPages)
{
return false;
var url = $"api/libraries/{Uri.EscapeDataString(libraryId)}/items?minified=1&limit={limit}&page={page}";
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Audiobookshelf API returned {(int)response.StatusCode} when checking for existing books. Body: {errorBody}");
}
var json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
var results = obj["results"] as JArray ?? new JArray();
Serilog.Log.Logger.Information("Audiobookshelf duplicate check page {Page}: received {Count} items (total={Total})", page, results.Count, obj["total"]?.Value<int>() ?? -1);
if (results.Count == 0)
break;
foreach (var item in results)
{
var itemTitle = item["media"]?["metadata"]?["title"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
var itemSubtitle = item["media"]?["metadata"]?["subtitle"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
var itemFullTitle = string.IsNullOrWhiteSpace(itemSubtitle)
? itemTitle
: $"{itemTitle}: {itemSubtitle}";
var normalizedItemTitle = NormalizeTitle(itemTitle);
var normalizedItemFullTitle = NormalizeTitle(itemFullTitle);
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: comparing search='{SearchTitle}'/'{BaseTitle}' against ABS '{ItemTitle}'/'{ItemFullTitle}' (normalized: '{NormItemTitle}'/'{NormItemFull}')", normalizedTitle, baseTitle, itemTitle, itemFullTitle, normalizedItemTitle, normalizedItemFullTitle);
if (!string.IsNullOrWhiteSpace(normalizedItemTitle))
{
// 1) Exact match against normalized full title
if (string.Equals(normalizedItemTitle, normalizedTitle, StringComparison.OrdinalIgnoreCase))
return true;
// 2) Exact match against base title (without subtitle)
if (string.Equals(normalizedItemTitle, baseTitle, StringComparison.OrdinalIgnoreCase))
return true;
// 3) Match against combined title:subtitle
if (!string.IsNullOrWhiteSpace(normalizedItemFullTitle)
&& string.Equals(normalizedItemFullTitle, normalizedTitle, StringComparison.OrdinalIgnoreCase))
return true;
// 4) Substring fallback: ABS title contains our base title (handles "The Hobbit" vs "The Hobbit: 75th Anniversary Edition")
if (!string.IsNullOrWhiteSpace(baseTitle)
&& baseTitle.Length > 5
&& normalizedItemTitle.Contains(baseTitle, StringComparison.OrdinalIgnoreCase))
return true;
}
}
// CRITICAL FIX: use results.Count < limit as primary end-of-pagination indicator.
// Do NOT fall back to results.Count for total — that caused breaking after page 1
// when the first page had exactly 'limit' items.
if (results.Count < limit)
break;
// Secondary: if API provides total, use it to avoid a blank-page round-trip
if (obj["total"]?.Value<int>() is int total && (page + 1) * limit >= total)
break;
page++;
}
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: '{SearchTitle}' not found in library {LibraryId}", normalizedTitle, libraryId);
return false;
}
public static async Task<bool> UploadBookAsync(
private static string NormalizeTitle(string? title)
{
if (string.IsNullOrWhiteSpace(title))
return title ?? "";
var normalized = title
.Replace("\u00A0", " ")
.Replace("(Unabridged)", "", StringComparison.OrdinalIgnoreCase)
.Replace("(Abridged)", "", StringComparison.OrdinalIgnoreCase)
.Trim();
// Collapse multiple spaces to single space
while (normalized.Contains(" ", StringComparison.Ordinal))
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
return normalized;
}
private static string GetBaseTitle(string fullTitle)
{
if (string.IsNullOrWhiteSpace(fullTitle))
return fullTitle;
var colonIndex = fullTitle.IndexOf(": ", StringComparison.Ordinal);
return colonIndex > 0 ? fullTitle[..colonIndex].Trim() : fullTitle;
}
public static async Task<UploadResult> UploadBookAsync(
string serverUrl,
string apiToken,
string libraryId,
@@ -95,6 +183,23 @@ public static class AudiobookshelfApiService
string? series,
IEnumerable<string> filePaths)
{
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
// Pre-check for existing item
try
{
if (await BookExistsAsync(serverUrl, apiToken, libraryId, title))
{
Serilog.Log.Logger.Information("Skipping Audiobookshelf upload: book '{Title}' already exists in library {LibraryId}", title, libraryId);
return UploadResult.AlreadyExists;
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Pre-check for existing book on Audiobookshelf failed; aborting upload");
return UploadResult.Failed;
}
using var client = CreateClient(serverUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.Timeout = TimeSpan.FromMinutes(30);
@@ -111,21 +216,49 @@ public static class AudiobookshelfApiService
form.Add(new StringContent(folderId), "folder");
int fileIndex = 0;
foreach (var path in filePaths.Where(File.Exists))
var streams = new List<Stream>();
try
{
var fileContent = new StreamContent(File.OpenRead(path));
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(fileContent, fileIndex.ToString(), Path.GetFileName(path));
fileIndex++;
foreach (var path in filePaths.Where(File.Exists))
{
var stream = File.OpenRead(path);
streams.Add(stream);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(fileContent, fileIndex.ToString(), Path.GetFileName(path));
fileIndex++;
}
if (fileIndex == 0)
{
Serilog.Log.Logger.Warning("No audio files found to upload to Audiobookshelf for '{Title}'", title);
return UploadResult.Failed;
}
var response = await client.PostAsync("api/upload", form);
if (response.IsSuccessStatusCode)
return UploadResult.Success;
var responseBody = await response.Content.ReadAsStringAsync();
Serilog.Log.Logger.Error("Audiobookshelf upload failed for '{Title}' with status {(int)response.StatusCode} ({StatusCode}). Response body: {ResponseBody}",
title, (int)response.StatusCode, response.StatusCode, responseBody);
// Treat "already exists" 500 errors as skip/success
if (response.StatusCode == System.Net.HttpStatusCode.InternalServerError
&& responseBody.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
return UploadResult.AlreadyExists;
}
return UploadResult.Failed;
}
finally
{
foreach (var stream in streams)
{
try { stream.Dispose(); } catch { /* ignored */ }
}
}
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");
}

View File

@@ -0,0 +1,56 @@
using AudibleApi.Authorization;
using LibationFileManager;
namespace ApplicationServices;
public static class AudiobookshelfTokenStorage
{
private const string EncryptedPrefix = "enc:";
private const string AssociatedData = "audiobookshelf-api-token";
public static string? EncryptToken(string? plaintextToken)
{
if (string.IsNullOrEmpty(plaintextToken))
return plaintextToken;
if (Configuration.Instance.TokenStorageMethod != TokenStorageMethod.Encrypted)
return plaintextToken;
var protector = IdentityTokenStorage.Protector;
if (protector is null)
return plaintextToken;
try
{
var encrypted = protector.Protect(plaintextToken, AssociatedData);
return EncryptedPrefix + encrypted;
}
catch
{
return plaintextToken;
}
}
public static string? DecryptToken(string? storedToken)
{
if (string.IsNullOrEmpty(storedToken))
return storedToken;
if (!storedToken.StartsWith(EncryptedPrefix))
return storedToken;
var payload = storedToken[EncryptedPrefix.Length..];
var protector = IdentityTokenStorage.Protector;
if (protector is null)
return storedToken;
try
{
return protector.Unprotect(payload, AssociatedData);
}
catch
{
return storedToken;
}
}
}

View File

@@ -38,7 +38,7 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
if (files.Count == 0)
{
OnStatusUpdate("No audio files found to upload");
return new StatusHandler { "No audio files found to upload" };
return new StatusHandler();
}
OnStatusUpdate($"Uploading {files.Count} file(s) to Audiobookshelf...");
@@ -47,7 +47,7 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
var author = libraryBook.Book.AuthorNames;
var series = libraryBook.Book.SeriesNames();
var success = await AudiobookshelfApiService.UploadBookAsync(
var result = await AudiobookshelfApiService.UploadBookAsync(
Configuration.AudiobookshelfServerUrl!,
Configuration.AudiobookshelfApiToken!,
Configuration.AudiobookshelfLibraryId!,
@@ -57,21 +57,31 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
series,
files);
if (success)
if (result == AudiobookshelfApiService.UploadResult.Success)
{
OnStatusUpdate("Upload to Audiobookshelf completed successfully");
return new StatusHandler();
}
else if (result == AudiobookshelfApiService.UploadResult.AlreadyExists)
{
OnStatusUpdate("Book already exists on Audiobookshelf; skipping upload");
Serilog.Log.Logger.Information("Book already exists on Audiobookshelf, skipping upload: {Book}", libraryBook.LogFriendly());
return new StatusHandler();
}
else
{
OnStatusUpdate("Upload to Audiobookshelf failed");
return new StatusHandler { "Upload to Audiobookshelf failed" };
OnStatusUpdate("Upload to Audiobookshelf failed; book remains liberated on disk");
Serilog.Log.Logger.Error("Audiobookshelf upload failed for {Book}, but continuing as soft-failure", libraryBook.LogFriendly());
// Soft-fail: log the error but do not mark the book as failed
return new StatusHandler();
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error uploading {Book} to Audiobookshelf", libraryBook.LogFriendly());
return new StatusHandler { $"Audiobookshelf upload error: {ex.Message}" };
Serilog.Log.Logger.Error(ex, "Error uploading {Book} to Audiobookshelf; continuing as soft-failure", libraryBook.LogFriendly());
OnStatusUpdate($"Audiobookshelf upload error: {ex.Message}");
// Soft-fail: log the error but do not mark the book as failed
return new StatusHandler();
}
finally
{
@@ -93,19 +103,20 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo
files.AddRange(audioFiles);
// Also look for cover art in the same directory as the first audio file
// Use Libation's known cover art output path (same logic as DownloadDecryptBook.DownloadCoverArt)
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();
var coverPath = AudibleFileStorage.Audio.GetCustomDirFilename(
libraryBook,
dir,
".jpg",
returnFirstExisting: false);
if (coverFile is not null)
files.Add(coverFile);
if (File.Exists(coverPath))
files.Add(coverPath);
}
}

View File

@@ -9,71 +9,86 @@
<StackPanel Margin="6" Spacing="0">
<CheckBox IsChecked="{Binding Enabled, Mode=TwoWay}"
Content="Automatically upload downloaded books to Audiobookshelf"
Content="{Binding EnabledText}"
Margin="0,0,0,10" />
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<Grid ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Server URL -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="Server URL"
Text="{Binding ServerUrlText}"
VerticalAlignment="Center"
Margin="0,0,10,6" />
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding ServerUrl, Mode=TwoWay}"
PlaceholderText="https://abs.example.com"
Margin="0,0,0,6" />
IsEnabled="{Binding Enabled}"
Margin="0,0,10,6" />
<!-- API Token -->
<TextBlock Grid.Row="1" Grid.Column="0"
Text="API Token"
Text="{Binding ApiTokenText}"
VerticalAlignment="Center"
Margin="0,0,10,10" />
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding ApiToken, Mode=TwoWay}"
PasswordChar="*"
PlaceholderText="Your ABS API token"
Margin="0,0,0,10" />
IsEnabled="{Binding Enabled}"
Margin="0,0,10,10" />
<!-- Connect Button -->
<Button Grid.Row="2" Grid.Column="1"
Content="Connect / Refresh"
<Button Grid.Row="0" Grid.Column="2"
Grid.RowSpan="2"
Content="{Binding ConnectButtonText}"
Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsConnecting}"
IsEnabled="{Binding Enabled}"
HorizontalAlignment="Left"
HorizontalContentAlignment="Center"
MinWidth="150"
MinHeight="34"
VerticalAlignment="Stretch"
Margin="0,0,0,10" />
<!-- Plaintext warning -->
<TextBlock Grid.Row="2" Grid.Column="1"
Text="{Binding PlaintextWarningText}"
Foreground="DarkOrange"
TextWrapping="Wrap"
IsVisible="{Binding PlaintextWarningVisible}"
Margin="0,0,0,10" />
<!-- Status -->
<ProgressBar Grid.Row="3" Grid.Column="1"
<ProgressBar Grid.Row="4" Grid.Column="1"
IsIndeterminate="True"
IsVisible="{Binding IsConnecting}"
Margin="0,0,0,6" />
<TextBlock Grid.Row="4" Grid.Column="1"
<TextBlock Grid.Row="5" Grid.Column="1"
Text="{Binding StatusText}"
TextWrapping="Wrap"
IsVisible="{Binding !IsConnecting}"
Margin="0,0,0,8" />
<!-- Library -->
<TextBlock Grid.Row="5" Grid.Column="0"
Text="Library"
<TextBlock Grid.Row="6" Grid.Column="0"
Text="{Binding LibraryText}"
VerticalAlignment="Center"
Margin="0,0,10,7" />
<ComboBox Grid.Row="5" Grid.Column="1"
<ComboBox Grid.Row="6" Grid.Column="1"
ItemsSource="{Binding LibraryNames}"
SelectedIndex="{Binding SelectedLibraryIndex, Mode=TwoWay}"
IsEnabled="{Binding Enabled}"
Margin="0,0,0,7" />
<!-- Folder -->
<TextBlock Grid.Row="6" Grid.Column="0"
Text="Folder"
<TextBlock Grid.Row="7" Grid.Column="0"
Text="{Binding FolderText}"
VerticalAlignment="Center"
Margin="0,0,10,0" />
<ComboBox Grid.Row="6" Grid.Column="1"
<ComboBox Grid.Row="7" Grid.Column="1"
ItemsSource="{Binding FolderNames}"
SelectedIndex="{Binding SelectedFolderIndex, Mode=TwoWay}" />
SelectedIndex="{Binding SelectedFolderIndex, Mode=TwoWay}"
IsEnabled="{Binding Enabled}"
Margin="0,0,0,0" />
</Grid>
</StackPanel>
</UserControl>

View File

@@ -1,6 +1,9 @@
using ApplicationServices;
using AudibleApi.Authorization;
using AudibleUtilities;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@@ -17,6 +20,7 @@ public class AudiobookshelfSettingsVM : ViewModelBase
private string apiToken = "";
private string statusText = "";
private bool statusIsError;
private bool plaintextWarningVisible;
private List<AudiobookshelfApiService.Library> libraries = [];
private int selectedLibraryIndex = -1;
private int selectedFolderIndex = -1;
@@ -29,7 +33,10 @@ public class AudiobookshelfSettingsVM : ViewModelBase
this.config = config;
enabled = config.AudiobookshelfEnabled;
serverUrl = config.AudiobookshelfServerUrl ?? "";
apiToken = config.AudiobookshelfApiToken ?? "";
apiToken = AudiobookshelfTokenStorage.DecryptToken(config.AudiobookshelfApiToken) ?? "";
var osSecretStoreAvailable = IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out _);
PlaintextWarningVisible = osSecretStoreAvailable && config.TokenStorageMethod == TokenStorageMethod.Plaintext;
ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync);
_ = RestoreLibrariesAsync();
@@ -65,6 +72,12 @@ public class AudiobookshelfSettingsVM : ViewModelBase
set => this.RaiseAndSetIfChanged(ref statusIsError, value);
}
public bool PlaintextWarningVisible
{
get => plaintextWarningVisible;
private set => this.RaiseAndSetIfChanged(ref plaintextWarningVisible, value);
}
public List<AudiobookshelfApiService.Library> Libraries
{
get => libraries;
@@ -113,6 +126,15 @@ public class AudiobookshelfSettingsVM : ViewModelBase
public ICommand ConnectCommand { get; }
// Labels from Configuration descriptions
public string EnabledText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfEnabled));
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));
public string FolderText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfFolderId));
public string PlaintextWarningText { get; } = "Warning: The API token is stored as plaintext in Settings.json.";
public string ConnectButtonText { get; } = "Connect / Refresh";
private void UpdateLibraryNames()
{
LibraryNames.Clear();
@@ -154,7 +176,7 @@ public class AudiobookshelfSettingsVM : ViewModelBase
var libs = await AudiobookshelfApiService.GetLibrariesAsync(ServerUrl.Trim(), ApiToken.Trim());
if (libs.Count == 0)
{
StatusText = "Could not connect or no libraries found. Check your settings.";
StatusText = "No book libraries found. Check your settings.";
StatusIsError = true;
return;
}
@@ -206,7 +228,7 @@ public class AudiobookshelfSettingsVM : ViewModelBase
{
config.AudiobookshelfEnabled = Enabled;
config.AudiobookshelfServerUrl = ServerUrl.Trim();
config.AudiobookshelfApiToken = ApiToken.Trim();
config.AudiobookshelfApiToken = AudiobookshelfTokenStorage.EncryptToken(ApiToken.Trim());
if (SelectedLibraryIndex >= 0 && Libraries.Count > SelectedLibraryIndex)
config.AudiobookshelfLibraryId = Libraries[SelectedLibraryIndex].Id;

View File

@@ -144,12 +144,14 @@ public class LiberateOptions : ProcessableOptionsBase
private static Processable CreateBackupBook(DownloadOptions.LicenseInfo? licenseInfo)
{
var downloadPdf = CreateProcessable<DownloadPdf>();
var uploadToAudiobookshelf = CreateProcessable<UploadToAudiobookshelf>();
//Chain pdf download on DownloadDecryptBook.Completed
// Chain pdf download and audiobookshelf upload on DownloadDecryptBook.Completed
void onDownloadDecryptBookCompleted(object? sender, LibraryBook e)
{
// this is fast anyway. run as sync for easy exception catching
downloadPdf.TryProcessAsync(e).GetAwaiter().GetResult();
uploadToAudiobookshelf.TryProcessAsync(e).GetAwaiter().GetResult();
}
var downloadDecryptBook = CreateProcessable<DownloadDecryptBook>(onDownloadDecryptBookCompleted);

View File

@@ -319,19 +319,19 @@ public partial class Configuration
public bool RequestSpatial { get => false; set { } } // { get => GetNonString(defaultValue: true); set => SetNonString(value); }
#region Audiobookshelf
[Description("Automatically upload downloaded books to Audiobookshelf")]
[Description("Automatically upload downloaded books")]
public bool AudiobookshelfEnabled { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Audiobookshelf server URL")]
[Description("Server URL")]
public string? AudiobookshelfServerUrl { get => GetString(); set => SetString(value); }
[Description("Audiobookshelf API token")]
[Description("API Token")]
public string? AudiobookshelfApiToken { get => GetString(); set => SetString(value); }
[Description("Audiobookshelf library ID")]
[Description("Library")]
public string? AudiobookshelfLibraryId { get => GetString(); set => SetString(value); }
[Description("Audiobookshelf folder ID")]
[Description("Folder")]
public string? AudiobookshelfFolderId { get => GetString(); set => SetString(value); }
#endregion

View File

@@ -286,6 +286,7 @@ public class ProcessBookViewModel : ReactiveObject
{
processable.Begin += Processable_Begin;
processable.Completed += Processable_Completed;
processable.StatusUpdate += Processable_StatusUpdate;
processable.StreamingProgressChanged += Streamable_StreamingProgressChanged;
processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining;
@@ -303,6 +304,7 @@ public class ProcessBookViewModel : ReactiveObject
{
processable.Begin -= Processable_Begin;
processable.Completed -= Processable_Completed;
processable.StatusUpdate -= Processable_StatusUpdate;
processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged;
processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining;
@@ -372,6 +374,9 @@ public class ProcessBookViewModel : ReactiveObject
Narrator = libraryBook.Book.NarratorNames;
}
private void Processable_StatusUpdate(object? sender, string statusUpdate)
=> LogInfo(statusUpdate);
private void Processable_Completed(object? sender, LibraryBook libraryBook)
{
if (sender is Processable processable)

View File

@@ -1,4 +1,6 @@
using ApplicationServices;
using AudibleApi.Authorization;
using AudibleUtilities;
using LibationFileManager;
using System;
using System.Collections.Generic;
@@ -10,38 +12,26 @@ namespace LibationWinForms.Dialogs;
public partial class SettingsDialog
{
private TabPage? tab5Audiobookshelf;
private CheckBox? absEnabledCb;
private TextBox? absUrlTb;
private TextBox? absTokenTb;
private Button? absConnectBtn;
private ComboBox? absLibraryCb;
private ComboBox? absFolderCb;
private Label? absUrlLbl;
private Label? absTokenLbl;
private Label? absLibraryLbl;
private Label? absFolderLbl;
private Label? absStatusLbl;
private List<AudiobookshelfApiService.Library> _absLibraries = [];
private void Load_Audiobookshelf(Configuration config)
{
CreateAudiobookshelfTab();
absEnabledCb!.Text = desc(nameof(config.AudiobookshelfEnabled));
absUrlLbl!.Text = "Server URL";
absTokenLbl!.Text = "API Token";
absLibraryLbl!.Text = "Library";
absFolderLbl!.Text = "Folder";
absConnectBtn!.Text = "Connect / Refresh";
absStatusLbl!.Text = "";
absEnabledCb.Text = desc(nameof(config.AudiobookshelfEnabled));
absUrlLbl.Text = desc(nameof(config.AudiobookshelfServerUrl));
absTokenLbl.Text = desc(nameof(config.AudiobookshelfApiToken));
absLibraryLbl.Text = desc(nameof(config.AudiobookshelfLibraryId));
absFolderLbl.Text = desc(nameof(config.AudiobookshelfFolderId));
absConnectBtn.Text = "Connect / Refresh";
absStatusLbl.Text = "";
absEnabledCb.Checked = config.AudiobookshelfEnabled;
absUrlTb!.Text = config.AudiobookshelfServerUrl ?? "";
absTokenTb!.Text = config.AudiobookshelfApiToken ?? "";
absUrlTb.Text = config.AudiobookshelfServerUrl ?? "";
absTokenTb.Text = AudiobookshelfTokenStorage.DecryptToken(config.AudiobookshelfApiToken) ?? "";
absTokenTb.PasswordChar = '*';
var osSecretStoreAvailable = IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out _);
absPlaintextWarningLbl.Visible = osSecretStoreAvailable && config.TokenStorageMethod == TokenStorageMethod.Plaintext;
ToggleAudiobookshelfControls(absEnabledCb.Checked);
// Try to restore saved selections if we have them
@@ -49,163 +39,28 @@ public partial class SettingsDialog
_ = RestoreLibraryAndFolderAsync(config);
}
private void CreateAudiobookshelfTab()
{
tab5Audiobookshelf = new TabPage
{
AutoScroll = true,
BackColor = System.Drawing.SystemColors.Window,
Location = new System.Drawing.Point(4, 24),
Name = "tab5Audiobookshelf",
Padding = new Padding(3),
Size = new System.Drawing.Size(856, 457),
TabIndex = 4,
Text = "Audiobookshelf"
};
absEnabledCb = new CheckBox
{
AutoSize = true,
Location = new System.Drawing.Point(6, 6),
Name = "absEnabledCb",
Size = new System.Drawing.Size(280, 19),
TabIndex = 0,
Text = "[AudiobookshelfEnabled desc]",
UseVisualStyleBackColor = true
};
absEnabledCb.CheckedChanged += absEnabledCb_CheckedChanged;
absUrlLbl = new Label
{
AutoSize = true,
Location = new System.Drawing.Point(6, 35),
Size = new System.Drawing.Size(70, 15),
TabIndex = 1,
Text = "Server URL"
};
absUrlTb = new TextBox
{
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
Location = new System.Drawing.Point(90, 32),
Size = new System.Drawing.Size(550, 23),
TabIndex = 2
};
absTokenLbl = new Label
{
AutoSize = true,
Location = new System.Drawing.Point(6, 64),
Size = new System.Drawing.Size(65, 15),
TabIndex = 3,
Text = "API Token"
};
absTokenTb = new TextBox
{
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
Location = new System.Drawing.Point(90, 61),
Size = new System.Drawing.Size(550, 23),
TabIndex = 4
};
absConnectBtn = new Button
{
Anchor = AnchorStyles.Top | AnchorStyles.Right,
Location = new System.Drawing.Point(646, 32),
Size = new System.Drawing.Size(130, 52),
TabIndex = 5,
Text = "Connect / Refresh",
UseVisualStyleBackColor = true
};
absConnectBtn.Click += absConnectBtn_Click;
absStatusLbl = new Label
{
AutoSize = true,
Location = new System.Drawing.Point(6, 94),
Size = new System.Drawing.Size(0, 15),
TabIndex = 6,
Text = ""
};
absLibraryLbl = new Label
{
AutoSize = true,
Location = new System.Drawing.Point(6, 120),
Size = new System.Drawing.Size(50, 15),
TabIndex = 7,
Text = "Library"
};
absLibraryCb = new ComboBox
{
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
DropDownStyle = ComboBoxStyle.DropDownList,
FormattingEnabled = true,
Location = new System.Drawing.Point(90, 117),
Size = new System.Drawing.Size(686, 23),
TabIndex = 8
};
absLibraryCb.SelectedIndexChanged += absLibraryCb_SelectedIndexChanged;
absFolderLbl = new Label
{
AutoSize = true,
Location = new System.Drawing.Point(6, 150),
Size = new System.Drawing.Size(45, 15),
TabIndex = 9,
Text = "Folder"
};
absFolderCb = new ComboBox
{
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
DropDownStyle = ComboBoxStyle.DropDownList,
FormattingEnabled = true,
Location = new System.Drawing.Point(90, 147),
Size = new System.Drawing.Size(686, 23),
TabIndex = 10
};
tab5Audiobookshelf.Controls.Add(absEnabledCb);
tab5Audiobookshelf.Controls.Add(absUrlLbl);
tab5Audiobookshelf.Controls.Add(absUrlTb);
tab5Audiobookshelf.Controls.Add(absTokenLbl);
tab5Audiobookshelf.Controls.Add(absTokenTb);
tab5Audiobookshelf.Controls.Add(absConnectBtn);
tab5Audiobookshelf.Controls.Add(absStatusLbl);
tab5Audiobookshelf.Controls.Add(absLibraryLbl);
tab5Audiobookshelf.Controls.Add(absLibraryCb);
tab5Audiobookshelf.Controls.Add(absFolderLbl);
tab5Audiobookshelf.Controls.Add(absFolderCb);
tabControl.Controls.Add(tab5Audiobookshelf);
}
private void ToggleAudiobookshelfControls(bool enabled)
{
if (absUrlTb is null) return;
absUrlTb.Enabled = enabled;
absTokenTb!.Enabled = enabled;
absConnectBtn!.Enabled = enabled;
absLibraryCb!.Enabled = enabled && _absLibraries.Count > 0;
absFolderCb!.Enabled = enabled && absLibraryCb!.SelectedIndex >= 0;
absTokenTb.Enabled = enabled;
absConnectBtn.Enabled = enabled;
absLibraryCb.Enabled = enabled && _absLibraries.Count > 0;
absFolderCb.Enabled = enabled && absLibraryCb.SelectedIndex >= 0;
}
private void absEnabledCb_CheckedChanged(object? sender, EventArgs e)
=> ToggleAudiobookshelfControls(absEnabledCb!.Checked);
=> ToggleAudiobookshelfControls(absEnabledCb.Checked);
private async void absConnectBtn_Click(object? sender, EventArgs e)
{
absConnectBtn!.Enabled = false;
absStatusLbl!.Text = "Connecting...";
absConnectBtn.Enabled = false;
absStatusLbl.Text = "Connecting...";
absStatusLbl.ForeColor = System.Drawing.SystemColors.ControlText;
try
{
var url = absUrlTb!.Text.Trim();
var token = absTokenTb!.Text.Trim();
var url = absUrlTb.Text.Trim();
var token = absTokenTb.Text.Trim();
if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(token))
{
@@ -218,20 +73,20 @@ public partial class SettingsDialog
if (libraries.Count == 0)
{
absStatusLbl.Text = "Could not connect or no libraries found. Check your settings.";
absStatusLbl.Text = "No book libraries found. Check your settings.";
absStatusLbl.ForeColor = System.Drawing.Color.DarkRed;
return;
}
_absLibraries = libraries;
absLibraryCb!.Items.Clear();
absLibraryCb.Items.Clear();
absLibraryCb.Items.AddRange(libraries.Select(l => $"{l.Name} ({l.Id})").ToArray());
absFolderCb!.Items.Clear();
absFolderCb.Items.Clear();
absFolderCb.SelectedIndex = -1;
absStatusLbl.Text = $"Connected. Found {libraries.Count} library(ies).";
absStatusLbl.ForeColor = System.Drawing.Color.DarkGreen;
ToggleAudiobookshelfControls(absEnabledCb!.Checked);
ToggleAudiobookshelfControls(absEnabledCb.Checked);
}
catch (Exception ex)
{
@@ -246,10 +101,10 @@ public partial class SettingsDialog
private void absLibraryCb_SelectedIndexChanged(object? sender, EventArgs e)
{
absFolderCb!.Items.Clear();
absFolderCb.Items.Clear();
absFolderCb.SelectedIndex = -1;
if (absLibraryCb!.SelectedIndex < 0 || _absLibraries.Count == 0)
if (absLibraryCb.SelectedIndex < 0 || _absLibraries.Count == 0)
return;
var library = _absLibraries[absLibraryCb.SelectedIndex];
@@ -272,7 +127,7 @@ public partial class SettingsDialog
var libraries = await AudiobookshelfApiService.GetLibrariesAsync(config.AudiobookshelfServerUrl, config.AudiobookshelfApiToken);
_absLibraries = libraries;
absLibraryCb!.Items.Clear();
absLibraryCb.Items.Clear();
absLibraryCb.Items.AddRange(libraries.Select(l => $"{l.Name} ({l.Id})").ToArray());
var savedLibIndex = libraries.FindIndex(l => l.Id == config.AudiobookshelfLibraryId);
@@ -282,28 +137,28 @@ public partial class SettingsDialog
var library = libraries[savedLibIndex];
var savedFolderIndex = library.Folders.FindIndex(f => f.Id == config.AudiobookshelfFolderId);
if (savedFolderIndex >= 0)
absFolderCb!.SelectedIndex = savedFolderIndex;
absFolderCb.SelectedIndex = savedFolderIndex;
}
ToggleAudiobookshelfControls(absEnabledCb!.Checked);
ToggleAudiobookshelfControls(absEnabledCb.Checked);
}
catch
catch (Exception ex)
{
// Silently fail; user can manually reconnect
Serilog.Log.Logger.Warning(ex, "Failed to restore Audiobookshelf library and folder selections");
}
}
private void Save_Audiobookshelf(Configuration config)
{
config.AudiobookshelfEnabled = absEnabledCb!.Checked;
config.AudiobookshelfServerUrl = absUrlTb!.Text.Trim();
config.AudiobookshelfApiToken = absTokenTb!.Text.Trim();
config.AudiobookshelfEnabled = absEnabledCb.Checked;
config.AudiobookshelfServerUrl = absUrlTb.Text.Trim();
config.AudiobookshelfApiToken = AudiobookshelfTokenStorage.EncryptToken(absTokenTb.Text.Trim());
if (absLibraryCb!.SelectedIndex >= 0 && _absLibraries.Count > absLibraryCb.SelectedIndex)
if (absLibraryCb.SelectedIndex >= 0 && _absLibraries.Count > absLibraryCb.SelectedIndex)
config.AudiobookshelfLibraryId = _absLibraries[absLibraryCb.SelectedIndex].Id;
else
config.AudiobookshelfLibraryId = null;
if (absFolderCb!.SelectedIndex >= 0 && absLibraryCb.SelectedIndex >= 0)
if (absFolderCb.SelectedIndex >= 0 && absLibraryCb.SelectedIndex >= 0)
{
var library = _absLibraries[absLibraryCb.SelectedIndex];
if (library.Folders.Count > absFolderCb.SelectedIndex)

View File

@@ -48,6 +48,19 @@
loggingLevelLbl = new System.Windows.Forms.Label();
loggingLevelCb = new System.Windows.Forms.ComboBox();
tabControl = new System.Windows.Forms.TabControl();
tab5Audiobookshelf = new System.Windows.Forms.TabPage();
absEnabledCb = new System.Windows.Forms.CheckBox();
absPlaintextWarningLbl = new System.Windows.Forms.Label();
absUrlLbl = new System.Windows.Forms.Label();
absUrlTb = new System.Windows.Forms.TextBox();
absTokenLbl = new System.Windows.Forms.Label();
absTokenTb = new System.Windows.Forms.TextBox();
absConnectBtn = new System.Windows.Forms.Button();
absStatusLbl = new System.Windows.Forms.Label();
absLibraryLbl = new System.Windows.Forms.Label();
absLibraryCb = new System.Windows.Forms.ComboBox();
absFolderLbl = new System.Windows.Forms.Label();
absFolderCb = new System.Windows.Forms.ComboBox();
tab1ImportantSettings = new System.Windows.Forms.TabPage();
themeLbl = new System.Windows.Forms.Label();
themeCb = new System.Windows.Forms.ComboBox();
@@ -160,6 +173,7 @@
inProgressFilesGb.SuspendLayout();
customFileNamingGb.SuspendLayout();
tab4AudioFileOptions.SuspendLayout();
tab5Audiobookshelf.SuspendLayout();
audiobookFixupsGb.SuspendLayout();
((System.ComponentModel.ISupportInitialize)minFileDurationNud).BeginInit();
chapterTitleTemplateGb.SuspendLayout();
@@ -388,6 +402,7 @@
tabControl.Controls.Add(tab2ImportLibrary);
tabControl.Controls.Add(tab3DownloadDecrypt);
tabControl.Controls.Add(tab4AudioFileOptions);
tabControl.Controls.Add(tab5Audiobookshelf);
tabControl.Location = new System.Drawing.Point(12, 12);
tabControl.Name = "tabControl";
tabControl.SelectedIndex = 0;
@@ -924,6 +939,29 @@
tab4AudioFileOptions.TabIndex = 3;
tab4AudioFileOptions.Text = "Audio File Options";
//
// tab5Audiobookshelf
//
tab5Audiobookshelf.AutoScroll = true;
tab5Audiobookshelf.BackColor = System.Drawing.SystemColors.Window;
tab5Audiobookshelf.Controls.Add(absEnabledCb);
tab5Audiobookshelf.Controls.Add(absPlaintextWarningLbl);
tab5Audiobookshelf.Controls.Add(absUrlLbl);
tab5Audiobookshelf.Controls.Add(absUrlTb);
tab5Audiobookshelf.Controls.Add(absTokenLbl);
tab5Audiobookshelf.Controls.Add(absTokenTb);
tab5Audiobookshelf.Controls.Add(absConnectBtn);
tab5Audiobookshelf.Controls.Add(absStatusLbl);
tab5Audiobookshelf.Controls.Add(absLibraryLbl);
tab5Audiobookshelf.Controls.Add(absLibraryCb);
tab5Audiobookshelf.Controls.Add(absFolderLbl);
tab5Audiobookshelf.Controls.Add(absFolderCb);
tab5Audiobookshelf.Location = new System.Drawing.Point(4, 24);
tab5Audiobookshelf.Name = "tab5Audiobookshelf";
tab5Audiobookshelf.Padding = new System.Windows.Forms.Padding(3);
tab5Audiobookshelf.Size = new System.Drawing.Size(856, 457);
tab5Audiobookshelf.TabIndex = 4;
tab5Audiobookshelf.Text = "Audiobookshelf";
//
// request_xHE_AAC_Cbox
//
request_xHE_AAC_Cbox.CheckAlign = System.Drawing.ContentAlignment.MiddleRight;
@@ -1531,6 +1569,120 @@
createCueSheetCbox.UseVisualStyleBackColor = true;
createCueSheetCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged;
//
// absEnabledCb
//
absEnabledCb.AutoSize = true;
absEnabledCb.Location = new System.Drawing.Point(6, 6);
absEnabledCb.Name = "absEnabledCb";
absEnabledCb.Size = new System.Drawing.Size(280, 19);
absEnabledCb.TabIndex = 0;
absEnabledCb.Text = "[AudiobookshelfEnabled desc]";
absEnabledCb.UseVisualStyleBackColor = true;
absEnabledCb.CheckedChanged += absEnabledCb_CheckedChanged;
//
// absPlaintextWarningLbl
//
absPlaintextWarningLbl.AutoSize = true;
absPlaintextWarningLbl.ForeColor = System.Drawing.Color.DarkOrange;
absPlaintextWarningLbl.Location = new System.Drawing.Point(6, 30);
absPlaintextWarningLbl.Name = "absPlaintextWarningLbl";
absPlaintextWarningLbl.Size = new System.Drawing.Size(480, 15);
absPlaintextWarningLbl.TabIndex = 1;
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.Name = "absUrlLbl";
absUrlLbl.Size = new System.Drawing.Size(70, 15);
absUrlLbl.TabIndex = 2;
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.Name = "absUrlTb";
absUrlTb.Size = new System.Drawing.Size(550, 23);
absUrlTb.TabIndex = 3;
//
// absTokenLbl
//
absTokenLbl.AutoSize = true;
absTokenLbl.Location = new System.Drawing.Point(6, 84);
absTokenLbl.Name = "absTokenLbl";
absTokenLbl.Size = new System.Drawing.Size(65, 15);
absTokenLbl.TabIndex = 4;
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.Name = "absTokenTb";
absTokenTb.Size = new System.Drawing.Size(550, 23);
absTokenTb.TabIndex = 5;
//
// absConnectBtn
//
absConnectBtn.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
absConnectBtn.Location = new System.Drawing.Point(646, 52);
absConnectBtn.Name = "absConnectBtn";
absConnectBtn.Size = new System.Drawing.Size(130, 52);
absConnectBtn.TabIndex = 6;
absConnectBtn.Text = "Connect / Refresh";
absConnectBtn.UseVisualStyleBackColor = true;
absConnectBtn.Click += absConnectBtn_Click;
//
// absStatusLbl
//
absStatusLbl.AutoSize = true;
absStatusLbl.Location = new System.Drawing.Point(6, 114);
absStatusLbl.Name = "absStatusLbl";
absStatusLbl.Size = new System.Drawing.Size(0, 15);
absStatusLbl.TabIndex = 7;
absStatusLbl.Text = "";
//
// absLibraryLbl
//
absLibraryLbl.AutoSize = true;
absLibraryLbl.Location = new System.Drawing.Point(6, 140);
absLibraryLbl.Name = "absLibraryLbl";
absLibraryLbl.Size = new System.Drawing.Size(50, 15);
absLibraryLbl.TabIndex = 8;
absLibraryLbl.Text = "[AudiobookshelfLibraryId desc]";
//
// absLibraryCb
//
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.Name = "absLibraryCb";
absLibraryCb.Size = new System.Drawing.Size(686, 23);
absLibraryCb.TabIndex = 9;
absLibraryCb.SelectedIndexChanged += absLibraryCb_SelectedIndexChanged;
//
// absFolderLbl
//
absFolderLbl.AutoSize = true;
absFolderLbl.Location = new System.Drawing.Point(6, 170);
absFolderLbl.Name = "absFolderLbl";
absFolderLbl.Size = new System.Drawing.Size(45, 15);
absFolderLbl.TabIndex = 10;
absFolderLbl.Text = "[AudiobookshelfFolderId desc]";
//
// absFolderCb
//
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.Name = "absFolderCb";
absFolderCb.Size = new System.Drawing.Size(686, 23);
absFolderCb.TabIndex = 11;
//
// SettingsDialog
//
AcceptButton = saveBtn;
@@ -1586,6 +1738,8 @@
((System.ComponentModel.ISupportInitialize)lameVBRQualityTb).EndInit();
groupBox2.ResumeLayout(false);
groupBox2.PerformLayout();
tab5Audiobookshelf.ResumeLayout(false);
tab5Audiobookshelf.PerformLayout();
ResumeLayout(false);
}
@@ -1709,5 +1863,18 @@
private System.Windows.Forms.CheckBox importPlusTitlesCb;
private System.Windows.Forms.Label minFileDurationLbl;
private System.Windows.Forms.NumericUpDown minFileDurationNud;
private System.Windows.Forms.TabPage tab5Audiobookshelf;
private System.Windows.Forms.CheckBox absEnabledCb;
private System.Windows.Forms.Label absPlaintextWarningLbl;
private System.Windows.Forms.Label absUrlLbl;
private System.Windows.Forms.TextBox absUrlTb;
private System.Windows.Forms.Label absTokenLbl;
private System.Windows.Forms.TextBox absTokenTb;
private System.Windows.Forms.Button absConnectBtn;
private System.Windows.Forms.Label absStatusLbl;
private System.Windows.Forms.Label absLibraryLbl;
private System.Windows.Forms.ComboBox absLibraryCb;
private System.Windows.Forms.Label absFolderLbl;
private System.Windows.Forms.ComboBox absFolderCb;
}
}