Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

namespace Listenarr.Application.Audiobooks.Contracts
{
/// <summary>
/// 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.
/// </summary>
public interface IAudiobookMetadataRefreshService
{
/// <summary>
/// Populates missing metadata on <paramref name="audiobook"/> from its ASIN.
/// Returns true if any field was filled in (and the audiobook was saved).
/// </summary>
Task<bool> TryPopulateMissingMetadataAsync(Audiobook audiobook, string? region = null, CancellationToken cancellationToken = default);
}
}
194 changes: 194 additions & 0 deletions listenarr.application/Audiobooks/Files/AudiobookFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -54,6 +56,16 @@ public async Task<bool> 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;
}

Expand Down Expand Up @@ -235,6 +247,11 @@ public async Task<bool> 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)
Expand All @@ -260,5 +277,182 @@ public async Task<bool> EnsureAudiobookFileAsync(Audiobook audiobook, string fil
return false;
}
}

/// <summary>
/// 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.
/// </summary>
private async Task<AudioMetadata?> 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;
}
}

/// <summary>
/// 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.
/// </summary>
private async Task<string?> ResolveUnanimousFileAsinAsync(Audiobook audiobook, AudioMetadata currentMeta)
{
var asins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(currentMeta.Asin))
{
asins.Add(currentMeta.Asin.Trim());
}

List<AudiobookFile> 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;
}

/// <summary>
/// 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.
/// </summary>
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<string> { 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
using Microsoft.Extensions.Logging;

namespace Listenarr.Application.Metadata.Core
{
/// <inheritdoc />
public class AudiobookMetadataRefreshService : IAudiobookMetadataRefreshService
{
private readonly IAudiobookMetadataService _metadataService;
private readonly MetadataConverters _metadataConverters;
private readonly IAudiobookRepository _audiobookRepository;
private readonly ILogger<AudiobookMetadataRefreshService> _logger;

public AudiobookMetadataRefreshService(
IAudiobookMetadataService metadataService,
MetadataConverters metadataConverters,
IAudiobookRepository audiobookRepository,
ILogger<AudiobookMetadataRefreshService> logger)
{
_metadataService = metadataService;
_metadataConverters = metadataConverters;
_audiobookRepository = audiobookRepository;
_logger = logger;
}

public async Task<bool> 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;
}

/// <summary>
/// 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.
/// </summary>
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<string>? values) => values == null || values.Count == 0;
}
}
Loading