From eadc214f830731dd594cc23978e6a268d05e2316 Mon Sep 17 00:00:00 2001 From: dny238 Date: Fri, 24 Jul 2026 14:48:58 -0600 Subject: [PATCH 1/5] Read embedded ASIN/ISBN tags during scan and adopt them onto the audiobook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files imported with an embedded ASIN (the freeform iTunes atom ----:com.apple.iTunes:ASIN that Audible rips and most audiobook taggers write) started life in the library with no identifier, so "Rescan Metadata" failed with "No ASIN or ISBN identifiers are available" even though the value was sitting in the file. - FfprobeTagMetadataMapper.Apply now reads the ASIN and ISBN tags into AudioMetadata (GetTag already matches case-insensitively). - AudiobookFileService.EnsureAudiobookFileAsync — the shared file- registration path used by the scan job — now adopts those identifiers onto the audiobook when it has none, persists, and records a history entry. Existing identifiers are never overwritten. Because AudiobookIdentifierMapper.GetEffectiveIdentifiers backfills from the legacy Asin/Isbn fields, this is enough for Rescan Metadata to work. - Adds FfprobeTagMetadataMapperTests covering ASIN/ISBN extraction, case-insensitivity, absent-tag, and no-overwrite. Fixes #780 Co-Authored-By: Claude Fable 5 --- .../Audiobooks/Files/AudiobookFileService.cs | 68 ++++++++++++++++ .../Metadata/FfprobeTagMetadataMapper.cs | 6 ++ .../Metadata/FfprobeTagMetadataMapperTests.cs | 80 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 tests/Features/Infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapperTests.cs diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index f5801f273..912053862 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -27,6 +27,7 @@ public class AudiobookFileService( IMemoryCache memoryCache, MetadataExtractionLimiter limiter, IAudiobookFileRepository audiobookFileRepository, + IAudiobookRepository audiobookRepository, IHistoryRepository historyRepository, IMetadataService metadataService, IToastService toastService, @@ -235,6 +236,11 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil logger.LogDebug(hx, "Failed to create history entry for added audiobook file {Path}", LogRedaction.SanitizeFilePath(filePath)); } + // Adopt identifiers embedded in the file's tags (e.g. ASIN) onto the audiobook + // when it has none, so "Rescan Metadata" can find upstream metadata without the + // user manually entering an identifier. + await AdoptFileIdentifiersAsync(audiobook, meta, filePath); + return true; } catch (UniqueConstraintViolationException) @@ -260,5 +266,67 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil return false; } } + + /// + /// Copies identifiers found in a scanned file's embedded tags (ASIN, ISBN) onto the + /// audiobook when it doesn't already have them. This lets "Rescan Metadata" resolve + /// upstream metadata for files that were imported with an embedded ASIN, without the + /// user having to type the identifier in by hand. Existing identifiers are never overwritten. + /// + private async Task AdoptFileIdentifiersAsync(Audiobook? audiobook, AudioMetadata? meta, string filePath) + { + if (audiobook == null || meta == null) + { + return; + } + + var changed = false; + + if (string.IsNullOrWhiteSpace(audiobook.Asin) && !string.IsNullOrWhiteSpace(meta.Asin)) + { + audiobook.Asin = meta.Asin.Trim(); + changed = true; + } + + if (!string.IsNullOrWhiteSpace(meta.Isbn)) + { + var isbn = meta.Isbn.Trim(); + audiobook.Isbn ??= new List(); + if (!audiobook.Isbn.Any(existing => string.Equals(existing, isbn, StringComparison.OrdinalIgnoreCase))) + { + audiobook.Isbn.Add(isbn); + changed = true; + } + } + + if (!changed) + { + return; + } + + try + { + await audiobookRepository.UpdateAsync(audiobook); + logger.LogInformation( + "Adopted identifiers from file tags for audiobook {AudiobookId} (ASIN set: {HasAsin})", + audiobook.Id, + !string.IsNullOrWhiteSpace(audiobook.Asin)); + + await historyRepository.AddAsync(new History + { + AudiobookId = audiobook.Id, + AudiobookTitle = audiobook.Title ?? "Unknown", + EventType = "Identifier Added", + Message = "Identifier read from embedded file tags during scan", + Source = "Scan", + Data = JsonSerializer.Serialize(new { audiobook.Asin, Isbn = audiobook.Isbn, FilePath = filePath }), + Timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Failed to persist adopted file identifiers for audiobook {AudiobookId}", audiobook.Id); + } + } } } diff --git a/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs b/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs index a41b6f8ee..f8710550e 100644 --- a/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs +++ b/listenarr.infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapper.cs @@ -24,6 +24,12 @@ public static void Apply(AudioMetadata metadata, JsonElement tags) metadata.TrackNumber ??= ParseNumericTag(tags, "track", "TRACK", "tracknumber", "TRACKNUMBER"); metadata.DiscNumber ??= ParseNumericTag(tags, "disc", "DISC", "discnumber", "DISCNUMBER"); metadata.Year ??= ParseNumericTag(tags, "date", "DATE", "year", "YEAR"); + + // Audiobook identifier tags. Audible-ripped and most audiobook taggers store the ASIN + // in the freeform iTunes atom (----:com.apple.iTunes:ASIN), which ffprobe surfaces as an + // "ASIN" tag. GetTag matches case-insensitively; the extra casings are for clarity. + metadata.Asin ??= GetTag(tags, "ASIN", "asin"); + metadata.Isbn ??= GetTag(tags, "ISBN", "isbn"); } private static string FirstNonEmpty(params string?[] candidates) diff --git a/tests/Features/Infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapperTests.cs b/tests/Features/Infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapperTests.cs new file mode 100644 index 000000000..9e278ea15 --- /dev/null +++ b/tests/Features/Infrastructure/Ffmpeg/Metadata/FfprobeTagMetadataMapperTests.cs @@ -0,0 +1,80 @@ +/* + * 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.Infrastructure.Ffmpeg.Metadata; + +namespace Listenarr.Tests.Features.Infrastructure.Ffmpeg.Metadata +{ + public class FfprobeTagMetadataMapperTests + { + private static JsonElement TagsFrom(string json) + { + // JsonDocument is disposable, but the returned element is only read synchronously + // within each test, so cloning keeps it valid without keeping the document alive. + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + [Fact] + public void Apply_ReadsAsinAndIsbnFromTags() + { + var metadata = new AudioMetadata(); + var tags = TagsFrom("{\"title\":\"A Book\",\"ASIN\":\"B0078PA1OA\",\"ISBN\":\"9781250120207\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Equal("B0078PA1OA", metadata.Asin); + Assert.Equal("9781250120207", metadata.Isbn); + Assert.Equal("A Book", metadata.Title); + } + + [Fact] + public void Apply_MatchesAsinTag_CaseInsensitively() + { + var metadata = new AudioMetadata(); + var tags = TagsFrom("{\"asin\":\"B0078PA1OA\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Equal("B0078PA1OA", metadata.Asin); + } + + [Fact] + public void Apply_LeavesAsinNull_WhenNoAsinTagPresent() + { + var metadata = new AudioMetadata(); + var tags = TagsFrom("{\"title\":\"A Book\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Null(metadata.Asin); + Assert.Null(metadata.Isbn); + } + + [Fact] + public void Apply_DoesNotOverwriteExistingAsin() + { + var metadata = new AudioMetadata { Asin = "EXISTING123" }; + var tags = TagsFrom("{\"ASIN\":\"B0078PA1OA\"}"); + + FfprobeTagMetadataMapper.Apply(metadata, tags); + + Assert.Equal("EXISTING123", metadata.Asin); + } + } +} From 214df3bb84cd7f1098ca207ec61ff133a1f3ce71 Mon Sep 17 00:00:00 2001 From: dny238 Date: Sat, 25 Jul 2026 09:17:51 -0600 Subject: [PATCH 2/5] Also adopt ASIN/ISBN when re-scanning an already-registered file The initial change only adopted identifiers when a *new* AudiobookFile was created. Re-scanning a file that was already imported (e.g. after tagging it with an ASIN) returned early before the adoption ran, so the identifier was never picked up. Now, when a file is already registered and the audiobook still has no ASIN, the scan re-reads the file's tags and adopts an identifier. Guarded on a missing ASIN so the extra ffprobe read only happens when there's something to gain. Co-Authored-By: Claude Fable 5 --- .../Audiobooks/Files/AudiobookFileService.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 912053862..3d086257c 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -55,6 +55,16 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil if (exists) { logger.LogDebug("AudiobookFile already exists for audiobook {AudiobookId} at path {Path}", audiobook.Id, LogRedaction.SanitizeFilePath(filePath)); + + // The file is already registered, but it may have been (re-)tagged with an + // identifier since it was first scanned. If the audiobook still has no ASIN, + // re-read the tags and adopt one so a rescan picks up a newly added ASIN. + if (string.IsNullOrWhiteSpace(audiobook.Asin)) + { + var existingMeta = await TryExtractMetadataAsync(filePath); + await AdoptFileIdentifiersAsync(audiobook, existingMeta, filePath); + } + return false; } @@ -267,6 +277,25 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil } } + /// + /// Extracts audio metadata for a single file, returning null on any failure. Used when + /// adopting identifiers from an already-registered file (the create path does its own + /// cached/retrying extraction). + /// + private async Task TryExtractMetadataAsync(string filePath) + { + try + { + using var _ = await limiter.Sem.LockAsync(); + return await metadataService.ExtractFileMetadataAsync(filePath); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogInformation(ex, "Metadata extraction failed while adopting identifiers for {Path}", LogRedaction.SanitizeFilePath(filePath)); + return null; + } + } + /// /// Copies identifiers found in a scanned file's embedded tags (ASIN, ISBN) onto the /// audiobook when it doesn't already have them. This lets "Rescan Metadata" resolve From 1135e418c0174c20b4ab67f52cd9bde1b24e8b91 Mon Sep 17 00:00:00 2001 From: dny238 Date: Mon, 27 Jul 2026 09:41:23 -0600 Subject: [PATCH 3/5] Auto-fetch metadata when a scan discovers an identifier on a bare book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, scanning a file with an embedded ASIN adopted the identifier but left the book otherwise blank — the user then had to know to click "Rescan Metadata" to actually see title/narrators/publisher/etc. Clicking Scan and seeing nothing change is confusing. Now, immediately after the scan adopts an ASIN onto a book that had none, the upstream metadata is fetched and any empty fields are filled in, so a single scan both discovers the identifier and populates the book. - New IAudiobookMetadataRefreshService (application) fetches Audible metadata by ASIN and fills only empty fields — existing/user-set values are never overwritten. Reuses IAudiobookMetadataService + MetadataConverters. - AudiobookFileService triggers it after adopting an identifier; failures are logged and never fail the scan. - Tests cover fill-empty-without-overwrite and the no-op case. Co-Authored-By: Claude Fable 5 --- .../IAudiobookMetadataRefreshService.cs | 35 +++++ .../Audiobooks/Files/AudiobookFileService.cs | 14 ++ .../Core/AudiobookMetadataRefreshService.cs | 134 ++++++++++++++++++ .../MetadataRegistrationExtensions.cs | 1 + .../AudiobookMetadataRefreshServiceTests.cs | 76 ++++++++++ 5 files changed, 260 insertions(+) create mode 100644 listenarr.application/Audiobooks/Contracts/IAudiobookMetadataRefreshService.cs create mode 100644 listenarr.application/Metadata/Core/AudiobookMetadataRefreshService.cs create mode 100644 tests/Features/Application/Metadata/AudiobookMetadataRefreshServiceTests.cs diff --git a/listenarr.application/Audiobooks/Contracts/IAudiobookMetadataRefreshService.cs b/listenarr.application/Audiobooks/Contracts/IAudiobookMetadataRefreshService.cs new file mode 100644 index 000000000..05bf110db --- /dev/null +++ b/listenarr.application/Audiobooks/Contracts/IAudiobookMetadataRefreshService.cs @@ -0,0 +1,35 @@ +/* + * 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.Contracts +{ + /// + /// Fetches upstream metadata for an audiobook using its ASIN and fills in fields that are + /// currently empty. Used to auto-populate a book immediately after a scan discovers an ASIN + /// embedded in the file, so the user doesn't have to separately click "Rescan Metadata". + /// Existing (non-empty) fields are never overwritten. + /// + public interface IAudiobookMetadataRefreshService + { + /// + /// Populates missing metadata on from its ASIN. + /// Returns true if any field was filled in (and the audiobook was saved). + /// + Task TryPopulateMissingMetadataAsync(Audiobook audiobook, string? region = null, CancellationToken cancellationToken = default); + } +} diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 3d086257c..f6d35214d 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -30,6 +30,7 @@ public class AudiobookFileService( IAudiobookRepository audiobookRepository, IHistoryRepository historyRepository, IMetadataService metadataService, + IAudiobookMetadataRefreshService metadataRefreshService, IToastService toastService, IFfmpegService ffmpegService, IFileSystem fileSystem, @@ -355,6 +356,19 @@ await historyRepository.AddAsync(new History catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { logger.LogWarning(ex, "Failed to persist adopted file identifiers for audiobook {AudiobookId}", audiobook.Id); + return; + } + + // A new identifier just appeared on a book that had none, so pull the upstream metadata + // now instead of leaving the user to click "Rescan Metadata" separately. Fills only empty + // fields and never fails the scan. + try + { + await metadataRefreshService.TryPopulateMissingMetadataAsync(audiobook); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogWarning(ex, "Auto metadata refresh after identifier adoption failed for audiobook {AudiobookId}", audiobook.Id); } } } diff --git a/listenarr.application/Metadata/Core/AudiobookMetadataRefreshService.cs b/listenarr.application/Metadata/Core/AudiobookMetadataRefreshService.cs new file mode 100644 index 000000000..8b3a84cf8 --- /dev/null +++ b/listenarr.application/Metadata/Core/AudiobookMetadataRefreshService.cs @@ -0,0 +1,134 @@ +/* + * 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 Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Metadata.Core +{ + /// + public class AudiobookMetadataRefreshService : IAudiobookMetadataRefreshService + { + private readonly IAudiobookMetadataService _metadataService; + private readonly MetadataConverters _metadataConverters; + private readonly IAudiobookRepository _audiobookRepository; + private readonly ILogger _logger; + + public AudiobookMetadataRefreshService( + IAudiobookMetadataService metadataService, + MetadataConverters metadataConverters, + IAudiobookRepository audiobookRepository, + ILogger logger) + { + _metadataService = metadataService; + _metadataConverters = metadataConverters; + _audiobookRepository = audiobookRepository; + _logger = logger; + } + + public async Task TryPopulateMissingMetadataAsync(Audiobook audiobook, string? region = null, CancellationToken cancellationToken = default) + { + if (audiobook == null || string.IsNullOrWhiteSpace(audiobook.Asin)) + { + return false; + } + + var resolvedRegion = string.IsNullOrWhiteSpace(region) ? "us" : region.Trim(); + + AudibleBookResponse? response; + try + { + response = await _metadataService.GetAudibleMetadataAsync(audiobook.Asin, resolvedRegion, cache: false); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Auto metadata refresh lookup failed for audiobook {AudiobookId} ASIN {Asin}", audiobook.Id, audiobook.Asin); + return false; + } + + if (response == null) + { + _logger.LogInformation("Auto metadata refresh found no upstream data for audiobook {AudiobookId} ASIN {Asin}", audiobook.Id, audiobook.Asin); + return false; + } + + var converted = _metadataConverters.ConvertAudibleToMetadata(response, audiobook.Asin, "Audible"); + if (!FillMissingFields(audiobook, converted)) + { + return false; + } + + try + { + await _audiobookRepository.UpdateAsync(audiobook); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to save auto-populated metadata for audiobook {AudiobookId}", audiobook.Id); + return false; + } + + _logger.LogInformation("Auto-populated missing metadata for audiobook {AudiobookId} ({Title}) from ASIN {Asin}", audiobook.Id, audiobook.Title, audiobook.Asin); + return true; + } + + /// + /// Fills only fields that are currently empty on the audiobook. Never overwrites values the + /// user (or a prior metadata fetch) already set. Returns true if anything changed. + /// + internal static bool FillMissingFields(Audiobook audiobook, AudibleBookMetadata metadata) + { + var changed = false; + + if (string.IsNullOrWhiteSpace(audiobook.Title) && !string.IsNullOrWhiteSpace(metadata.Title)) { audiobook.Title = metadata.Title; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Subtitle) && !string.IsNullOrWhiteSpace(metadata.Subtitle)) { audiobook.Subtitle = metadata.Subtitle; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Publisher) && !string.IsNullOrWhiteSpace(metadata.Publisher)) { audiobook.Publisher = metadata.Publisher; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.PublishYear) && !string.IsNullOrWhiteSpace(metadata.PublishYear)) { audiobook.PublishYear = metadata.PublishYear; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.PublishedDate) && !string.IsNullOrWhiteSpace(metadata.PublishedDate)) { audiobook.PublishedDate = metadata.PublishedDate; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Description) && !string.IsNullOrWhiteSpace(metadata.Description)) { audiobook.Description = metadata.Description; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.Language) && !string.IsNullOrWhiteSpace(metadata.Language)) { audiobook.Language = metadata.Language; changed = true; } + if (string.IsNullOrWhiteSpace(audiobook.ImageUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl)) { audiobook.ImageUrl = metadata.ImageUrl; changed = true; } + + if ((audiobook.Runtime == null || audiobook.Runtime == 0) && metadata.Runtime.HasValue && metadata.Runtime.Value > 0) + { + audiobook.Runtime = metadata.Runtime; + changed = true; + } + + if (IsEmpty(audiobook.Authors) && metadata.Authors is { Count: > 0 }) + { + audiobook.Authors = metadata.Authors.ToList(); + changed = true; + } + + if (IsEmpty(audiobook.Narrators) && metadata.Narrators is { Count: > 0 }) + { + audiobook.Narrators = metadata.Narrators.ToList(); + changed = true; + } + + if (IsEmpty(audiobook.Genres) && metadata.Genres is { Count: > 0 }) + { + audiobook.Genres = metadata.Genres.ToList(); + changed = true; + } + + return changed; + } + + private static bool IsEmpty(List? values) => values == null || values.Count == 0; + } +} diff --git a/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs index 2c7aaaae2..5c6ab75e4 100644 --- a/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs @@ -38,6 +38,7 @@ public static IServiceCollection AddMetadataServices(this IServiceCollection ser services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddHttpClient("Ffmpeg"); diff --git a/tests/Features/Application/Metadata/AudiobookMetadataRefreshServiceTests.cs b/tests/Features/Application/Metadata/AudiobookMetadataRefreshServiceTests.cs new file mode 100644 index 000000000..145253e3b --- /dev/null +++ b/tests/Features/Application/Metadata/AudiobookMetadataRefreshServiceTests.cs @@ -0,0 +1,76 @@ +/* + * 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.Core; + +namespace Listenarr.Tests.Features.Application.Metadata +{ + public class AudiobookMetadataRefreshServiceTests + { + [Fact] + public void FillMissingFields_FillsEmptyFields_WithoutOverwritingExisting() + { + var audiobook = new Audiobook + { + Title = "My Existing Title", // already set — must be preserved + Narrators = new List() // empty — should be filled + }; + var metadata = new AudibleBookMetadata + { + Title = "Provider Title", + Narrators = new List { "Erin Bennett" }, + Publisher = "Little, Brown & Company", + PublishYear = "2018", + Description = "A novel.", + Runtime = 728 + }; + + var changed = AudiobookMetadataRefreshService.FillMissingFields(audiobook, metadata); + + Assert.True(changed); + Assert.Equal("My Existing Title", audiobook.Title); // not overwritten + Assert.Equal(new[] { "Erin Bennett" }, audiobook.Narrators); // filled + Assert.Equal("Little, Brown & Company", audiobook.Publisher); + Assert.Equal("2018", audiobook.PublishYear); + Assert.Equal("A novel.", audiobook.Description); + Assert.Equal(728, audiobook.Runtime); + } + + [Fact] + public void FillMissingFields_ReturnsFalse_WhenNothingToFill() + { + var audiobook = new Audiobook + { + Title = "Title", + Publisher = "Publisher", + Narrators = new List { "Someone" } + }; + var metadata = new AudibleBookMetadata + { + Title = "Other Title", + Publisher = "Other Publisher", + Narrators = new List { "Someone Else" } + }; + + var changed = AudiobookMetadataRefreshService.FillMissingFields(audiobook, metadata); + + Assert.False(changed); + Assert.Equal("Title", audiobook.Title); + Assert.Equal("Publisher", audiobook.Publisher); + } + } +} From 4def14c94f273d4cf37a241395ba0ed2e0f73c7f Mon Sep 17 00:00:00 2001 From: dny238 Date: Thu, 30 Jul 2026 13:29:25 -0600 Subject: [PATCH 4/5] Guard identifier adoption against mis-attributed files (review #781) Per @m4bard's review: - ASIN adoption now requires agreement: it only adopts when every linked file that carries an ASIN carries the same one. If the linked files disagree, that's a signal the file-to-book attribution is wrong, so nothing is adopted rather than picking one and letting the metadata auto-refresh act on a wrong identifier (ResolveUnanimousFileAsinAsync). - ISBN is now adopted only when the book has none, mirroring ASIN. It no longer appends new values on top of an existing set, which was the route a wrongly linked file could take to accumulate a stray identifier. - The already-registered-file re-read now reuses the per-file/mtime metadata cache, so rescanning a book that has no embedded ASIN no longer re-runs ffprobe on every file each time. Co-Authored-By: Claude Fable 5 --- .../Audiobooks/Files/AudiobookFileService.cs | 107 +++++++++++++++--- 1 file changed, 92 insertions(+), 15 deletions(-) diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index f6d35214d..721e4ec4d 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -279,16 +279,31 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil } /// - /// Extracts audio metadata for a single file, returning null on any failure. Used when - /// adopting identifiers from an already-registered file (the create path does its own - /// cached/retrying extraction). + /// Extracts audio metadata for a single file, reusing the same per-file/mtime cache the + /// create path populates so a rescan of a book that has no embedded ASIN doesn't re-run + /// ffprobe on every already-registered file each time. Returns null on any failure. /// private async Task TryExtractMetadataAsync(string filePath) { try { + if (!fileSystem.FileExists(filePath)) + { + return null; + } + + var fileInfo = new FileInfo(filePath); + var ticks = fileInfo.Exists ? fileInfo.LastWriteTimeUtc.Ticks : 0L; + var cacheKey = $"meta::{filePath}::{ticks}"; + if (memoryCache.TryGetValue(cacheKey, out var cachedObj) && cachedObj is AudioMetadata cachedMeta) + { + return cachedMeta; + } + using var _ = await limiter.Sem.LockAsync(); - return await metadataService.ExtractFileMetadataAsync(filePath); + var meta = await metadataService.ExtractFileMetadataAsync(filePath); + memoryCache.Set(cacheKey, meta, TimeSpan.FromMinutes(5)); + return meta; } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) { @@ -297,6 +312,63 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil } } + /// + /// Returns the single ASIN shared by every ASIN-carrying file linked to this audiobook + /// (plus the file currently being processed), or null when there is none or when the files + /// disagree. A disagreement is treated as a sign the file-to-book attribution is wrong, so + /// no identifier is adopted rather than picking one arbitrarily. + /// + private async Task ResolveUnanimousFileAsinAsync(Audiobook audiobook, AudioMetadata currentMeta) + { + var asins = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(currentMeta.Asin)) + { + asins.Add(currentMeta.Asin.Trim()); + } + + List linkedFiles; + try + { + linkedFiles = await audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id); + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + logger.LogDebug(ex, "Could not load linked files to verify ASIN agreement for audiobook {AudiobookId}", audiobook.Id); + return asins.Count == 1 ? asins.First() : null; + } + + foreach (var linked in linkedFiles) + { + var path = linked.Path; + if (string.IsNullOrWhiteSpace(path)) + { + continue; + } + + if (!Path.IsPathRooted(path) && !string.IsNullOrWhiteSpace(audiobook.BasePath)) + { + path = Path.Combine(audiobook.BasePath, path); + } + + var meta = await TryExtractMetadataAsync(path); + if (!string.IsNullOrWhiteSpace(meta?.Asin)) + { + asins.Add(meta!.Asin!.Trim()); + } + } + + if (asins.Count > 1) + { + logger.LogInformation( + "Not adopting an ASIN for audiobook {AudiobookId}: its linked files carry {Count} distinct ASINs, so attribution is uncertain", + audiobook.Id, + asins.Count); + return null; + } + + return asins.Count == 1 ? asins.First() : null; + } + /// /// Copies identifiers found in a scanned file's embedded tags (ASIN, ISBN) onto the /// audiobook when it doesn't already have them. This lets "Rescan Metadata" resolve @@ -312,23 +384,28 @@ private async Task AdoptFileIdentifiersAsync(Audiobook? audiobook, AudioMetadata var changed = false; - if (string.IsNullOrWhiteSpace(audiobook.Asin) && !string.IsNullOrWhiteSpace(meta.Asin)) + if (string.IsNullOrWhiteSpace(audiobook.Asin)) { - audiobook.Asin = meta.Asin.Trim(); - changed = true; - } - - if (!string.IsNullOrWhiteSpace(meta.Isbn)) - { - var isbn = meta.Isbn.Trim(); - audiobook.Isbn ??= new List(); - if (!audiobook.Isbn.Any(existing => string.Equals(existing, isbn, StringComparison.OrdinalIgnoreCase))) + // Only adopt when every file linked to this audiobook that carries an ASIN carries the + // same one. Disagreement means a file was likely mis-attributed to this book, and a + // wrongly linked file must not be allowed to donate its identifier (which the metadata + // auto-refresh would then act on). Returns null on conflict, so nothing is adopted. + var agreedAsin = await ResolveUnanimousFileAsinAsync(audiobook, meta); + if (!string.IsNullOrWhiteSpace(agreedAsin)) { - audiobook.Isbn.Add(isbn); + audiobook.Asin = agreedAsin; changed = true; } } + // ISBN, like ASIN, is only adopted when the book has none — never appended on top of an + // existing set, so a mis-attributed file cannot accumulate a stray identifier. + if ((audiobook.Isbn == null || audiobook.Isbn.Count == 0) && !string.IsNullOrWhiteSpace(meta.Isbn)) + { + audiobook.Isbn = new List { meta.Isbn.Trim() }; + changed = true; + } + if (!changed) { return; From 4a2e9d2cd4d83e7cc329c166e2180d4c4bc48c58 Mon Sep 17 00:00:00 2001 From: dny238 Date: Mon, 3 Aug 2026 10:38:56 -0600 Subject: [PATCH 5/5] docs: note first-scan limitation of ASIN agreement guard Document that ResolveUnanimousFileAsinAsync only sees files linked at the moment it runs, so a lone early tagged file is trivially 'unanimous' and the guard is weakest on first-scan mis-attribution. Addresses @m4bard review feedback on #781. Co-Authored-By: Claude Opus 4.8 --- .../Audiobooks/Files/AudiobookFileService.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 721e4ec4d..536c98738 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -317,6 +317,12 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil /// (plus the file currently being processed), or null when there is none or when the files /// disagree. A disagreement is treated as a sign the file-to-book attribution is wrong, so /// no identifier is adopted rather than picking one arbitrarily. + /// + /// Limitation: the agreement set is only the files linked at the moment this runs. On a + /// first scan the first tagged file to arrive is "unanimous" simply by being the sole + /// member, so the guard is weakest exactly when a fresh mis-attribution is most likely. + /// It still refuses once a second, disagreeing file appears; it cannot retroactively + /// un-adopt an ASIN taken from a lone early file that later proves to be the odd one out. /// private async Task ResolveUnanimousFileAsinAsync(Audiobook audiobook, AudioMetadata currentMeta) {