diff --git a/README.md b/README.md index 0811c7c1f..e4306a74f 100644 --- a/README.md +++ b/README.md @@ -406,9 +406,12 @@ Supported download clients: - `GET /api/library` - Get all audiobooks - `GET /api/library/{id}` - Get specific audiobook - `POST /api/library` - Add audiobook +- `POST /api/v{version}/library/import/goodreads` - Import Goodreads books into the library from either `csvContent` (Goodreads export CSV) or a public Goodreads `url` - `PUT /api/library/{id}` - Update audiobook - `DELETE /api/library/{id}` - Remove audiobook +Goodreads imports reuse Listenarr's normal library add flow. A CSV payload is the most reliable option because Goodreads exports include ISBN values that Listenarr can use for duplicate detection. Public Goodreads list/shelf URLs are parsed from page HTML as a best-effort fallback and usually include title, author, Goodreads ID, and source URL only. Optional request fields include `monitored`, `qualityProfileId`, `autoSearch`, `destinationPath`, and `limit`. + ### Configuration - `GET /api/configuration` - Get all settings - `POST /api/configuration` - Save settings diff --git a/listenarr.api/Features/Library/LibraryController.cs b/listenarr.api/Features/Library/LibraryController.cs index 6f8134d5d..f869bea4a 100644 --- a/listenarr.api/Features/Library/LibraryController.cs +++ b/listenarr.api/Features/Library/LibraryController.cs @@ -40,6 +40,7 @@ public partial class LibraryController : ControllerBase private readonly LibraryPreviewPathWorkflow _previewPathWorkflow; private readonly LibraryQueryWorkflow _queryWorkflow; private readonly LibraryRenameWorkflow _renameWorkflow; + private readonly IGoodreadsImportService _goodreadsImportService; /// Initializes the library transport façade. public LibraryController( ILibraryListService libraryListService, @@ -55,7 +56,8 @@ public LibraryController( LibraryIdentifierWorkflow identifierWorkflow, LibraryPreviewPathWorkflow previewPathWorkflow, LibraryQueryWorkflow queryWorkflow, - LibraryRenameWorkflow renameWorkflow) + LibraryRenameWorkflow renameWorkflow, + IGoodreadsImportService goodreadsImportService) { _libraryListService = libraryListService; _addWorkflow = addWorkflow; @@ -71,6 +73,7 @@ public LibraryController( _previewPathWorkflow = previewPathWorkflow; _queryWorkflow = queryWorkflow; _renameWorkflow = renameWorkflow; + _goodreadsImportService = goodreadsImportService; } /// @@ -84,6 +87,18 @@ public async Task AddToLibrary([FromBody] AddToLibraryRequest req return await _addWorkflow.AddAsync(request); } + /// + /// Import books from a Goodreads export CSV or a public Goodreads list/shelf URL. + /// + /// Goodreads CSV content or URL plus library defaults to apply to imported books. + /// Request cancellation token. + [HttpPost("import/goodreads")] + public async Task ImportGoodreads([FromBody] GoodreadsImportRequest request, CancellationToken cancellationToken) + { + var result = await _goodreadsImportService.ImportAsync(request, cancellationToken); + return Ok(result); + } + /// /// Preview the destination path that would be computed for an audiobook based on current naming settings. /// diff --git a/listenarr.api/GlobalUsings.cs b/listenarr.api/GlobalUsings.cs index 9080d5199..56dc62c49 100644 --- a/listenarr.api/GlobalUsings.cs +++ b/listenarr.api/GlobalUsings.cs @@ -9,6 +9,7 @@ global using Listenarr.Api.Features.Search; global using Listenarr.Application.Downloads.Submission; global using Listenarr.Application.Audiobooks.Files; +global using Listenarr.Application.Audiobooks.Goodreads; global using Listenarr.Application.Audiobooks.Identifiers; global using Listenarr.Application.Audiobooks.Jobs; global using Listenarr.Application.Audiobooks.Matching; diff --git a/listenarr.application/Audiobooks/Contracts/IGoodreadsImportService.cs b/listenarr.application/Audiobooks/Contracts/IGoodreadsImportService.cs new file mode 100644 index 000000000..bbd45ac80 --- /dev/null +++ b/listenarr.application/Audiobooks/Contracts/IGoodreadsImportService.cs @@ -0,0 +1,25 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +namespace Listenarr.Application.Audiobooks.Contracts; + +public interface IGoodreadsImportService +{ + Task ImportAsync( + GoodreadsImportRequest request, + CancellationToken cancellationToken = default); +} + +public interface IGoodreadsListReader +{ + Task> ReadAsync( + GoodreadsImportRequest request, + CancellationToken cancellationToken = default); +} diff --git a/listenarr.application/Audiobooks/Goodreads/GoodreadsImportModels.cs b/listenarr.application/Audiobooks/Goodreads/GoodreadsImportModels.cs new file mode 100644 index 000000000..8a04ac644 --- /dev/null +++ b/listenarr.application/Audiobooks/Goodreads/GoodreadsImportModels.cs @@ -0,0 +1,56 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +namespace Listenarr.Application.Audiobooks.Goodreads; + +public sealed class GoodreadsImportRequest +{ + public string? Url { get; set; } + public string? CsvContent { get; set; } + public bool Monitored { get; set; } = true; + public int? QualityProfileId { get; set; } + public bool AutoSearch { get; set; } + public string? DestinationPath { get; set; } + public int? Limit { get; set; } +} + +public sealed class GoodreadsImportBook +{ + public int SourceIndex { get; set; } + public string? GoodreadsId { get; set; } + public string Title { get; set; } = string.Empty; + public string? Author { get; set; } + public List Isbn { get; set; } = []; + public string? PublishYear { get; set; } + public string? PublishedDate { get; set; } + public string? Bookshelf { get; set; } + public string? SourceUrl { get; set; } +} + +public sealed class GoodreadsImportResult +{ + public int Total { get; set; } + public int AddedCount { get; set; } + public int SkippedCount { get; set; } + public int ErrorCount { get; set; } + public List Items { get; set; } = []; + public List Warnings { get; set; } = []; +} + +public sealed class GoodreadsImportRowResult +{ + public int SourceIndex { get; set; } + public string? GoodreadsId { get; set; } + public string? Title { get; set; } + public string? Author { get; set; } + public string Status { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public int? AudiobookId { get; set; } +} diff --git a/listenarr.application/Audiobooks/Goodreads/GoodreadsImportService.cs b/listenarr.application/Audiobooks/Goodreads/GoodreadsImportService.cs new file mode 100644 index 000000000..a1543649c --- /dev/null +++ b/listenarr.application/Audiobooks/Goodreads/GoodreadsImportService.cs @@ -0,0 +1,144 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks.Goodreads; + +public sealed class GoodreadsImportService : IGoodreadsImportService +{ + private const int DefaultLimit = 500; + private const int MaximumLimit = 5000; + + private readonly IGoodreadsListReader _reader; + private readonly ILibraryAddService _libraryAddService; + private readonly ILogger _logger; + + public GoodreadsImportService( + IGoodreadsListReader reader, + ILibraryAddService libraryAddService, + ILogger logger) + { + _reader = reader; + _libraryAddService = libraryAddService; + _logger = logger; + } + + public async Task ImportAsync( + GoodreadsImportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var books = await _reader.ReadAsync(request, cancellationToken); + var limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaximumLimit); + var selectedBooks = books.Take(limit).ToList(); + + var result = new GoodreadsImportResult + { + Total = selectedBooks.Count + }; + + if (books.Count > selectedBooks.Count) + { + result.Warnings.Add($"Only the first {selectedBooks.Count} Goodreads items were imported. Increase limit to import more."); + } + + foreach (var book in selectedBooks) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(book.Title)) + { + result.SkippedCount++; + result.Items.Add(CreateRow(book, "Skipped", "Goodreads item did not include a title.", null)); + continue; + } + + try + { + var addResult = await _libraryAddService.AddToLibraryAsync( + new LibraryAddOperationRequest + { + Metadata = ToMetadata(book), + Monitored = request.Monitored, + QualityProfileId = request.QualityProfileId, + AutoSearch = request.AutoSearch, + DestinationPath = request.DestinationPath, + HistorySource = "Goodreads", + HistoryMessage = $"Audiobook '{book.Title}' imported from Goodreads" + }, + cancellationToken); + + if (addResult.AlreadyExists) + { + result.SkippedCount++; + result.Items.Add(CreateRow(book, "Skipped", addResult.Message, addResult.Audiobook?.Id)); + continue; + } + + result.AddedCount++; + result.Items.Add(CreateRow(book, "Added", addResult.Message, addResult.Audiobook?.Id)); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + result.ErrorCount++; + result.Items.Add(CreateRow(book, "Error", ex.Message, null)); + _logger.LogWarning(ex, "Failed to import Goodreads item {SourceIndex}: {Title}", book.SourceIndex, book.Title); + } + } + + return result; + } + + private static AudibleBookMetadata ToMetadata(GoodreadsImportBook book) + { + var tags = new List { "Goodreads" }; + if (!string.IsNullOrWhiteSpace(book.Bookshelf)) + { + tags.AddRange(book.Bookshelf + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(shelf => $"Goodreads:{shelf}")); + } + + return new AudibleBookMetadata + { + Source = "Goodreads", + Title = book.Title.Trim(), + Author = book.Author?.Trim(), + Authors = string.IsNullOrWhiteSpace(book.Author) ? [] : [book.Author.Trim()], + Isbn = book.Isbn, + PublishYear = book.PublishYear, + PublishedDate = book.PublishedDate, + Tags = tags, + Description = string.IsNullOrWhiteSpace(book.SourceUrl) + ? "Imported from Goodreads." + : $"Imported from Goodreads: {book.SourceUrl}" + }; + } + + private static GoodreadsImportRowResult CreateRow( + GoodreadsImportBook book, + string status, + string message, + int? audiobookId) + { + return new GoodreadsImportRowResult + { + SourceIndex = book.SourceIndex, + GoodreadsId = book.GoodreadsId, + Title = book.Title, + Author = book.Author, + Status = status, + Message = message, + AudiobookId = audiobookId + }; + } +} diff --git a/listenarr.application/GlobalUsings.cs b/listenarr.application/GlobalUsings.cs index 464e217a8..b8d565c62 100644 --- a/listenarr.application/GlobalUsings.cs +++ b/listenarr.application/GlobalUsings.cs @@ -6,6 +6,7 @@ global using Listenarr.Application.Audiobooks.Catalog; global using Listenarr.Application.Audiobooks.Common; global using Listenarr.Application.Audiobooks.Files; +global using Listenarr.Application.Audiobooks.Goodreads; global using Listenarr.Application.Audiobooks.Identifiers; global using Listenarr.Application.Audiobooks.Jobs; global using Listenarr.Application.Audiobooks.Matching; diff --git a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs index 28c0bb0e8..61e83045a 100644 --- a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs @@ -9,6 +9,7 @@ */ using Listenarr.Infrastructure.Persistence.Repositories; using Microsoft.Extensions.DependencyInjection; +using System.Net; namespace Listenarr.Infrastructure.DependencyInjection.Library; @@ -20,6 +21,7 @@ public static IServiceCollection AddLibraryServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -41,4 +43,16 @@ public static IServiceCollection AddLibraryInfrastructure(this IServiceCollectio services.AddScoped(); return services; } + + public static IServiceCollection AddLibraryHttpClients(this IServiceCollection services) + { + services.AddHttpClient() + .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(30)) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + UseProxy = false + }); + return services; + } } diff --git a/listenarr.infrastructure/DependencyInjection/ServiceRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/ServiceRegistrationExtensions.cs index 6402eb2ce..38006b4c8 100644 --- a/listenarr.infrastructure/DependencyInjection/ServiceRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/ServiceRegistrationExtensions.cs @@ -9,6 +9,7 @@ */ using Listenarr.Infrastructure.DependencyInjection.DownloadClients; using Listenarr.Infrastructure.DependencyInjection.Downloads; +using Listenarr.Infrastructure.DependencyInjection.Library; using Listenarr.Infrastructure.DependencyInjection.Metadata; using Listenarr.Infrastructure.DependencyInjection.Platform; using Microsoft.Extensions.Configuration; @@ -30,6 +31,7 @@ public static IServiceCollection AddListenarrHttpClients( services.AddDownloadClientHttpClients(); services.AddDownloadHttpClients(); services.AddMetadataHttpClients(configuration); + services.AddLibraryHttpClients(); return services; } diff --git a/listenarr.infrastructure/GlobalUsings.cs b/listenarr.infrastructure/GlobalUsings.cs index 69e4c51f6..e1c5b843f 100644 --- a/listenarr.infrastructure/GlobalUsings.cs +++ b/listenarr.infrastructure/GlobalUsings.cs @@ -11,6 +11,7 @@ global using Listenarr.Application.ActivityHistory.Contracts.Repositories; global using Listenarr.Application.Audiobooks.Contracts; global using Listenarr.Application.Audiobooks.Contracts.Repositories; +global using Listenarr.Application.Audiobooks.Goodreads; global using Listenarr.Application.Common.Contracts; global using Listenarr.Application.Configuration.Contracts; global using Listenarr.Application.Configuration.Contracts.Repositories; @@ -96,6 +97,7 @@ global using Listenarr.Infrastructure.Metadata.Jobs; global using Listenarr.Infrastructure.Metadata.Providers.Audible; global using Listenarr.Infrastructure.Metadata.Providers.Audnexus; +global using Listenarr.Infrastructure.Metadata.Providers.Goodreads; global using Listenarr.Infrastructure.Metadata.Providers.OpenLibrary; global using Listenarr.Infrastructure.HostedServices.Search; global using Listenarr.Infrastructure.DownloadClients.Common; diff --git a/listenarr.infrastructure/Metadata/Providers/Goodreads/GoodreadsListReader.cs b/listenarr.infrastructure/Metadata/Providers/Goodreads/GoodreadsListReader.cs new file mode 100644 index 000000000..1f9041fbc --- /dev/null +++ b/listenarr.infrastructure/Metadata/Providers/Goodreads/GoodreadsListReader.cs @@ -0,0 +1,300 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +using System.Net; +using System.Text; +using HtmlAgilityPack; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Metadata.Providers.Goodreads; + +public sealed class GoodreadsListReader : IGoodreadsListReader +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public GoodreadsListReader(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task> ReadAsync( + GoodreadsImportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (!string.IsNullOrWhiteSpace(request.CsvContent)) + { + return ParseCsv(request.CsvContent); + } + + if (!string.IsNullOrWhiteSpace(request.Url)) + { + return await ReadUrlAsync(request.Url, cancellationToken); + } + + throw new ArgumentException("Provide either csvContent from a Goodreads export or a public Goodreads url."); + } + + internal static IReadOnlyList ParseCsv(string csvContent) + { + var rows = ParseCsvRows(csvContent).ToList(); + if (rows.Count == 0) + { + return []; + } + + var headers = rows[0].Select(NormalizeHeader).ToList(); + var books = new List(); + + for (var i = 1; i < rows.Count; i++) + { + var row = rows[i]; + if (row.All(string.IsNullOrWhiteSpace)) + { + continue; + } + + string? Get(string header) + { + var index = headers.IndexOf(NormalizeHeader(header)); + return index >= 0 && index < row.Count ? CleanCell(row[index]) : null; + } + + var title = FirstNonEmpty(Get("Title"), Get("Book Title")); + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + + var isbn = NormalizeIsbnValues(Get("ISBN"), Get("ISBN13")); + books.Add(new GoodreadsImportBook + { + SourceIndex = i, + GoodreadsId = Get("Book Id"), + Title = title, + Author = FirstNonEmpty(Get("Author"), Get("Author l-f"), Get("Additional Authors")), + Isbn = isbn, + PublishYear = FirstNonEmpty(Get("Original Publication Year"), Get("Year Published")), + Bookshelf = FirstNonEmpty(Get("Bookshelves"), Get("Exclusive Shelf")), + SourceUrl = BuildGoodreadsBookUrl(Get("Book Id")) + }); + } + + return books; + } + + private async Task> ReadUrlAsync( + string url, + CancellationToken cancellationToken) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + throw new ArgumentException("Goodreads URL is invalid."); + } + + if (!IsGoodreadsHost(uri.Host)) + { + throw new ArgumentException("Only goodreads.com URLs can be imported."); + } + + if (!OutboundRequestSecurity.TryValidateExternalHttpUri(uri, out var validationReason)) + { + throw new ArgumentException($"Goodreads URL is not allowed: {validationReason}"); + } + + if (!await OutboundRequestSecurity.TryValidateResolvedExternalHttpUriAsync(uri, _logger)) + { + throw new ArgumentException("Goodreads URL is not allowed because it resolved to a private or loopback address."); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.UserAgent.ParseAdd("Listenarr/1.0 GoodreadsImport"); + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var html = await response.Content.ReadAsStringAsync(cancellationToken); + return ParseHtml(html, uri); + } + + internal static IReadOnlyList ParseHtml(string html, Uri sourceUri) + { + var document = new HtmlDocument(); + document.LoadHtml(html); + + var rows = (IEnumerable?)document.DocumentNode.SelectNodes("//tr[contains(@class,'bookalike') or .//a[contains(@class,'bookTitle')]]") + ?? document.DocumentNode.SelectNodes("//a[contains(@class,'bookTitle')]") + ?? Enumerable.Empty(); + var books = new List(); + var sourceIndex = 0; + + foreach (var row in rows) + { + var titleNode = row.SelectSingleNode(".//a[contains(@class,'bookTitle')]") + ?? (row.Name.Equals("a", StringComparison.OrdinalIgnoreCase) ? row : null); + var title = WebUtility.HtmlDecode(titleNode?.InnerText ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + + var authorNode = row.SelectSingleNode(".//a[contains(@class,'authorName')]") + ?? row.SelectSingleNode(".//*[contains(@class,'authorName')]"); + var href = titleNode?.GetAttributeValue("href", string.Empty); + + sourceIndex++; + books.Add(new GoodreadsImportBook + { + SourceIndex = sourceIndex, + GoodreadsId = ExtractGoodreadsId(href), + Title = CollapseWhitespace(title) ?? title, + Author = CollapseWhitespace(WebUtility.HtmlDecode(authorNode?.InnerText ?? string.Empty)), + SourceUrl = BuildAbsoluteUrl(sourceUri, href) + }); + } + + return books + .GroupBy(book => !string.IsNullOrWhiteSpace(book.GoodreadsId) + ? $"id:{book.GoodreadsId}" + : $"title:{book.Title}|author:{book.Author}", StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + private static IEnumerable> ParseCsvRows(string csv) + { + var row = new List(); + var field = new StringBuilder(); + var inQuotes = false; + + for (var i = 0; i < csv.Length; i++) + { + var ch = csv[i]; + if (inQuotes) + { + if (ch == '"' && i + 1 < csv.Length && csv[i + 1] == '"') + { + field.Append('"'); + i++; + } + else if (ch == '"') + { + inQuotes = false; + } + else + { + field.Append(ch); + } + + continue; + } + + if (ch == '"') + { + inQuotes = true; + } + else if (ch == ',') + { + row.Add(field.ToString()); + field.Clear(); + } + else if (ch == '\r' || ch == '\n') + { + if (ch == '\r' && i + 1 < csv.Length && csv[i + 1] == '\n') + { + i++; + } + + row.Add(field.ToString()); + field.Clear(); + yield return row; + row = []; + } + else + { + field.Append(ch); + } + } + + if (field.Length > 0 || row.Count > 0) + { + row.Add(field.ToString()); + yield return row; + } + } + + private static bool IsGoodreadsHost(string host) + { + var normalized = host.Trim().ToLowerInvariant(); + return normalized == "goodreads.com" || normalized.EndsWith(".goodreads.com", StringComparison.Ordinal); + } + + private static string NormalizeHeader(string value) => + value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); + + private static string? CleanCell(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim().Trim('=').Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') + { + trimmed = trimmed[1..^1]; + } + + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); + + private static List NormalizeIsbnValues(params string?[] values) => + values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => new string(value!.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant()) + .Where(value => value.Length is 10 or 13) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static string? BuildGoodreadsBookUrl(string? goodreadsId) => + string.IsNullOrWhiteSpace(goodreadsId) ? null : $"https://www.goodreads.com/book/show/{goodreadsId.Trim()}"; + + private static string? BuildAbsoluteUrl(Uri sourceUri, string? href) + { + if (string.IsNullOrWhiteSpace(href)) + { + return null; + } + + return Uri.TryCreate(sourceUri, href, out var absoluteUri) ? absoluteUri.ToString() : null; + } + + private static string? ExtractGoodreadsId(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + { + return null; + } + + var path = href.Split('?', '#')[0]; + var match = System.Text.RegularExpressions.Regex.Match(path, @"/book/show/(\d+)"); + return match.Success ? match.Groups[1].Value : null; + } + + private static string? CollapseWhitespace(string? value) => + string.IsNullOrWhiteSpace(value) + ? null + : System.Text.RegularExpressions.Regex.Replace(value.Trim(), @"\s+", " "); +} diff --git a/tests/Features/Application/Audiobooks/Goodreads/GoodreadsImportServiceTests.cs b/tests/Features/Application/Audiobooks/Goodreads/GoodreadsImportServiceTests.cs new file mode 100644 index 000000000..9dcd71585 --- /dev/null +++ b/tests/Features/Application/Audiobooks/Goodreads/GoodreadsImportServiceTests.cs @@ -0,0 +1,99 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +namespace Listenarr.Tests.Features.Application.Audiobooks.Goodreads; + +public class GoodreadsImportServiceTests +{ + [Fact] + public async Task ImportAsync_AddsParsedBooksThroughLibraryAddService() + { + var reader = new Mock(); + reader.Setup(r => r.ReadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() + { + SourceIndex = 1, + GoodreadsId = "123", + Title = "Imported Title", + Author = "Imported Author", + Isbn = ["9780441478125"], + Bookshelf = "to-read" + } + }); + + var libraryAddService = new Mock(); + libraryAddService + .Setup(s => s.AddToLibraryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LibraryAddOperationResult + { + Added = true, + Message = "Audiobook added to library successfully", + Audiobook = new Audiobook { Id = 42, Title = "Imported Title" } + }); + + var service = new GoodreadsImportService( + reader.Object, + libraryAddService.Object, + Mock.Of>()); + + var result = await service.ImportAsync(new GoodreadsImportRequest { Monitored = false }); + + Assert.Equal(1, result.Total); + Assert.Equal(1, result.AddedCount); + Assert.Equal(0, result.SkippedCount); + Assert.Equal(0, result.ErrorCount); + Assert.Equal(42, result.Items.Single().AudiobookId); + + libraryAddService.Verify(s => s.AddToLibraryAsync( + It.Is(request => + request.HistorySource == "Goodreads" + && request.Monitored == false + && request.Metadata.Title == "Imported Title" + && request.Metadata.Authors!.Contains("Imported Author") + && request.Metadata.Isbn.Contains("9780441478125") + && request.Metadata.Tags!.Contains("Goodreads:to-read")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task ImportAsync_ReportsExistingBooksAsSkipped() + { + var reader = new Mock(); + reader.Setup(r => r.ReadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { SourceIndex = 1, Title = "Existing Title", Author = "Existing Author" } + }); + + var libraryAddService = new Mock(); + libraryAddService + .Setup(s => s.AddToLibraryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LibraryAddOperationResult + { + AlreadyExists = true, + Message = "Audiobook already exists in library", + Audiobook = new Audiobook { Id = 7, Title = "Existing Title" } + }); + + var service = new GoodreadsImportService( + reader.Object, + libraryAddService.Object, + Mock.Of>()); + + var result = await service.ImportAsync(new GoodreadsImportRequest()); + + Assert.Equal(0, result.AddedCount); + Assert.Equal(1, result.SkippedCount); + Assert.Equal("Skipped", result.Items.Single().Status); + Assert.Equal(7, result.Items.Single().AudiobookId); + } +} diff --git a/tests/Features/Infrastructure/Metadata/Providers/Goodreads/GoodreadsListReaderTests.cs b/tests/Features/Infrastructure/Metadata/Providers/Goodreads/GoodreadsListReaderTests.cs new file mode 100644 index 000000000..6cc23fd9e --- /dev/null +++ b/tests/Features/Infrastructure/Metadata/Providers/Goodreads/GoodreadsListReaderTests.cs @@ -0,0 +1,55 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +namespace Listenarr.Tests.Features.Infrastructure.Metadata.Providers.Goodreads; + +public class GoodreadsListReaderTests +{ + [Fact] + public void ParseCsv_ReadsGoodreadsExportRows() + { + const string csv = + "Book Id,Title,Author,ISBN,ISBN13,Original Publication Year,Bookshelves\r\n" + + "1,\"The Left Hand of Darkness\",\"Ursula K. Le Guin\",\"=\"\"0441478123\"\"\",\"=\"\"9780441478125\"\"\",1969,\"sci-fi, favorites\"\r\n" + + "2,\"A \"\"Quoted\"\" Book\",\"Example Author\",,,2024,to-read\r\n"; + + var books = GoodreadsListReader.ParseCsv(csv); + + Assert.Equal(2, books.Count); + Assert.Equal("The Left Hand of Darkness", books[0].Title); + Assert.Equal("Ursula K. Le Guin", books[0].Author); + Assert.Contains("0441478123", books[0].Isbn); + Assert.Contains("9780441478125", books[0].Isbn); + Assert.Equal("1969", books[0].PublishYear); + Assert.Equal("sci-fi, favorites", books[0].Bookshelf); + Assert.Equal("A \"Quoted\" Book", books[1].Title); + } + + [Fact] + public void ParseHtml_ReadsPublicShelfBookRows() + { + const string html = """ + + + + + +
Test BookTest Author
+ """; + + var books = GoodreadsListReader.ParseHtml(html, new Uri("https://www.goodreads.com/review/list/1")); + + var book = Assert.Single(books); + Assert.Equal("12345", book.GoodreadsId); + Assert.Equal("Test Book", book.Title); + Assert.Equal("Test Author", book.Author); + Assert.Equal("https://www.goodreads.com/book/show/12345.Test_Book", book.SourceUrl); + } +} diff --git a/tests/GlobalUsings.cs b/tests/GlobalUsings.cs index 7bd033450..5ea75b871 100644 --- a/tests/GlobalUsings.cs +++ b/tests/GlobalUsings.cs @@ -18,6 +18,7 @@ global using Listenarr.Application.Configuration.Core; global using Listenarr.Application.Audiobooks.Catalog; global using Listenarr.Application.Audiobooks.Files; +global using Listenarr.Application.Audiobooks.Goodreads; global using Listenarr.Application.Audiobooks.Identifiers; global using Listenarr.Application.Audiobooks.Jobs; global using Listenarr.Application.Audiobooks.Matching; @@ -96,6 +97,7 @@ global using Listenarr.Infrastructure.Metadata.Jobs; global using Listenarr.Infrastructure.Metadata.Parsing; global using Listenarr.Infrastructure.Metadata.Providers.Audnexus; +global using Listenarr.Infrastructure.Metadata.Providers.Goodreads; global using Listenarr.Infrastructure.HostedServices.Search; global using Listenarr.Infrastructure.DownloadClients.Nzbget; global using Listenarr.Infrastructure.DownloadClients.Qbittorrent;