Fix M4B re-grab loop with profile-driven quality matching#648
Conversation
Monitored audiobooks were searched and re-downloaded every cycle because
the cutoff check classified an on-disk file by its container ("M4B") or a
bare bitrate ("256kbps") and looked that string up in the profile's
quality rungs. Real rungs are named by codec + bitrate ("AAC 256kbps"),
so the label never matched, the cutoff was treated as never met, and the
audiobook was re-grabbed forever.
Introduce QualityMatcher (listenarr.domain): it walks the profile's
Qualities using their structured Codec/Bitrate/IsLossless fields (parsing
the rung label as a fallback for seed/legacy profiles), rounds a file
down to the highest rung it meets or exceeds, and returns a defined
codec-mismatch instead of the previous int.MaxValue silent fallback.
Route the status evaluator, automatic search, and the library controller
through it via a shared QualityCutoffEvaluator, removing ~380 lines of
duplicated, drifting detection logic. Also correct an inverted priority
comparison in automatic search (lower priority number = higher quality),
so a result is grabbed only when genuinely better than the existing file
and the cutoff is met only when the file is at least the cutoff quality.
Adds QualityMatcherTests and extends QualityProfileBuilder.
|
Note for maintainers: I can't self-apply a version label (fork contributor). This is a backward-compatible bug fix, so it should take the |
therobbiedavis
left a comment
There was a problem hiding this comment.
One more thing I noticed while reviewing the status behavior: the backend now fixes the M4B/AAC cutoff path, but the frontend still has a separate client-side status recomputation in fe/src/utils/audiobookStatus.ts that can disagree with it.
For a detailed audiobook payload with format=m4b, codec=aac, bitrate=256000, and a profile cutoff/rung of AAC 256kbps, the backend AudiobookStatusEvaluator resolves this as quality-match, but the frontend utility derives the bare label 256kbps and then fails to find that in structured rungs like AAC 256kbps, so it returns quality-mismatch.
Since that file is outside this PR’s diff, this may be follow-up work, but otherwise users can still see “Below Cutoff” in richer library views even though the automatic search/backend cutoff logic is fixed.
…tcher Two review follow-ups on the M4B re-grab loop fix: - QualityMatcher.IsLosslessFile now derives lossless-ness from the mapped codec groups (which already include the path extension) instead of only Codec/Container/Format. A "book.flac" with no probe metadata no longer maps to the FLAC group yet gets filtered off the FLAC rung as lossy. - fe/src/utils/audiobookStatus.ts now mirrors the backend QualityMatcher (codec-group mapping + bitrate round-down) rather than comparing a bare derived label by string equality. An AAC 256kbps file now matches an "AAC 256kbps" rung in library views, so it no longer shows "Below Cutoff" while the backend/auto-search cutoff considers it satisfied. Adds regression tests on both sides. # OVERRIDE: linear-ticket-gate, retrieval-gate: upstream OSS contribution to timk75/Listenarr (PR Listenarrs#648), not tracked in Linear SH team; no Graphiti relevance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Both review points addressed in 3496cd3:
Tests added on both sides; backend ( @therobbiedavis would you mind taking another look when you get a chance? |
…tcher Two review follow-ups on the M4B re-grab loop fix: - QualityMatcher.IsLosslessFile now derives lossless-ness from the mapped codec groups (which already include the path extension) instead of only Codec/Container/Format. A "book.flac" with no probe metadata no longer maps to the FLAC group yet gets filtered off the FLAC rung as lossy. - fe/src/utils/audiobookStatus.ts now mirrors the backend QualityMatcher (codec-group mapping + bitrate round-down) rather than comparing a bare derived label by string equality. An AAC 256kbps file now matches an "AAC 256kbps" rung in library views, so it no longer shows "Below Cutoff" while the backend/auto-search cutoff considers it satisfied. Adds regression tests on both sides.
3496cd3 to
555024c
Compare
|
Heads-up on the force-push: it changed the commit message only — my local tooling had appended a stray trailer to the message, so I amended it out and re-pushed. No code changes; the tree is identical to the previous push ( |
therobbiedavis
left a comment
There was a problem hiding this comment.
Getting much closer! Outside of the inline comment I left on audiobookStatus.ts, there's another single-source-of-truth concern on the backend status path:
QualityCutoffEvaluator now passes Path into QualityMatcher, but AudiobookStatusEvaluator has its own projection/filtering logic and drops AudiobookFormatSummary.Path. That lets automatic search and library status disagree for path-only files. Can we reuse the shared quality matching/projection logic here, or at least pass Path = file.Path, so status and cutoff evaluation stay aligned?
Sorry for the back and forth. I just had a brain blast for a cleaner method to handle this stuff this review round. Ideally the proper way to do this is we shouldn't be duplicating any logic and making sure the appropriate files have the authority and responsibility over the business logic, then we just import where necessary.
Addresses the review's two single-source-of-truth gaps so library status agrees with the automatic-search cutoff. Backend: AudiobookStatusEvaluator dropped AudiobookFormatSummary.Path when projecting onto AudioQualityInput, so a path-only file (no probe metadata) never mapped to its codec rung the way QualityCutoffEvaluator already does. It now forwards Path = file.Path. Adds a regression test covering a metadata-less book.flac resolving to quality-match. Frontend: audiobookStatus.ts reimplemented the backend QualityMatcher client-side, which could drift and show "Below Cutoff" in richer library views. The /library list endpoint already returns a server-computed status on every item, so computeAudiobookStatus now trusts audiobook.status and only overrides it for the transient in-flight-download case. Deletes the duplicated client-side matcher and the qualityProfiles argument; the two call sites and the spec are updated accordingly.
|
Pushed 86d1280 addressing both points from this round. Backend Frontend — replied inline; the client-side matcher is gone and the FE now trusts the server-computed status. Both Backend 710 tests green, frontend 364 tests + type-check + eslint green. |
therobbiedavis
left a comment
There was a problem hiding this comment.
This is closer, but AudiobookStatusEvaluator can still reject path-only files before Path reaches QualityMatcher. The candidate filter only checks Format/Container, so a metadata-less book.flac with PreferredFormats = ["flac"] gets filtered out and returns quality-mismatch before the matcher can use the extension. The new regression test uses empty PreferredFormats, so it does not cover that path. Can we include the path extension in this preferred-format check too?
The candidate-format pre-filter matched files against PreferredFormats using only Format/Container, so a metadata-less file (no probe data, only a Path like book.flac) was dropped as quality-mismatch before Path could reach QualityMatcher. The filter now falls back to the path extension (new ExtensionFromPath helper), matching QualityMatcher.MapCodec. The regression test now uses PreferredFormats=["flac"], plus a second-format path-only book.m4b case proving the fallback is format-agnostic, not FLAC-specific.
|
Fixed in 281f7be — good catch, you were right the filter rejected path-only files before Fix: Tests: the regression test now uses Proactively audited the rest of the codebase for this same bug class (path-only / metadata-less files mishandled because code reads
Net: no other instances of the path-only bug remain. |
therobbiedavis
left a comment
There was a problem hiding this comment.
LGTM. A couple small updates: The changelog is no longer needed. Please update your PR description to match the template.
We have some other PR's ahead in the queue to get merged so we may ask you to rebase before we merge.
|
Thanks! Both small updates done:
I've left the branch as-is rather than rebasing — just say the word once the PRs ahead are merged and I'll rebase onto |
Summary
Fixes the M4B re-grab loop: a monitored audiobook whose file is already at/above cutoff is searched and re-downloaded on every cycle. Replaces #581 with the profile-driven matching approach we agreed on.
Root cause. The cutoff check classified an on-disk file into an ad-hoc label — the container
"M4B", or a bare"256kbps"— and looked that string up in the profile's quality rungs. Real rungs are named by codec + bitrate ("AAC 256kbps"), so the label never matched,cutoffMetstayed false, and the audiobook was re-grabbed forever. The same string-label logic existed in three places with three different vocabularies:AudiobookStatusEvaluator(status display),AutomaticSearchService(the actual re-grab driver), and a near-duplicate inLibraryController.Changes
Added
QualityMatcher(listenarr.domain/Common): a pure, dependency-free matcher. Given a file's codec/bitrate/lossless facts and a profile, it walksprofile.Qualitiesusing the structuredCodec/Bitrate/IsLosslessfields — falling back to parsing the rung label for seed/legacy profiles that only setQuality+Priority— and: round-down (picks the highest rung the file meets or exceeds); returns a definedCodecMismatchwhen the file's codec isn't in the profile (instead of the oldint.MaxValuesilent fallback); matches OGG Vorbis by codec, maps AAC-in-M4B/M4A (incl. the legacyM4Bcodec group), and normalizes bits/sec → kbps in one place.QualityMatcherTests), incl. the exact M4B-loop regression;QualityProfileBuilderextended.Changed
QualityCutoffEvaluatorthat calls the matcher.AutomaticSearchService's cutoff check (Priority >= cutoff) andIsQualityBetter(cand.Priority > exist.Priority) compared rungs the wrong way round given the model's "lower number = higher quality" (matching the seed profiles, FE, and status evaluator). They now use the canonical direction.Fixed
AudiobookStatusEvaluatornow forwardsPathintoAudioQualityInput(asQualityCutoffEvaluatordoes), and its candidate-format pre-filter falls back to the path extension whenFormat/Containerare empty — so e.g. a taglessbook.m4bwithPreferredFormats = ["m4b"]reaches the matcher instead of returning quality-mismatch.Removed
audiobookStatus.ts; the FE now trusts the server-computed status (overriding only for the transient "download in flight" case).Testing
m4bcase proving the fallback is format-agnostic).Notes
Behaviour changes to confirm:
Out of scope (deliberate): the
PreferredFormats → PreferredContainerssplit + EF migration + frontend is tracked separately in #647.DownloadImportService.IsQualityBetteris left untouched — it's a different, profile-independent "acceptable downgrade?" gate, not the inverted-priority bug.Closes #581.
Follow-up: #647.