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 f5801f273..536c98738 100644
--- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs
+++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs
@@ -27,8 +27,10 @@ public class AudiobookFileService(
IMemoryCache memoryCache,
MetadataExtractionLimiter limiter,
IAudiobookFileRepository audiobookFileRepository,
+ IAudiobookRepository audiobookRepository,
IHistoryRepository historyRepository,
IMetadataService metadataService,
+ IAudiobookMetadataRefreshService metadataRefreshService,
IToastService toastService,
IFfmpegService ffmpegService,
IFileSystem fileSystem,
@@ -54,6 +56,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;
}
@@ -235,6 +247,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 +277,182 @@ public async Task EnsureAudiobookFileAsync(Audiobook audiobook, string fil
return false;
}
}
+
+ ///
+ /// 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();
+ 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)
+ {
+ logger.LogInformation(ex, "Metadata extraction failed while adopting identifiers for {Path}", LogRedaction.SanitizeFilePath(filePath));
+ return null;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ /// 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)
+ {
+ 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
+ /// 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))
+ {
+ // 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.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;
+ }
+
+ 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);
+ 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/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/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);
+ }
+ }
+}
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);
+ }
+ }
+}