diff --git a/fe/src/__tests__/audiobookStatus.spec.ts b/fe/src/__tests__/audiobookStatus.spec.ts
index 8d45d3352..91cd79267 100644
--- a/fe/src/__tests__/audiobookStatus.spec.ts
+++ b/fe/src/__tests__/audiobookStatus.spec.ts
@@ -17,91 +17,50 @@
*/
import { describe, expect, it } from 'vitest'
import { computeAudiobookStatus } from '@/utils/audiobookStatus'
-import type { Audiobook, QualityProfile } from '@/types'
+import type { Audiobook } from '@/types'
describe('computeAudiobookStatus', () => {
- it('uses the server-provided slim status when files are not present', () => {
- const audiobook = {
- id: 1,
- title: 'Slim Book',
- status: 'quality-match',
- wanted: false,
- } as Audiobook
-
- expect(computeAudiobookStatus(audiobook, new Set(), [])).toBe('quality-match')
+ it('trusts the server-computed quality-match status', () => {
+ const audiobook = { id: 1, title: 'Matched', status: 'quality-match' } as Audiobook
+ expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-match')
})
- it('lets active downloads override the cached list status', () => {
- const audiobook = {
- id: 2,
- title: 'Downloading Book',
- status: 'quality-match',
- wanted: false,
- } as Audiobook
-
- expect(computeAudiobookStatus(audiobook, new Set([2]), [])).toBe('downloading')
+ it('trusts the server-computed quality-mismatch status', () => {
+ const audiobook = { id: 2, title: 'Below Cutoff', status: 'quality-mismatch' } as Audiobook
+ expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-mismatch')
})
- it('recomputes from files when a richer audiobook payload is available', () => {
- const audiobook = {
- id: 3,
- title: 'Detailed Book',
- qualityProfileId: 10,
- files: [{ id: 100, format: 'm4b', bitrate: 320000 }],
- } as Audiobook
-
- const profiles: QualityProfile[] = [
- {
- id: 10,
- name: 'High Quality',
- cutoffQuality: '320kbps',
- preferredFormats: ['m4b'],
- qualities: [{ quality: '320kbps', allowed: true, priority: 0 }],
- },
- ]
-
- expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match')
+ it('trusts the server-computed no-file status', () => {
+ const audiobook = { id: 3, title: 'Missing', status: 'no-file' } as Audiobook
+ expect(computeAudiobookStatus(audiobook, new Set())).toBe('no-file')
})
- it('handles bitrate values stored in bits per second', () => {
- const audiobook = {
- id: 4,
- title: 'Bitrate Book',
- qualityProfileId: 10,
- files: [{ id: 101, format: 'm4b', bitrate: 256000 }],
- } as Audiobook
-
- const profiles: QualityProfile[] = [
- {
- id: 10,
- name: 'High Quality',
- cutoffQuality: '256kbps',
- preferredFormats: ['m4b'],
- qualities: [{ quality: '256kbps', allowed: true, priority: 0 }],
- },
- ]
-
- expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match')
+ it('lets an active download override the cached server status', () => {
+ const audiobook = { id: 4, title: 'Downloading', status: 'quality-match' } as Audiobook
+ expect(computeAudiobookStatus(audiobook, new Set([4]))).toBe('downloading')
})
- it('treats WavPack files as lossless', () => {
+ it('does not recompute from files/profiles — the server is the source of truth', () => {
+ // Files look like a healthy 320kbps M4B, but the server says it is below cutoff
+ // (e.g. its profile cutoff is higher). The frontend must defer to the server.
const audiobook = {
id: 5,
- title: 'Lossless Book',
+ title: 'Defer To Server',
+ status: 'quality-mismatch',
qualityProfileId: 10,
- files: [{ id: 102, format: 'wv', container: 'wv' }],
+ files: [{ id: 100, format: 'm4b', codec: 'aac', bitrate: 320000 }],
} as Audiobook
- const profiles: QualityProfile[] = [
- {
- id: 10,
- name: 'Lossless',
- cutoffQuality: 'lossless',
- preferredFormats: ['wv'],
- qualities: [{ quality: 'lossless', allowed: true, priority: 0 }],
- },
- ]
+ expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-mismatch')
+ })
+
+ it('falls back to no-file when the server sent no recognizable status', () => {
+ const audiobook = { id: 6, title: 'No Status' } as Audiobook
+ expect(computeAudiobookStatus(audiobook, new Set())).toBe('no-file')
+ })
- expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match')
+ it('falls back to no-file when the status is an unrelated download-state string', () => {
+ const audiobook = { id: 7, title: 'Stray Status', status: 'completed' } as unknown as Audiobook
+ expect(computeAudiobookStatus(audiobook, new Set())).toBe('no-file')
})
})
diff --git a/fe/src/utils/audiobookStatus.ts b/fe/src/utils/audiobookStatus.ts
index 22b0b519c..48606bab8 100644
--- a/fe/src/utils/audiobookStatus.ts
+++ b/fe/src/utils/audiobookStatus.ts
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-import type { Audiobook, AudiobookStatus, QualityProfile } from '@/types'
+import type { Audiobook, AudiobookStatus } from '@/types'
const AUDIOBOOK_STATUSES: AudiobookStatus[] = [
'downloading',
@@ -24,8 +24,6 @@ const AUDIOBOOK_STATUSES: AudiobookStatus[] = [
'quality-match',
]
-const normalize = (value?: string): string => (value || '').toString().trim().toLowerCase()
-
function isAudiobookStatus(value: unknown): value is AudiobookStatus {
return typeof value === 'string' && AUDIOBOOK_STATUSES.includes(value as AudiobookStatus)
}
@@ -45,124 +43,29 @@ export function formatAudiobookStatus(status: AudiobookStatus): string {
}
}
+/**
+ * Resolve the display status for an audiobook.
+ *
+ * The backend is the single source of truth for quality matching: the `/library`
+ * list endpoint runs {@link AudiobookStatusEvaluator.ComputeStatus} server-side and
+ * returns the result as `audiobook.status`. The frontend trusts that value and only
+ * applies a client-side override for transient state the server's snapshot can't see —
+ * an in-flight download for this audiobook.
+ *
+ * This deliberately no longer mirrors the backend QualityMatcher client-side; that
+ * duplicated business logic and could disagree with the automatic-search cutoff.
+ */
export function computeAudiobookStatus(
- audiobook: Audiobook,
+ audiobook: Pick,
activeDownloadAudiobookIds: ReadonlySet,
- qualityProfiles: QualityProfile[],
): AudiobookStatus {
if (activeDownloadAudiobookIds.has(audiobook.id)) {
return 'downloading'
}
- const hasFiles = Array.isArray(audiobook.files) && audiobook.files.length > 0
- if (hasFiles) {
- const profile = qualityProfiles.find((item) => item.id === audiobook.qualityProfileId)
-
- if (!profile) {
- const hasFileSummary =
- !!(audiobook.filePath && audiobook.fileSize && audiobook.fileSize > 0) || hasFiles
- return hasFileSummary ? 'quality-match' : 'no-file'
- }
-
- const preferredFormats = (profile.preferredFormats || []).map((item) => normalize(item))
- const candidateFiles = audiobook.files!.filter((file) => {
- if (!file) return false
- const fileFormat = normalize(file.format) || normalize(file.container) || ''
- if (preferredFormats.length === 0) return true
- return (
- preferredFormats.includes(fileFormat) ||
- preferredFormats.some((preferredFormat) => fileFormat.includes(preferredFormat))
- )
- })
-
- if (candidateFiles.length === 0) {
- return 'quality-mismatch'
- }
-
- if (!profile.cutoffQuality || !profile.qualities || profile.qualities.length === 0) {
- return 'quality-match'
- }
-
- const qualityPriority = new Map()
- for (const quality of profile.qualities) {
- if (!quality || !quality.quality) continue
- qualityPriority.set(normalize(quality.quality), quality.priority)
- }
-
- const cutoff = normalize(profile.cutoffQuality)
- const cutoffPriority = qualityPriority.has(cutoff)
- ? qualityPriority.get(cutoff)!
- : Number.POSITIVE_INFINITY
-
- for (const file of candidateFiles) {
- const derivedQuality = deriveQualityLabel(audiobook, file)
- if (!derivedQuality) continue
-
- const priority = qualityPriority.has(derivedQuality)
- ? qualityPriority.get(derivedQuality)!
- : Number.POSITIVE_INFINITY
-
- if (priority <= cutoffPriority) {
- return 'quality-match'
- }
- }
-
- return 'quality-mismatch'
- }
-
if (isAudiobookStatus(audiobook.status)) {
return audiobook.status
}
return 'no-file'
}
-
-function deriveQualityLabel(
- audiobook: Audiobook,
- file:
- | {
- bitrate?: number
- container?: string
- codec?: string
- format?: string
- }
- | undefined,
-): string {
- if (audiobook.quality) return normalize(audiobook.quality)
-
- if (file && file.bitrate) {
- const bitrate = Number(file.bitrate)
- if (!Number.isNaN(bitrate)) {
- const bitrateKbps = bitrate >= 1000 ? bitrate / 1000 : bitrate
- if (bitrateKbps >= 320) return '320kbps'
- if (bitrateKbps >= 256) return '256kbps'
- if (bitrateKbps >= 192) return '192kbps'
- return `${Math.round(bitrateKbps)}kbps`
- }
- }
-
- const container = normalize(file?.container)
- const codec = normalize(file?.codec)
- if (
- container.includes('flac') ||
- codec.includes('flac') ||
- container.includes('alac') ||
- codec.includes('alac') ||
- container.includes('aiff') ||
- codec.includes('aiff') ||
- container.includes('ape') ||
- codec.includes('ape') ||
- container.includes('dsd') ||
- codec.includes('dsd') ||
- container.includes('wv') ||
- codec.includes('wv') ||
- container.includes('wav') ||
- codec.includes('wav')
- ) {
- return 'lossless'
- }
-
- if (file?.format) return normalize(file.format)
-
- return ''
-}
diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue
index 14e3b7b7a..254940610 100644
--- a/fe/src/views/library/AudiobooksView.vue
+++ b/fe/src/views/library/AudiobooksView.vue
@@ -1790,7 +1790,7 @@ const activeDownloadAudiobookIds = computed(() => {
})
function computeAudiobookStatusRaw(audiobook: Audiobook): AudiobookStatus {
- return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value, qualityProfiles.value)
+ return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value)
}
const audiobookStatusById = computed(() => {
diff --git a/fe/src/views/library/CollectionView.vue b/fe/src/views/library/CollectionView.vue
index b25af88f4..286995d0d 100644
--- a/fe/src/views/library/CollectionView.vue
+++ b/fe/src/views/library/CollectionView.vue
@@ -2190,7 +2190,7 @@ function getAudiobookStatus(audiobook: CollectionDisplayItem): CollectionStatus
return 'not-added'
}
- return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value, qualityProfiles.value)
+ return computeAudiobookStatus(audiobook, activeDownloadAudiobookIds.value)
}
function getMonitoringLabel(audiobook: CollectionDisplayItem): string {
diff --git a/listenarr.api/Controllers/LibraryController.cs b/listenarr.api/Controllers/LibraryController.cs
index 3d9cdc1e1..687383ada 100644
--- a/listenarr.api/Controllers/LibraryController.cs
+++ b/listenarr.api/Controllers/LibraryController.cs
@@ -3123,153 +3123,12 @@ private async Task ProcessAudiobookForSearchAsync(
return downloadsQueued;
}
- private async Task IsQualityCutoffMetAsync(
+ private Task IsQualityCutoffMetAsync(
Audiobook audiobook,
IQualityProfileService qualityProfileService,
IDownloadRepository downloadRepository,
IAudiobookFileRepository audioFileRepository)
- {
- if (audiobook.QualityProfile == null)
- return false;
-
- // Get existing downloads for this audiobook
- var existingDownloads = (await downloadRepository.GetByAudiobookIdAsync(audiobook.Id))
- .Where(d => d.Status == DownloadStatus.Completed ||
- d.Status == DownloadStatus.Downloading ||
- d.Status == DownloadStatus.ImportPending)
- .ToList();
-
- // Get existing files for this audiobook
- var existingFiles = await audioFileRepository.GetByAudiobookIdAsync(audiobook.Id);
-
- if (!existingDownloads.Any() && !existingFiles.Any())
- return false;
-
- // Check if any existing download meets or exceeds the cutoff quality
- var cutoffQuality = audiobook.QualityProfile.Qualities
- .FirstOrDefault(q => q.Quality == audiobook.QualityProfile.CutoffQuality);
-
- if (cutoffQuality == null)
- return false;
-
- // Check downloads first
- foreach (var download in existingDownloads)
- {
- // For completed downloads, check if the file quality meets cutoff
- if (download.Status == DownloadStatus.Completed && !string.IsNullOrEmpty(download.Metadata?.GetValueOrDefault("Quality")?.ToString()))
- {
- var downloadQuality = download.Metadata["Quality"].ToString();
- var downloadQualityDefinition = audiobook.QualityProfile.Qualities
- .FirstOrDefault(q => q.Quality == downloadQuality);
-
- if (downloadQualityDefinition != null && downloadQualityDefinition.Priority >= cutoffQuality.Priority)
- {
- _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by completed download (Quality: {Quality})",
- audiobook.Title, downloadQuality);
- return true;
- }
- }
- // For active downloads, assume they will meet quality requirements
- else if (download.Status == DownloadStatus.Downloading ||
- download.Status == DownloadStatus.ImportPending)
- {
- _logger.LogDebug("Quality cutoff assumed met for audiobook '{Title}' due to active download/import", LogRedaction.SanitizeText(audiobook.Title));
- return true;
- }
- }
-
- // Check existing files
- foreach (var file in existingFiles)
- {
- var fileQuality = DetermineFileQuality(file);
- if (!string.IsNullOrEmpty(fileQuality))
- {
- var fileQualityDefinition = audiobook.QualityProfile.Qualities
- .FirstOrDefault(q => q.Quality == fileQuality);
-
- if (fileQualityDefinition != null && fileQualityDefinition.Priority >= cutoffQuality.Priority)
- {
- _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by existing file (Quality: {Quality}, File: {FileName})",
- audiobook.Title, fileQuality, Path.GetFileName(file.Path));
- return true;
- }
- }
- }
-
- return false;
- }
-
- private string? DetermineFileQuality(AudiobookFile file)
- {
- // Determine quality based on file properties
- // This mirrors the logic in QualityProfileService.GetQualityScore but works with file metadata
-
- // Check format/container first
- if (!string.IsNullOrEmpty(file.Container))
- {
- var container = file.Container.ToLower();
- if (container.Contains("flac")) return "FLAC";
- if (container.Contains("m4b") || container.Contains("m4a")) return "M4B";
- }
-
- if (!string.IsNullOrEmpty(file.Format))
- {
- var format = file.Format.ToLower();
- if (format.Contains("flac")) return "FLAC";
- if (format.Contains("m4b") || format.Contains("m4a")) return "M4B";
- if (format.Contains("aac")) return "M4B"; // AAC in M4B container
- }
-
- // Check bitrate for MP3 quality determination
- if (file.Bitrate.HasValue)
- {
- var bitrate = file.Bitrate.Value;
-
- // Convert bits per second to kilobits per second for easier comparison
- var kbps = bitrate / 1000;
-
- if (kbps >= 320) return "MP3 320kbps";
- if (kbps >= 256) return "MP3 256kbps";
- if (kbps >= 192) return "MP3 192kbps";
- if (kbps >= 128) return "MP3 128kbps";
- if (kbps >= 64) return "MP3 64kbps";
-
- // For very low bitrates, still classify as MP3
- return "MP3 64kbps";
- }
-
- // Check codec
- if (!string.IsNullOrEmpty(file.Codec))
- {
- var codec = file.Codec.ToLower();
- if (codec.Contains("flac")) return "FLAC";
- if (codec.Contains("aac")) return "M4B";
- if (codec.Contains("mp3")) return "MP3 128kbps"; // Default MP3 quality if no bitrate info
- if (codec.Contains("opus")) return "M4B"; // Opus is often in M4B containers
- }
-
- // If we can't determine quality from metadata, try to infer from file extension
- if (!string.IsNullOrEmpty(file.Path))
- {
- var extension = Path.GetExtension(file.Path).ToLower();
- switch (extension)
- {
- case ".flac":
- return "FLAC";
- case ".m4b":
- case ".m4a":
- return "M4B";
- case ".mp3":
- return "MP3 128kbps"; // Conservative default for MP3
- case ".aac":
- return "M4B";
- case ".opus":
- return "M4B";
- }
- }
-
- return null; // Unable to determine quality
- }
+ => QualityCutoffEvaluator.IsCutoffMetAsync(audiobook, downloadRepository, audioFileRepository, _logger);
private string BuildSearchQuery(Audiobook audiobook)
{
diff --git a/listenarr.application/Metadata/AudiobookStatusEvaluator.cs b/listenarr.application/Metadata/AudiobookStatusEvaluator.cs
index 477a3ed55..9d1baca45 100644
--- a/listenarr.application/Metadata/AudiobookStatusEvaluator.cs
+++ b/listenarr.application/Metadata/AudiobookStatusEvaluator.cs
@@ -15,6 +15,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
+using Listenarr.Domain.Common;
using Listenarr.Domain.Models;
using Listenarr.Application.Audiobooks;
@@ -62,6 +63,13 @@ public static string ComputeStatus(
{
fileFormat = Normalize(f.Container);
}
+ if (fileFormat.Length == 0)
+ {
+ // Path-only file (no probe metadata): fall back to the path extension so a
+ // metadata-less book.flac still satisfies a PreferredFormats = ["flac"] filter
+ // instead of being dropped before QualityMatcher can use the extension.
+ fileFormat = ExtensionFromPath(f.Path);
+ }
if (preferredFormats.Count == 0)
{
@@ -90,34 +98,29 @@ public static string ComputeStatus(
return QualityMatch;
}
- var qualityPriority = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var quality in qualityProfile.Qualities)
+ // A pinned audiobook-level quality short-circuits per-file derivation.
+ if (!string.IsNullOrWhiteSpace(audiobookQuality))
{
- if (quality == null || string.IsNullOrWhiteSpace(quality.Quality))
- {
- continue;
- }
-
- qualityPriority[Normalize(quality.Quality)] = quality.Priority;
+ return QualityMatcher.LabelMeetsCutoff(audiobookQuality, qualityProfile)
+ ? QualityMatch
+ : QualityMismatch;
}
- var cutoff = Normalize(qualityProfile.CutoffQuality);
- var cutoffPriority = qualityPriority.TryGetValue(cutoff, out var foundCutoffPriority)
- ? foundCutoffPriority
- : int.MaxValue;
-
- foreach (var derivedQuality in candidateFiles.Select(file => DeriveQualityLabel(file, audiobookQuality)))
+ foreach (var file in candidateFiles)
{
- if (derivedQuality.Length == 0)
+ var input = new AudioQualityInput
{
- continue;
- }
-
- var priority = qualityPriority.TryGetValue(derivedQuality, out var foundPriority)
- ? foundPriority
- : int.MaxValue;
-
- if (priority <= cutoffPriority)
+ Codec = file.Codec,
+ Container = file.Container,
+ Format = file.Format,
+ BitrateBitsPerSecond = file.Bitrate,
+ // Path is the only quality signal when metadata processing is disabled,
+ // ffprobe is unavailable, or extraction failed. Mirrors QualityCutoffEvaluator
+ // so automatic-search and library status agree for path-only files.
+ Path = file.Path
+ };
+
+ if (QualityMatcher.MeetsCutoff(input, qualityProfile))
{
return QualityMatch;
}
@@ -126,62 +129,19 @@ public static string ComputeStatus(
return QualityMismatch;
}
- private static string DeriveQualityLabel(AudiobookFormatSummary? file, string? audiobookQuality)
+ private static string Normalize(string? value)
{
- var normalizedAudiobookQuality = Normalize(audiobookQuality);
- if (normalizedAudiobookQuality.Length > 0)
- {
- return normalizedAudiobookQuality;
- }
-
- if (file?.Bitrate is int bitrate)
- {
- var bitrateKbps = bitrate >= 1000 ? bitrate / 1000d : bitrate;
-
- if (bitrateKbps >= 320)
- {
- return "320kbps";
- }
-
- if (bitrateKbps >= 256)
- {
- return "256kbps";
- }
-
- if (bitrateKbps >= 192)
- {
- return "192kbps";
- }
-
- return $"{Math.Round(bitrateKbps)}kbps";
- }
+ return (value ?? string.Empty).Trim().ToLowerInvariant();
+ }
- var container = Normalize(file?.Container);
- var codec = Normalize(file?.Codec);
- if (container.Contains("flac", StringComparison.Ordinal)
- || codec.Contains("flac", StringComparison.Ordinal)
- || container.Contains("alac", StringComparison.Ordinal)
- || codec.Contains("alac", StringComparison.Ordinal)
- || container.Contains("aiff", StringComparison.Ordinal)
- || codec.Contains("aiff", StringComparison.Ordinal)
- || container.Contains("ape", StringComparison.Ordinal)
- || codec.Contains("ape", StringComparison.Ordinal)
- || container.Contains("dsd", StringComparison.Ordinal)
- || codec.Contains("dsd", StringComparison.Ordinal)
- || container.Contains("wv", StringComparison.Ordinal)
- || codec.Contains("wv", StringComparison.Ordinal)
- || container.Contains("wav", StringComparison.Ordinal)
- || codec.Contains("wav", StringComparison.Ordinal))
+ private static string ExtensionFromPath(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
{
- return "lossless";
+ return string.Empty;
}
- return Normalize(file?.Format);
- }
-
- private static string Normalize(string? value)
- {
- return (value ?? string.Empty).Trim().ToLowerInvariant();
+ return Normalize(System.IO.Path.GetExtension(path).TrimStart('.'));
}
}
}
diff --git a/listenarr.application/Search/AutomaticSearchService.cs b/listenarr.application/Search/AutomaticSearchService.cs
index 2e48bea4e..0463438a1 100644
--- a/listenarr.application/Search/AutomaticSearchService.cs
+++ b/listenarr.application/Search/AutomaticSearchService.cs
@@ -19,6 +19,7 @@
using Listenarr.Application.Interfaces;
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Application.Notification;
+using Listenarr.Domain.Common;
using Listenarr.Domain.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
@@ -299,7 +300,7 @@ private async Task ProcessAudiobookAsync(
// Check if the found result is better quality than what we already have
if (!string.IsNullOrEmpty(bestExistingQuality))
{
- var resultIsBetter = IsQualityBetter(topResult.SearchResult.Quality, bestExistingQuality, audiobook.QualityProfile);
+ var resultIsBetter = QualityMatcher.IsLabelBetter(topResult.SearchResult.Quality, bestExistingQuality, audiobook.QualityProfile);
if (!resultIsBetter)
{
_logger.LogInformation("Top result quality '{ResultQuality}' is not better than existing quality '{ExistingQuality}' for audiobook '{Title}', skipping download",
@@ -346,153 +347,13 @@ private async Task ProcessAudiobookAsync(
return downloadsQueued;
}
- private async Task IsQualityCutoffMetAsync(
+ private Task IsQualityCutoffMetAsync(
Audiobook audiobook,
IQualityProfileService qualityProfileService,
IDownloadRepository downloadRepository,
IAudiobookFileRepository fileRepository,
CancellationToken ct = default)
- {
- if (audiobook.QualityProfile == null)
- return false;
-
- // Get existing downloads for this audiobook
- var allDownloads = await downloadRepository.GetByAudiobookIdAsync(audiobook.Id, ct);
- var existingDownloads = allDownloads.Where(d =>
- d.Status == DownloadStatus.Completed ||
- d.Status == DownloadStatus.Downloading ||
- d.Status == DownloadStatus.ImportPending).ToList();
-
- // Get existing files for this audiobook
- var existingFiles = await fileRepository.GetByAudiobookIdAsync(audiobook.Id, ct);
-
- if (!existingDownloads.Any() && !existingFiles.Any())
- return false;
-
- // Check if any existing download meets or exceeds the cutoff quality
- var cutoffQuality = audiobook.QualityProfile.Qualities
- .FirstOrDefault(q => q.Quality == audiobook.QualityProfile.CutoffQuality);
-
- if (cutoffQuality == null)
- return false;
-
- // Check downloads first
- foreach (var download in existingDownloads)
- {
- // For completed downloads, check if the file quality meets cutoff
- if (download.Status == DownloadStatus.Completed && !string.IsNullOrEmpty(download.Metadata?.GetValueOrDefault("Quality")?.ToString()))
- {
- var downloadQuality = download.Metadata["Quality"].ToString();
- var downloadQualityDefinition = audiobook.QualityProfile.Qualities
- .FirstOrDefault(q => q.Quality == downloadQuality);
-
- if (downloadQualityDefinition != null && downloadQualityDefinition.Priority >= cutoffQuality.Priority)
- {
- _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by completed download (Quality: {Quality})",
- audiobook.Title, downloadQuality);
- return true;
- }
- }
- // For active downloads, assume they will meet quality requirements
- else if (download.Status == DownloadStatus.Downloading || download.Status == DownloadStatus.ImportPending)
- {
- _logger.LogDebug("Quality cutoff assumed met for audiobook '{Title}' due to active download", audiobook.Title);
- return true;
- }
- }
-
- // Check existing files
- foreach (var file in existingFiles)
- {
- var fileQuality = DetermineFileQuality(file);
- if (!string.IsNullOrEmpty(fileQuality))
- {
- var fileQualityDefinition = audiobook.QualityProfile.Qualities
- .FirstOrDefault(q => q.Quality == fileQuality);
-
- if (fileQualityDefinition != null && fileQualityDefinition.Priority >= cutoffQuality.Priority)
- {
- _logger.LogDebug("Quality cutoff met for audiobook '{Title}' by existing file (Quality: {Quality}, File: {FileName})",
- audiobook.Title, fileQuality, Path.GetFileName(file.Path));
- return true;
- }
- }
- }
-
- return false;
- }
-
- private string? DetermineFileQuality(AudiobookFile file)
- {
- // Determine quality based on file properties
- // This mirrors the logic in QualityProfileService.GetQualityScore but works with file metadata
-
- // Check format/container first
- if (!string.IsNullOrEmpty(file.Container))
- {
- var container = file.Container.ToLower();
- if (container.Contains("flac")) return "FLAC";
- if (container.Contains("m4b") || container.Contains("m4a")) return "M4B";
- }
-
- if (!string.IsNullOrEmpty(file.Format))
- {
- var format = file.Format.ToLower();
- if (format.Contains("flac")) return "FLAC";
- if (format.Contains("m4b") || format.Contains("m4a")) return "M4B";
- if (format.Contains("aac")) return "M4B"; // AAC in M4B container
- }
-
- // Check bitrate for MP3 quality determination
- if (file.Bitrate.HasValue)
- {
- var bitrate = file.Bitrate.Value;
-
- // Convert bits per second to kilobits per second for easier comparison
- var kbps = bitrate / 1000;
-
- if (kbps >= 320) return "MP3 320kbps";
- if (kbps >= 256) return "MP3 256kbps";
- if (kbps >= 192) return "MP3 192kbps";
- if (kbps >= 128) return "MP3 128kbps";
- if (kbps >= 64) return "MP3 64kbps";
-
- // For very low bitrates, still classify as MP3
- return "MP3 64kbps";
- }
-
- // Check codec
- if (!string.IsNullOrEmpty(file.Codec))
- {
- var codec = file.Codec.ToLower();
- if (codec.Contains("flac")) return "FLAC";
- if (codec.Contains("aac")) return "M4B";
- if (codec.Contains("mp3")) return "MP3 128kbps"; // Default MP3 quality if no bitrate info
- if (codec.Contains("opus")) return "M4B"; // Opus is often in M4B containers
- }
-
- // If we can't determine quality from metadata, try to infer from file extension
- if (!string.IsNullOrEmpty(file.Path))
- {
- var extension = Path.GetExtension(file.Path).ToLower();
- switch (extension)
- {
- case ".flac":
- return "FLAC";
- case ".m4b":
- case ".m4a":
- return "M4B";
- case ".mp3":
- return "MP3 128kbps"; // Conservative default for MP3
- case ".aac":
- return "M4B";
- case ".opus":
- return "M4B";
- }
- }
-
- return null; // Unable to determine quality
- }
+ => QualityCutoffEvaluator.IsCutoffMetAsync(audiobook, downloadRepository, fileRepository, _logger, ct);
private string BuildSearchQuery(Audiobook audiobook)
{
@@ -579,41 +440,24 @@ private bool IsTorrentResult(SearchResult result)
if (!string.IsNullOrEmpty(q))
{
if (bestQuality == null) bestQuality = q;
- else if (IsQualityBetter(q, bestQuality, audiobook.QualityProfile)) bestQuality = q;
+ else if (QualityMatcher.IsLabelBetter(q, bestQuality, audiobook.QualityProfile)) bestQuality = q;
}
}
}
var existingFiles = await fileRepository.GetByAudiobookIdAsync(audiobook.Id, ct);
- foreach (var fq in existingFiles.Select(DetermineFileQuality).Where(fq => !string.IsNullOrEmpty(fq)))
+ foreach (var fq in existingFiles
+ .Select(f => QualityCutoffEvaluator.ResolveFileQualityLabel(f, audiobook.QualityProfile))
+ .Where(fq => !string.IsNullOrEmpty(fq)))
{
if (bestQuality == null) bestQuality = fq;
- else if (IsQualityBetter(fq, bestQuality, audiobook.QualityProfile)) bestQuality = fq;
+ else if (QualityMatcher.IsLabelBetter(fq, bestQuality, audiobook.QualityProfile)) bestQuality = fq;
}
return (cutoffMet, bestQuality);
}
- ///
- /// Compare two quality strings using the quality profile priorities.
- /// Returns true if candidateQuality is better (higher priority) than existingQuality.
- ///
- private bool IsQualityBetter(string? candidateQuality, string? existingQuality, QualityProfile? profile)
- {
- if (string.IsNullOrEmpty(candidateQuality)) return false;
- if (string.IsNullOrEmpty(existingQuality)) return true;
- if (profile == null) return false;
-
- var cand = profile.Qualities.FirstOrDefault(q => q.Quality == candidateQuality);
- var exist = profile.Qualities.FirstOrDefault(q => q.Quality == existingQuality);
-
- if (cand == null) return false;
- if (exist == null) return true; // unknown existing quality -> treat candidate as better
-
- return cand.Priority > exist.Priority;
- }
-
private async Task GetAppropriateDownloadClientAsync(SearchResult searchResult, bool isTorrent)
{
using var scope = _serviceScopeFactory.CreateScope();
diff --git a/listenarr.application/Search/QualityCutoffEvaluator.cs b/listenarr.application/Search/QualityCutoffEvaluator.cs
new file mode 100644
index 000000000..fdb8a84db
--- /dev/null
+++ b/listenarr.application/Search/QualityCutoffEvaluator.cs
@@ -0,0 +1,115 @@
+/*
+ * 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.Interfaces.Repositories;
+using Listenarr.Domain.Common;
+using Listenarr.Domain.Models;
+using Microsoft.Extensions.Logging;
+
+namespace Listenarr.Application.Search
+{
+ ///
+ /// Shared "is the audiobook already at/above its quality cutoff?" evaluation.
+ ///
+ /// Previously this logic was duplicated verbatim in AutomaticSearchService and
+ /// LibraryController, each with its own string-label quality detector that emitted
+ /// container labels (e.g. "M4B") which never equalled a codec/bitrate rung — so the cutoff
+ /// was never met and the audiobook was searched/re-grabbed on every cycle. Both now delegate
+ /// here, and per-file/-download quality is resolved through .
+ ///
+ public static class QualityCutoffEvaluator
+ {
+ public static async Task IsCutoffMetAsync(
+ Audiobook audiobook,
+ IDownloadRepository downloadRepository,
+ IAudiobookFileRepository fileRepository,
+ ILogger logger,
+ CancellationToken ct = default)
+ {
+ var profile = audiobook.QualityProfile;
+ if (profile == null)
+ {
+ return false;
+ }
+
+ var existingDownloads = (await downloadRepository.GetByAudiobookIdAsync(audiobook.Id, ct))
+ .Where(d => d.Status == DownloadStatus.Completed
+ || d.Status == DownloadStatus.Downloading
+ || d.Status == DownloadStatus.ImportPending)
+ .ToList();
+
+ var existingFiles = await fileRepository.GetByAudiobookIdAsync(audiobook.Id, ct);
+
+ if (existingDownloads.Count == 0 && existingFiles.Count == 0)
+ {
+ return false;
+ }
+
+ // Preserve the original guard: an unset or unknown cutoff means "keep searching".
+ var cutoffRung = profile.Qualities.FirstOrDefault(q => q.Quality == profile.CutoffQuality);
+ if (cutoffRung == null)
+ {
+ return false;
+ }
+
+ foreach (var download in existingDownloads)
+ {
+ if (download.Status == DownloadStatus.Completed
+ && !string.IsNullOrEmpty(download.Metadata?.GetValueOrDefault("Quality")?.ToString()))
+ {
+ var downloadQuality = download.Metadata["Quality"].ToString();
+ if (QualityMatcher.LabelMeetsCutoff(downloadQuality, profile))
+ {
+ logger.LogDebug("Quality cutoff met for audiobook {AudiobookId} by completed download (Quality: {Quality})",
+ audiobook.Id, downloadQuality);
+ return true;
+ }
+ }
+ else if (download.Status == DownloadStatus.Downloading || download.Status == DownloadStatus.ImportPending)
+ {
+ logger.LogDebug("Quality cutoff assumed met for audiobook {AudiobookId} due to active download/import", audiobook.Id);
+ return true;
+ }
+ }
+
+ foreach (var file in existingFiles)
+ {
+ if (QualityMatcher.MeetsCutoff(ToInput(file), profile))
+ {
+ logger.LogDebug("Quality cutoff met for audiobook {AudiobookId} by existing file {FileName}",
+ audiobook.Id, System.IO.Path.GetFileName(file.Path));
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// The profile rung label a stored file maps to, or null if it does not match.
+ public static string? ResolveFileQualityLabel(AudiobookFile file, QualityProfile? profile)
+ => QualityMatcher.MatchLabel(ToInput(file), profile);
+
+ private static AudioQualityInput ToInput(AudiobookFile file) => new()
+ {
+ Codec = file.Codec,
+ Container = file.Container,
+ Format = file.Format,
+ BitrateBitsPerSecond = file.Bitrate,
+ Path = file.Path
+ };
+ }
+}
diff --git a/listenarr.domain/Common/QualityMatcher.cs b/listenarr.domain/Common/QualityMatcher.cs
new file mode 100644
index 000000000..bbd146bcc
--- /dev/null
+++ b/listenarr.domain/Common/QualityMatcher.cs
@@ -0,0 +1,410 @@
+/*
+ * 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.RegularExpressions;
+using Listenarr.Domain.Models;
+
+namespace Listenarr.Domain.Common
+{
+ ///
+ /// Normalised, unit-explicit view of an audio file's quality-relevant facts.
+ /// All three call-sites (status evaluator, automatic search, library controller)
+ /// hold different carrier types, so they project into this single record before
+ /// asking to match against a profile.
+ ///
+ public readonly record struct AudioQualityInput
+ {
+ /// Raw codec as reported by ffprobe (e.g. "aac", "mp3", "flac", "opus", "vorbis").
+ public string? Codec { get; init; }
+
+ /// Container (e.g. "M4B", "M4A", "MP4", "OGG").
+ public string? Container { get; init; }
+
+ /// Format/extension fallback (e.g. "mp3", "flac").
+ public string? Format { get; init; }
+
+ /// Bitrate in bits per second (the unit used by file/metadata carriers).
+ public int? BitrateBitsPerSecond { get; init; }
+
+ /// Optional file path, used only as an extension fallback.
+ public string? Path { get; init; }
+ }
+
+ /// Outcome category for .
+ public enum QualityMatchKind
+ {
+ /// A profile rung was matched.
+ Matched,
+
+ /// The file's codec is not present in the profile at all.
+ CodecMismatch,
+
+ /// The codec matched but the profile has no bitrate rung the file can land on.
+ NoBitrateRung,
+
+ /// The profile has no qualities to match against.
+ Unknown
+ }
+
+ /// Result of matching a file to a profile.
+ public readonly record struct QualityMatchResult(QualityMatchKind Kind, QualityDefinition? Rung)
+ {
+ public bool IsMatch => Kind == QualityMatchKind.Matched && Rung is not null;
+ }
+
+ ///
+ /// Profile-driven quality matching. The single source of truth for what a profile's
+ /// ordering means (lower number = higher quality)
+ /// and how an on-disk file maps onto a profile's quality rungs.
+ ///
+ /// Replaces the previous string-label classifiers that emitted container labels such as
+ /// "M4B" (which never equalled a codec/bitrate rung like "AAC 256kbps", so the cutoff was
+ /// never met and the audiobook was re-downloaded forever).
+ ///
+ public static class QualityMatcher
+ {
+ ///
+ /// Match a file to the highest profile rung it meets or exceeds (round-down).
+ /// Returns when the file's codec is not in
+ /// the profile at all (instead of silently treating it as the lowest possible quality).
+ ///
+ public static QualityMatchResult Match(AudioQualityInput file, QualityProfile? profile)
+ {
+ if (profile?.Qualities == null || profile.Qualities.Count == 0)
+ {
+ return new QualityMatchResult(QualityMatchKind.Unknown, null);
+ }
+
+ var fileGroups = MapCodec(file);
+ var fileIsLossless = IsLosslessFile(file);
+ var fileKbps = NormalizeKbps(file.BitrateBitsPerSecond);
+
+ var effective = profile.Qualities
+ .Where(q => q != null && !string.IsNullOrWhiteSpace(q.Quality))
+ .Select(EffectiveRung)
+ .ToList();
+
+ // Codec-specific rungs take precedence; wildcard (codec-less) rungs are the fallback
+ // and exist mainly for legacy/bare profiles such as "320kbps" / "lossless".
+ var codecCandidates = effective
+ .Where(r => r.Codec != null
+ && r.IsLossless == fileIsLossless
+ && fileGroups.Contains(r.Codec))
+ .ToList();
+
+ var pool = codecCandidates.Count > 0
+ ? codecCandidates
+ : effective.Where(r => r.Codec == null && r.IsLossless == fileIsLossless).ToList();
+
+ if (pool.Count == 0)
+ {
+ return new QualityMatchResult(QualityMatchKind.CodecMismatch, null);
+ }
+
+ // Lossless files ignore bitrate: take the best (lowest-priority) lossless rung.
+ if (fileIsLossless)
+ {
+ return new QualityMatchResult(QualityMatchKind.Matched, Best(pool).Source);
+ }
+
+ var withBitrate = pool.Where(r => r.BitrateKbps is not null).ToList();
+ var vbr = pool.Where(r => r.BitrateKbps is null).ToList();
+
+ if (fileKbps is int kbps)
+ {
+ var eligible = withBitrate.Where(r => r.BitrateKbps <= kbps).ToList();
+ if (eligible.Count > 0)
+ {
+ return new QualityMatchResult(QualityMatchKind.Matched, Best(eligible).Source);
+ }
+
+ // File is below the lowest configured rung: fall to the worst rung (never over-claim).
+ if (withBitrate.Count > 0)
+ {
+ return new QualityMatchResult(QualityMatchKind.Matched, Worst(withBitrate).Source);
+ }
+
+ if (vbr.Count > 0)
+ {
+ return new QualityMatchResult(QualityMatchKind.Matched, Best(vbr).Source);
+ }
+
+ return new QualityMatchResult(QualityMatchKind.NoBitrateRung, null);
+ }
+
+ // Unknown bitrate: prefer a VBR rung, else conservatively the worst bitrate rung.
+ if (vbr.Count > 0)
+ {
+ return new QualityMatchResult(QualityMatchKind.Matched, Best(vbr).Source);
+ }
+
+ if (withBitrate.Count > 0)
+ {
+ return new QualityMatchResult(QualityMatchKind.Matched, Worst(withBitrate).Source);
+ }
+
+ return new QualityMatchResult(QualityMatchKind.NoBitrateRung, null);
+ }
+
+ /// The profile rung label a file maps to, or null if it does not match.
+ public static string? MatchLabel(AudioQualityInput file, QualityProfile? profile)
+ => Match(file, profile).Rung?.Quality;
+
+ ///
+ /// Whether a file meets or exceeds the profile cutoff. A blank cutoff is always met;
+ /// a missing/codec-mismatched file is not.
+ ///
+ public static bool MeetsCutoff(AudioQualityInput file, QualityProfile? profile)
+ {
+ var cutoff = ResolveCutoff(profile, out var cutoffBlank);
+ if (cutoffBlank)
+ {
+ return true;
+ }
+
+ if (cutoff == null)
+ {
+ return false;
+ }
+
+ var match = Match(file, profile);
+ return match.IsMatch && match.Rung!.Priority <= cutoff.Priority;
+ }
+
+ ///
+ /// Whether a known quality (e.g. a value stored in download
+ /// metadata) meets or exceeds the profile cutoff.
+ ///
+ public static bool LabelMeetsCutoff(string? qualityLabel, QualityProfile? profile)
+ {
+ var cutoff = ResolveCutoff(profile, out var cutoffBlank);
+ if (cutoffBlank)
+ {
+ return true;
+ }
+
+ if (cutoff == null || string.IsNullOrWhiteSpace(qualityLabel))
+ {
+ return false;
+ }
+
+ var rung = FindRung(profile!, qualityLabel);
+ return rung != null && rung.Priority <= cutoff.Priority;
+ }
+
+ ///
+ /// Whether is strictly higher quality than
+ /// (lower priority number). An unknown candidate is never better; an unknown existing is always beaten.
+ ///
+ public static bool IsLabelBetter(string? candidate, string? existing, QualityProfile? profile)
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(existing))
+ {
+ return true;
+ }
+
+ if (profile == null)
+ {
+ return false;
+ }
+
+ var cand = FindRung(profile, candidate);
+ var exist = FindRung(profile, existing);
+
+ if (cand == null)
+ {
+ return false;
+ }
+
+ if (exist == null)
+ {
+ return true;
+ }
+
+ return cand.Priority < exist.Priority;
+ }
+
+ /// Whether the profile declares a rung whose label equals (case-insensitive).
+ public static bool ProfileContainsHint(QualityProfile? profile, string? hint)
+ {
+ if (profile?.Qualities == null || string.IsNullOrWhiteSpace(hint))
+ {
+ return false;
+ }
+
+ return profile.Qualities.Any(q => string.Equals(q.Quality, hint, StringComparison.OrdinalIgnoreCase));
+ }
+
+ // ---- internals --------------------------------------------------------------------
+
+ private readonly record struct EffectiveRungInfo(QualityDefinition Source, string? Codec, int? BitrateKbps, bool IsLossless)
+ {
+ public int Priority => Source.Priority;
+ }
+
+ private static EffectiveRungInfo Best(IEnumerable rungs)
+ => rungs.OrderBy(r => r.Priority).First();
+
+ private static EffectiveRungInfo Worst(IEnumerable rungs)
+ => rungs.OrderByDescending(r => r.Priority).First();
+
+ private static QualityDefinition? ResolveCutoff(QualityProfile? profile, out bool cutoffBlank)
+ {
+ cutoffBlank = false;
+ if (profile?.Qualities == null || profile.Qualities.Count == 0
+ || string.IsNullOrWhiteSpace(profile.CutoffQuality))
+ {
+ cutoffBlank = true;
+ return null;
+ }
+
+ return FindRung(profile, profile.CutoffQuality);
+ }
+
+ private static QualityDefinition? FindRung(QualityProfile profile, string label)
+ => profile.Qualities.FirstOrDefault(q => string.Equals(q.Quality, label, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Resolve a rung's effective (codec group, bitrate-kbps, lossless) using the structured
+ /// fields when present and parsing the label otherwise
+ /// (seed/legacy rungs only set Quality + Priority).
+ ///
+ private static EffectiveRungInfo EffectiveRung(QualityDefinition rung)
+ {
+ if (!string.IsNullOrWhiteSpace(rung.Codec))
+ {
+ return new EffectiveRungInfo(rung, CanonicalCodec(rung.Codec), rung.Bitrate, rung.IsLossless);
+ }
+
+ var (codec, bitrate, lossless) = ParseQualityLabel(rung.Quality);
+ return new EffectiveRungInfo(rung, codec, rung.Bitrate ?? bitrate, rung.IsLossless || lossless);
+ }
+
+ private static (string? Codec, int? BitrateKbps, bool IsLossless) ParseQualityLabel(string quality)
+ {
+ var lower = (quality ?? string.Empty).Trim().ToLowerInvariant();
+
+ int? bitrate = null;
+ var match = Regex.Match(lower, @"\d{2,}");
+ if (match.Success && int.TryParse(match.Value, out var kb))
+ {
+ bitrate = kb;
+ }
+
+ if (Contains(lower, "flac")) return ("FLAC", bitrate, true);
+ if (Contains(lower, "alac")) return ("ALAC", bitrate, true);
+ if (Contains(lower, "aac") || Contains(lower, "m4b") || Contains(lower, "m4a")) return ("AAC", bitrate, false);
+ if (Contains(lower, "mp3")) return ("MP3", bitrate, false);
+ if (Contains(lower, "opus")) return ("OPUS", bitrate, false);
+ if (Contains(lower, "vorbis") || Contains(lower, "ogg")) return ("OGG Vorbis", bitrate, false);
+ if (Contains(lower, "aiff")) return ("AIFF", bitrate, true);
+ if (Contains(lower, "ape")) return ("APE", bitrate, true);
+ if (Contains(lower, "dsd")) return ("DSD", bitrate, true);
+ if (Contains(lower, "wav") || Contains(lower, "wv")) return ("WavPack", bitrate, true);
+ if (Contains(lower, "lossless")) return (null, bitrate, true);
+
+ // Bare bitrate (e.g. "320kbps") acts as a codec-agnostic wildcard rung.
+ return (null, bitrate, false);
+ }
+
+ /// Map a file's codec/container/format/extension onto the set of profile codec groups it satisfies.
+ private static HashSet MapCodec(AudioQualityInput file)
+ {
+ var tokens = new List();
+ AddToken(tokens, file.Codec);
+ AddToken(tokens, file.Container);
+ AddToken(tokens, file.Format);
+ if (!string.IsNullOrWhiteSpace(file.Path))
+ {
+ AddToken(tokens, System.IO.Path.GetExtension(file.Path)?.TrimStart('.'));
+ }
+
+ var groups = new HashSet(StringComparer.OrdinalIgnoreCase);
+ bool Any(string needle) => tokens.Any(t => Contains(t, needle));
+
+ if (Any("flac")) groups.Add("FLAC");
+ if (Any("alac")) groups.Add("ALAC");
+ if (Any("aiff")) groups.Add("AIFF");
+ if (Any("ape")) groups.Add("APE");
+ if (Any("dsd")) groups.Add("DSD");
+ if (Any("wav") || Any("wv")) groups.Add("WavPack");
+ if (Any("mp3")) groups.Add("MP3");
+ if (Any("opus")) groups.Add("OPUS");
+ if (Any("vorbis") || Any("ogg")) groups.Add("OGG Vorbis");
+ // AAC commonly lives in M4B/M4A/MP4 containers; cover the legacy "M4B" codec group too.
+ if (Any("aac") || Any("m4b") || Any("m4a") || Any("mp4"))
+ {
+ groups.Add("AAC");
+ groups.Add("M4B");
+ }
+
+ return groups;
+ }
+
+ /// Codec groups that represent lossless audio (see ).
+ private static readonly HashSet LosslessGroups =
+ new(StringComparer.OrdinalIgnoreCase) { "FLAC", "ALAC", "AIFF", "APE", "DSD", "WavPack" };
+
+ private static bool IsLosslessFile(AudioQualityInput file)
+ {
+ // Derive lossless-ness from the same mapped codec groups used for matching, so the
+ // path extension fallback (e.g. "book.flac" with no codec/container/format metadata)
+ // is honoured consistently — otherwise such a file maps to the FLAC group yet is
+ // treated as lossy and filtered off the FLAC rung.
+ return MapCodec(file).Overlaps(LosslessGroups);
+ }
+
+ /// Convert a bitrate to kbps, guarding values already expressed in kbps.
+ private static int? NormalizeKbps(int? bitsPerSecond)
+ {
+ if (bitsPerSecond is not int bps || bps <= 0)
+ {
+ return null;
+ }
+
+ return bps >= 1000 ? (int)Math.Round(bps / 1000d) : bps;
+ }
+
+ private static string CanonicalCodec(string codec)
+ {
+ var lower = codec.Trim().ToLowerInvariant();
+ if (Contains(lower, "flac")) return "FLAC";
+ if (Contains(lower, "alac")) return "ALAC";
+ if (Contains(lower, "aac") || Contains(lower, "m4b") || Contains(lower, "m4a")) return "AAC";
+ if (Contains(lower, "mp3")) return "MP3";
+ if (Contains(lower, "opus")) return "OPUS";
+ if (Contains(lower, "vorbis") || Contains(lower, "ogg")) return "OGG Vorbis";
+ return codec.Trim();
+ }
+
+ private static void AddToken(List tokens, string? value)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ tokens.Add(value.Trim().ToLowerInvariant());
+ }
+ }
+
+ private static bool Contains(string haystack, string needle)
+ => haystack.Contains(needle, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Builders/QualityProfileBuilder.cs b/tests/Builders/QualityProfileBuilder.cs
index 9c2b90ace..3a303779c 100644
--- a/tests/Builders/QualityProfileBuilder.cs
+++ b/tests/Builders/QualityProfileBuilder.cs
@@ -10,6 +10,7 @@ public class QualityProfileBuilder
public QualityProfileBuilder()
{
_qualityProfile.Id = ++IdCounter;
+ _qualityProfile.Qualities = new List();
}
public QualityProfileBuilder WithName(string value)
@@ -18,6 +19,51 @@ public QualityProfileBuilder WithName(string value)
return this;
}
+ public QualityProfileBuilder WithCutoff(string value)
+ {
+ _qualityProfile.CutoffQuality = value;
+ return this;
+ }
+
+ public QualityProfileBuilder WithPreferredFormats(params string[] values)
+ {
+ _qualityProfile.PreferredFormats = values.ToList();
+ return this;
+ }
+
+ public QualityProfileBuilder WithQuality(string quality, int priority, string? codec = null, int? bitrate = null, bool lossless = false)
+ {
+ _qualityProfile.Qualities.Add(new QualityDefinition
+ {
+ Quality = quality,
+ Priority = priority,
+ Codec = codec,
+ Bitrate = bitrate,
+ IsLossless = lossless
+ });
+ return this;
+ }
+
+ ///
+ /// Adds a structured AAC + MP3 bitrate ladder plus a FLAC lossless rung, mirroring the
+ /// frontend's default codec/bitrate ordering (lower priority number = higher quality).
+ ///
+ public QualityProfileBuilder WithStructuredDefaults()
+ {
+ WithQuality("FLAC", 0, codec: "FLAC", lossless: true);
+ WithQuality("AAC 320kbps", 1, codec: "AAC", bitrate: 320);
+ WithQuality("AAC 256kbps", 2, codec: "AAC", bitrate: 256);
+ WithQuality("AAC 192kbps", 3, codec: "AAC", bitrate: 192);
+ WithQuality("AAC 128kbps", 4, codec: "AAC", bitrate: 128);
+ WithQuality("AAC 64kbps", 5, codec: "AAC", bitrate: 64);
+ WithQuality("MP3 320kbps", 6, codec: "MP3", bitrate: 320);
+ WithQuality("MP3 256kbps", 7, codec: "MP3", bitrate: 256);
+ WithQuality("MP3 VBR", 8, codec: "MP3");
+ WithQuality("MP3 192kbps", 9, codec: "MP3", bitrate: 192);
+ WithQuality("MP3 128kbps", 10, codec: "MP3", bitrate: 128);
+ return this;
+ }
+
public QualityProfile Build()
{
return _qualityProfile;
diff --git a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs
index 50eb6db68..ad2bef563 100644
--- a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs
+++ b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs
@@ -112,6 +112,62 @@ public void ComputeStatus_TreatsWavPackAsLossless()
Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status);
}
+ [Fact]
+ public void ComputeStatus_ReturnsQualityMatch_ForPathOnlyFile_WhenProbeMetadataMissing()
+ {
+ // Regression: when metadata processing is disabled / ffprobe is unavailable, the file
+ // summary carries only a Path. The status evaluator must forward that Path to the
+ // QualityMatcher (as QualityCutoffEvaluator does) so a "book.flac" maps to the FLAC
+ // lossless rung. Previously Path was dropped, so this resolved as quality-mismatch and
+ // disagreed with the automatic-search cutoff.
+ // PreferredFormats = ["flac"] exercises the candidate filter too: a metadata-less file
+ // must match the preferred format via its path extension, not be dropped before the matcher.
+ var profile = new QualityProfile
+ {
+ Name = "Lossless Profile",
+ CutoffQuality = "lossless",
+ PreferredFormats = new List { "flac" },
+ Qualities = new List
+ {
+ new() { Quality = "lossless", Priority = 0 }
+ }
+ };
+ var files = new List
+ {
+ new() { Path = "/audiobooks/Author/Title/book.flac" }
+ };
+
+ var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files);
+
+ Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status);
+ }
+
+ [Fact]
+ public void ComputeStatus_ReturnsQualityMatch_ForPathOnlyLossyFile_WhenPreferredFormatMatchesExtension()
+ {
+ // Generality beyond FLAC: the path-extension fallback is format-agnostic. A metadata-less
+ // book.m4b with PreferredFormats = ["m4b"] must pass the candidate filter via its extension
+ // (AAC group) and resolve through the matcher, not be dropped as quality-mismatch.
+ var profile = new QualityProfile
+ {
+ Name = "AAC Profile",
+ CutoffQuality = "AAC 256kbps",
+ PreferredFormats = new List { "m4b" },
+ Qualities = new List
+ {
+ new() { Quality = "AAC 256kbps", Codec = "AAC", Bitrate = 256, Priority = 0 }
+ }
+ };
+ var files = new List
+ {
+ new() { Path = "/audiobooks/Author/Title/book.m4b" }
+ };
+
+ var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files);
+
+ Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status);
+ }
+
private static QualityProfile CreateProfile(string cutoffQuality, List preferredFormats)
{
return new QualityProfile
diff --git a/tests/Features/Domain/Utils/QualityMatcherTests.cs b/tests/Features/Domain/Utils/QualityMatcherTests.cs
new file mode 100644
index 000000000..a819ca9f5
--- /dev/null
+++ b/tests/Features/Domain/Utils/QualityMatcherTests.cs
@@ -0,0 +1,354 @@
+/*
+ * 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.Domain.Common;
+using Listenarr.Tests.Builders;
+using Xunit;
+
+namespace Listenarr.Tests.Features.Domain.Utils
+{
+ public class QualityMatcherTests
+ {
+ private static QualityProfileBuilder StructuredProfile() =>
+ new QualityProfileBuilder().WithName("Structured").WithStructuredDefaults();
+
+ // ---- round-down within a codec ----------------------------------------------------
+
+ [Fact]
+ public void Match_RoundsDownToHighestRungFileMeets()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 224_000 };
+
+ var result = QualityMatcher.Match(file, profile);
+
+ Assert.True(result.IsMatch);
+ Assert.Equal("AAC 192kbps", result.Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_ReturnsExactRung_WhenBitrateMatchesExactly()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 256_000 };
+
+ Assert.Equal("AAC 256kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_ClampsToTopRung_WhenAboveHighestBitrate()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 400_000 };
+
+ Assert.Equal("AAC 320kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_FallsToWorstRung_WhenBelowLowestBitrate()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 32_000 };
+
+ Assert.Equal("AAC 64kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ // ---- the M4B re-grab loop regression ----------------------------------------------
+
+ [Fact]
+ public void Match_M4bContainerAacFile_MatchesAacRung_NotMismatch()
+ {
+ // The original bug: container "M4B" was emitted as the quality label and never
+ // equalled the "AAC 256kbps" rung, so the cutoff was never met -> re-grab forever.
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "aac", Container = "M4B", BitrateBitsPerSecond = 256_000 };
+
+ var result = QualityMatcher.Match(file, profile);
+
+ Assert.True(result.IsMatch);
+ Assert.Equal("AAC 256kbps", result.Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_LegacyM4bCodecGroup_Matches()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("Legacy")
+ .WithQuality("M4B 256kbps", 0, codec: "M4B", bitrate: 256)
+ .Build();
+ var file = new AudioQualityInput { Codec = "aac", Container = "M4A", BitrateBitsPerSecond = 256_000 };
+
+ Assert.True(QualityMatcher.Match(file, profile).IsMatch);
+ }
+
+ // ---- codec absent from profile ----------------------------------------------------
+
+ [Fact]
+ public void Match_CodecNotInProfile_ReturnsCodecMismatch()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("Mp3AacOnly")
+ .WithQuality("MP3 320kbps", 0, codec: "MP3", bitrate: 320)
+ .WithQuality("AAC 320kbps", 1, codec: "AAC", bitrate: 320)
+ .Build();
+ var file = new AudioQualityInput { Codec = "opus", BitrateBitsPerSecond = 192_000 };
+
+ var result = QualityMatcher.Match(file, profile);
+
+ Assert.False(result.IsMatch);
+ Assert.Equal(QualityMatchKind.CodecMismatch, result.Kind);
+ }
+
+ // ---- lossless ---------------------------------------------------------------------
+
+ [Fact]
+ public void Match_FlacFile_MatchesFlacRung()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "flac", Container = "FLAC" };
+
+ Assert.Equal("FLAC", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_FlacFile_WithOnlyPathExtension_MatchesFlacRung()
+ {
+ // No codec/container/format metadata (metadata processing off, ffprobe missing, or
+ // extraction failed) — the path extension is the only quality signal. MapCodec already
+ // uses it, so lossless-ness must be derived consistently or "book.flac" maps to the
+ // FLAC group yet is treated as lossy and filtered off the FLAC rung.
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Path = "/audiobooks/book.flac" };
+
+ var result = QualityMatcher.Match(file, profile);
+
+ Assert.True(result.IsMatch);
+ Assert.Equal("FLAC", result.Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_LosslessFile_NoLosslessRung_ReturnsCodecMismatch()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("LossyOnly")
+ .WithQuality("MP3 320kbps", 0, codec: "MP3", bitrate: 320)
+ .WithQuality("AAC 320kbps", 1, codec: "AAC", bitrate: 320)
+ .Build();
+ var file = new AudioQualityInput { Codec = "flac", Container = "FLAC" };
+
+ Assert.Equal(QualityMatchKind.CodecMismatch, QualityMatcher.Match(file, profile).Kind);
+ }
+
+ // ---- bitrate unit normalization ---------------------------------------------------
+
+ [Fact]
+ public void Match_NormalizesBitsPerSecond()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 320_000 };
+
+ Assert.Equal("AAC 320kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_GuardsValuesAlreadyInKbps()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 320 };
+
+ Assert.Equal("AAC 320kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ // ---- VBR and unknown bitrate ------------------------------------------------------
+
+ [Fact]
+ public void Match_UnknownBitrate_PrefersVbrRung()
+ {
+ var profile = StructuredProfile().Build();
+ var file = new AudioQualityInput { Codec = "mp3", BitrateBitsPerSecond = null };
+
+ Assert.Equal("MP3 VBR", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ [Fact]
+ public void Match_UnknownBitrate_NoVbr_FallsToWorstRung()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("AacNoVbr")
+ .WithQuality("AAC 320kbps", 0, codec: "AAC", bitrate: 320)
+ .WithQuality("AAC 128kbps", 1, codec: "AAC", bitrate: 128)
+ .Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = null };
+
+ Assert.Equal("AAC 128kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ // ---- OGG Vorbis by codec ----------------------------------------------------------
+
+ [Fact]
+ public void Match_OggVorbis_MatchesByCodec()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("Ogg")
+ .WithQuality("OGG Vorbis 256kbps", 0, codec: "OGG Vorbis", bitrate: 256)
+ .WithQuality("OGG Vorbis 192kbps", 1, codec: "OGG Vorbis", bitrate: 192)
+ .Build();
+ var file = new AudioQualityInput { Codec = "vorbis", Container = "OGG", BitrateBitsPerSecond = 192_000 };
+
+ Assert.Equal("OGG Vorbis 192kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ // ---- seed-style rungs (Codec null, parse from label) ------------------------------
+
+ [Fact]
+ public void Match_SeedStyleRung_ParsesLabelForCodecAndBitrate()
+ {
+ // Seed rungs only set Quality + Priority (no structured Codec/Bitrate).
+ var profile = new QualityProfileBuilder()
+ .WithName("Seed")
+ .WithQuality("AAC 320kbps", 0)
+ .WithQuality("AAC 256kbps", 1)
+ .WithQuality("AAC 192kbps", 2)
+ .Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 256_000 };
+
+ Assert.Equal("AAC 256kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ // ---- bare-bitrate wildcard (AudiobookStatusEvaluator parity) ----------------------
+
+ [Fact]
+ public void Match_BareBitrateRung_ActsAsCodecWildcard()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("Bare")
+ .WithQuality("320kbps", 0)
+ .WithQuality("256kbps", 1)
+ .WithQuality("192kbps", 2)
+ .Build();
+ var file = new AudioQualityInput { Format = "m4b", BitrateBitsPerSecond = 256_000 };
+
+ Assert.Equal("256kbps", QualityMatcher.Match(file, profile).Rung!.Quality);
+ }
+
+ // ---- MeetsCutoff direction (lower priority = higher quality) ----------------------
+
+ [Fact]
+ public void MeetsCutoff_BelowCutoff_IsFalse()
+ {
+ var profile = StructuredProfile().WithCutoff("AAC 256kbps").Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 192_000 };
+
+ Assert.False(QualityMatcher.MeetsCutoff(file, profile));
+ }
+
+ [Fact]
+ public void MeetsCutoff_AtCutoffBoundary_IsTrue()
+ {
+ var profile = StructuredProfile().WithCutoff("AAC 256kbps").Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 256_000 };
+
+ Assert.True(QualityMatcher.MeetsCutoff(file, profile));
+ }
+
+ [Fact]
+ public void MeetsCutoff_ExceedsCutoff_IsTrue()
+ {
+ var profile = StructuredProfile().WithCutoff("AAC 256kbps").Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 320_000 };
+
+ Assert.True(QualityMatcher.MeetsCutoff(file, profile));
+ }
+
+ [Fact]
+ public void MeetsCutoff_CodecMismatch_IsFalse()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("Mp3Only")
+ .WithCutoff("MP3 256kbps")
+ .WithQuality("MP3 320kbps", 0, codec: "MP3", bitrate: 320)
+ .WithQuality("MP3 256kbps", 1, codec: "MP3", bitrate: 256)
+ .Build();
+ var file = new AudioQualityInput { Codec = "opus", BitrateBitsPerSecond = 320_000 };
+
+ Assert.False(QualityMatcher.MeetsCutoff(file, profile));
+ }
+
+ [Fact]
+ public void MeetsCutoff_NoCutoffConfigured_IsTrue()
+ {
+ var profile = StructuredProfile().Build(); // no cutoff set
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 64_000 };
+
+ Assert.True(QualityMatcher.MeetsCutoff(file, profile));
+ }
+
+ [Fact]
+ public void MeetsCutoff_CutoffRungMissing_IsFalse()
+ {
+ var profile = StructuredProfile().WithCutoff("Nonexistent 999kbps").Build();
+ var file = new AudioQualityInput { Codec = "aac", BitrateBitsPerSecond = 320_000 };
+
+ Assert.False(QualityMatcher.MeetsCutoff(file, profile));
+ }
+
+ // ---- LabelMeetsCutoff & IsLabelBetter & ProfileContainsHint -----------------------
+
+ [Fact]
+ public void LabelMeetsCutoff_HonoursPriorityDirection()
+ {
+ var profile = StructuredProfile().WithCutoff("AAC 256kbps").Build();
+
+ Assert.True(QualityMatcher.LabelMeetsCutoff("AAC 320kbps", profile));
+ Assert.True(QualityMatcher.LabelMeetsCutoff("AAC 256kbps", profile));
+ Assert.False(QualityMatcher.LabelMeetsCutoff("AAC 192kbps", profile));
+ }
+
+ [Fact]
+ public void IsLabelBetter_LowerPriorityNumberWins()
+ {
+ var profile = StructuredProfile().Build();
+
+ Assert.True(QualityMatcher.IsLabelBetter("AAC 320kbps", "AAC 256kbps", profile));
+ Assert.False(QualityMatcher.IsLabelBetter("AAC 192kbps", "AAC 256kbps", profile));
+ Assert.False(QualityMatcher.IsLabelBetter("AAC 256kbps", "AAC 256kbps", profile));
+ }
+
+ [Fact]
+ public void IsLabelBetter_HandlesUnknownInputs()
+ {
+ var profile = StructuredProfile().Build();
+
+ Assert.False(QualityMatcher.IsLabelBetter(null, "AAC 256kbps", profile));
+ Assert.True(QualityMatcher.IsLabelBetter("AAC 256kbps", null, profile));
+ Assert.False(QualityMatcher.IsLabelBetter("Unknown", "AAC 256kbps", profile));
+ Assert.True(QualityMatcher.IsLabelBetter("AAC 256kbps", "Unknown", profile));
+ }
+
+ [Fact]
+ public void ProfileContainsHint_IsCaseInsensitive()
+ {
+ var profile = new QualityProfileBuilder()
+ .WithName("Custom")
+ .WithQuality("Audible AAX", 0)
+ .Build();
+
+ Assert.True(QualityMatcher.ProfileContainsHint(profile, "audible aax"));
+ Assert.False(QualityMatcher.ProfileContainsHint(profile, "FLAC"));
+ }
+ }
+}