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
39 changes: 16 additions & 23 deletions listenarr.application/Downloads/Import/DownloadImportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,31 +90,20 @@ public async Task<List<ImportResult>> ImportDownloadFilesAsync(Audiobook audiobo
try
{
// Precompute audiobook and best existing quality to avoid import-order races
string? bestExisting = null;
QualityProfile? abProfile = null;

abProfile = audiobook.QualityProfile;
AudioQualityInput? bestExisting = null;
var abProfile = audiobook.QualityProfile;

if (audiobook.Files != null && audiobook.Files.Count != 0)
{
foreach (var f in audiobook.Files)
foreach (var existingFile in audiobook.Files)
{
string q = string.Empty;
if (!string.IsNullOrEmpty(f.Format)) q = f.Format;
if (f.Bitrate.HasValue)
{
var kb = f.Bitrate.Value / 1000;
if (kb >= 320) q = "MP3 320kbps";
else if (kb >= 256) q = "MP3 256kbps";
else if (kb >= 192) q = "MP3 192kbps";
else if (kb >= 128) q = "MP3 128kbps";
}
if (string.IsNullOrEmpty(q) && !string.IsNullOrEmpty(f.Path)) q = ImportQualityEvaluator.Determine(null, f.Path);
var existingInput = ImportQualityEvaluator.FromFile(existingFile);

if (string.IsNullOrEmpty(bestExisting)) bestExisting = q;
else if (!string.IsNullOrEmpty(q) && !string.IsNullOrEmpty(bestExisting) && abProfile != null && ImportQualityEvaluator.IsAcceptable(q, bestExisting, abProfile))
// "Not worse than the current best" climbs the list to the hardest quality to beat.
if (bestExisting is null
|| ImportQualityEvaluator.IsAcceptable(existingInput, bestExisting.Value, abProfile))
{
bestExisting = q;
bestExisting = existingInput;
}
}
}
Expand Down Expand Up @@ -184,14 +173,18 @@ public async Task<List<ImportResult>> ImportDownloadFilesAsync(Audiobook audiobo
candidateMetadata = await metadataService.ExtractFileMetadataAsync(file);
}

var candidateQuality = ImportQualityEvaluator.Determine(candidateMetadata, file);
var candidateInput = ImportQualityEvaluator.FromMetadata(candidateMetadata, file);

try
{
if (audiobook.Files != null && audiobook.Files.Count != 0 && !ImportQualityEvaluator.IsAcceptable(candidateQuality, bestExisting, abProfile))
if (bestExisting is not null
&& !ImportQualityEvaluator.IsAcceptable(candidateInput, bestExisting.Value, abProfile))
{
results.Add(ImportResult.Skipped($"candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'"));
logger.LogInformation($"Skipping import of file {file} for audiobook {audiobook.Id} because candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'");
var candidateQuality = ImportQualityEvaluator.Describe(candidateInput, abProfile);
var existingQuality = ImportQualityEvaluator.Describe(bestExisting.Value, abProfile);

results.Add(ImportResult.Skipped($"candidate quality '{candidateQuality}' is not better than existing '{existingQuality}'"));
logger.LogInformation($"Skipping import of file {file} for audiobook {audiobook.Id} because candidate quality '{candidateQuality}' is not better than existing '{existingQuality}'");
continue;
}
}
Expand Down
76 changes: 64 additions & 12 deletions listenarr.application/Downloads/Import/ImportQualityEvaluator.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Listenarr.Domain.Common;

namespace Listenarr.Application.Downloads.Import;

public static class ImportQualityEvaluator
Expand Down Expand Up @@ -31,22 +33,72 @@ public static string Determine(AudioMetadata? metadata, string path)
};
}

public static bool IsAcceptable(string? candidate, string? existing, QualityProfile? profile)
/// <summary>Project an already-imported library file onto the shared quality vocabulary.</summary>
public static AudioQualityInput FromFile(AudiobookFile file) => new()
{
if (string.IsNullOrWhiteSpace(candidate) || string.IsNullOrWhiteSpace(existing) || profile == null)
{
return true;
}
Codec = file.Codec,
Container = file.Container,
Format = file.Format,
BitrateBitsPerSecond = file.Bitrate,
Path = file.Path
};

/// <summary>Project an incoming download's extracted metadata onto the shared quality vocabulary.</summary>
public static AudioQualityInput FromMetadata(AudioMetadata? metadata, string path) => new()
{
Codec = metadata?.Codec,
Container = metadata?.Container,
Format = metadata?.Format,
BitrateBitsPerSecond = metadata?.BitRate,
Path = path
};

return !TryParseBitrate(candidate, out var candidateBitrate)
|| !TryParseBitrate(existing, out var existingBitrate)
|| candidateBitrate >= existingBitrate;
/// <summary>Human-readable quality label for import logs: the matched profile rung when the
/// profile can rank the file, otherwise the file's own format/extension.</summary>
public static string Describe(AudioQualityInput input, QualityProfile? profile)
{
var rung = QualityMatcher.MatchLabel(input, profile);
if (!string.IsNullOrWhiteSpace(rung)) return rung!;
if (!string.IsNullOrWhiteSpace(input.Format)) return input.Format!;
return Determine(null, input.Path ?? string.Empty);
}

private static bool TryParseBitrate(string quality, out int bitrate)
/// <summary>
/// Whether <paramref name="candidate"/> is <b>not worse</b> than <paramref name="existing"/> and may
/// therefore be imported. Equal quality is acceptable on purpose: a multi-file audiobook imports
/// parts that all share the same quality as the parts already on disk, and requiring a strict
/// upgrade would skip every one of them. (Automatic search wants the stricter
/// <see cref="QualityMatcher.IsLabelBetter"/> instead.)
/// </summary>
public static bool IsAcceptable(AudioQualityInput candidate, AudioQualityInput existing, QualityProfile? profile)
{
bitrate = 0;
var match = System.Text.RegularExpressions.Regex.Match(quality, @"\d{2,}");
return match.Success && int.TryParse(match.Value, out bitrate);
if (profile?.Qualities is { Count: > 0 })
{
var candidateMatch = QualityMatcher.Match(candidate, profile);
var existingMatch = QualityMatcher.Match(existing, profile);

// Both sides land on a profile rung, so the profile's own ordering decides.
if (candidateMatch.IsMatch && existingMatch.IsMatch)
{
return candidateMatch.Rung!.Priority <= existingMatch.Rung!.Priority;
}
}

// No profile, an empty profile, or a side the profile cannot rank. Fall back to the codec and
// bitrate facts so gating still holds for unconfigured/partial profiles.
if (QualityMatcher.IsLossless(existing) && !QualityMatcher.IsLossless(candidate))
{
return false;
}

if (candidate.BitrateBitsPerSecond is int candidateBitrate and > 0
&& existing.BitrateBitsPerSecond is int existingBitrate and > 0)
{
return candidateBitrate >= existingBitrate;
}

// Quality is genuinely unknown on at least one side: allow the import rather than silently
// dropping a file we cannot reason about.
return true;
}
}
7 changes: 7 additions & 0 deletions listenarr.domain/Common/QualityMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ static QualityMatchResult ToAllowedMatch(QualityDefinition rung)
public static string? MatchLabel(AudioQualityInput file, QualityProfile? profile)
=> Match(file, profile).Rung?.Quality;

/// <summary>
/// Whether a file is lossless, derived from the same mapped codec groups used for matching.
/// Exposed so profile-agnostic callers (e.g. the import gate) can honour lossless-vs-lossy
/// even when no profile rung is available.
/// </summary>
public static bool IsLossless(AudioQualityInput file) => IsLosslessFile(file);

/// <summary>
/// Whether a file meets or exceeds the profile cutoff. A blank cutoff is always met;
/// a missing/codec-mismatched file is not.
Expand Down
Loading