From 837a75087a70ef102fb2b1545cbcad06d2d061e1 Mon Sep 17 00:00:00 2001 From: Tim Kaiser Date: Thu, 4 Jun 2026 13:07:12 +0200 Subject: [PATCH 1/5] fix: profile-driven quality matching to stop the M4B re-grab loop Monitored audiobooks were searched and re-downloaded every cycle because the cutoff check classified an on-disk file by its container ("M4B") or a bare bitrate ("256kbps") and looked that string up in the profile's quality rungs. Real rungs are named by codec + bitrate ("AAC 256kbps"), so the label never matched, the cutoff was treated as never met, and the audiobook was re-grabbed forever. Introduce QualityMatcher (listenarr.domain): it walks the profile's Qualities using their structured Codec/Bitrate/IsLossless fields (parsing the rung label as a fallback for seed/legacy profiles), rounds a file down to the highest rung it meets or exceeds, and returns a defined codec-mismatch instead of the previous int.MaxValue silent fallback. Route the status evaluator, automatic search, and the library controller through it via a shared QualityCutoffEvaluator, removing ~380 lines of duplicated, drifting detection logic. Also correct an inverted priority comparison in automatic search (lower priority number = higher quality), so a result is grabbed only when genuinely better than the existing file and the cutoff is met only when the file is at least the cutoff quality. Adds QualityMatcherTests and extends QualityProfileBuilder. --- CHANGELOG.md | 2 + .../Controllers/LibraryController.cs | 145 +----- .../Metadata/AudiobookStatusEvaluator.cs | 89 +--- .../Search/AutomaticSearchService.cs | 174 +------- .../Search/QualityCutoffEvaluator.cs | 115 +++++ listenarr.domain/Common/QualityMatcher.cs | 412 ++++++++++++++++++ tests/Builders/QualityProfileBuilder.cs | 46 ++ .../Domain/Utils/QualityMatcherTests.cs | 338 ++++++++++++++ 8 files changed, 938 insertions(+), 383 deletions(-) create mode 100644 listenarr.application/Search/QualityCutoffEvaluator.cs create mode 100644 listenarr.domain/Common/QualityMatcher.cs create mode 100644 tests/Features/Domain/Utils/QualityMatcherTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 982b7c6df..7342696a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Monitored audiobooks no longer re-download in a loop when the file is already at cutoff (the "M4B re-grab loop").** Quality matching is now profile-driven. The automatic-search cutoff check, the library controller's cutoff check, and the library status evaluator each previously classified an on-disk file into an ad-hoc label string (e.g. the container `"M4B"`, or a bare `"256kbps"`) and then looked that string up in the profile's quality rungs. Real rungs are named by codec + bitrate (e.g. `"AAC 256kbps"`), so the container/bare labels never matched, the cutoff was treated as never met, and the audiobook was searched and re-grabbed on every cycle. A new `QualityMatcher` (in `listenarr.domain`) walks the profile's `Qualities` using their structured `Codec` / `Bitrate` / `IsLossless` fields (falling back to parsing the rung label for seed/legacy profiles), rounds a file down to the highest rung it meets or exceeds, and returns a defined "codec not in profile" mismatch instead of silently treating an unmatched file as the lowest possible quality. The three call sites now share a single `QualityCutoffEvaluator`, removing the duplicated, drifting detection logic. +- **Corrected an inverted quality-priority comparison in automatic search.** `AutomaticSearchService`'s cutoff check and its "is this result better than what we already have?" gate compared rung priorities the wrong way round (treating a *lower*-quality file as satisfying the cutoff, and a *worse* search result as an upgrade) — the opposite of the ordering used by the quality model, the seed profiles, the frontend, and the status evaluator (lower priority number = higher quality). With matching fixed these now agree: a result is only grabbed when it is genuinely higher quality than the existing file, and the cutoff is met only when the file is at least the cutoff quality. - **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk." - **Authentication settings: enabling the login screen now refuses to persist when no admin user exists.** `ConfigurationService.SaveStartupConfigAsync` queries `IUserService.GetAdminUsersAsync` whenever the incoming save *transitions* `AuthenticationRequired` from disabled to enabled, and throws if the admin user list is empty. This closes the carveout left by the credential-visibility and admin-provisioning fixes below: the settings DTO clears blank fields before save, so a user who flips "Enable login screen" with empty (or username-only) admin credentials silently skipped provisioning entirely and still reached the startup-config write, locking themselves out of an admin-less instance (recoverable by editing `config/config.json` back to `"AuthenticationRequired": "false"`, but a confusing first-time-setup trap). The check is scoped to the transition: subsequent saves while auth is already on (API key regenerations, port changes, log-level tweaks) don't re-query the admin list, and the common "just updating other startup fields with auth off" path stays unaffected. The admin block in `SaveApplicationSettings` runs before the startup-config write in the same save flow, so the typical "supply credentials and enable login in the same save" sequence has the admin row in place by the time the check runs. - **Authentication settings: admin provisioning failures no longer silently let the auth-required toggle proceed.** `ConfigurationService.SaveApplicationSettingsAsync` previously caught any exception from `CreateUserAsync` / `UpdatePasswordAsync`, logged it, and returned successfully — so when admin credentials were supplied but the user-service rejected them (password policy violation, repo I/O error, concurrent-write race), `SettingsView.saveSettings()` would still go on to persist `AuthenticationRequired=true` on its second request. The result was an instance that required login but had no working admin account — exactly the lockout shape the credential-visibility fix below was meant to prevent. The catch now re-throws the failure so the caller aborts before the auth-toggle write. The settings row itself is still saved before the admin block (non-admin changes like notification triggers and webhooks shouldn't disappear because admin provisioning failed), and the no-credentials path remains an unchanged silent skip. 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..fa03a5c13 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; @@ -90,34 +91,25 @@ 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; + Codec = file.Codec, + Container = file.Container, + Format = file.Format, + BitrateBitsPerSecond = file.Bitrate + }; - if (priority <= cutoffPriority) + if (QualityMatcher.MeetsCutoff(input, qualityProfile)) { return QualityMatch; } @@ -126,59 +118,6 @@ public static string ComputeStatus( return QualityMismatch; } - private static string DeriveQualityLabel(AudiobookFormatSummary? file, string? audiobookQuality) - { - 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"; - } - - 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)) - { - return "lossless"; - } - - return Normalize(file?.Format); - } - private static string Normalize(string? value) { return (value ?? string.Empty).Trim().ToLowerInvariant(); 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..6fe3b8900 --- /dev/null +++ b/listenarr.domain/Common/QualityMatcher.cs @@ -0,0 +1,412 @@ +/* + * 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; + } + + private static bool IsLosslessFile(AudioQualityInput file) + { + foreach (var value in new[] { file.Codec, file.Container, file.Format }) + { + var v = (value ?? string.Empty).ToLowerInvariant(); + if (Contains(v, "flac") || Contains(v, "alac") || Contains(v, "aiff") + || Contains(v, "ape") || Contains(v, "dsd") || Contains(v, "wv") || Contains(v, "wav")) + { + return true; + } + } + + return false; + } + + /// 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/Domain/Utils/QualityMatcherTests.cs b/tests/Features/Domain/Utils/QualityMatcherTests.cs new file mode 100644 index 000000000..a4b53aefe --- /dev/null +++ b/tests/Features/Domain/Utils/QualityMatcherTests.cs @@ -0,0 +1,338 @@ +/* + * 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_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")); + } + } +} From 555024c3ab23e902f9815cad006239c553c7a209 Mon Sep 17 00:00:00 2001 From: Tim Kaiser Date: Sun, 7 Jun 2026 19:11:05 +0200 Subject: [PATCH 2/5] fix: align lossless detection and frontend status with the quality matcher Two review follow-ups on the M4B re-grab loop fix: - QualityMatcher.IsLosslessFile now derives lossless-ness from the mapped codec groups (which already include the path extension) instead of only Codec/Container/Format. A "book.flac" with no probe metadata no longer maps to the FLAC group yet gets filtered off the FLAC rung as lossy. - fe/src/utils/audiobookStatus.ts now mirrors the backend QualityMatcher (codec-group mapping + bitrate round-down) rather than comparing a bare derived label by string equality. An AAC 256kbps file now matches an "AAC 256kbps" rung in library views, so it no longer shows "Below Cutoff" while the backend/auto-search cutoff considers it satisfied. Adds regression tests on both sides. --- fe/src/__tests__/audiobookStatus.spec.ts | 72 ++++++ fe/src/utils/audiobookStatus.ts | 233 +++++++++++++----- listenarr.domain/Common/QualityMatcher.cs | 20 +- .../Domain/Utils/QualityMatcherTests.cs | 16 ++ 4 files changed, 272 insertions(+), 69 deletions(-) diff --git a/fe/src/__tests__/audiobookStatus.spec.ts b/fe/src/__tests__/audiobookStatus.spec.ts index 8d45d3352..a699cb44a 100644 --- a/fe/src/__tests__/audiobookStatus.spec.ts +++ b/fe/src/__tests__/audiobookStatus.spec.ts @@ -84,6 +84,78 @@ describe('computeAudiobookStatus', () => { expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') }) + it('matches a codec-prefixed rung for an AAC file (agrees with the backend matcher)', () => { + const audiobook = { + id: 6, + title: 'Codec Rung Book', + qualityProfileId: 10, + files: [{ id: 103, format: 'm4b', codec: 'aac', bitrate: 256000 }], + } as Audiobook + + const profiles: QualityProfile[] = [ + { + id: 10, + name: 'Structured', + cutoffQuality: 'AAC 256kbps', + preferredFormats: ['m4b'], + qualities: [ + { quality: 'AAC 256kbps', allowed: true, priority: 0, codec: 'AAC', bitrate: 256 }, + ], + }, + ] + + expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') + }) + + it('rounds an AAC file down to a lower codec-prefixed rung', () => { + const audiobook = { + id: 7, + title: 'Round Down Book', + qualityProfileId: 10, + files: [{ id: 104, format: 'm4b', codec: 'aac', bitrate: 224000 }], + } as Audiobook + + const profiles: QualityProfile[] = [ + { + id: 10, + name: 'Structured', + cutoffQuality: 'AAC 192kbps', + preferredFormats: ['m4b'], + qualities: [ + { quality: 'AAC 256kbps', allowed: true, priority: 0, codec: 'AAC', bitrate: 256 }, + { quality: 'AAC 192kbps', allowed: true, priority: 1, codec: 'AAC', bitrate: 192 }, + ], + }, + ] + + // 224kbps rounds down to the 192kbps rung (priority 1), which meets the 192kbps cutoff. + expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') + }) + + it('reports below-cutoff when an AAC file undershoots every codec rung', () => { + const audiobook = { + id: 8, + title: 'Undershoot Book', + qualityProfileId: 10, + files: [{ id: 105, format: 'm4b', codec: 'aac', bitrate: 128000 }], + } as Audiobook + + const profiles: QualityProfile[] = [ + { + id: 10, + name: 'Structured', + cutoffQuality: 'AAC 256kbps', + preferredFormats: ['m4b'], + qualities: [ + { quality: 'AAC 256kbps', allowed: true, priority: 0, codec: 'AAC', bitrate: 256 }, + { quality: 'AAC 192kbps', allowed: true, priority: 1, codec: 'AAC', bitrate: 192 }, + ], + }, + ] + + expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-mismatch') + }) + it('treats WavPack files as lossless', () => { const audiobook = { id: 5, diff --git a/fe/src/utils/audiobookStatus.ts b/fe/src/utils/audiobookStatus.ts index 22b0b519c..fdf79f54a 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, QualityDefinition, QualityProfile } from '@/types' const AUDIOBOOK_STATUSES: AudiobookStatus[] = [ 'downloading', @@ -83,26 +83,34 @@ export function computeAudiobookStatus( 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) - } + // Mirror the backend QualityMatcher: map each file's codec/container/bitrate onto the + // profile's structured rungs (round-down by bitrate) instead of comparing a bare derived + // label by string equality. Otherwise an AAC 256kbps file derives "256kbps" and never + // equals a structured rung like "AAC 256kbps", so the cutoff is reported unmet even though + // the backend (and the automatic-search cutoff) consider it satisfied. + const rungs = profile.qualities + .filter((q): q is QualityDefinition => !!q && !!q.quality) + .map(effectiveRung) const cutoff = normalize(profile.cutoffQuality) - const cutoffPriority = qualityPriority.has(cutoff) - ? qualityPriority.get(cutoff)! - : Number.POSITIVE_INFINITY + const cutoffRung = rungs.find((rung) => rung.label === cutoff) + if (!cutoffRung) { + // Cutoff label is not a known rung — the backend treats this as "never met". + return 'quality-mismatch' + } for (const file of candidateFiles) { - const derivedQuality = deriveQualityLabel(audiobook, file) - if (!derivedQuality) continue - - const priority = qualityPriority.has(derivedQuality) - ? qualityPriority.get(derivedQuality)! - : Number.POSITIVE_INFINITY + const matched = matchRung(file, rungs) + if (matched && matched.priority <= cutoffRung.priority) { + return 'quality-match' + } + } - if (priority <= cutoffPriority) { + // Fallback for files lacking codec/bitrate metadata: match the stored quality label + // directly (mirrors the backend's label-based cutoff check). + if (audiobook.quality) { + const labelRung = rungs.find((rung) => rung.label === normalize(audiobook.quality)) + if (labelRung && labelRung.priority <= cutoffRung.priority) { return 'quality-match' } } @@ -117,52 +125,161 @@ export function computeAudiobookStatus( 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` +// --- Frontend mirror of listenarr.domain/Common/QualityMatcher.cs --------------------- +// Kept deliberately parallel to the backend so library views and the automatic-search +// cutoff agree. If the backend matcher changes, update this in lockstep. + +interface QualityFacts { + bitrate?: number + container?: string + codec?: string + format?: string +} + +interface EffectiveRung { + codec: string | null + bitrateKbps: number | null + isLossless: boolean + priority: number + label: string // normalized rung label +} + +const LOSSLESS_GROUPS = new Set(['FLAC', 'ALAC', 'AIFF', 'APE', 'DSD', 'WavPack']) + +/** Convert a bitrate (kbps or bits-per-second) to kbps; null for missing/invalid. */ +function normalizeKbps(bitrate?: number): number | null { + const value = Number(bitrate) + if (!bitrate || Number.isNaN(value) || value <= 0) return null + return value >= 1000 ? Math.round(value / 1000) : Math.round(value) +} + +/** Map a file's codec/container/format onto the set of profile codec groups it satisfies. */ +function mapCodecGroups(file: QualityFacts): Set { + const tokens = [file.codec, file.container, file.format].map((t) => normalize(t)).filter(Boolean) + const groups = new Set() + const any = (needle: string) => tokens.some((token) => token.includes(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') + if (any('aac') || any('m4b') || any('m4a') || any('mp4')) { + groups.add('AAC') + groups.add('M4B') + } + return groups +} + +/** Canonicalize a structured rung codec onto a codec-group name. */ +function canonicalCodec(codec: string): string { + const lower = normalize(codec) + if (lower.includes('flac')) return 'FLAC' + if (lower.includes('alac')) return 'ALAC' + if (lower.includes('aac') || lower.includes('m4b') || lower.includes('m4a')) return 'AAC' + if (lower.includes('mp3')) return 'MP3' + if (lower.includes('opus')) return 'OPUS' + if (lower.includes('vorbis') || lower.includes('ogg')) return 'OGG Vorbis' + return codec.trim() +} + +/** Parse a bare rung label (e.g. "AAC 256kbps", "lossless", "320kbps") into codec/bitrate/lossless. */ +function parseQualityLabel(quality: string): { + codec: string | null + bitrateKbps: number | null + isLossless: boolean +} { + const lower = normalize(quality) + let bitrateKbps: number | null = null + const match = lower.match(/\d{2,}/) + if (match) { + const parsed = parseInt(match[0], 10) + if (!Number.isNaN(parsed)) bitrateKbps = parsed + } + + const has = (needle: string) => lower.includes(needle) + if (has('flac')) return { codec: 'FLAC', bitrateKbps, isLossless: true } + if (has('alac')) return { codec: 'ALAC', bitrateKbps, isLossless: true } + if (has('aac') || has('m4b') || has('m4a')) return { codec: 'AAC', bitrateKbps, isLossless: false } + if (has('mp3')) return { codec: 'MP3', bitrateKbps, isLossless: false } + if (has('opus')) return { codec: 'OPUS', bitrateKbps, isLossless: false } + if (has('vorbis') || has('ogg')) return { codec: 'OGG Vorbis', bitrateKbps, isLossless: false } + if (has('aiff')) return { codec: 'AIFF', bitrateKbps, isLossless: true } + if (has('ape')) return { codec: 'APE', bitrateKbps, isLossless: true } + if (has('dsd')) return { codec: 'DSD', bitrateKbps, isLossless: true } + if (has('wav') || has('wv')) return { codec: 'WavPack', bitrateKbps, isLossless: true } + if (has('lossless')) return { codec: null, bitrateKbps, isLossless: true } + + // Bare bitrate (e.g. "320kbps") acts as a codec-agnostic wildcard rung. + return { codec: null, bitrateKbps, isLossless: false } +} + +/** Resolve a rung's effective codec/bitrate/lossless, preferring structured fields. */ +function effectiveRung(quality: QualityDefinition): EffectiveRung { + const label = normalize(quality.quality) + if (quality.codec && quality.codec.trim()) { + return { + codec: canonicalCodec(quality.codec), + bitrateKbps: quality.bitrate ?? null, + isLossless: !!quality.isLossless, + priority: quality.priority, + label, } } - 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' + const parsed = parseQualityLabel(quality.quality) + return { + codec: parsed.codec, + bitrateKbps: quality.bitrate ?? parsed.bitrateKbps, + isLossless: !!quality.isLossless || parsed.isLossless, + priority: quality.priority, + label, } +} + +/** Match a file to the highest profile rung it meets or exceeds (round-down by bitrate). */ +function matchRung(file: QualityFacts, rungs: EffectiveRung[]): EffectiveRung | null { + if (rungs.length === 0) return null + + const fileGroups = mapCodecGroups(file) + const fileIsLossless = [...fileGroups].some((group) => LOSSLESS_GROUPS.has(group)) + const fileKbps = normalizeKbps(file.bitrate) + + // Codec-specific rungs take precedence; codec-less rungs are the wildcard fallback. + const codecCandidates = rungs.filter( + (rung) => rung.codec !== null && rung.isLossless === fileIsLossless && fileGroups.has(rung.codec), + ) + const pool = + codecCandidates.length > 0 + ? codecCandidates + : rungs.filter((rung) => rung.codec === null && rung.isLossless === fileIsLossless) + if (pool.length === 0) return null - if (file?.format) return normalize(file.format) + const best = (rungSet: EffectiveRung[]) => + rungSet.reduce((acc, rung) => (rung.priority < acc.priority ? rung : acc)) + const worst = (rungSet: EffectiveRung[]) => + rungSet.reduce((acc, rung) => (rung.priority > acc.priority ? rung : acc)) + + // Lossless files ignore bitrate: take the best (lowest-priority) lossless rung. + if (fileIsLossless) return best(pool) + + const withBitrate = pool.filter((rung) => rung.bitrateKbps !== null) + const vbr = pool.filter((rung) => rung.bitrateKbps === null) + + if (fileKbps !== null) { + const eligible = withBitrate.filter((rung) => (rung.bitrateKbps as number) <= fileKbps) + if (eligible.length > 0) return best(eligible) + if (withBitrate.length > 0) return worst(withBitrate) + if (vbr.length > 0) return best(vbr) + return null + } - return '' + // Unknown bitrate: prefer a VBR rung, else conservatively the worst bitrate rung. + if (vbr.length > 0) return best(vbr) + if (withBitrate.length > 0) return worst(withBitrate) + return null } diff --git a/listenarr.domain/Common/QualityMatcher.cs b/listenarr.domain/Common/QualityMatcher.cs index 6fe3b8900..bbd146bcc 100644 --- a/listenarr.domain/Common/QualityMatcher.cs +++ b/listenarr.domain/Common/QualityMatcher.cs @@ -360,19 +360,17 @@ private static HashSet MapCodec(AudioQualityInput file) 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) { - foreach (var value in new[] { file.Codec, file.Container, file.Format }) - { - var v = (value ?? string.Empty).ToLowerInvariant(); - if (Contains(v, "flac") || Contains(v, "alac") || Contains(v, "aiff") - || Contains(v, "ape") || Contains(v, "dsd") || Contains(v, "wv") || Contains(v, "wav")) - { - return true; - } - } - - return false; + // 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. diff --git a/tests/Features/Domain/Utils/QualityMatcherTests.cs b/tests/Features/Domain/Utils/QualityMatcherTests.cs index a4b53aefe..a819ca9f5 100644 --- a/tests/Features/Domain/Utils/QualityMatcherTests.cs +++ b/tests/Features/Domain/Utils/QualityMatcherTests.cs @@ -124,6 +124,22 @@ public void Match_FlacFile_MatchesFlacRung() 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() { From 86d12804c18496ef236c1234bbcb286d3081bf27 Mon Sep 17 00:00:00 2001 From: Tim Kaiser Date: Sun, 7 Jun 2026 22:44:31 +0200 Subject: [PATCH 3/5] fix: make status the backend's job and forward path-only file quality Addresses the review's two single-source-of-truth gaps so library status agrees with the automatic-search cutoff. Backend: AudiobookStatusEvaluator dropped AudiobookFormatSummary.Path when projecting onto AudioQualityInput, so a path-only file (no probe metadata) never mapped to its codec rung the way QualityCutoffEvaluator already does. It now forwards Path = file.Path. Adds a regression test covering a metadata-less book.flac resolving to quality-match. Frontend: audiobookStatus.ts reimplemented the backend QualityMatcher client-side, which could drift and show "Below Cutoff" in richer library views. The /library list endpoint already returns a server-computed status on every item, so computeAudiobookStatus now trusts audiobook.status and only overrides it for the transient in-flight-download case. Deletes the duplicated client-side matcher and the qualityProfiles argument; the two call sites and the spec are updated accordingly. --- CHANGELOG.md | 1 + fe/src/__tests__/audiobookStatus.spec.ts | 167 ++---------- fe/src/utils/audiobookStatus.ts | 242 +----------------- fe/src/views/library/AudiobooksView.vue | 2 +- fe/src/views/library/CollectionView.vue | 2 +- .../Metadata/AudiobookStatusEvaluator.cs | 6 +- .../Services/AudiobookStatusEvaluatorTests.cs | 28 ++ 7 files changed, 77 insertions(+), 371 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7342696a8..bee743cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Monitored audiobooks no longer re-download in a loop when the file is already at cutoff (the "M4B re-grab loop").** Quality matching is now profile-driven. The automatic-search cutoff check, the library controller's cutoff check, and the library status evaluator each previously classified an on-disk file into an ad-hoc label string (e.g. the container `"M4B"`, or a bare `"256kbps"`) and then looked that string up in the profile's quality rungs. Real rungs are named by codec + bitrate (e.g. `"AAC 256kbps"`), so the container/bare labels never matched, the cutoff was treated as never met, and the audiobook was searched and re-grabbed on every cycle. A new `QualityMatcher` (in `listenarr.domain`) walks the profile's `Qualities` using their structured `Codec` / `Bitrate` / `IsLossless` fields (falling back to parsing the rung label for seed/legacy profiles), rounds a file down to the highest rung it meets or exceeds, and returns a defined "codec not in profile" mismatch instead of silently treating an unmatched file as the lowest possible quality. The three call sites now share a single `QualityCutoffEvaluator`, removing the duplicated, drifting detection logic. +- **Library status now agrees with the automatic-search cutoff for files that have only a path.** Two single-source-of-truth gaps could still make a library view show "Below Cutoff" for an audiobook the backend considers complete. (1) `AudiobookStatusEvaluator` dropped the file's `Path` when projecting onto the quality matcher, so a path-only file (metadata processing disabled, ffprobe unavailable, or extraction failed) — e.g. `book.flac` with no probe data — failed to map to its codec rung; it now forwards `Path` exactly as `QualityCutoffEvaluator` does. (2) The frontend's `audiobookStatus.ts` carried its own client-side reimplementation of the quality matcher that could drift from the backend; it now trusts the server-computed `status` already returned on each `/library` list item and only overrides it for the transient "a download is in flight" case, deleting the duplicated client-side matching logic entirely. - **Corrected an inverted quality-priority comparison in automatic search.** `AutomaticSearchService`'s cutoff check and its "is this result better than what we already have?" gate compared rung priorities the wrong way round (treating a *lower*-quality file as satisfying the cutoff, and a *worse* search result as an upgrade) — the opposite of the ordering used by the quality model, the seed profiles, the frontend, and the status evaluator (lower priority number = higher quality). With matching fixed these now agree: a result is only grabbed when it is genuinely higher quality than the existing file, and the cutoff is met only when the file is at least the cutoff quality. - **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk." - **Authentication settings: enabling the login screen now refuses to persist when no admin user exists.** `ConfigurationService.SaveStartupConfigAsync` queries `IUserService.GetAdminUsersAsync` whenever the incoming save *transitions* `AuthenticationRequired` from disabled to enabled, and throws if the admin user list is empty. This closes the carveout left by the credential-visibility and admin-provisioning fixes below: the settings DTO clears blank fields before save, so a user who flips "Enable login screen" with empty (or username-only) admin credentials silently skipped provisioning entirely and still reached the startup-config write, locking themselves out of an admin-less instance (recoverable by editing `config/config.json` back to `"AuthenticationRequired": "false"`, but a confusing first-time-setup trap). The check is scoped to the transition: subsequent saves while auth is already on (API key regenerations, port changes, log-level tweaks) don't re-query the admin list, and the common "just updating other startup fields with auth off" path stays unaffected. The admin block in `SaveApplicationSettings` runs before the startup-config write in the same save flow, so the typical "supply credentials and enable login in the same save" sequence has the admin row in place by the time the check runs. diff --git a/fe/src/__tests__/audiobookStatus.spec.ts b/fe/src/__tests__/audiobookStatus.spec.ts index a699cb44a..91cd79267 100644 --- a/fe/src/__tests__/audiobookStatus.spec.ts +++ b/fe/src/__tests__/audiobookStatus.spec.ts @@ -17,163 +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('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 quality-mismatch status', () => { + const audiobook = { id: 2, title: 'Below Cutoff', status: 'quality-mismatch' } as Audiobook + expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-mismatch') }) - 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('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('matches a codec-prefixed rung for an AAC file (agrees with the backend matcher)', () => { - const audiobook = { - id: 6, - title: 'Codec Rung Book', - qualityProfileId: 10, - files: [{ id: 103, format: 'm4b', codec: 'aac', bitrate: 256000 }], - } as Audiobook - - const profiles: QualityProfile[] = [ - { - id: 10, - name: 'Structured', - cutoffQuality: 'AAC 256kbps', - preferredFormats: ['m4b'], - qualities: [ - { quality: 'AAC 256kbps', allowed: true, priority: 0, codec: 'AAC', bitrate: 256 }, - ], - }, - ] - - 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('rounds an AAC file down to a lower codec-prefixed rung', () => { + 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: 7, - title: 'Round Down Book', + id: 5, + title: 'Defer To Server', + status: 'quality-mismatch', qualityProfileId: 10, - files: [{ id: 104, format: 'm4b', codec: 'aac', bitrate: 224000 }], + files: [{ id: 100, format: 'm4b', codec: 'aac', bitrate: 320000 }], } as Audiobook - const profiles: QualityProfile[] = [ - { - id: 10, - name: 'Structured', - cutoffQuality: 'AAC 192kbps', - preferredFormats: ['m4b'], - qualities: [ - { quality: 'AAC 256kbps', allowed: true, priority: 0, codec: 'AAC', bitrate: 256 }, - { quality: 'AAC 192kbps', allowed: true, priority: 1, codec: 'AAC', bitrate: 192 }, - ], - }, - ] - - // 224kbps rounds down to the 192kbps rung (priority 1), which meets the 192kbps cutoff. - expect(computeAudiobookStatus(audiobook, new Set(), profiles)).toBe('quality-match') + expect(computeAudiobookStatus(audiobook, new Set())).toBe('quality-mismatch') }) - it('reports below-cutoff when an AAC file undershoots every codec rung', () => { - const audiobook = { - id: 8, - title: 'Undershoot Book', - qualityProfileId: 10, - files: [{ id: 105, format: 'm4b', codec: 'aac', bitrate: 128000 }], - } as Audiobook - - const profiles: QualityProfile[] = [ - { - id: 10, - name: 'Structured', - cutoffQuality: 'AAC 256kbps', - preferredFormats: ['m4b'], - qualities: [ - { quality: 'AAC 256kbps', allowed: true, priority: 0, codec: 'AAC', bitrate: 256 }, - { quality: 'AAC 192kbps', allowed: true, priority: 1, codec: 'AAC', bitrate: 192 }, - ], - }, - ] - - expect(computeAudiobookStatus(audiobook, new Set(), profiles)).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') }) - it('treats WavPack files as lossless', () => { - const audiobook = { - id: 5, - title: 'Lossless Book', - qualityProfileId: 10, - files: [{ id: 102, format: 'wv', container: 'wv' }], - } 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(), 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 fdf79f54a..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, QualityDefinition, 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,241 +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' - } - - // Mirror the backend QualityMatcher: map each file's codec/container/bitrate onto the - // profile's structured rungs (round-down by bitrate) instead of comparing a bare derived - // label by string equality. Otherwise an AAC 256kbps file derives "256kbps" and never - // equals a structured rung like "AAC 256kbps", so the cutoff is reported unmet even though - // the backend (and the automatic-search cutoff) consider it satisfied. - const rungs = profile.qualities - .filter((q): q is QualityDefinition => !!q && !!q.quality) - .map(effectiveRung) - - const cutoff = normalize(profile.cutoffQuality) - const cutoffRung = rungs.find((rung) => rung.label === cutoff) - if (!cutoffRung) { - // Cutoff label is not a known rung — the backend treats this as "never met". - return 'quality-mismatch' - } - - for (const file of candidateFiles) { - const matched = matchRung(file, rungs) - if (matched && matched.priority <= cutoffRung.priority) { - return 'quality-match' - } - } - - // Fallback for files lacking codec/bitrate metadata: match the stored quality label - // directly (mirrors the backend's label-based cutoff check). - if (audiobook.quality) { - const labelRung = rungs.find((rung) => rung.label === normalize(audiobook.quality)) - if (labelRung && labelRung.priority <= cutoffRung.priority) { - return 'quality-match' - } - } - - return 'quality-mismatch' - } - if (isAudiobookStatus(audiobook.status)) { return audiobook.status } return 'no-file' } - -// --- Frontend mirror of listenarr.domain/Common/QualityMatcher.cs --------------------- -// Kept deliberately parallel to the backend so library views and the automatic-search -// cutoff agree. If the backend matcher changes, update this in lockstep. - -interface QualityFacts { - bitrate?: number - container?: string - codec?: string - format?: string -} - -interface EffectiveRung { - codec: string | null - bitrateKbps: number | null - isLossless: boolean - priority: number - label: string // normalized rung label -} - -const LOSSLESS_GROUPS = new Set(['FLAC', 'ALAC', 'AIFF', 'APE', 'DSD', 'WavPack']) - -/** Convert a bitrate (kbps or bits-per-second) to kbps; null for missing/invalid. */ -function normalizeKbps(bitrate?: number): number | null { - const value = Number(bitrate) - if (!bitrate || Number.isNaN(value) || value <= 0) return null - return value >= 1000 ? Math.round(value / 1000) : Math.round(value) -} - -/** Map a file's codec/container/format onto the set of profile codec groups it satisfies. */ -function mapCodecGroups(file: QualityFacts): Set { - const tokens = [file.codec, file.container, file.format].map((t) => normalize(t)).filter(Boolean) - const groups = new Set() - const any = (needle: string) => tokens.some((token) => token.includes(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') - if (any('aac') || any('m4b') || any('m4a') || any('mp4')) { - groups.add('AAC') - groups.add('M4B') - } - return groups -} - -/** Canonicalize a structured rung codec onto a codec-group name. */ -function canonicalCodec(codec: string): string { - const lower = normalize(codec) - if (lower.includes('flac')) return 'FLAC' - if (lower.includes('alac')) return 'ALAC' - if (lower.includes('aac') || lower.includes('m4b') || lower.includes('m4a')) return 'AAC' - if (lower.includes('mp3')) return 'MP3' - if (lower.includes('opus')) return 'OPUS' - if (lower.includes('vorbis') || lower.includes('ogg')) return 'OGG Vorbis' - return codec.trim() -} - -/** Parse a bare rung label (e.g. "AAC 256kbps", "lossless", "320kbps") into codec/bitrate/lossless. */ -function parseQualityLabel(quality: string): { - codec: string | null - bitrateKbps: number | null - isLossless: boolean -} { - const lower = normalize(quality) - let bitrateKbps: number | null = null - const match = lower.match(/\d{2,}/) - if (match) { - const parsed = parseInt(match[0], 10) - if (!Number.isNaN(parsed)) bitrateKbps = parsed - } - - const has = (needle: string) => lower.includes(needle) - if (has('flac')) return { codec: 'FLAC', bitrateKbps, isLossless: true } - if (has('alac')) return { codec: 'ALAC', bitrateKbps, isLossless: true } - if (has('aac') || has('m4b') || has('m4a')) return { codec: 'AAC', bitrateKbps, isLossless: false } - if (has('mp3')) return { codec: 'MP3', bitrateKbps, isLossless: false } - if (has('opus')) return { codec: 'OPUS', bitrateKbps, isLossless: false } - if (has('vorbis') || has('ogg')) return { codec: 'OGG Vorbis', bitrateKbps, isLossless: false } - if (has('aiff')) return { codec: 'AIFF', bitrateKbps, isLossless: true } - if (has('ape')) return { codec: 'APE', bitrateKbps, isLossless: true } - if (has('dsd')) return { codec: 'DSD', bitrateKbps, isLossless: true } - if (has('wav') || has('wv')) return { codec: 'WavPack', bitrateKbps, isLossless: true } - if (has('lossless')) return { codec: null, bitrateKbps, isLossless: true } - - // Bare bitrate (e.g. "320kbps") acts as a codec-agnostic wildcard rung. - return { codec: null, bitrateKbps, isLossless: false } -} - -/** Resolve a rung's effective codec/bitrate/lossless, preferring structured fields. */ -function effectiveRung(quality: QualityDefinition): EffectiveRung { - const label = normalize(quality.quality) - if (quality.codec && quality.codec.trim()) { - return { - codec: canonicalCodec(quality.codec), - bitrateKbps: quality.bitrate ?? null, - isLossless: !!quality.isLossless, - priority: quality.priority, - label, - } - } - - const parsed = parseQualityLabel(quality.quality) - return { - codec: parsed.codec, - bitrateKbps: quality.bitrate ?? parsed.bitrateKbps, - isLossless: !!quality.isLossless || parsed.isLossless, - priority: quality.priority, - label, - } -} - -/** Match a file to the highest profile rung it meets or exceeds (round-down by bitrate). */ -function matchRung(file: QualityFacts, rungs: EffectiveRung[]): EffectiveRung | null { - if (rungs.length === 0) return null - - const fileGroups = mapCodecGroups(file) - const fileIsLossless = [...fileGroups].some((group) => LOSSLESS_GROUPS.has(group)) - const fileKbps = normalizeKbps(file.bitrate) - - // Codec-specific rungs take precedence; codec-less rungs are the wildcard fallback. - const codecCandidates = rungs.filter( - (rung) => rung.codec !== null && rung.isLossless === fileIsLossless && fileGroups.has(rung.codec), - ) - const pool = - codecCandidates.length > 0 - ? codecCandidates - : rungs.filter((rung) => rung.codec === null && rung.isLossless === fileIsLossless) - if (pool.length === 0) return null - - const best = (rungSet: EffectiveRung[]) => - rungSet.reduce((acc, rung) => (rung.priority < acc.priority ? rung : acc)) - const worst = (rungSet: EffectiveRung[]) => - rungSet.reduce((acc, rung) => (rung.priority > acc.priority ? rung : acc)) - - // Lossless files ignore bitrate: take the best (lowest-priority) lossless rung. - if (fileIsLossless) return best(pool) - - const withBitrate = pool.filter((rung) => rung.bitrateKbps !== null) - const vbr = pool.filter((rung) => rung.bitrateKbps === null) - - if (fileKbps !== null) { - const eligible = withBitrate.filter((rung) => (rung.bitrateKbps as number) <= fileKbps) - if (eligible.length > 0) return best(eligible) - if (withBitrate.length > 0) return worst(withBitrate) - if (vbr.length > 0) return best(vbr) - return null - } - - // Unknown bitrate: prefer a VBR rung, else conservatively the worst bitrate rung. - if (vbr.length > 0) return best(vbr) - if (withBitrate.length > 0) return worst(withBitrate) - return null -} 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.application/Metadata/AudiobookStatusEvaluator.cs b/listenarr.application/Metadata/AudiobookStatusEvaluator.cs index fa03a5c13..395fb6927 100644 --- a/listenarr.application/Metadata/AudiobookStatusEvaluator.cs +++ b/listenarr.application/Metadata/AudiobookStatusEvaluator.cs @@ -106,7 +106,11 @@ public static string ComputeStatus( Codec = file.Codec, Container = file.Container, Format = file.Format, - BitrateBitsPerSecond = file.Bitrate + 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)) diff --git a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs index 50eb6db68..c42339723 100644 --- a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs +++ b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs @@ -112,6 +112,34 @@ 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. + var profile = new QualityProfile + { + Name = "Lossless Profile", + CutoffQuality = "lossless", + PreferredFormats = new List(), + 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); + } + private static QualityProfile CreateProfile(string cutoffQuality, List preferredFormats) { return new QualityProfile From 281f7beecb4aca268927b5da55b6ecf28997c583 Mon Sep 17 00:00:00 2001 From: Tim Kaiser Date: Mon, 8 Jun 2026 10:10:50 +0200 Subject: [PATCH 4/5] fix: path-only files survive the AudiobookStatusEvaluator format filter The candidate-format pre-filter matched files against PreferredFormats using only Format/Container, so a metadata-less file (no probe data, only a Path like book.flac) was dropped as quality-mismatch before Path could reach QualityMatcher. The filter now falls back to the path extension (new ExtensionFromPath helper), matching QualityMatcher.MapCodec. The regression test now uses PreferredFormats=["flac"], plus a second-format path-only book.m4b case proving the fallback is format-agnostic, not FLAC-specific. --- CHANGELOG.md | 2 +- .../Metadata/AudiobookStatusEvaluator.cs | 17 +++++++++++ .../Services/AudiobookStatusEvaluatorTests.cs | 30 ++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bee743cd4..2fc551582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Monitored audiobooks no longer re-download in a loop when the file is already at cutoff (the "M4B re-grab loop").** Quality matching is now profile-driven. The automatic-search cutoff check, the library controller's cutoff check, and the library status evaluator each previously classified an on-disk file into an ad-hoc label string (e.g. the container `"M4B"`, or a bare `"256kbps"`) and then looked that string up in the profile's quality rungs. Real rungs are named by codec + bitrate (e.g. `"AAC 256kbps"`), so the container/bare labels never matched, the cutoff was treated as never met, and the audiobook was searched and re-grabbed on every cycle. A new `QualityMatcher` (in `listenarr.domain`) walks the profile's `Qualities` using their structured `Codec` / `Bitrate` / `IsLossless` fields (falling back to parsing the rung label for seed/legacy profiles), rounds a file down to the highest rung it meets or exceeds, and returns a defined "codec not in profile" mismatch instead of silently treating an unmatched file as the lowest possible quality. The three call sites now share a single `QualityCutoffEvaluator`, removing the duplicated, drifting detection logic. -- **Library status now agrees with the automatic-search cutoff for files that have only a path.** Two single-source-of-truth gaps could still make a library view show "Below Cutoff" for an audiobook the backend considers complete. (1) `AudiobookStatusEvaluator` dropped the file's `Path` when projecting onto the quality matcher, so a path-only file (metadata processing disabled, ffprobe unavailable, or extraction failed) — e.g. `book.flac` with no probe data — failed to map to its codec rung; it now forwards `Path` exactly as `QualityCutoffEvaluator` does. (2) The frontend's `audiobookStatus.ts` carried its own client-side reimplementation of the quality matcher that could drift from the backend; it now trusts the server-computed `status` already returned on each `/library` list item and only overrides it for the transient "a download is in flight" case, deleting the duplicated client-side matching logic entirely. +- **Library status now agrees with the automatic-search cutoff for files that have only a path.** Two single-source-of-truth gaps could still make a library view show "Below Cutoff" for an audiobook the backend considers complete. (1) `AudiobookStatusEvaluator` dropped the file's `Path` when projecting onto the quality matcher, so a path-only file (metadata processing disabled, ffprobe unavailable, or extraction failed) — e.g. `book.flac` with no probe data — failed to map to its codec rung; it now forwards `Path` exactly as `QualityCutoffEvaluator` does, and its candidate-format pre-filter falls back to the path extension so a path-only file that matches `PreferredFormats` by extension isn't dropped before the matcher runs. (2) The frontend's `audiobookStatus.ts` carried its own client-side reimplementation of the quality matcher that could drift from the backend; it now trusts the server-computed `status` already returned on each `/library` list item and only overrides it for the transient "a download is in flight" case, deleting the duplicated client-side matching logic entirely. - **Corrected an inverted quality-priority comparison in automatic search.** `AutomaticSearchService`'s cutoff check and its "is this result better than what we already have?" gate compared rung priorities the wrong way round (treating a *lower*-quality file as satisfying the cutoff, and a *worse* search result as an upgrade) — the opposite of the ordering used by the quality model, the seed profiles, the frontend, and the status evaluator (lower priority number = higher quality). With matching fixed these now agree: a result is only grabbed when it is genuinely higher quality than the existing file, and the cutoff is met only when the file is at least the cutoff quality. - **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk." - **Authentication settings: enabling the login screen now refuses to persist when no admin user exists.** `ConfigurationService.SaveStartupConfigAsync` queries `IUserService.GetAdminUsersAsync` whenever the incoming save *transitions* `AuthenticationRequired` from disabled to enabled, and throws if the admin user list is empty. This closes the carveout left by the credential-visibility and admin-provisioning fixes below: the settings DTO clears blank fields before save, so a user who flips "Enable login screen" with empty (or username-only) admin credentials silently skipped provisioning entirely and still reached the startup-config write, locking themselves out of an admin-less instance (recoverable by editing `config/config.json` back to `"AuthenticationRequired": "false"`, but a confusing first-time-setup trap). The check is scoped to the transition: subsequent saves while auth is already on (API key regenerations, port changes, log-level tweaks) don't re-query the admin list, and the common "just updating other startup fields with auth off" path stays unaffected. The admin block in `SaveApplicationSettings` runs before the startup-config write in the same save flow, so the typical "supply credentials and enable login in the same save" sequence has the admin row in place by the time the check runs. diff --git a/listenarr.application/Metadata/AudiobookStatusEvaluator.cs b/listenarr.application/Metadata/AudiobookStatusEvaluator.cs index 395fb6927..9d1baca45 100644 --- a/listenarr.application/Metadata/AudiobookStatusEvaluator.cs +++ b/listenarr.application/Metadata/AudiobookStatusEvaluator.cs @@ -63,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) { @@ -126,5 +133,15 @@ private static string Normalize(string? value) { return (value ?? string.Empty).Trim().ToLowerInvariant(); } + + private static string ExtensionFromPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + return Normalize(System.IO.Path.GetExtension(path).TrimStart('.')); + } } } diff --git a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs index c42339723..ad2bef563 100644 --- a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs +++ b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs @@ -120,11 +120,13 @@ public void ComputeStatus_ReturnsQualityMatch_ForPathOnlyFile_WhenProbeMetadataM // 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(), + PreferredFormats = new List { "flac" }, Qualities = new List { new() { Quality = "lossless", Priority = 0 } @@ -140,6 +142,32 @@ public void ComputeStatus_ReturnsQualityMatch_ForPathOnlyFile_WhenProbeMetadataM 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 From 42d120a96455bf9d17ecfe21d2cef298df4fdea0 Mon Sep 17 00:00:00 2001 From: Tim Kaiser Date: Mon, 8 Jun 2026 18:22:36 +0200 Subject: [PATCH 5/5] docs: drop CHANGELOG entry (no longer required per maintainer review) --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc551582..982b7c6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- **Monitored audiobooks no longer re-download in a loop when the file is already at cutoff (the "M4B re-grab loop").** Quality matching is now profile-driven. The automatic-search cutoff check, the library controller's cutoff check, and the library status evaluator each previously classified an on-disk file into an ad-hoc label string (e.g. the container `"M4B"`, or a bare `"256kbps"`) and then looked that string up in the profile's quality rungs. Real rungs are named by codec + bitrate (e.g. `"AAC 256kbps"`), so the container/bare labels never matched, the cutoff was treated as never met, and the audiobook was searched and re-grabbed on every cycle. A new `QualityMatcher` (in `listenarr.domain`) walks the profile's `Qualities` using their structured `Codec` / `Bitrate` / `IsLossless` fields (falling back to parsing the rung label for seed/legacy profiles), rounds a file down to the highest rung it meets or exceeds, and returns a defined "codec not in profile" mismatch instead of silently treating an unmatched file as the lowest possible quality. The three call sites now share a single `QualityCutoffEvaluator`, removing the duplicated, drifting detection logic. -- **Library status now agrees with the automatic-search cutoff for files that have only a path.** Two single-source-of-truth gaps could still make a library view show "Below Cutoff" for an audiobook the backend considers complete. (1) `AudiobookStatusEvaluator` dropped the file's `Path` when projecting onto the quality matcher, so a path-only file (metadata processing disabled, ffprobe unavailable, or extraction failed) — e.g. `book.flac` with no probe data — failed to map to its codec rung; it now forwards `Path` exactly as `QualityCutoffEvaluator` does, and its candidate-format pre-filter falls back to the path extension so a path-only file that matches `PreferredFormats` by extension isn't dropped before the matcher runs. (2) The frontend's `audiobookStatus.ts` carried its own client-side reimplementation of the quality matcher that could drift from the backend; it now trusts the server-computed `status` already returned on each `/library` list item and only overrides it for the transient "a download is in flight" case, deleting the duplicated client-side matching logic entirely. -- **Corrected an inverted quality-priority comparison in automatic search.** `AutomaticSearchService`'s cutoff check and its "is this result better than what we already have?" gate compared rung priorities the wrong way round (treating a *lower*-quality file as satisfying the cutoff, and a *worse* search result as an upgrade) — the opposite of the ordering used by the quality model, the seed profiles, the frontend, and the status evaluator (lower priority number = higher quality). With matching fixed these now agree: a result is only grabbed when it is genuinely higher quality than the existing file, and the cutoff is met only when the file is at least the cutoff quality. - **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk." - **Authentication settings: enabling the login screen now refuses to persist when no admin user exists.** `ConfigurationService.SaveStartupConfigAsync` queries `IUserService.GetAdminUsersAsync` whenever the incoming save *transitions* `AuthenticationRequired` from disabled to enabled, and throws if the admin user list is empty. This closes the carveout left by the credential-visibility and admin-provisioning fixes below: the settings DTO clears blank fields before save, so a user who flips "Enable login screen" with empty (or username-only) admin credentials silently skipped provisioning entirely and still reached the startup-config write, locking themselves out of an admin-less instance (recoverable by editing `config/config.json` back to `"AuthenticationRequired": "false"`, but a confusing first-time-setup trap). The check is scoped to the transition: subsequent saves while auth is already on (API key regenerations, port changes, log-level tweaks) don't re-query the admin list, and the common "just updating other startup fields with auth off" path stays unaffected. The admin block in `SaveApplicationSettings` runs before the startup-config write in the same save flow, so the typical "supply credentials and enable login in the same save" sequence has the admin row in place by the time the check runs. - **Authentication settings: admin provisioning failures no longer silently let the auth-required toggle proceed.** `ConfigurationService.SaveApplicationSettingsAsync` previously caught any exception from `CreateUserAsync` / `UpdatePasswordAsync`, logged it, and returned successfully — so when admin credentials were supplied but the user-service rejected them (password policy violation, repo I/O error, concurrent-write race), `SettingsView.saveSettings()` would still go on to persist `AuthenticationRequired=true` on its second request. The result was an instance that required login but had no working admin account — exactly the lockout shape the credential-visibility fix below was meant to prevent. The catch now re-throws the failure so the caller aborts before the auth-toggle write. The settings row itself is still saved before the admin block (non-admin changes like notification triggers and webhooks shouldn't disappear because admin provisioning failed), and the no-credentials path remains an unchanged silent skip.