From 2bb3b826f9ff412ce478660eed904bcbb6746138 Mon Sep 17 00:00:00 2001 From: Nexal Date: Mon, 31 Aug 2026 20:40:36 -0600 Subject: [PATCH 1/2] fix(library): let distinct books sharing an identifier both into the library An add was refused whenever any book already held carried the same ASIN or first ISBN, whatever else differed. Audible publishes one collection ASIN for every novella inside it, so B002V8MRS2 answers for "Dilation Sleep", "Nightingale" and "Grafenwalder's Bestiary" alike: importing one of them locked the other two out of the library for good. Two narrations of a book matched to the same product hit the same wall. An identifier now has to agree with the title and the narrators before the incoming book counts as one already held. That still refuses a genuine re-import, where all three match, and a book with no narrator recorded on either side compares equal on that count rather than manufacturing a difference out of missing data. Every book carrying the identifier is now considered rather than whichever one the database returned first, which is what GetAllByAsinAsync and GetAllByIsbnAsync are for - an identifier is no longer unique here, so the first row back is not necessarily the namesake worth comparing against. The rule lives in AudiobookEditionIdentity because both add paths need it: the application service behind manual import, and the API workflow behind Add New. 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. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TQvEWJmh71bNydZiKtDqfo --- .../Features/Library/LibraryAddWorkflow.cs | 25 +-- listenarr.api/GlobalUsings.cs | 1 + .../Catalog/AudiobookEditionIdentity.cs | 139 +++++++++++++++++ .../Audiobooks/Catalog/LibraryAddService.cs | 56 ++----- .../Repositories/IAudiobookRepository.cs | 13 ++ .../Repositories/AudiobookRepository.cs | 32 ++++ .../Catalog/AudiobookEditionIdentityTests.cs | 143 ++++++++++++++++++ 7 files changed, 348 insertions(+), 61 deletions(-) create mode 100644 listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs create mode 100644 tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs 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..c30dff540 --- /dev/null +++ b/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs @@ -0,0 +1,139 @@ +/* + * 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.Application.Audiobooks.Contracts.Repositories; + +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.cs b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs index 0e632149e..8b3bc7cbf 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -114,6 +114,38 @@ public async Task>> GetAllSeries 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(); + } + public async Task GetByIdAsync(int id) { // Include QualityProfile and Files for callers that need full audiobook details diff --git a/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs b/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs new file mode 100644 index 000000000..a1eb076b3 --- /dev/null +++ b/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs @@ -0,0 +1,143 @@ +/* + * 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.Application.Audiobooks.Catalog; +using Listenarr.Application.Audiobooks.Contracts.Repositories; +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); + } + } +} From 786bb5bf4454ec7a59d8a883b15a05b41f21f7c7 Mon Sep 17 00:00:00 2001 From: Nexal Date: Mon, 31 Aug 2026 20:55:39 -0600 Subject: [PATCH 2/2] fix(library): keep the repository file under the focus limit, drop stale usings Adding the two "all matches" lookups pushed AudiobookRepository.cs to 530 lines, past the 500 the architecture test holds production sources to. The identifier lookups move to their own partial, which is a real seam rather than a split for the line count: they are the only members that resolve a book from an external identifier, and the reason they exist in "all" form is a property of identifiers rather than of the repository. dotnet format also removed two using directives the global usings already cover. Verified in a container this time, which is what should have happened before the first push: Release build clean, the eight new tests pass, and the architecture test is green again. Two LibraryController_AddToLibraryTests fail here, and fail identically on unmodified canary in the same container, so they are the Linux environment rather than this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TQvEWJmh71bNydZiKtDqfo --- .../Catalog/AudiobookEditionIdentity.cs | 2 - .../AudiobookRepository.Identifiers.cs | 77 +++++++++++++++++++ .../Repositories/AudiobookRepository.cs | 61 --------------- .../Catalog/AudiobookEditionIdentityTests.cs | 2 - 4 files changed, 77 insertions(+), 65 deletions(-) create mode 100644 listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.Identifiers.cs diff --git a/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs b/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs index c30dff540..364511f0d 100644 --- a/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs +++ b/listenarr.application/Audiobooks/Catalog/AudiobookEditionIdentity.cs @@ -15,8 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Audiobooks.Contracts.Repositories; - namespace Listenarr.Application.Audiobooks.Catalog; /// 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 8b3bc7cbf..ab770465e 100644 --- a/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs +++ b/listenarr.infrastructure/Persistence/Repositories/AudiobookRepository.cs @@ -84,67 +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> 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(); - } public async Task GetByIdAsync(int id) { diff --git a/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs b/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs index a1eb076b3..ae9201cf8 100644 --- a/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs +++ b/tests/Features/Application/Audiobooks/Catalog/AudiobookEditionIdentityTests.cs @@ -15,8 +15,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -using Listenarr.Application.Audiobooks.Catalog; -using Listenarr.Application.Audiobooks.Contracts.Repositories; using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Application.Audiobooks.Catalog