From 6e70e07e5d6d0eac9902a7a48eb2db55e6edbcc1 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:20:50 -0500 Subject: [PATCH] fix(metadata): populate SeriesAsin from the Audnexus series record AudnexusSeries carries Asin, Name and Position. ConvertAudnexusToMetadata mapped the name and the position but dropped the ASIN, so AudiobookSeriesMembership.SeriesAsin was null for every audiobook even though the column exists for it and the repository preserves it when set. The series ASIN is the stable identifier for a series. A book ASIN is not: it is per-marketplace and per-narrator, and the same work carries different book ASINs in different catalogues. A series name is free text that varies between editions and translations. The series ASIN is stable across both, so (SeriesAsin, SeriesNumber) is the durable way to say "book N of that series". Both the primary and the secondary membership now carry it. Fixes #767 --- .../Search/Metadata/MetadataConverters.cs | 2 + .../MetadataConvertersSeriesAsinTests.cs | 95 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/Features/Application/Search/Metadata/MetadataConvertersSeriesAsinTests.cs diff --git a/listenarr.application/Search/Metadata/MetadataConverters.cs b/listenarr.application/Search/Metadata/MetadataConverters.cs index b70e2b443..a319df931 100644 --- a/listenarr.application/Search/Metadata/MetadataConverters.cs +++ b/listenarr.application/Search/Metadata/MetadataConverters.cs @@ -143,6 +143,7 @@ public AudibleBookMetadata ConvertAudnexusToMetadata(AudnexusBookResponse audnex { audnexusSeriesMemberships.Add(new AudiobookSeriesMembership { + SeriesAsin = audnexusData.SeriesPrimary.Asin, SeriesName = audnexusData.SeriesPrimary.Name, SeriesNumber = audnexusData.SeriesPrimary.Position, IsPrimary = true, @@ -154,6 +155,7 @@ public AudibleBookMetadata ConvertAudnexusToMetadata(AudnexusBookResponse audnex { audnexusSeriesMemberships.Add(new AudiobookSeriesMembership { + SeriesAsin = audnexusData.SeriesSecondary.Asin, SeriesName = audnexusData.SeriesSecondary.Name, SeriesNumber = audnexusData.SeriesSecondary.Position, IsPrimary = audnexusSeriesMemberships.Count == 0, diff --git a/tests/Features/Application/Search/Metadata/MetadataConvertersSeriesAsinTests.cs b/tests/Features/Application/Search/Metadata/MetadataConvertersSeriesAsinTests.cs new file mode 100644 index 000000000..1919b8f0f --- /dev/null +++ b/tests/Features/Application/Search/Metadata/MetadataConvertersSeriesAsinTests.cs @@ -0,0 +1,95 @@ +/* + * 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.Metadata.Audnexus; +using Listenarr.Application.Search.Metadata; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Listenarr.Tests.Features.Application.Search.Metadata +{ + /// + /// Audnexus returns an ASIN for each series a book belongs to, and + /// AudiobookSeriesMembership has a SeriesAsin column to hold it. The converter maps the + /// series name and position but drops the ASIN, so SeriesAsin is never populated. + /// + /// The series ASIN is the stable identifier: a book ASIN is per-marketplace and + /// per-narrator, and a series name is free text that varies between editions. + /// + /// Data below is from the live Audnexus record for H. Rider Haggard's "She and Allan" + /// (B00CQ5WAXW), which belongs to two series at once. + /// + public class MetadataConvertersSeriesAsinTests + { + private static MetadataConverters Converter() => + new(imageCacheService: null, + NullLogger.Instance, + requestContextAccessor: null); + + private static AudnexusBookResponse SheAndAllan() => new() + { + Asin = "B00CQ5WAXW", + Title = "She and Allan", + SeriesPrimary = new AudnexusSeries + { + Asin = "B01E633FQM", + Name = "Ayesha", + Position = "0", + }, + SeriesSecondary = new AudnexusSeries + { + Asin = "B01F5TL5K4", + Name = "Allan Quatermain", + Position = "7", + }, + }; + + [Fact] + public void PrimarySeriesAsin_IsMapped() + { + var metadata = Converter().ConvertAudnexusToMetadata(SheAndAllan(), "B00CQ5WAXW"); + + var primary = metadata.SeriesMemberships?.Single(m => m.IsPrimary); + + Assert.NotNull(primary); + Assert.Equal("Ayesha", primary!.SeriesName); + Assert.Equal("0", primary.SeriesNumber); + Assert.Equal("B01E633FQM", primary.SeriesAsin); + } + + [Fact] + public void SecondarySeriesAsin_IsMapped() + { + var metadata = Converter().ConvertAudnexusToMetadata(SheAndAllan(), "B00CQ5WAXW"); + + var secondary = metadata.SeriesMemberships?.Single(m => !m.IsPrimary); + + Assert.NotNull(secondary); + Assert.Equal("Allan Quatermain", secondary!.SeriesName); + Assert.Equal("7", secondary.SeriesNumber); + Assert.Equal("B01F5TL5K4", secondary.SeriesAsin); + } + + [Fact] + public void ABookInTwoSeries_KeepsBothMemberships() + { + var metadata = Converter().ConvertAudnexusToMetadata(SheAndAllan(), "B00CQ5WAXW"); + + Assert.Equal(2, metadata.SeriesMemberships?.Count); + } + } +}