Fix M4B re-grab loop: emit codec-prefixed quality labels#581
Open
timk75 wants to merge 2 commits into
Open
Conversation
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 "<codec> <bucket>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#549
T4g1
reviewed
May 12, 2026
Contributor
T4g1
left a comment
There was a problem hiding this comment.
I'll leave it to someone with more experience in codecs/bitrate to approve or not, but this is much needed and lgtm!
| } | ||
|
|
||
| if (bitrateKbps >= 320) | ||
| if (codec.Length > 0 && file?.Bitrate is int bitrate) |
Contributor
There was a problem hiding this comment.
Very minor: Why not use string.IsNullOrEmpty() instead of the classic Length > 0 ?
Author
There was a problem hiding this comment.
Good call — switched to string.IsNullOrEmpty() in 83fc8e6.
| || codec.Contains("wav", StringComparison.Ordinal)) | ||
| private static bool IsCodecPrefixedLabel(string normalizedLabel) | ||
| { | ||
| if (normalizedLabel.Length == 0) |
Per @T4g1's review on Listenarrs#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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #549.
AudiobookStatusEvaluator.DeriveQualityLabelwas emitting quality labels ("M4B","lossless","320kbps","64kbps") that never matched the codec-prefixed keys seeded byQualityProfileService.EnsureProfileHasRequiredQualitiesAsync("AAC 320kbps"..."AAC 64kbps","MP3 320kbps"..."MP3 64kbps").ComputeStatuslooks up the derived label in a case-insensitive dictionary built fromQualityProfile.Qualities. With no match, priority defaults toint.MaxValue, which is never≤ cutoffPriority, so the cutoff is reported as not met even when the file on disk is at or above the profile's cutoff. Combined with the ~6 h RSS cycle, any monitored audiobook with an.m4bon disk and a matching indexer release ends up re-downloaded every cycle — leaving a stack of…(1) / (2) / (3) …staging folders and duplicate metadata files in the library dir.Today's repro on a current canary install (
Listenarr 0.3.1):Despite the file on disk being
m4b/aac/64 kbps, matchingAAC 64kbpsin the default QP.Patch
DeriveQualityLabelnow:Codec/Container/Format, includingm4b/m4a/mp4→aac(audiobooks effectively always use AAC inside MP4 containers);320 / 256 / 192 / 128 / 64);"<codec> <bucket>kbps"(for example"aac 64kbps"), which matchesQualityProfile.Qualitieskeys via the existing case-insensitive dictionary lookup;"lossless"forflac/alac/wav/aiff/ape/dsd/wavpackso user-defined lossless rungs keep matching;audiobookQualityhint when the hint is already in codec-prefixed shape ("MP3 320kbps","AAC 64kbps","FLAC","MP3 VBR"). Legacy values such as"M4B"produced byLibraryController.DeriveAudiobookQualityAsyncare now ignored so we re-derive from the actual file fields.No public API change.
Test plan
tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cswas updated to use a production-shapedQualityProfile(the same 11-rung ladder seeded byQualityProfileService) and now also covers:ComputeStatus_ReturnsDownloading_WhenIsDownloadingComputeStatus_ReturnsQualityMatch_WhenProfileIsNullComputeStatus_ReturnsQualityMatch_ForM4BFileMeetingAacCutoff— the issue AudiobookStatusEvaluator.DeriveQualityLabel produces labels that never match QualityProfile.Qualities keys → all books report quality-mismatch #549 regressionComputeStatus_TreatsM4AContainerAsAacComputeStatus_BucketsBitrateDownToNearestRung(200 kbps must bucket to 192, not 256)ComputeStatus_AcceptsBitrateBelowLowestRungAsLowestRung(< 64 kbps still resolves to a known rung so we don't re-introduce a perpetual re-grab loop)ComputeStatus_UsesAudiobookQualityHintWhenCodecPrefixedComputeStatus_IgnoresLegacyM4BHintAndRederivesFromFileComputeStatus_TreatsFlacAsLosslessComputeStatus_TreatsAlacAsLosslessComputeStatus_TreatsWavPackAsLosslessretained, now backed by a profile shape consistent with the rest of the tests.Local results (.NET SDK 10.0.203,
Releaseconfiguration):Things I deliberately did NOT change
LibraryController.DeriveAudiobookQualityAsyncstill returns the legacy"M4B"string for the per-audiobookQualitycolumn. That value is now harmless because the evaluator ignores hints that aren't in codec-prefixed shape, and changing the controller as well would require a DB-side rewrite of existing values. Happy to follow up if maintainers prefer it cleaned up in the same PR.