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