Skip to content

Fix M4B re-grab loop: emit codec-prefixed quality labels#581

Open
timk75 wants to merge 2 commits into
Listenarrs:canaryfrom
timk75:fix/quality-label-codec-prefix
Open

Fix M4B re-grab loop: emit codec-prefixed quality labels#581
timk75 wants to merge 2 commits into
Listenarrs:canaryfrom
timk75:fix/quality-label-codec-prefix

Conversation

@timk75
Copy link
Copy Markdown

@timk75 timk75 commented May 12, 2026

Summary

Closes #549.

AudiobookStatusEvaluator.DeriveQualityLabel was emitting quality labels ("M4B", "lossless", "320kbps", "64kbps") that never matched the codec-prefixed keys seeded by QualityProfileService.EnsureProfileHasRequiredQualitiesAsync ("AAC 320kbps" ... "AAC 64kbps", "MP3 320kbps" ... "MP3 64kbps").

ComputeStatus looks up the derived label in a case-insensitive dictionary built from QualityProfile.Qualities. With no match, priority defaults to int.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 .m4b on 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):

[INF] Cutoff not met for "Artificial Condition" (id=179):
      cutoff met=False, best existing quality=M4B

Despite the file on disk being m4b / aac / 64 kbps, matching AAC 64kbps in the default QP.

Patch

DeriveQualityLabel now:

  • detects the codec from Codec / Container / Format, including m4b / m4a / mp4aac (audiobooks effectively always use AAC inside MP4 containers);
  • buckets the file bitrate to the nearest seeded rung (320 / 256 / 192 / 128 / 64);
  • emits "<codec> <bucket>kbps" (for example "aac 64kbps"), which matches QualityProfile.Qualities keys via the existing case-insensitive dictionary lookup;
  • still emits "lossless" for flac / alac / wav / aiff / ape / dsd / wavpack so user-defined lossless rungs keep matching;
  • only short-circuits to the audiobookQuality hint when the hint is already in codec-prefixed shape ("MP3 320kbps", "AAC 64kbps", "FLAC", "MP3 VBR"). Legacy values such as "M4B" produced by LibraryController.DeriveAudiobookQualityAsync are now ignored so we re-derive from the actual file fields.

No public API change.

Test plan

tests/Features/Api/Services/AudiobookStatusEvaluatorTests.cs was updated to use a production-shaped QualityProfile (the same 11-rung ladder seeded by QualityProfileService) and now also covers:

  • ComputeStatus_ReturnsDownloading_WhenIsDownloading
  • ComputeStatus_ReturnsQualityMatch_WhenProfileIsNull
  • ComputeStatus_ReturnsQualityMatch_ForM4BFileMeetingAacCutoff — the issue AudiobookStatusEvaluator.DeriveQualityLabel produces labels that never match QualityProfile.Qualities keys → all books report quality-mismatch #549 regression
  • ComputeStatus_TreatsM4AContainerAsAac
  • ComputeStatus_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_UsesAudiobookQualityHintWhenCodecPrefixed
  • ComputeStatus_IgnoresLegacyM4BHintAndRederivesFromFile
  • ComputeStatus_TreatsFlacAsLossless
  • ComputeStatus_TreatsAlacAsLossless
  • existing ComputeStatus_TreatsWavPackAsLossless retained, now backed by a profile shape consistent with the rest of the tests.

Local results (.NET SDK 10.0.203, Release configuration):

Passed!  - Failed:     0, Passed:    16, Skipped:     0, Total:    16   (evaluator tests)
Passed!  - Failed:     0, Passed:   623, Skipped:     0, Total:   623   (full suite)
dotnet format listenarr.slnx --verify-no-changes  → exit 0

Things I deliberately did NOT change

  • LibraryController.DeriveAudiobookQualityAsync still returns the legacy "M4B" string for the per-audiobook Quality column. 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.
  • No 96 kbps rung was added (none exists in the seed table) and VBR is not auto-detected (would require multi-sample probing) — files below 64 kbps map to the lowest rung so they still satisfy the lowest possible cutoff.

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
Copy link
Copy Markdown
Contributor

@T4g1 T4g1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor: Why not use string.IsNullOrEmpty() instead of the classic Length > 0 ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — switched to string.IsNullOrEmpty() in 83fc8e6.

|| codec.Contains("wav", StringComparison.Ordinal))
private static bool IsCodecPrefixedLabel(string normalizedLabel)
{
if (normalizedLabel.Length == 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same remark here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same — fixed in 83fc8e6.

@T4g1 T4g1 added bug Something isn't working patch patch version bump - backward compatible bug fixes labels May 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working patch patch version bump - backward compatible bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AudiobookStatusEvaluator.DeriveQualityLabel produces labels that never match QualityProfile.Qualities keys → all books report quality-mismatch

2 participants