Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion listenarr.api/Features/Library/LibraryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/// <summary>Initializes the library transport façade.</summary>
public LibraryController(
ILibraryListService libraryListService,
Expand All @@ -55,7 +56,8 @@ public LibraryController(
LibraryIdentifierWorkflow identifierWorkflow,
LibraryPreviewPathWorkflow previewPathWorkflow,
LibraryQueryWorkflow queryWorkflow,
LibraryRenameWorkflow renameWorkflow)
LibraryRenameWorkflow renameWorkflow,
IGoodreadsImportService goodreadsImportService)
{
_libraryListService = libraryListService;
_addWorkflow = addWorkflow;
Expand All @@ -71,6 +73,7 @@ public LibraryController(
_previewPathWorkflow = previewPathWorkflow;
_queryWorkflow = queryWorkflow;
_renameWorkflow = renameWorkflow;
_goodreadsImportService = goodreadsImportService;
}

/// <summary>
Expand All @@ -84,6 +87,18 @@ public async Task<IActionResult> AddToLibrary([FromBody] AddToLibraryRequest req
return await _addWorkflow.AddAsync(request);
}

/// <summary>
/// Import books from a Goodreads export CSV or a public Goodreads list/shelf URL.
/// </summary>
/// <param name="request">Goodreads CSV content or URL plus library defaults to apply to imported books.</param>
/// <param name="cancellationToken">Request cancellation token.</param>
[HttpPost("import/goodreads")]
public async Task<IActionResult> ImportGoodreads([FromBody] GoodreadsImportRequest request, CancellationToken cancellationToken)
{
var result = await _goodreadsImportService.ImportAsync(request, cancellationToken);
return Ok(result);
}

/// <summary>
/// Preview the destination path that would be computed for an audiobook based on current naming settings.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions listenarr.api/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GoodreadsImportResult> ImportAsync(
GoodreadsImportRequest request,
CancellationToken cancellationToken = default);
}

public interface IGoodreadsListReader
{
Task<IReadOnlyList<GoodreadsImportBook>> ReadAsync(
GoodreadsImportRequest request,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -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<string> 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<GoodreadsImportRowResult> Items { get; set; } = [];
public List<string> 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; }
}
144 changes: 144 additions & 0 deletions listenarr.application/Audiobooks/Goodreads/GoodreadsImportService.cs
Original file line number Diff line number Diff line change
@@ -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<GoodreadsImportService> _logger;

public GoodreadsImportService(
IGoodreadsListReader reader,
ILibraryAddService libraryAddService,
ILogger<GoodreadsImportService> logger)
{
_reader = reader;
_libraryAddService = libraryAddService;
_logger = logger;
}

public async Task<GoodreadsImportResult> 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<string> { "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
};
}
}
1 change: 1 addition & 0 deletions listenarr.application/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
using Listenarr.Infrastructure.Persistence.Repositories;
using Microsoft.Extensions.DependencyInjection;
using System.Net;

namespace Listenarr.Infrastructure.DependencyInjection.Library;

Expand All @@ -20,6 +21,7 @@ public static IServiceCollection AddLibraryServices(this IServiceCollection serv
services.AddScoped<IAuthorCatalogService, AuthorCatalogService>();
services.AddScoped<ISeriesCatalogService, SeriesCatalogService>();
services.AddScoped<ILibraryAddService, LibraryAddService>();
services.AddScoped<IGoodreadsImportService, GoodreadsImportService>();
services.AddScoped<IAudiobookFilesystemDeleteService, AudiobookFilesystemDeleteService>();
services.AddScoped<ILibraryListService, LibraryListService>();
services.AddScoped<IAuthorMonitoringService, AuthorMonitoringService>();
Expand All @@ -41,4 +43,16 @@ public static IServiceCollection AddLibraryInfrastructure(this IServiceCollectio
services.AddScoped<IRootFolderRepository, EfRootFolderRepository>();
return services;
}

public static IServiceCollection AddLibraryHttpClients(this IServiceCollection services)
{
services.AddHttpClient<IGoodreadsListReader, GoodreadsListReader>()
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(30))
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
UseProxy = false
});
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +31,7 @@ public static IServiceCollection AddListenarrHttpClients(
services.AddDownloadClientHttpClients();
services.AddDownloadHttpClients();
services.AddMetadataHttpClients(configuration);
services.AddLibraryHttpClients();
return services;
}

Expand Down
2 changes: 2 additions & 0 deletions listenarr.infrastructure/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading