diff --git a/listenarr.api/Features/Library/LibraryAddWorkflow.cs b/listenarr.api/Features/Library/LibraryAddWorkflow.cs index 08b879e1f..1b8ee74e7 100644 --- a/listenarr.api/Features/Library/LibraryAddWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryAddWorkflow.cs @@ -111,24 +111,15 @@ private async Task 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(); @@ -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); } } diff --git a/listenarr.api/GlobalUsings.cs b/listenarr.api/GlobalUsings.cs index 614c3dbcd..75a062949 100644 --- a/listenarr.api/GlobalUsings.cs +++ b/listenarr.api/GlobalUsings.cs @@ -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; diff --git a/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs b/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs new file mode 100644 index 000000000..364511f0d --- /dev/null +++ b/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs @@ -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 . + */ +namespace Listenarr.Application.Audiobooks.Catalog; + +/// +/// Whether a book being added is one the library already holds. +/// +/// +/// 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. +/// +public static class AudiobookEditionIdentity +{ + /// + /// The audiobook already held that is the same edition as , + /// or null when nothing held matches. + /// + /// + /// Every book carrying the identifier is considered rather than whichever one the + /// database returns first, because an identifier is not unique in this library. + /// + public static async Task 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()) + .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; + } + + /// + /// Whether an audiobook already held is the same edition as the one being added. + /// + /// + /// 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. + /// + 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)); + } + + /// + /// The narrators as the incoming metadata carries them, from either the list or the + /// single legacy field, whichever the source populated. + /// + private static IEnumerable 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? left, + IEnumerable? right) => + Normalize(left).SequenceEqual(Normalize(right), StringComparer.OrdinalIgnoreCase); + + private static IEnumerable Normalize(IEnumerable? values) => + (values ?? []) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase); +} diff --git a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs index aa305b1c7..9f9db74e8 100644 --- a/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs +++ b/listenarr.application/Audiobooks/Catalog/LibraryAddService.cs @@ -102,37 +102,20 @@ public async Task 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()) .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(); @@ -184,7 +167,6 @@ public async Task AddToLibraryAsync( audiobook, metadata, request, - firstIsbn, preparedImage, preparedAuthorImages, token), @@ -218,31 +200,17 @@ private async Task CommitAsync( Audiobook audiobook, AudibleBookMetadata metadata, LibraryAddOperationRequest request, - string? firstIsbn, PreparedLibraryImage preparedImage, IReadOnlyList 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( diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs index 3df20b2a3..b9926bf5f 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookRepository.cs @@ -41,6 +41,19 @@ Task> GetOtherPathReferenceSnapshotsAsync( Task NormalizeJsonColumnsAsync(CancellationToken ct = default); Task GetByAsinAsync(string asin); Task GetByIsbnAsync(string isbn); + + /// + /// Every audiobook carrying this identifier, not just the first. + /// + /// + /// 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. + /// + Task> GetAllByAsinAsync(string asin); + Task> GetAllByIsbnAsync(string isbn); Task GetByIdAsync(int id); Task GetByIdSnapshotAsync(int id, CancellationToken ct = default); Task GetForUpdateSnapshotAsync(int id, CancellationToken ct = default); diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.Identifiers.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.Identifiers.cs new file mode 100644 index 000000000..dbfa13a2d --- /dev/null +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.Identifiers.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.Persistence.Repositories; + +/// +/// Lookups by external identifier. +/// +/// +/// 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. +/// +public partial class AudiobookRepository +{ + public async Task 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 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> 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> 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(); + } +} diff --git a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs index 0e632149e..ab770465e 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -84,35 +84,6 @@ public async Task>> GetAllSeries .ToDictionary(g => g.Key, g => g.ToList()); } - public async Task 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 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 GetByIdAsync(int id) { diff --git a/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs b/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs new file mode 100644 index 000000000..ae9201cf8 --- /dev/null +++ b/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs @@ -0,0 +1,141 @@ +/* + * 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 . + */ +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Audiobooks.Catalog +{ + [Trait("Name", "AudiobookEditionIdentityTests")] + [Trait("Category", "LibraryAdd")] + public sealed class AudiobookEditionIdentityTests : BaseTests + { + private const string CollectionAsin = "B002V8MRS2"; + + private static Audiobook Held(string title, params string[] narrators) => new() + { + Title = title, + Asin = CollectionAsin, + Narrators = narrators.ToList() + }; + + private static AudibleBookMetadata Incoming(string title, params string[] narrators) => new() + { + Title = title, + Asin = CollectionAsin, + Narrators = narrators.ToList() + }; + + [Fact] + public void RepresentsSameEdition_SameAsinDifferentTitle_IsNotTheSameBook() + { + // Audible files every novella in a collection under one ASIN, so the first + // one imported must not lock the rest of the collection out. + var held = Held("Dilation Sleep", "John Lee"); + + Assert.False(AudiobookEditionIdentity.RepresentsSameEdition( + held, Incoming("Nightingale", "John Lee"))); + Assert.False(AudiobookEditionIdentity.RepresentsSameEdition( + held, Incoming("Grafenwalder's Bestiary", "John Lee"))); + } + + [Fact] + public void RepresentsSameEdition_SameTitleDifferentNarrator_IsNotTheSameBook() + { + var held = Held("Project Hail Mary", "Ray Porter"); + + Assert.False(AudiobookEditionIdentity.RepresentsSameEdition( + held, Incoming("Project Hail Mary", "Andy Weir"))); + } + + [Fact] + public void RepresentsSameEdition_AsinTitleAndNarratorAllAgree_IsTheSameBook() + { + var held = Held("Century Rain", "John Lee"); + + Assert.True(AudiobookEditionIdentity.RepresentsSameEdition( + held, Incoming("Century Rain", "John Lee"))); + } + + [Fact] + public void RepresentsSameEdition_NarratorOrderDiffers_IsStillTheSameBook() + { + var held = Held("A War of Gifts", "Scott Brick", "Stefan Rudnicki"); + + Assert.True(AudiobookEditionIdentity.RepresentsSameEdition( + held, Incoming("A War of Gifts", "Stefan Rudnicki", "Scott Brick"))); + } + + [Fact] + public void RepresentsSameEdition_NoNarratorOnEitherSide_LeavesTheTitleToDecide() + { + // Missing data on both sides is not a difference; inventing one would let a + // genuine re-import through whenever the source omitted the narrator. + var held = Held("Radicalized"); + + Assert.True(AudiobookEditionIdentity.RepresentsSameEdition(held, Incoming("Radicalized"))); + Assert.False(AudiobookEditionIdentity.RepresentsSameEdition(held, Incoming("Unauthorized Bread"))); + } + + [Fact] + public void RepresentsSameEdition_TakesTheLegacySingleNarratorField() + { + var held = Held("Permafrost", "John Lee"); + var incoming = new AudibleBookMetadata + { + Title = "Permafrost", + Asin = CollectionAsin, + Narrator = "John Lee" + }; + + Assert.True(AudiobookEditionIdentity.RepresentsSameEdition(held, incoming)); + } + + [Fact] + public async Task FindExistingEditionAsync_ChecksEveryBookSharingTheAsin() + { + // The database returns namesakes in no particular order, so the match cannot + // depend on which one comes back first. + var repo = new Mock(); + repo.Setup(r => r.GetAllByAsinAsync(CollectionAsin)) + .ReturnsAsync(new List + { + Held("Dilation Sleep", "John Lee"), + Held("Nightingale", "John Lee"), + Held("Grafenwalder's Bestiary", "John Lee") + }); + + var match = await AudiobookEditionIdentity.FindExistingEditionAsync( + repo.Object, Incoming("Nightingale", "John Lee")); + + Assert.NotNull(match); + Assert.Equal("Nightingale", match!.Title); + } + + [Fact] + public async Task FindExistingEditionAsync_NoNamesakeMatches_AllowsTheAdd() + { + var repo = new Mock(); + repo.Setup(r => r.GetAllByAsinAsync(CollectionAsin)) + .ReturnsAsync(new List { Held("Dilation Sleep", "John Lee") }); + + var match = await AudiobookEditionIdentity.FindExistingEditionAsync( + repo.Object, Incoming("Nightingale", "John Lee")); + + Assert.Null(match); + } + } +}