Fix M4B re-grab loop: emit codec-prefixed quality labels#581
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
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) |
There was a problem hiding this comment.
Very minor: Why not use string.IsNullOrEmpty() instead of the classic Length > 0 ?
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.
therobbiedavis
left a comment
There was a problem hiding this comment.
@timk75 There's some more long-term questions we need to resolve here. Also this branch needs to be rebased, files moved to their new appropriate directories, and the tests should follow the new framework. This is rather new, in the last month, but you can find this info documented in CONTRIBUTING.md
|
|
||
| // 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 }; |
There was a problem hiding this comment.
So given this, shouldn't we also update the FE to remove the ability for users to create 96 kbps AAC/OPUS qualities? I think it would be confusing for it to get bucketed down to 64 here.
| if (IsLosslessCodec(codec)) | ||
| { | ||
| var bitrateKbps = bitrate >= 1000 ? bitrate / 1000d : bitrate; | ||
| return "lossless"; |
There was a problem hiding this comment.
The normalization here is fine, but I think the returned label may still not match real FLAC profiles. A file with codec FLAC gets normalized to flac, passes IsLosslessCodec, and then returns lossless, but the profile model documents quality IDs like FLAC in QualityProfile.cs, and the quality profile UI parses/adds FLAC as FLAC in QualityProfileFormModal.vue. So the lookup can still miss unless the profile has a custom lossless quality.
There are two options: Revert back to the quality label or add a compatibility path and keep lossless:
For the former we'd need to use the profile quality IDs instead of using the a generic category to match. For FLAC that means returning flac/FLAC instead of lossless, or better, deriving against the current profile’s Qualities so custom lossless labels and future codec names don’t need special-case branches.
If we want to keep lossless as a generic label, I think the comparison needs to treat it as equivalent to the actual lossless profile keys. For example, when derived quality is lossless, check whether the profile contains FLAC, ALAC, WAV, etc., or map the detected codec to a profile key first and fall back to lossless only for legacy/custom profiles.
What do you think?
| return "opus"; | ||
| } | ||
|
|
||
| if (codec.Contains("vorbis", StringComparison.Ordinal) |
There was a problem hiding this comment.
Small naming mismatch here: this returns vorbis, which I think is technically correct, but the UI/profile shape looks like OGG Vorbis. That means OGG Vorbis profiles still won’t match correctly.
| || codec.Contains("wv", StringComparison.Ordinal) | ||
| || container.Contains("wav", StringComparison.Ordinal) | ||
| || codec.Contains("wav", StringComparison.Ordinal)) | ||
| private static bool IsCodecPrefixedLabel(string normalizedLabel) |
There was a problem hiding this comment.
Should we base this on the actual profile qualities instead of a fixed list of trusted labels? That would make custom qualities and future codecs work without needing to keep extending this method.
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeStatus_TreatsFlacAsLossless() |
There was a problem hiding this comment.
Depending on which we we go as far as "lossless" or not, could we add a test for the normal FLAC shape too?
|
Thanks for the review — you're right on all five points, and reading your comments together with the canary code ( Plan: rebase onto canary, move to the new file/test conventions, and replace Two semantic decisions I'd like you to make before I push, so I'm not guessing your intent:
On the side points: yes to Happy to force-push #581 or open a draft PR if you'd rather review the matcher fresh — let me know which you prefer. |
Yes, we should use round-down, using the highest rung that the profile actually meetsso we can keep the cutoff semantics as consistent as “file is at least this quality”. As for "codec not in profile", yes we should make that defined as For the legacy m4b return, we should clean that up, but I also think for longer term maintainability What that means for this PR is: We also need to migrate user's currently existing PreferredFormats column/data, and have the matcher treat the new field strictly as container/extension tokens. |
|
Round-down + On the larger refactor: the That's substantially separate from the evaluator fix in scope and risk — especially the migration. I'd rather land them as two PRs so the migration gets its own review; happy to file a follow-up issue with a concrete plan. 108 commits behind canary + new test framework means the rebase is a rewrite anyway. I'd rather open a fresh draft for the matcher and close #581 — OK? |
Sorry I somehow missed this, yes please go ahead. |
|
Superseded by #648, which implements the profile-driven matching we discussed (round-down via a shared |
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.