Skip to content
Merged
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
25 changes: 8 additions & 17 deletions listenarr.api/Features/Library/LibraryAddWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,15 @@ private async Task<IActionResult> AddCoreAsync(LibraryController.AddToLibraryReq

TryExtractPublishYear(request);

if (!string.IsNullOrEmpty(metadata.Asin))
// One rule with the application add path: a shared identifier is not by
// itself evidence of the same book. See AudiobookEditionIdentity.
var existingEdition = await AudiobookEditionIdentity.FindExistingEditionAsync(_repo, metadata);
if (existingEdition != null)
{
var existingByAsin = await _repo.GetByAsinAsync(metadata.Asin);
if (existingByAsin != null)
{
return new ConflictObjectResult(new { message = "Audiobook already exists in library", audiobook = existingByAsin });
}
return new ConflictObjectResult(new { message = "Audiobook already exists in library", audiobook = existingEdition });
}

var firstIsbn = (metadata.Isbn != null && metadata.Isbn.Any()) ? metadata.Isbn.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)) : null;
if (!string.IsNullOrWhiteSpace(firstIsbn))
{
var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn);
if (existingByIsbn != null)
{
return new ConflictObjectResult(new { message = "Audiobook already exists in library", audiobook = existingByIsbn });
}
}

var audiobook = metadata.ToAudiobook();

Expand Down Expand Up @@ -291,10 +282,10 @@ private void TryExtractPublishYear(LibraryController.AddToLibraryRequest request
firstIsbn = metadata.Isbn.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i));
if (!string.IsNullOrWhiteSpace(firstIsbn))
{
var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn);
if (existingByIsbn != null)
var conflicting = await AudiobookEditionIdentity.FindExistingEditionAsync(_repo, metadata);
if (conflicting != null)
{
throw new LibraryAddConflictException(existingByIsbn);
throw new LibraryAddConflictException(conflicting);
}
}

Expand Down
1 change: 1 addition & 0 deletions listenarr.api/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
global using Listenarr.Api.Features.Prowlarr;
global using Listenarr.Api.Features.Search;
global using Listenarr.Application.Downloads.Submission;
global using Listenarr.Application.Audiobooks.Catalog;
global using Listenarr.Application.Audiobooks.Files;
global using Listenarr.Application.Audiobooks.Identifiers;
global using Listenarr.Application.Audiobooks.Jobs;
Expand Down
137 changes: 137 additions & 0 deletions listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* 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.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace Listenarr.Application.Audiobooks.Catalog;

/// <summary>
/// Whether a book being added is one the library already holds.
/// </summary>
/// <remarks>
/// There are two add paths - the application service and the API workflow - and one
/// rule between them, deliberately. Two implementations of "is this the same book"
/// would eventually disagree, and a library that accepts a book down one path and
/// refuses it down the other is worse than either answer.
/// </remarks>
public static class AudiobookEditionIdentity
{
/// <summary>
/// The audiobook already held that is the same edition as <paramref name="metadata"/>,
/// or null when nothing held matches.
/// </summary>
/// <remarks>
/// Every book carrying the identifier is considered rather than whichever one the
/// database returns first, because an identifier is not unique in this library.
/// </remarks>
public static async Task<Audiobook?> FindExistingEditionAsync(
IAudiobookRepository repository,
AudibleBookMetadata metadata,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(metadata);

if (!string.IsNullOrWhiteSpace(metadata.Asin))
{
var sharingAsin = await repository.GetAllByAsinAsync(metadata.Asin);
cancellationToken.ThrowIfCancellationRequested();
var match = sharingAsin.FirstOrDefault(existing => RepresentsSameEdition(existing, metadata));
if (match != null)
{
return match;
}
}

var isbn = (metadata.Isbn ?? Enumerable.Empty<string>())
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
if (!string.IsNullOrWhiteSpace(isbn))
{
var sharingIsbn = await repository.GetAllByIsbnAsync(isbn);
cancellationToken.ThrowIfCancellationRequested();
var match = sharingIsbn.FirstOrDefault(existing => RepresentsSameEdition(existing, metadata));
if (match != null)
{
return match;
}
}

return null;
}

/// <summary>
/// Whether an audiobook already held is the same edition as the one being added.
/// </summary>
/// <remarks>
/// A shared identifier is not enough on its own. Audible returns one collection
/// ASIN for every novella in it - "Dilation Sleep", "Nightingale" and
/// "Grafenwalder's Bestiary" all answer to B002V8MRS2 - so matching on the ASIN
/// alone lets whichever novella imported first lock the rest of its collection out
/// of the library. Two narrations of one book hit the same wall when both are
/// matched to the same product.
///
/// The title and the narrators are what separate those from a genuine re-import,
/// where all three agree. A book with no narrator recorded on either side compares
/// equal on that count, leaving the title to carry the distinction rather than
/// manufacturing a difference out of missing data.
/// </remarks>
public static bool RepresentsSameEdition(Audiobook existing, AudibleBookMetadata incoming)
{
ArgumentNullException.ThrowIfNull(existing);
ArgumentNullException.ThrowIfNull(incoming);

if (!EquivalentText(existing.Title, incoming.Title))
{
return false;
}

return EquivalentNarrators(existing.Narrators, IncomingNarrators(incoming));
}

/// <summary>
/// The narrators as the incoming metadata carries them, from either the list or the
/// single legacy field, whichever the source populated.
/// </summary>
private static IEnumerable<string> IncomingNarrators(AudibleBookMetadata incoming)
{
if (incoming.Narrators is { Count: > 0 })
{
return incoming.Narrators;
}

return string.IsNullOrWhiteSpace(incoming.Narrator)
? []
: incoming.Narrator.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}

private static bool EquivalentText(string? left, string? right) =>
string.Equals(
left?.Trim() ?? string.Empty,
right?.Trim() ?? string.Empty,
StringComparison.OrdinalIgnoreCase);

private static bool EquivalentNarrators(
IEnumerable<string>? left,
IEnumerable<string>? right) =>
Normalize(left).SequenceEqual(Normalize(right), StringComparer.OrdinalIgnoreCase);

private static IEnumerable<string> Normalize(IEnumerable<string>? values) =>
(values ?? [])
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase);
}
56 changes: 12 additions & 44 deletions listenarr.application/Audiobooks/Catalog/LibraryAddService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,37 +102,20 @@ public async Task<LibraryAddOperationResult> AddToLibraryAsync(
}
}

if (!string.IsNullOrWhiteSpace(metadata.Asin))
var duplicate = await AudiobookEditionIdentity.FindExistingEditionAsync(
_repo, metadata, cancellationToken);
if (duplicate != null)
{
var existingByAsin = await _repo.GetByAsinAsync(metadata.Asin);
cancellationToken.ThrowIfCancellationRequested();
if (existingByAsin != null)
return new LibraryAddOperationResult
{
return new LibraryAddOperationResult
{
AlreadyExists = true,
Message = "Audiobook already exists in library",
Audiobook = existingByAsin
};
}
AlreadyExists = true,
Message = "Audiobook already exists in library",
Audiobook = duplicate
};
}

var firstIsbn = (metadata.Isbn ?? Enumerable.Empty<string>())
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i));
if (!string.IsNullOrWhiteSpace(firstIsbn))
{
var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn);
cancellationToken.ThrowIfCancellationRequested();
if (existingByIsbn != null)
{
return new LibraryAddOperationResult
{
AlreadyExists = true,
Message = "Audiobook already exists in library",
Audiobook = existingByIsbn
};
}
}

var audiobook = metadata.ToAudiobook();

Expand Down Expand Up @@ -184,7 +167,6 @@ public async Task<LibraryAddOperationResult> AddToLibraryAsync(
audiobook,
metadata,
request,
firstIsbn,
preparedImage,
preparedAuthorImages,
token),
Expand Down Expand Up @@ -218,31 +200,17 @@ private async Task<LibraryAddOperationResult> CommitAsync(
Audiobook audiobook,
AudibleBookMetadata metadata,
LibraryAddOperationRequest request,
string? firstIsbn,
PreparedLibraryImage preparedImage,
IReadOnlyList<string> preparedAuthorImages,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

if (!string.IsNullOrWhiteSpace(metadata.Asin))
var duplicate = await AudiobookEditionIdentity.FindExistingEditionAsync(
_repo, metadata, cancellationToken);
if (duplicate != null)
{
var existingByAsin = await _repo.GetByAsinAsync(metadata.Asin);
cancellationToken.ThrowIfCancellationRequested();
if (existingByAsin != null)
{
return AlreadyExists(existingByAsin);
}
}

if (!string.IsNullOrWhiteSpace(firstIsbn))
{
var existingByIsbn = await _repo.GetByIsbnAsync(firstIsbn);
cancellationToken.ThrowIfCancellationRequested();
if (existingByIsbn != null)
{
return AlreadyExists(existingByIsbn);
}
return AlreadyExists(duplicate);
}

var destinationFailure = await ResolveAndValidateDestinationAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ Task<List<AudiobookPathReferenceSnapshot>> GetOtherPathReferenceSnapshotsAsync(
Task NormalizeJsonColumnsAsync(CancellationToken ct = default);
Task<Audiobook?> GetByAsinAsync(string asin);
Task<Audiobook?> GetByIsbnAsync(string isbn);

/// <summary>
/// Every audiobook carrying this identifier, not just the first.
/// </summary>
/// <remarks>
/// An identifier is not unique in this library. Audible publishes several
/// novellas under one collection ASIN, and two narrations of one book can be
/// filed against the same product, so deciding whether an incoming book is
/// already held means comparing against all of its namesakes rather than
/// whichever one the database happened to return first.
/// </remarks>
Task<IReadOnlyList<Audiobook>> GetAllByAsinAsync(string asin);
Task<IReadOnlyList<Audiobook>> GetAllByIsbnAsync(string isbn);
Task<Audiobook?> GetByIdAsync(int id);
Task<Audiobook?> GetByIdSnapshotAsync(int id, CancellationToken ct = default);
Task<Audiobook?> GetForUpdateSnapshotAsync(int id, CancellationToken ct = default);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;

namespace Listenarr.Infrastructure.Persistence.Repositories;

/// <summary>
/// Lookups by external identifier.
/// </summary>
/// <remarks>
/// An identifier does not single out one book here. Audible publishes a whole
/// collection of novellas under one ASIN, so the "all" variants exist for callers that
/// have to weigh every namesake rather than trust whichever row came back first - see
/// AudiobookEditionIdentity.
/// </remarks>
public partial class AudiobookRepository
{
public async Task<Audiobook?> GetByAsinAsync(string asin)
{
var normalizedAsin = NormalizeAsin(asin);
if (string.IsNullOrWhiteSpace(normalizedAsin)) return null;

return await _db.Audiobooks
.Include(a => a.ExternalIdentifiers)
.FirstOrDefaultAsync(a =>
(a.Asin != null && a.Asin.ToUpper() == normalizedAsin) ||
(a.ExternalIdentifiers != null && a.ExternalIdentifiers.Any(i =>
i.Type == AudiobookExternalIdentifierType.Asin &&
i.ValueNormalized == normalizedAsin)));
}

public async Task<Audiobook?> GetByIsbnAsync(string isbn)
{
var normalizedIsbn = NormalizeIsbn(isbn);
if (string.IsNullOrWhiteSpace(normalizedIsbn)) return null;

var audiobooks = await _db.Audiobooks
.Include(a => a.ExternalIdentifiers)
.ToListAsync();

return audiobooks.FirstOrDefault(a =>
(a.Isbn != null && a.Isbn.Any(i => NormalizeIsbn(i) == normalizedIsbn)) ||
(a.ExternalIdentifiers != null && a.ExternalIdentifiers.Any(i =>
i.Type == AudiobookExternalIdentifierType.Isbn &&
string.Equals(i.ValueNormalized, normalizedIsbn, StringComparison.OrdinalIgnoreCase))));
}

public async Task<IReadOnlyList<Audiobook>> GetAllByAsinAsync(string asin)
{
var normalizedAsin = NormalizeAsin(asin);
if (string.IsNullOrWhiteSpace(normalizedAsin)) return [];

return await _db.Audiobooks
.Include(a => a.ExternalIdentifiers)
.Where(a =>
(a.Asin != null && a.Asin.ToUpper() == normalizedAsin) ||
(a.ExternalIdentifiers != null && a.ExternalIdentifiers.Any(i =>
i.Type == AudiobookExternalIdentifierType.Asin &&
i.ValueNormalized == normalizedAsin)))
.ToListAsync();
}

public async Task<IReadOnlyList<Audiobook>> GetAllByIsbnAsync(string isbn)
{
var normalizedIsbn = NormalizeIsbn(isbn);
if (string.IsNullOrWhiteSpace(normalizedIsbn)) return [];

var audiobooks = await _db.Audiobooks
.Include(a => a.ExternalIdentifiers)
.ToListAsync();

return audiobooks.Where(a =>
(a.Isbn != null && a.Isbn.Any(i => NormalizeIsbn(i) == normalizedIsbn)) ||
(a.ExternalIdentifiers != null && a.ExternalIdentifiers.Any(i =>
i.Type == AudiobookExternalIdentifierType.Isbn &&
string.Equals(i.ValueNormalized, normalizedIsbn, StringComparison.OrdinalIgnoreCase))))
.ToList();
}
}
Loading
Loading