diff --git a/listenarr.api/Services/AudiobookStatusEvaluator.cs b/listenarr.api/Services/AudiobookStatusEvaluator.cs index c222ebf55..d839a6ed8 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 (!string.IsNullOrEmpty(codec) && 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 (string.IsNullOrEmpty(normalizedLabel)) { - 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 } } }; }