From 23a0022b164192e78c4e6fc508fe6b5ca1aee633 Mon Sep 17 00:00:00 2001 From: Tim Kaiser Date: Tue, 12 May 2026 15:43:12 +0200 Subject: [PATCH 1/2] Fix M4B re-grab loop: emit codec-prefixed quality labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudiobookStatusEvaluator.DeriveQualityLabel was emitting labels like "M4B", "lossless", "320kbps", or "64kbps" that never matched the codec-prefixed keys seeded by QualityProfileService ("AAC 320kbps" ... "AAC 64kbps", "MP3 320kbps" ... "MP3 64kbps"). Any monitored audiobook with an M4B file on disk therefore failed the cutoff check on every cycle and Listenarr re-grabbed it every ~6 hours against any matching indexer release — for example, MP3 64k releases that score above an unmatched "M4B" — leaving a trail of "…(1) / (2) / (3) …" staging folders. DeriveQualityLabel now: - detects the codec from Codec/Container/Format (m4b/m4a/mp4 -> aac); - buckets the file bitrate to the nearest seeded rung (320/256/192/128/64); - emits " kbps" (e.g. "aac 64kbps") that matches QualityProfile.Qualities keys via the existing case-insensitive dictionary; and - falls back from a legacy audiobookQuality hint (e.g. "M4B") to the file-based derivation when the hint isn't already in codec-prefixed shape. Lossless codecs (flac/alac/wav/aiff/ape/dsd/wavpack) still resolve to "lossless" so user-defined lossless rungs keep matching. Closes Listenarrs/Listenarr#549 --- .../Services/AudiobookStatusEvaluator.cs | 168 +++++++++++--- .../Services/AudiobookStatusEvaluatorTests.cs | 209 ++++++++++++++++-- 2 files changed, 324 insertions(+), 53 deletions(-) diff --git a/listenarr.api/Services/AudiobookStatusEvaluator.cs b/listenarr.api/Services/AudiobookStatusEvaluator.cs index c222ebf55..47c162079 100644 --- a/listenarr.api/Services/AudiobookStatusEvaluator.cs +++ b/listenarr.api/Services/AudiobookStatusEvaluator.cs @@ -34,6 +34,10 @@ public static class AudiobookStatusEvaluator public const string QualityMismatch = "quality-mismatch"; public const string QualityMatch = "quality-match"; + // Bitrate rungs seeded by QualityProfileService.EnsureProfileHasRequiredQualitiesAsync. + // Ordered high → low so BucketBitrate returns the largest rung the file meets. + private static readonly int[] BitrateBuckets = { 320, 256, 192, 128, 64 }; + public static string ComputeStatus( bool isDownloading, bool hasAnyFile, @@ -135,55 +139,153 @@ public static string ComputeStatus( private static string DeriveQualityLabel(AudiobookFileStatusInfo? file, string? audiobookQuality) { - var normalizedAudiobookQuality = Normalize(audiobookQuality); - if (normalizedAudiobookQuality.Length > 0) + // Only trust the audiobookQuality hint if it already matches a production QP key + // shape (e.g. "MP3 320kbps", "AAC 64kbps", "FLAC"). Legacy values like "M4B" must + // fall through so we re-derive from the file's codec/container/bitrate fields and + // produce a label that can actually round-trip with QualityProfile.Qualities keys. + var hint = Normalize(audiobookQuality); + if (IsCodecPrefixedLabel(hint)) { - return normalizedAudiobookQuality; + return hint; } - if (file?.Bitrate is int bitrate) + var codec = DetectCodec(file); + + if (IsLosslessCodec(codec)) { - var bitrateKbps = bitrate >= 1000 ? bitrate / 1000d : bitrate; + return "lossless"; + } - if (bitrateKbps >= 320) + if (codec.Length > 0 && file?.Bitrate is int bitrate) + { + var bitrateKbps = bitrate >= 1000 ? bitrate / 1000d : bitrate; + var bucket = BucketBitrate(bitrateKbps); + if (bucket > 0) { - return "320kbps"; + return $"{codec} {bucket}kbps"; } + } - if (bitrateKbps >= 256) - { - return "256kbps"; - } + return Normalize(file?.Format); + } + + private static string DetectCodec(AudiobookFileStatusInfo? file) + { + var codec = Normalize(file?.Codec); + var container = Normalize(file?.Container); + var format = Normalize(file?.Format); + + if (codec.Contains("mp3", StringComparison.Ordinal) + || container.Contains("mp3", StringComparison.Ordinal) + || format == "mp3") + { + return "mp3"; + } + + if (codec.Contains("flac", StringComparison.Ordinal) + || container.Contains("flac", StringComparison.Ordinal) + || format == "flac") + { + return "flac"; + } + + if (codec.Contains("alac", StringComparison.Ordinal) + || container.Contains("alac", StringComparison.Ordinal)) + { + return "alac"; + } + + if (codec.Contains("opus", StringComparison.Ordinal) + || container.Contains("opus", StringComparison.Ordinal) + || format == "opus") + { + return "opus"; + } + + if (codec.Contains("vorbis", StringComparison.Ordinal) + || container.Contains("ogg", StringComparison.Ordinal) + || format == "ogg") + { + return "vorbis"; + } + + if (codec.Contains("aac", StringComparison.Ordinal) + || codec.Contains("mp4a", StringComparison.Ordinal)) + { + return "aac"; + } - if (bitrateKbps >= 192) + if (codec.Contains("aiff", StringComparison.Ordinal) + || container.Contains("aiff", StringComparison.Ordinal)) + { + return "aiff"; + } + + if (codec.Contains("ape", StringComparison.Ordinal) + || container.Contains("ape", StringComparison.Ordinal)) + { + return "ape"; + } + + if (codec.Contains("dsd", StringComparison.Ordinal) + || container.Contains("dsd", StringComparison.Ordinal)) + { + return "dsd"; + } + + if (codec.Contains("wavpack", StringComparison.Ordinal) + || container == "wv") + { + return "wavpack"; + } + + if (codec.Contains("wav", StringComparison.Ordinal) + || container == "wav" + || format == "wav") + { + return "wav"; + } + + // M4B/M4A/MP4 containers carry AAC for virtually all audiobooks. + if (container is "m4b" or "m4a" or "mp4" + || format is "m4b" or "m4a" or "mp4") + { + return "aac"; + } + + return string.Empty; + } + + private static bool IsLosslessCodec(string codec) + { + return codec is "flac" or "alac" or "wav" or "aiff" or "ape" or "dsd" or "wavpack"; + } + + private static int BucketBitrate(double bitrateKbps) + { + foreach (var bucket in BitrateBuckets) + { + if (bitrateKbps >= bucket) { - return "192kbps"; + return bucket; } - - return $"{Math.Round(bitrateKbps)}kbps"; } + // Below the lowest seeded rung — map to it so we don't trigger + // a perpetual re-grab loop for unusually low-bitrate sources. + return BitrateBuckets[^1]; + } - var container = Normalize(file?.Container); - var codec = Normalize(file?.Codec); - if (container.Contains("flac", StringComparison.Ordinal) - || codec.Contains("flac", StringComparison.Ordinal) - || container.Contains("alac", StringComparison.Ordinal) - || codec.Contains("alac", StringComparison.Ordinal) - || container.Contains("aiff", StringComparison.Ordinal) - || codec.Contains("aiff", StringComparison.Ordinal) - || container.Contains("ape", StringComparison.Ordinal) - || codec.Contains("ape", StringComparison.Ordinal) - || container.Contains("dsd", StringComparison.Ordinal) - || codec.Contains("dsd", StringComparison.Ordinal) - || container.Contains("wv", StringComparison.Ordinal) - || codec.Contains("wv", StringComparison.Ordinal) - || container.Contains("wav", StringComparison.Ordinal) - || codec.Contains("wav", StringComparison.Ordinal)) + private static bool IsCodecPrefixedLabel(string normalizedLabel) + { + if (normalizedLabel.Length == 0) { - return "lossless"; + return false; } - return Normalize(file?.Format); + return normalizedLabel.StartsWith("mp3 ", StringComparison.Ordinal) + || normalizedLabel.StartsWith("aac ", StringComparison.Ordinal) + || normalizedLabel == "mp3 vbr" + || normalizedLabel == "flac"; } private static string Normalize(string? value) diff --git a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs index e0c2ed29f..ce5a825ce 100644 --- a/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs +++ b/tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs @@ -23,6 +23,19 @@ namespace Listenarr.Tests.Features.Api.Services { public class AudiobookStatusEvaluatorTests { + [Fact] + public void ComputeStatus_ReturnsDownloading_WhenIsDownloading() + { + var status = AudiobookStatusEvaluator.ComputeStatus( + isDownloading: true, + hasAnyFile: false, + audiobookQuality: null, + qualityProfile: null, + files: null); + + Assert.Equal(AudiobookStatusEvaluator.Downloading, status); + } + [Fact] public void ComputeStatus_ReturnsNoFile_WhenHasNoFiles() { @@ -36,10 +49,23 @@ public void ComputeStatus_ReturnsNoFile_WhenHasNoFiles() Assert.Equal(AudiobookStatusEvaluator.NoFile, status); } + [Fact] + public void ComputeStatus_ReturnsQualityMatch_WhenProfileIsNull() + { + var files = new List + { + new() { Format = "mp3", Bitrate = 128000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, null, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + [Fact] public void ComputeStatus_ReturnsQualityMismatch_WhenNoFilesMatchPreferredFormats() { - var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + var profile = CreateDefaultProfile(cutoffQuality: "MP3 256kbps", preferredFormats: new List { "m4b" }); var files = new List { new() { Format = "mp3", Bitrate = 320000 } @@ -53,10 +79,10 @@ public void ComputeStatus_ReturnsQualityMismatch_WhenNoFilesMatchPreferredFormat [Fact] public void ComputeStatus_ReturnsQualityMatch_WhenDerivedQualityMeetsCutoffBoundary() { - var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + var profile = CreateDefaultProfile(cutoffQuality: "MP3 256kbps", preferredFormats: new List { "mp3" }); var files = new List { - new() { Format = "m4b", Bitrate = 256000 } + new() { Format = "mp3", Codec = "mp3", Bitrate = 256000 } }; var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); @@ -67,10 +93,10 @@ public void ComputeStatus_ReturnsQualityMatch_WhenDerivedQualityMeetsCutoffBound [Fact] public void ComputeStatus_ReturnsQualityMismatch_WhenDerivedQualityIsBelowCutoff() { - var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + var profile = CreateDefaultProfile(cutoffQuality: "MP3 256kbps", preferredFormats: new List { "mp3" }); var files = new List { - new() { Format = "m4b", Bitrate = 192000 } + new() { Format = "mp3", Codec = "mp3", Bitrate = 128000 } }; var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); @@ -81,26 +107,144 @@ public void ComputeStatus_ReturnsQualityMismatch_WhenDerivedQualityIsBelowCutoff [Fact] public void ComputeStatus_ReturnsQualityMatch_WhenOnlyLegacyFileSummaryExists() { - var profile = CreateProfile(cutoffQuality: "256kbps", preferredFormats: new List { "m4b" }); + var profile = CreateDefaultProfile(cutoffQuality: "MP3 256kbps", preferredFormats: new List { "m4b" }); var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files: null); Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); } + // Regression: an M4B file at the cutoff bitrate must satisfy the AAC rung — the + // bug behind https://github.com/Listenarrs/Listenarr/issues/549 was that M4B + // files derived to the raw label "m4b" (or "64kbps"), which never matched any + // QualityProfile.Qualities key like "AAC 64kbps" and triggered an endless + // ~6h re-grab loop for monitored audiobooks with matching indexer releases. [Fact] - public void ComputeStatus_TreatsWavPackAsLossless() + public void ComputeStatus_ReturnsQualityMatch_ForM4BFileMeetingAacCutoff() { - var profile = new QualityProfile + var profile = CreateDefaultProfile(cutoffQuality: "AAC 64kbps", preferredFormats: new List { "m4b" }); + var files = new List { - Name = "Lossless Profile", - CutoffQuality = "lossless", - PreferredFormats = new List { "wv" }, - Qualities = new List - { - new() { Quality = "lossless", Priority = 0 } - } + new() { Format = "m4b", Container = "m4b", Codec = "aac", Bitrate = 64000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_TreatsM4AContainerAsAac() + { + var profile = CreateDefaultProfile(cutoffQuality: "AAC 128kbps", preferredFormats: new List { "m4a" }); + var files = new List + { + new() { Format = "m4a", Container = "m4a", Codec = "mp4a", Bitrate = 128000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_BucketsBitrateDownToNearestRung() + { + // 200kbps lives between the 192 and 256 rungs and must bucket DOWN to 192; + // otherwise a sub-cutoff file would be treated as if it met the cutoff. + var profile = CreateDefaultProfile(cutoffQuality: "MP3 256kbps", preferredFormats: new List { "mp3" }); + var files = new List + { + new() { Format = "mp3", Codec = "mp3", Bitrate = 200000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMismatch, status); + } + + [Fact] + public void ComputeStatus_AcceptsBitrateBelowLowestRungAsLowestRung() + { + // 32kbps is below the lowest seeded rung (64). It must still resolve to a + // known rung so that we don't trigger a perpetual re-grab loop for unusually + // low-bitrate sources whose cutoff happens to be the lowest rung. + var profile = CreateDefaultProfile(cutoffQuality: "MP3 64kbps", preferredFormats: new List { "mp3" }); + var files = new List + { + new() { Format = "mp3", Codec = "mp3", Bitrate = 32000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_UsesAudiobookQualityHintWhenCodecPrefixed() + { + var profile = CreateDefaultProfile(cutoffQuality: "MP3 256kbps", preferredFormats: new List { "mp3" }); + var files = new List + { + // File metadata says 64kbps, but the legacy hint says "MP3 320kbps". + // The codec-prefixed hint must be trusted as-is, overriding the bitrate. + new() { Format = "mp3", Codec = "mp3", Bitrate = 64000 } }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, "MP3 320kbps", profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_IgnoresLegacyM4BHintAndRederivesFromFile() + { + // The legacy LibraryController hint returns "M4B" for m4b/m4a containers. + // "M4B" is not a production QP key, so it must NOT short-circuit the + // file-based derivation — otherwise the cutoff is never met (issue #549). + var profile = CreateDefaultProfile(cutoffQuality: "AAC 64kbps", preferredFormats: new List { "m4b" }); + var files = new List + { + new() { Format = "m4b", Container = "m4b", Codec = "aac", Bitrate = 64000 } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, "M4B", profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_TreatsFlacAsLossless() + { + var profile = CreateLosslessProfile(); + var files = new List + { + new() { Format = "flac", Container = "flac", Codec = "flac" } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_TreatsAlacAsLossless() + { + var profile = CreateLosslessProfile(); + var files = new List + { + new() { Format = "m4a", Container = "m4a", Codec = "alac" } + }; + + var status = AudiobookStatusEvaluator.ComputeStatus(false, true, null, profile, files); + + Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); + } + + [Fact] + public void ComputeStatus_TreatsWavPackAsLossless() + { + var profile = CreateLosslessProfile(); var files = new List { new() { Format = "wv", Container = "wv" } @@ -111,18 +255,43 @@ public void ComputeStatus_TreatsWavPackAsLossless() Assert.Equal(AudiobookStatusEvaluator.QualityMatch, status); } - private static QualityProfile CreateProfile(string cutoffQuality, List preferredFormats) + // Mirrors the production seed in QualityProfileService.EnsureProfileHasRequiredQualitiesAsync. + // Keeping this in sync is what guarantees DeriveQualityLabel output round-trips + // with the default QP. + private static QualityProfile CreateDefaultProfile(string cutoffQuality, List preferredFormats) { return new QualityProfile { - Name = "Test Profile", + Name = "Default", CutoffQuality = cutoffQuality, PreferredFormats = preferredFormats, Qualities = new List { - new() { Quality = "320kbps", Priority = 0 }, - new() { Quality = "256kbps", Priority = 1 }, - new() { Quality = "192kbps", Priority = 2 } + new() { Quality = "AAC 320kbps", Priority = 0, Allowed = true }, + new() { Quality = "AAC 256kbps", Priority = 1, Allowed = true }, + new() { Quality = "AAC 192kbps", Priority = 2, Allowed = true }, + new() { Quality = "AAC 128kbps", Priority = 3, Allowed = true }, + new() { Quality = "AAC 64kbps", Priority = 4, Allowed = true }, + new() { Quality = "MP3 320kbps", Priority = 5, Allowed = true }, + new() { Quality = "MP3 256kbps", Priority = 6, Allowed = true }, + new() { Quality = "MP3 VBR", Priority = 7, Allowed = true }, + new() { Quality = "MP3 192kbps", Priority = 8, Allowed = true }, + new() { Quality = "MP3 128kbps", Priority = 9, Allowed = true }, + new() { Quality = "MP3 64kbps", Priority = 10, Allowed = true } + } + }; + } + + private static QualityProfile CreateLosslessProfile() + { + return new QualityProfile + { + Name = "Lossless Profile", + CutoffQuality = "lossless", + PreferredFormats = new List { "flac", "m4a", "wv" }, + Qualities = new List + { + new() { Quality = "lossless", Priority = 0, Allowed = true } } }; } From 83fc8e63a0e26b020cc630d61e30f454b3fc8562 Mon Sep 17 00:00:00 2001 From: Tim Kaiser Date: Tue, 12 May 2026 22:11:01 +0200 Subject: [PATCH 2/2] Address review: prefer string.IsNullOrEmpty over Length checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @T4g1's review on #581, use string.IsNullOrEmpty() for the two new empty-string guards in DeriveQualityLabel / IsCodecPrefixedLabel. No behavior change — Normalize() never returns null, so this is purely stylistic. --- listenarr.api/Services/AudiobookStatusEvaluator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/listenarr.api/Services/AudiobookStatusEvaluator.cs b/listenarr.api/Services/AudiobookStatusEvaluator.cs index 47c162079..d839a6ed8 100644 --- a/listenarr.api/Services/AudiobookStatusEvaluator.cs +++ b/listenarr.api/Services/AudiobookStatusEvaluator.cs @@ -156,7 +156,7 @@ private static string DeriveQualityLabel(AudiobookFileStatusInfo? file, string? return "lossless"; } - if (codec.Length > 0 && file?.Bitrate is int bitrate) + if (!string.IsNullOrEmpty(codec) && file?.Bitrate is int bitrate) { var bitrateKbps = bitrate >= 1000 ? bitrate / 1000d : bitrate; var bucket = BucketBitrate(bitrateKbps); @@ -277,7 +277,7 @@ private static int BucketBitrate(double bitrateKbps) private static bool IsCodecPrefixedLabel(string normalizedLabel) { - if (normalizedLabel.Length == 0) + if (string.IsNullOrEmpty(normalizedLabel)) { return false; }