mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
567 lines
18 KiB
C#
567 lines
18 KiB
C#
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace ApplicationServices;
|
|
|
|
public static class AudiobookshelfApiService
|
|
{
|
|
public record Library(string Id, string Name, string MediaType, List<Folder> Folders);
|
|
public record Folder(string Id, string FullPath);
|
|
public enum UploadResult { Success, AlreadyExists, Failed }
|
|
|
|
/// <summary>
|
|
/// Audiobookshelf client (browser) route segments. If present in a pasted URL,
|
|
/// everything from that segment onward is UI path, not part of the API base.
|
|
/// </summary>
|
|
private static readonly HashSet<string> ClientRouteSegments = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"library",
|
|
"item",
|
|
"collection",
|
|
"playlist",
|
|
"author",
|
|
"series",
|
|
"audiobook",
|
|
"config",
|
|
"login",
|
|
"account",
|
|
};
|
|
|
|
/// <summary>
|
|
/// Normalize a user-supplied Audiobookshelf server URL to an API base address.
|
|
/// Compensates for common mistakes such as pasting a browser library/item page URL,
|
|
/// including a trailing <c>/api</c> path, query strings, or omitting the scheme.
|
|
/// </summary>
|
|
/// <returns>Absolute http(s) base URL with no trailing slash.</returns>
|
|
public static string NormalizeServerUrl(string? serverUrl)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(serverUrl))
|
|
throw new ArgumentException("Server URL is required.", nameof(serverUrl));
|
|
|
|
var trimmed = serverUrl.Trim();
|
|
|
|
// Allow host:port without a scheme (common when copying from docs or local setups).
|
|
if (!trimmed.Contains("://", StringComparison.Ordinal))
|
|
trimmed = "http://" + trimmed;
|
|
|
|
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
|
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
|
|| string.IsNullOrWhiteSpace(uri.Host))
|
|
{
|
|
throw new ArgumentException(
|
|
"Server URL must be an absolute http(s) address, for example http://localhost:13378 or https://abs.example.com",
|
|
nameof(serverUrl));
|
|
}
|
|
|
|
var path = StripClientRoutesAndApi(uri.AbsolutePath);
|
|
var authority = uri.GetLeftPart(UriPartial.Authority);
|
|
if (string.IsNullOrEmpty(path) || path == "/")
|
|
return authority;
|
|
|
|
return authority + path.TrimEnd('/');
|
|
}
|
|
|
|
private static string StripClientRoutesAndApi(string absolutePath)
|
|
{
|
|
if (string.IsNullOrEmpty(absolutePath) || absolutePath == "/")
|
|
return "";
|
|
|
|
var parts = absolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
var prefixLength = 0;
|
|
|
|
for (var i = 0; i < parts.Length; i++)
|
|
{
|
|
var part = parts[i];
|
|
|
|
// Trailing or mid-path /api/... means the user included the API root.
|
|
if (string.Equals(part, "api", StringComparison.OrdinalIgnoreCase))
|
|
break;
|
|
|
|
if (ClientRouteSegments.Contains(part))
|
|
break;
|
|
|
|
prefixLength += 1 + part.Length;
|
|
}
|
|
|
|
if (prefixLength <= 0)
|
|
return "";
|
|
|
|
return absolutePath[..prefixLength];
|
|
}
|
|
|
|
private static HttpClient CreateClient(string serverUrl)
|
|
{
|
|
var normalized = NormalizeServerUrl(serverUrl);
|
|
var client = new HttpClient();
|
|
client.BaseAddress = new Uri(normalized + "/");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
return client;
|
|
}
|
|
|
|
public static async Task<List<Library>> GetLibrariesAsync(string serverUrl, string apiToken)
|
|
{
|
|
var normalizedUrl = NormalizeServerUrl(serverUrl);
|
|
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
|
|
|
using var client = CreateClient(normalizedUrl);
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
|
|
|
HttpResponseMessage response;
|
|
try
|
|
{
|
|
response = await client.GetAsync("api/libraries");
|
|
}
|
|
catch (TaskCanceledException ex)
|
|
{
|
|
throw new HttpRequestException(
|
|
$"Timed out connecting to Audiobookshelf at {normalizedUrl}. Check the Server URL and that the server is running.",
|
|
ex);
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
throw new HttpRequestException(
|
|
$"Could not reach Audiobookshelf at {normalizedUrl}. Check the Server URL, network, and that the server is running. Details: {ex.Message}",
|
|
ex);
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorBody = await response.Content.ReadAsStringAsync();
|
|
throw CreateLibrariesFetchException(normalizedUrl, response.StatusCode, errorBody);
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var obj = JObject.Parse(json);
|
|
var libraries = obj["libraries"] as JArray ?? new JArray();
|
|
|
|
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();
|
|
}
|
|
|
|
private static HttpRequestException CreateLibrariesFetchException(string normalizedUrl, HttpStatusCode statusCode, string errorBody)
|
|
{
|
|
var code = (int)statusCode;
|
|
var bodyPreview = SummarizeErrorBody(errorBody);
|
|
|
|
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
{
|
|
return new HttpRequestException(
|
|
$"Audiobookshelf rejected the API token ({code} {statusCode}). " +
|
|
$"Check that the token is correct and not expired. Server: {normalizedUrl}");
|
|
}
|
|
|
|
if (statusCode == HttpStatusCode.NotFound)
|
|
{
|
|
return new HttpRequestException(
|
|
$"Audiobookshelf returned 404 when calling {normalizedUrl}/api/libraries. " +
|
|
"Server URL should be the Audiobookshelf base address only " +
|
|
"(for example http://localhost:13378 or https://abs.example.com), " +
|
|
"not a library or book page from the browser. " +
|
|
"If Audiobookshelf is served under a subpath, include that subpath " +
|
|
$"(for example http://host/audiobookshelf). Response: {bodyPreview}");
|
|
}
|
|
|
|
return new HttpRequestException(
|
|
$"Audiobookshelf API returned {code} ({statusCode}) when fetching libraries from {normalizedUrl}. Response: {bodyPreview}");
|
|
}
|
|
|
|
private static string SummarizeErrorBody(string errorBody)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(errorBody))
|
|
return "(empty)";
|
|
|
|
var trimmed = errorBody.Trim();
|
|
const int maxLen = 280;
|
|
if (trimmed.Length <= maxLen)
|
|
return trimmed;
|
|
|
|
return trimmed[..maxLen] + "...";
|
|
}
|
|
|
|
public sealed record ConnectResult(
|
|
bool Success,
|
|
string StatusMessage,
|
|
string? NormalizedServerUrl,
|
|
bool ServerUrlAdjusted,
|
|
IReadOnlyList<Library> Libraries);
|
|
|
|
/// <summary>
|
|
/// Normalize URL, fetch book libraries, and produce UI status text.
|
|
/// Callers apply busy-state and bind <see cref="ConnectResult.Libraries"/> to controls.
|
|
/// </summary>
|
|
public static async Task<ConnectResult> ConnectAsync(string? serverUrl, string? apiToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(apiToken))
|
|
{
|
|
return new ConnectResult(
|
|
Success: false,
|
|
StatusMessage: "Please enter both server URL and API token.",
|
|
NormalizedServerUrl: null,
|
|
ServerUrlAdjusted: false,
|
|
Libraries: []);
|
|
}
|
|
|
|
string normalizedUrl;
|
|
bool urlAdjusted;
|
|
try
|
|
{
|
|
var trimmedUrl = serverUrl.Trim();
|
|
normalizedUrl = NormalizeServerUrl(trimmedUrl);
|
|
urlAdjusted = !string.Equals(trimmedUrl.TrimEnd('/'), normalizedUrl, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ConnectResult(
|
|
Success: false,
|
|
StatusMessage: $"Connection failed: {ex.Message}",
|
|
NormalizedServerUrl: null,
|
|
ServerUrlAdjusted: false,
|
|
Libraries: []);
|
|
}
|
|
|
|
try
|
|
{
|
|
var libraries = await GetLibrariesAsync(normalizedUrl, apiToken.Trim());
|
|
if (libraries.Count == 0)
|
|
{
|
|
return new ConnectResult(
|
|
Success: false,
|
|
StatusMessage: "No book libraries found. Check your settings.",
|
|
NormalizedServerUrl: normalizedUrl,
|
|
ServerUrlAdjusted: urlAdjusted,
|
|
Libraries: []);
|
|
}
|
|
|
|
return new ConnectResult(
|
|
Success: true,
|
|
StatusMessage: FormatConnectedStatus(libraries.Count, urlAdjusted),
|
|
NormalizedServerUrl: normalizedUrl,
|
|
ServerUrlAdjusted: urlAdjusted,
|
|
Libraries: libraries);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new ConnectResult(
|
|
Success: false,
|
|
StatusMessage: $"Connection failed: {ex.Message}",
|
|
NormalizedServerUrl: normalizedUrl,
|
|
ServerUrlAdjusted: urlAdjusted,
|
|
Libraries: []);
|
|
}
|
|
}
|
|
|
|
public static string FormatConnectedStatus(int libraryCount, bool urlAdjusted)
|
|
{
|
|
var libraryWord = libraryCount == 1 ? "library" : "libraries";
|
|
var message = $"Connected. Found {libraryCount} {libraryWord}.";
|
|
if (urlAdjusted)
|
|
message += " Server URL adjusted to the API base address.";
|
|
return message;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalize for persistence when possible; otherwise keep the trimmed raw value
|
|
/// so the user can fix it on the next connect attempt.
|
|
/// </summary>
|
|
public static string TryNormalizeServerUrlForSave(string? url)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
return url?.Trim() ?? "";
|
|
|
|
try
|
|
{
|
|
return NormalizeServerUrl(url);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return url.Trim();
|
|
}
|
|
}
|
|
|
|
public static async Task<bool> BookExistsAsync(string serverUrl, string apiToken, string libraryId, string title, string? author = null)
|
|
{
|
|
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
|
using var client = CreateClient(serverUrl);
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
|
|
var normalizedTitle = NormalizeTitle(title);
|
|
var baseTitle = GetBaseTitle(normalizedTitle);
|
|
|
|
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: looking for '{Title}' (base: '{BaseTitle}') in library {LibraryId}", normalizedTitle, baseTitle, libraryId);
|
|
|
|
try
|
|
{
|
|
// Search with base title first (shorter = broader fuzzy matches, better recall)
|
|
var candidates = await SearchLibraryAsync(client, libraryId, baseTitle, 20);
|
|
|
|
// Also search with full title to catch cases where ABS stores the longer/subtitled version
|
|
if (!string.Equals(normalizedTitle, baseTitle, StringComparison.Ordinal))
|
|
{
|
|
var fullCandidates = await SearchLibraryAsync(client, libraryId, normalizedTitle, 10);
|
|
foreach (var c in fullCandidates)
|
|
{
|
|
if (!candidates.Any(existing => string.Equals(existing.Id, c.Id, StringComparison.Ordinal)))
|
|
candidates.Add(c);
|
|
}
|
|
}
|
|
|
|
if (candidates.Count == 0)
|
|
{
|
|
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: no candidates found for '{Title}' in library {LibraryId}", normalizedTitle, libraryId);
|
|
return false;
|
|
}
|
|
|
|
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: found {Count} candidate(s) for '{Title}' in library {LibraryId}", candidates.Count, normalizedTitle, libraryId);
|
|
|
|
foreach (var candidate in candidates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(candidate.Title))
|
|
continue;
|
|
|
|
bool titleMatch = false;
|
|
|
|
// 1) Exact match against normalized title
|
|
if (string.Equals(candidate.Title, normalizedTitle, StringComparison.OrdinalIgnoreCase))
|
|
titleMatch = true;
|
|
|
|
// 2) Exact match against base title (without subtitle)
|
|
if (!titleMatch && string.Equals(candidate.Title, baseTitle, StringComparison.OrdinalIgnoreCase))
|
|
titleMatch = true;
|
|
|
|
// 3) Exact match against combined title:subtitle
|
|
if (!titleMatch && !string.IsNullOrWhiteSpace(candidate.FullTitle)
|
|
&& string.Equals(candidate.FullTitle, normalizedTitle, StringComparison.OrdinalIgnoreCase))
|
|
titleMatch = true;
|
|
|
|
if (titleMatch && AuthorsCompatible(author, candidate.Authors))
|
|
{
|
|
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: found match for '{Title}' (id={ItemId})", title, candidate.Id);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: '{Title}' not found in library {LibraryId}", normalizedTitle, libraryId);
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Serilog.Log.Logger.Debug(ex, "Audiobookshelf duplicate check failed for '{Title}' in library {LibraryId}", title, libraryId);
|
|
throw; // Let caller decide whether to proceed with upload
|
|
}
|
|
}
|
|
|
|
private record SearchCandidate(string Id, string Title, string FullTitle, List<string> Authors);
|
|
|
|
private static async Task<List<SearchCandidate>> SearchLibraryAsync(HttpClient client, string libraryId, string query, int limit)
|
|
{
|
|
var url = $"api/libraries/{Uri.EscapeDataString(libraryId)}/search?q={Uri.EscapeDataString(query)}&limit={limit}";
|
|
var response = await client.GetAsync(url);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorBody = await response.Content.ReadAsStringAsync();
|
|
throw new HttpRequestException($"Audiobookshelf search returned {(int)response.StatusCode} for query '{query}'. Body: {errorBody}");
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var obj = JObject.Parse(json);
|
|
var books = obj["book"] as JArray ?? new JArray();
|
|
|
|
var results = new List<SearchCandidate>();
|
|
foreach (var entry in books)
|
|
{
|
|
var item = entry["libraryItem"] ?? entry;
|
|
var id = item["id"]?.Value<string>() ?? entry["id"]?.Value<string>() ?? "";
|
|
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 authors = item["media"]?["metadata"]?["authors"] as JArray;
|
|
var authorList = authors?.Select(a => a["name"]?.Value<string>()?.Trim() ?? "")
|
|
.Where(n => !string.IsNullOrWhiteSpace(n))
|
|
.ToList() ?? [];
|
|
|
|
results.Add(new SearchCandidate(
|
|
id,
|
|
NormalizeTitle(itemTitle),
|
|
NormalizeTitle(itemFullTitle),
|
|
authorList));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private static bool AuthorsCompatible(string? libationAuthor, List<string> absAuthors)
|
|
{
|
|
// If either side has no author info, we can't use authors to disambiguate
|
|
if (string.IsNullOrWhiteSpace(libationAuthor))
|
|
return true;
|
|
if (absAuthors == null || absAuthors.Count == 0)
|
|
return true;
|
|
|
|
var libationAuthors = libationAuthor.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(a => NormalizeAuthor(a))
|
|
.Where(a => !string.IsNullOrWhiteSpace(a))
|
|
.ToList();
|
|
|
|
if (libationAuthors.Count == 0)
|
|
return true;
|
|
|
|
foreach (var absAuthor in absAuthors.Select(NormalizeAuthor))
|
|
{
|
|
foreach (var la in libationAuthors)
|
|
{
|
|
if (string.Equals(absAuthor, la, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string NormalizeAuthor(string? author)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(author))
|
|
return author ?? "";
|
|
return author.Replace("\u00A0", " ").Trim();
|
|
}
|
|
|
|
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,
|
|
string folderId,
|
|
string title,
|
|
string? author,
|
|
string? series,
|
|
IEnumerable<string> filePaths)
|
|
{
|
|
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
|
|
|
// Pre-check for existing item
|
|
try
|
|
{
|
|
if (await BookExistsAsync(serverUrl, apiToken, libraryId, title, author))
|
|
{
|
|
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.Warning(ex, "Pre-check for existing book on Audiobookshelf failed; will still attempt upload");
|
|
// Continue to upload attempt below
|
|
}
|
|
|
|
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;
|
|
var streams = new List<Stream>();
|
|
try
|
|
{
|
|
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 */ }
|
|
}
|
|
}
|
|
}
|
|
}
|