From a66670312b027b6152442b3f6d6c60e52a7abde6 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:05:49 -0500 Subject: [PATCH] fix(search): stop reporting a series name as the series identifier MapMetadataResultToAudibleAsync falls back to the fields a MetadataSearchResult already carries when the Audible product lookup cannot be made. That result holds the series as a name only, with no identifier, and the fallback filled the series entry's asin with a copy of the name. Every client downstream treats that field as an identifier. The library import wizard reads it, keeps it because it is the right length, and posts it as a membership's seriesAsin, so a series name lands in the one column whose value is that it is not a name. Foundation, Bartimaeus and Peripheral are all ten characters and all real series. The author fallback three lines above already reports a null asin for exactly this reason. The series fallback now matches it. Co-Authored-By: Claude Fable 5.1 --- .../Features/Search/SearchResponseMapper.cs | 7 +- ...SearchResponseMapperSeriesFallbackTests.cs | 115 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 tests/Features/Api/Features/Search/SearchResponseMapperSeriesFallbackTests.cs diff --git a/listenarr.api/Features/Search/SearchResponseMapper.cs b/listenarr.api/Features/Search/SearchResponseMapper.cs index 7c116f0483..1cffc02cf5 100644 --- a/listenarr.api/Features/Search/SearchResponseMapper.cs +++ b/listenarr.api/Features/Search/SearchResponseMapper.cs @@ -253,7 +253,12 @@ public async Task MapMetadataResultToAudibleAsync( if (!string.IsNullOrWhiteSpace(md?.Author)) fallbackAuthors.Add(new { asin = (string?)null, name = md.Author, region = region, regions = new[] { region }, image = (string?)null, updatedAt = (string?)null }); var fallbackSeries = new List(); - if (!string.IsNullOrWhiteSpace(md?.Series)) fallbackSeries.Add(new { asin = md.Series, name = md.Series, region = region, position = md.SeriesNumber, updatedAt = (string?)null }); + // A MetadataSearchResult carries the series as a name only, so there is no series + // identifier to report here. The author fallback three lines above says so with a + // null asin; this one used to repeat the name into the field instead, and clients + // that store what they are handed then wrote a series name into a column whose + // whole value is that it is not a name. + if (!string.IsNullOrWhiteSpace(md?.Series)) fallbackSeries.Add(new { asin = (string?)null, name = md.Series, region = region, position = md.SeriesNumber, updatedAt = (string?)null }); return new { diff --git a/tests/Features/Api/Features/Search/SearchResponseMapperSeriesFallbackTests.cs b/tests/Features/Api/Features/Search/SearchResponseMapperSeriesFallbackTests.cs new file mode 100644 index 0000000000..ea8b9e7c23 --- /dev/null +++ b/tests/Features/Api/Features/Search/SearchResponseMapperSeriesFallbackTests.cs @@ -0,0 +1,115 @@ +/* + * 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 System.Text.Json; +using Listenarr.Tests.Common; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Api.Features.Search +{ + /// + /// MapMetadataResultToAudibleAsync reshapes a MetadataSearchResult into the Audible response + /// shape. When the Audible product lookup cannot be made, it falls back to the fields the + /// metadata result already carries. A MetadataSearchResult holds the series as a name only, + /// so the fallback has no series identifier to report and must say so with a null rather + /// than repeating the name into the asin field. Clients that store what they are handed + /// otherwise write a series name into a series identifier column. + /// + [Trait("Name", "SearchResponseMapperSeriesFallbackTests")] + [Trait("Category", "Api")] + public class SearchResponseMapperSeriesFallbackTests : BaseTests + { + private static SearchResponseMapper MapperWithNoAudibleLookup() + { + var metadataService = new Mock(); + metadataService + .Setup(s => s.GetAudibleMetadataAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((AudibleBookResponse?)null); + + return new SearchResponseMapper( + metadataService.Object, + NullLogger.Instance); + } + + private static async Task MapAsync(MetadataSearchResult md) + { + var mapped = await MapperWithNoAudibleLookup() + .MapMetadataResultToAudibleAsync(md, "us", new DefaultHttpContext()); + + return JsonSerializer.SerializeToElement(mapped); + } + + private static MetadataSearchResult SheAndAllan() => new() + { + Asin = "B00CQ5WAXW", + Title = "She and Allan", + Author = "H. Rider Haggard", + Series = "Ayesha", + SeriesNumber = "0", + }; + + [Fact] + public async Task SeriesFallback_ReportsNoSeriesIdentifier() + { + var element = await MapAsync(SheAndAllan()); + + var series = Assert.Single(element.GetProperty("series").EnumerateArray().ToList()); + + Assert.Equal(JsonValueKind.Null, series.GetProperty("asin").ValueKind); + } + + [Fact] + public async Task SeriesFallback_StillCarriesTheNameAndPosition() + { + var element = await MapAsync(SheAndAllan()); + + var series = Assert.Single(element.GetProperty("series").EnumerateArray().ToList()); + + Assert.Equal("Ayesha", series.GetProperty("name").GetString()); + Assert.Equal("0", series.GetProperty("position").GetString()); + } + + /// + /// The author fallback immediately above already reports a null asin for the same reason. + /// Pinned here so the two stay consistent: if somebody ever fills one of them in from a + /// name, this test says which convention the file follows. + /// + [Fact] + public async Task AuthorFallback_AlsoReportsNoIdentifier() + { + var element = await MapAsync(SheAndAllan()); + + var author = Assert.Single(element.GetProperty("authors").EnumerateArray().ToList()); + + Assert.Equal(JsonValueKind.Null, author.GetProperty("asin").ValueKind); + Assert.Equal("H. Rider Haggard", author.GetProperty("name").GetString()); + } + + [Fact] + public async Task NoSeries_ReportsAnEmptySeriesList() + { + var md = SheAndAllan(); + md.Series = null; + md.SeriesNumber = null; + + var element = await MapAsync(md); + + Assert.Empty(element.GetProperty("series").EnumerateArray().ToList()); + } + } +}