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 = """
+
+ """;
+
+ 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;