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); + } + } +}