Skip to content

Quality profiles: make the quality label and the profile settings do what they say - #1023

Open
m4bard wants to merge 8 commits into
Listenarrs:canaryfrom
m4bard:bundle/quality-profiles
Open

m4bard wants to merge 8 commits into
Listenarrs:canaryfrom
m4bard:bundle/quality-profiles

Conversation

@m4bard

@m4bard m4bard commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

This bundle is about quality profiles and quality labels doing what their settings say: a file's real bitrate reaches the rung it was encoded for, the profile modal shows Maximum Age and loads a saved profile's qualities, a release's quality label only reads a bitrate the text actually states, an unchecked rung refuses a release, and the cutoff is validated on save with an UpgradeAllowed flag beside it. It collects five fixes: two from my open PRs #934 and #920, and three that have not been opened as PRs before. Each fix is one commit, so any of them can be dropped or cherry-picked on its own. Two integration commits at the end are explained under merge order. The gate in commit 4 also changes which releases some existing profiles accept, and the biggest visible change is that a profile saved from the UI without Prefer M4B now accepts M4B releases. It comes first under default changes.

Commit Original PR Issue Effect
2e5fa72e2 #934 #549 (one cause) A file encoded at 128kbps that reports 127241 bps now matches the 128 rung, not the one below
d420f6e73 #920 Fixes #895, Fixes #923 Maximum Age is always shown, and opening a saved profile loads its qualities
81c0c6a46 new none "x264" in a release description no longer labels the release MP3 64kbps
3c251b5e4 new none An unchecked rung refuses a release even when PreferredFormats holds its codec
f00e8c826 new none The API refuses a cutoff that is not an allowed rung, and profiles gain UpgradeAllowed
c52d15859 integration none Moves QualityMatcher's private codec and bitrate helpers into a partial file, no behaviour change
c5e23e9e1 integration none Keeps canary's refusal of a codec the ladder has no rung for, unless a preferred format names it

All file:line references below are at a630572e9 unless I say otherwise.

1. A real encoder bitrate reaches its rung (2e5fa72e2, from #934)

QualityMatcher.NormalizeKbps rounds to whole kbps (QualityMatcher.cs:390-398), and rung eligibility was withBitrate.Where(r => r.BitrateKbps <= kbps) (:138). A file encoded at a nominal 128kbps reports something like 127241 bps, which rounds to 127, so the 128 rung is excluded and the file falls to the tier below. Where that tier is under the cutoff, the book reports quality-mismatch. (Read.)

Eligibility now goes through MeetsRung, which lets a file reach a rung it falls short of by up to five percent, with a floor of one kbps. Ordinary adjacent rungs are at least a third apart, so I don't think the tolerance can carry a file across a real tier; Match_FileWellBelowATier_DoesNotGetPromotedToIt pins that a 96kbps file still matches 64 against a 128/64 profile.

Tests: Match_FileJustUnderItsNominalTier_StillMatchesThatTier, Match_ReachesARungOnlyWithinTheTolerance, Match_KeepsAWholeKbpsOfSlackOnASmallRung in QualityMatcherTests, and two ComputeStatus_ReturnsQualityMatch_... tests in AudiobookStatusEvaluatorTests so the user-visible status is covered. On the original branch, Match_FileJustUnderItsNominalTier_StillMatchesThatTier and ComputeStatus_ReturnsQualityMatch_ForARealEncoderBitrateJustUnderTheCutoff were seen failing without the change.

This is one cause of #549, not all of it. The other one I found is that AudiobookStatusEvaluator filters files by PreferredFormats before the matcher runs, so an MP3-only book against an m4b-only PreferredFormats reports a mismatch at any bitrate. I left that alone because I think redefining that status is your call. Full write-up in #934.

2. Maximum Age, and loading a profile's qualities (d420f6e73, from #920)

Maximum Age (Days) rendered behind v-if="formData.preferNewerReleases" (QualityProfileFormModal.vue:479), but SearchResultScorer rejects on profile.MaximumAge whenever it is above zero (SearchResultScorer.cs:153 and :171) and never reads PreferNewerReleases. Unticking the box hid the input while the filter stayed on. The input now always renders. (Read.)

Separately, the watch that copies a profile into the form runs with { immediate: true } (:892) and calls initializeQualitiesFromProfile (:858), which was a const arrow declared below it (:898), with parseQualityString (:950) the same shape one level down. Every mount with a profile threw ReferenceError, Vue swallowed it, and the quality list came up empty, so saving was refused until the ladder was picked again. Both are function declarations now. That is #923.

Tests: a new QualityProfileFormModal.spec.ts with four tests. On the original branch, re-hiding Maximum Age failed one, and putting either helper back to a const arrow failed all four.

Left alone: PreferNewerReleases still has no backend reader. Full write-up in #920.

3. Read a bitrate only where the text states one (81c0c6a46, new)

The description branch of the Torznab parser builds title + description and runs bare Contains checks, "320" down to Contains("64") (TorznabResponseParser.cs:350-367, the 64 check at :362), which x264 satisfies. It also assigns Quality unconditionally, overwriting what the format attr set at :142-149. The same chain is in TorznabNewznabSearchProvider.Parsing.cs:343-358 and SearchResultAttributeParser.DetectQualityFromTags (:41-49). (Read.)

Measured on a throwaway stock ghcr.io/listenarrs/listenarr:canary container (Listenarr.Api/1.3.4) with a stub Torznab indexer, reading labels back from GET /api/v1/search/{indexerId}:

Release <description> Quality on stock
The Governor x264 WEBRip, format m4b yes MP3 64kbps
same release no (control) M4B
The Governor MP3 64kbps, format mp3 yes (control) MP3 64kbps

Against a profile with M4B allowed and MP3 64kbps unchecked, the described copy scored 30 and was rejected; the undescribed copy scored 100 and was accepted.

The change: SearchResultAttributeParser.DetectBitrateQuality counts a number as a bitrate only beside a rate word (mp3 320, MP3CBR320) or a rate unit (320kbps, [64k]), or as a bracketed tier when the text also says MP3. All three call sites use it, and the description scan now fills Quality only when the attributes left it empty. MP3 320 MB still reads as 320kbps, and a test pins that.

On an image built from the original branch, which ran the same code as commits 3 and 4 here, the same run gave M4B for the described x264 release and both controls came out unchanged. Tests: BitrateTokenQualityTests and additions to IndexersNewznabParsingTests.

4. An unchecked rung refuses (3c251b5e4, new)

The scorer builds the allowed list from the Allowed rungs and then appends every PreferredFormats entry whatever its flag (SearchResultScorer.cs:287-297), and the match is a two-way substring test (:300). With the default formats (m4b, mp3, m4a, flac, opus, QualityProfile.cs:59), any label containing one of them passes. A release with no quality label takes the branch at :264 and never reaches the veto. (Read.)

Measured on the same stock container, straight to POST /api/v1/qualityprofile/{id}/score, profile with FLAC and MP3 320kbps unchecked, M4B and AAC allowed:

Release default preferredFormats preferredFormats empty (control)
FLAC 100, accepted 90, rejected
MP3 320kbps 82, accepted 70, rejected
M4B 92, accepted 100, accepted

This commit adds a QualityGate that decides from the Allowed flags. A rung name match decides first, then the codec group, so M4B is judged by the AAC rungs. PreferredFormats stays a score adjustment. A release that declares only a format goes through the same gate. QualityMatcher now maps MP4, AAX and AAXC to the AAC group, so those labels follow the AAC rungs. That tightens a profile that refuses every AAC rung and loosens one where any AAC rung is allowed, the stock default included, because canary refused all three on any profile with a ladder. No shipped parser emits them today, as far as I can tell. The mapping also reaches cutoff matching for an .aax file (read; not measured on the import side). Both effects are listed under default changes. Blank-named rungs are ignored, since one allowed blank rung used to permit every label through Contains("").

On an image built from the original branch, whose code is the same as this commit apart from the later QualityMatcher file split, the default-preferredFormats profile rejected FLAC and MP3 320kbps with not allowed by profile and still accepted M4B; the empty-preferredFormats control rejected them on both builds.

A codec the ladder has no rung for is handled by c5e23e9e1, below. Not changed: Usenet results still skip the quality checks, and ImportQualityEvaluator.cs:14-17 still runs the same bare Contains chain on file names. Tests: QualityAllowedGateTests, QualityGateTests, additions to QualityMatcherTests.

Integration fix to the gate (c5e23e9e1)

With commit 4 alone, the gate stayed silent on a label whose codec has no rung, so the release went through. That gives the right answer for the stock default profile but the wrong one for a profile saved from the UI. Switching a codec off in the UI deletes its rungs (QualityProfileFormModal.vue:600-602), and the form saves PreferredFormats as [] or ['m4b'] (:1185). Canary's allow-list was the allowed rung names plus the PreferredFormats tokens (SearchResultScorer.cs:287-300), so on such a profile it refused FLAC and OPUS. Measured on stock canary (ghcr.io/listenarrs/listenarr:canary, digest sha256:216f95f37d0179fc8c2638f24c599ed09d18c808a3d727d8a22b4bf8a2bff9c4, Listenarr.Api/1.3.4), through POST /api/v1/qualityprofile/{id}/score, with the eleven seeded AAC and MP3 rungs all ticked:

Release (quality / format) preferredFormats [] ['m4b'] ['m4b'], every AAC rung unticked
MP3 320kbps / MP3 (control) 85, accepted 73, accepted 73, accepted
FLAC / FLAC 85, rejected 73, rejected 73, rejected
OPUS / OPUS 70, rejected 58, rejected 58, rejected
M4B / M4B 75, rejected 96, accepted 96, accepted
no quality / FLAC 100, accepted 93, accepted 93, accepted
no quality / M4B 100, accepted 100, accepted 100, accepted

With commit 4 alone, the first profile's FLAC release was accepted. I measured that with an in-process test (QualityProfileService.ScoreSearchResult, the method behind that endpoint), not on an image: FLAC must be refused by a ladder with no FLAC rung (score 100). So switching FLAC off in the UI would have stopped refusing FLAC. c5e23e9e1 keeps canary's answer there. When no rung covers a label, the gate lets it through only if a PreferredFormats token names it (the same two-way substring test canary used), and refuses it otherwise. The stock default profile keeps the domain's five tokens, so FLAC and OPUS stay permitted there. A UI-saved profile goes back to refusing them. The tests, UiSavedProfile_StillRefusesACodecItsLadderDoesNotCarry and ACodecTheLadderDoesNotCarry_IsRefusedUnlessAPreferredFormatNamesIt, were written first and seen failing without this change. I have not re-measured this commit on an image.

5. Validate the cutoff on save, and add UpgradeAllowed (f00e8c826, new)

The API does not validate CutoffQuality. Measured on a throwaway stock container, POSTing a profile with MP3 64 and 128 allowed and 320 present but not allowed: cutoffs of "MP3 128kbps", "", null, "\t", "FLAC" and "MP3 320kbps" all came back 201. The control, the same body with an empty name, came back 400, so the request does reach model validation. The UI already refuses a blank or disabled cutoff while upgrades are on (QualityProfileFormModal.vue:1197-1209), so these only get in through the API. Readarr and Sonarr refuse them with the same one-line check (Readarr src/Readarr.Api.V1/Profiles/Quality/QualityCutoffValidator.cs:18-28 at 0b79d3000).

The validation can't go in alone. Listenarr stores "upgrades off" as a blank cutoff: the save path clears it (QualityProfileFormModal.vue:1210-1213) and the modal infers the checkbox back from !!cutoffQuality (:861). With only the rule, an upgrades-off profile could not survive GET then PUT. So the commit also adds QualityProfile.UpgradeAllowed, as Readarr and Sonarr have it (src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs:17 in both), and the rule only applies while it is true.

What changes, read at the bundle head: ValidCutoffAttribute on CutoffQuality, which passes when UpgradeAllowed is false; QualityMatcher.ResolveCutoff treats upgrades-off like a blank cutoff (QualityMatcher.cs:341); migration 20260920025621_AddQualityProfileUpgradeAllowed, one non-nullable AddColumn<bool> with default true and no SQL; a startup repair, ListenarrDatabaseMigrationPreflight.RepairQualityProfileUpgradeFlags (:121), that sets the flag false on every profile whose cutoff is blank by string.IsNullOrWhiteSpace (:136); and the frontend binds the checkbox to the flag and drops the "No Cutoff (Always Upgrade)" option, which could only produce a 400 now. That last point touches #519, which asks for the opposite, and I would rather you decided it than this diff.

Measured on an image built from the original branch, which ran the same code as this commit (on canary, without the bundle's other commits), started on a copy of the stock container's database: the startup log said it turned upgrades off on 3 profiles. The blank, null and tab cutoffs came out with UpgradeAllowed 0, and the profile with a real cutoff came out 1, as the control. The same POSTs then gave 201 for the allowed cutoff and 400 with Cutoff must be an allowed quality or group for the other five. Those POSTs did not send upgradeAllowed, so it defaulted to true: a client that leaves the field out and sends a blank cutoff now gets a 400 where it used to get a 201. A blank cutoff with "upgradeAllowed": false gave 201.

Not fixed: AudiobookQualityCutoffEvaluator.cs:59-66 still treats an unset cutoff as "keep searching", so I'm not claiming this changes #965. A profile already stored with an off-ladder cutoff and upgrades on still loads, but can't be saved back unchanged until a valid cutoff is picked. Tests: QualityProfileController_CutoffValidationTests, QualityProfileCutoffValidationTests, QualityProfileUpgradeAllowedMigrationTests, AudiobookQualityCutoffEvaluator_UpgradeAllowedTests, and QualityProfileUpgradeAllowed.spec.ts on the frontend.

Merge order and dependencies

The commits are in the order above. Each one also merges cleanly onto a630572e9 on its own (git merge-tree with that commit's parent as base, exit 0 for all five), so I don't think any of them needs another to apply. The two integration commits are the exception and are described below. Commits 1, 4 and 5 all edit QualityMatcher.cs, and 2 and 5 both edit QualityProfileFormModal.vue; they merged without conflicts here. Commit 4 gates on the label commit 3 assigns, so I think 4 without 3 would apply but gate on the old, looser label (inferred). I have built and tested the five together, not each one alone.

c52d15859 exists only because of the bundle. Commits 1, 4 and 5 each keep QualityMatcher.cs under the 500-line cap that BackendArchitectureTests.ActiveProductionSourceFiles_RemainFocused enforces, but together they take it to 535 lines, and that test failed on the bundle without this commit (measured: 1 failed, the rest passed). It moves the private codec, container and bitrate helpers unchanged into QualityMatcher.Codecs.cs as a partial of the same class, the way FileUtils.*.cs is already split in listenarr.domain/Common. It depends on the commits before it and does not apply on its own; if you drop any of 1, 4 or 5, it can be dropped too as long as the file stays under 500 lines.

c5e23e9e1 is a fix to commit 4 and only makes sense with it. It is a separate commit so the review can see the change on its own, and it could be squashed into commit 4.

Default changes

Commit 4 with c5e23e9e1, the Allowed gate

I think the largest visible change is to M4B releases. A profile saved from the UI without Prefer M4B has PreferredFormats [] (QualityProfileFormModal.vue:1185, form default [] at :795). On stock canary that profile refused every release labelled M4B: 75, rejected, in the table above. No rung name or token contains m4b, and the required rungs are all AAC ... and MP3 ... (QualityProfileService.cs:75-85). With this bundle an M4B release is judged by the profile's AAC rungs, so it is accepted whenever one of them is ticked, which is the default. I measured that in-process (UiSavedProfile_StillRefusesACodecItsLadderDoesNotCarry pins it), not on an image.

Beyond that, the gate makes a rung's Allowed flag take effect where it was ignored. A rung's domain default is Allowed = true (QualityProfile.cs:132), the required rungs are re-added with Allowed = true (QualityProfileService.cs:97), and in the UI the rungs for a newly enabled codec start ticked (QualityProfileFormModal.vue:626, :638, :650). The domain default for PreferredFormats is m4b, mp3, m4a, flac, opus (QualityProfile.cs:59). The UI default is [], or ['m4b'] with Prefer M4B.

Newly refused, and who is affected:

  • An unticked rung whose name contains one of the profile's own PreferredFormats tokens. With the domain's default list, an unticked MP3 or FLAC rung refused nothing on stock (measured, section 4) and now refuses. That mostly reaches the seeded default profile and profiles created through the API without the field. I don't think a UI-saved profile is affected, because m4b names no rung (read).
  • An M4B release against a ['m4b'] profile with every AAC rung unticked. On stock it was accepted (96, in the table above), because the m4b token let it through. Now it is judged by the AAC rungs and refused (read, from the gate's codec-group rule; not measured on an image).
  • A release with no quality label that declares a format. On stock the gate never looked at these (the format-only rows above, all accepted). Now the format is gated like a label. It is refused when its codec has rungs and all of them are unticked, or when no rung covers it and no token names it. That includes FLAC or OPUS against a UI-saved profile, where a labelled FLAC release was already refused on stock. As far as I can tell, the parsers can only put M4B, FLAC, OPUS, AAC or MP3 in that field (TorznabResponseParser.cs:135-139, SearchResultAttributeParser.DetectFormatFromTags, and the same chain in TorznabNewznabSearchProvider.Parsing.cs), so I don't think the "can't place it" refusal (a format CodecGroupOfLabel returns null for, such as EPUB) is reachable through a shipped indexer today. The Internet Archive provider's other format strings (128Kbps MP3, Ogg Vorbis and so on) always come with a quality (InternetArchiveRepresentationPlanner.cs:281-295 at the bundle head), so the format-only gate never sees them. That is read, not measured.

Newly accepted:

  • M4B on a [] profile, above.
  • AAX, AAXC and MP4 labels on any profile with an allowed AAC rung, the stock default included. Canary refused all three on any profile with a ladder, because no rung name or default token contains them. TheMpeg4ContainerLabels_AreGatedOnTheAacRungs pins the new answer. M4A goes the same way on [] and ['m4b'] profiles, and a label like M4B 128kbps on [] profiles, where canary had no token to let them through. As far as I can tell no shipped parser emits any of these labels today, so I think this is unreachable for now (read).

FLAC and OPUS against a ladder without their rungs are refused or accepted exactly as on stock, depending on whether a token names them.

.aax files: QualityMatcher now maps AAX and AAXC to the AAC group (QualityMatcher.Codecs.cs, MapCodec, ParseQualityLabel, CanonicalCodec at the bundle head; canary's MapCodec at QualityMatcher.cs:366-370 has no aax). Cutoff matching goes through MapCodec (AudiobookQualityCutoffEvaluator.cs:95, AudiobookStatusEvaluator.cs:125 at the bundle head). So an existing .aax file identified only by its extension or container, with no codec recorded, can now meet an AAC cutoff, where before it was a codec mismatch and kept the book in the cutoff-unmet set to be searched again. An .aax whose probe recorded aac as the codec was already matched. Read, not measured.

Readarr and Sonarr reject a release whose quality item is not Allowed, with nothing able to override it: Readarr src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs:27-31 at 0b79d3000, Sonarr the same file :26-30 at 76c684e09. Their ladders list every quality, so there is no "no rung for this codec" case for them to decide (read).

Commit 5, the cutoff rule

This makes CutoffQuality validated where it was accepted as anything. Its domain default is null (QualityProfile.cs:44), and the form's default is '' (QualityProfileFormModal.vue:792) with the UI already refusing to save a blank or disabled cutoff while upgrades are on (:1197-1209). So the UI's behaviour doesn't change; what changes is that an API client sending a blank, off-ladder or disallowed cutoff with upgrades on now gets a 400 instead of a 201 (measured above). Readarr and Sonarr apply the same rule on every save (Readarr src/Readarr.Api.V1/Profiles/Quality/QualityProfileController.cs:25 and QualityCutoffValidator.cs:18-28 at 0b79d3000; Sonarr the same files and lines under src/Sonarr.Api.V3 at 76c684e09), and theirs also applies with upgrades off. This bundle skips it when upgrades are off, because a blank cutoff is how every existing upgrades-off profile is stored.

Commit 5, UpgradeAllowed

this bundle Readarr and Sonarr
New profile true false
Existing profile, when the column is added true, except false where the cutoff is blank true

Here, new profiles default to true (QualityProfile.cs:72 at the bundle head, and the modal's form default). Existing profiles get the column default of true from the migration, and the startup repair then sets false wherever the cutoff is blank, so each existing profile keeps the upgrade behaviour it has today.

The family, all read, not run:

  • Readarr and Sonarr declare public bool UpgradeAllowed { get; set; } with no initializer (src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs:17 in both). GetDefaultProfile does not set it (Readarr src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs:192-200 at 0b79d3000, Sonarr :259-268 at 76c684e09), and the new-profile schema comes from GetDefaultProfile (QualityProfileSchemaController.cs:20 in both). So a new profile there starts with upgrades off.
  • When Sonarr added the column, it set every existing profile to true, with the comment "default will be false for new profiles" (Sonarr src/NzbDrone.Core/Datastore/Migration/128_rename_quality_profiles_add_upgrade_allowed.cs:16-18 at 76c684e09).
  • With the flag off, both treat the first allowed quality as the cutoff and refuse upgrades: Readarr src/NzbDrone.Core/Books/Services/BookCutoffService.cs:34 and src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs:99 and :171-175; Sonarr src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs:60-64, :126 and :201-205. In this bundle, upgrades-off counts as a met cutoff (QualityMatcher.cs:341), which I think comes to the same answer for an allowed file.

I kept true for new profiles because today's upgrades checkbox starts on for a new profile (QualityProfileFormModal.vue:572), and I didn't want to quietly change what it does. If you'd rather match the family and start new profiles with upgrades off, it's a one-line change and I'm happy to make it.

Commits 1 to 3

These change no settings and make none newly take effect. Commit 2 only stops hiding Maximum Age; the scorer already enforced it whenever it was above zero (SearchResultScorer.cs:153), and its form default stays 0 (QualityProfileFormModal.vue:804). Commit 1 changes which rung a file just under a tier matches, and commit 3 changes which label a release gets, both described above.

Overlap with other open PRs

Checked with git merge-tree against every open PR whose head still merges into a630572e9. Your own #579, #611 and #841 don't merge into a630572e9 themselves at the moment, so I left them out of this check. #841 also edits ListenarrDatabaseMigrationPreflight.cs and InfrastructureStartupCompositionExtensions.cs, the two files commit 5's startup repair touches, so whichever lands second will need those resolved.

Tests

At the bundle head, against stock canary at a630572e9 on the same machine:

canary this bundle
Backend 3118 passed, 0 failed, 130 skipped, 3248 total 3275 passed, 0 failed, 130 skipped, 3405 total
vue-tsc clean clean
vitest 607 passed in 89 files 624 passed in 91 files

If you'd rather take any of these as separate PRs, I'll split them back out.

Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.

@m4bard

m4bard commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto canary (a8a77aa), new head e9364ab. Two conflicts, both resolved by keeping both sides: SearchResultAttributeParser, where the new language-code tables from #1031 and this PR's bitrate patterns are added at the same spot, and the migration-history list in SqliteMigrationSchemaTests.

The last commit re-scaffolds AddQualityProfileUpgradeAllowed with dotnet ef migrations add, because 20261021212418_AddAutomaticSearchTiming landed after this PR was opened and the old ID (20261018212549) sorted before it. The new ID is 20261029020317, the scaffold's own timestamp. Up, Down and the snapshot are unchanged, and the Designer now carries the three AutomaticSearchTiming columns. At the old ID, measured on this canary: the newest migration's target model had no UpgradeAllowed, migrating an empty database to AddAutomaticSearchTiming added the column, and rolling back to it left the column behind. With the new ID all three come out right. The startup backfill gates on the migration ID, so its constant moved with it; leaving the constant on the old ID fails 11 tests, including the backfill tests. The migration test now starts from AddAutomaticSearchTiming, which is the migration just before this one. That change is for accuracy only: the tests pass from either starting point.

A database that ran an earlier head of this PR fails at startup with duplicate column name: UpgradeAllowed (measured); renaming its history row to 20261029020317_AddQualityProfileUpgradeAllowed fixes it.

#918 also adds a migration (20261029020400, which sorts just after this one). Whichever of the two merges first, the other's conflict is the same list and constant block, resolved by keeping both in ID order. I ran both orders and they give the same tree. Whichever lands second also needs its migration re-scaffolded on top of the first, so its Designer has the other's change; I'll do that when the first one merges.

Full backend suite: 4035 passed, 0 failed, 152 skipped; canary alone 3878/0/152.

Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.

m4bard and others added 8 commits October 30, 2026 15:36
…d for

A file encoded at a nominal tier reports a bitrate slightly under
it (127241 bps for 128kbps), so rung eligibility dropped it to the
tier below. Eligibility now goes through MeetsRung, which lets a
file reach a rung it falls short of by up to five percent (floor
of one kbps). One of the causes behind Listenarrs#549.

Originally Listenarrs#934.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…qualities

Maximum Age rendered only while Prefer newer releases was ticked,
though the scorer applies it whenever it is above zero. And the
profile watch runs immediately during setup and called helpers
declared below it as const arrows, so every mount with a profile
threw and showed an empty quality list. Both helpers are function
declarations now.

Fixes Listenarrs#895
Fixes Listenarrs#923

Originally Listenarrs#920.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tes one

The Torznab parsers labelled quality with bare substring checks
(Contains("320") down to Contains("64")), so "x264" became MP3 64kbps,
and the description scan overwrote the label the format attribute had
already set. SearchResultAttributeParser.DetectBitrateQuality now
reads a number as a bitrate only beside a rate word or unit, all
three call sites use it, and the description scan fills Quality
only when the attributes left it empty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SearchResultScorer appended every PreferredFormats entry to the allowed
list whatever its Allowed flag, matching by two-way substring, so with
the default formats an unchecked MP3 or FLAC rung refused nothing.
QualityGate now decides from the Allowed flags alone (rung name
first, then codec group), PreferredFormats stays a score adjustment,
and a release that declares only a format goes through the same gate.
QualityMatcher maps MP4, AAX and AAXC to the AAC group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lowed

The quality profile API accepted any CutoffQuality, including blank,
off-ladder and disallowed values. A cutoff must now name an allowed
rung while upgrades are on, as in Readarr and Sonarr. Listenarr
stored "upgrades off" as a blank cutoff, so the profile gains an
UpgradeAllowed flag (default true); a migration adds the column and
a startup repair turns it off for existing profiles whose cutoff is
blank, so they keep their current behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Integration fix for this bundle. Three of its fixes each add to
QualityMatcher.cs and each stays under the 500-line cap enforced by
BackendArchitectureTests.ActiveProductionSourceFiles_RemainFocused on
its own, but together they reach 535 lines and fail that test. This
moves the private codec, container and bitrate helpers, unchanged, into
QualityMatcher.Codecs.cs as a partial of the same class, following
the FileUtils.*.cs pattern already in listenarr.domain/Common. No
behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ary did

Integration fix to "fix(search): let a quality profile's Allowed
flag refuse a release". That commit's gate returned NoOpinion
for a label whose codec has no rung. A profile saved from the UI
deletes a switched-off codec's rungs and stores PreferredFormats
as [] or ["m4b"], so with that gate it stopped refusing FLAC and
OPUS. Canary's allow-list (allowed rung names plus PreferredFormats
tokens) refused them, measured on stock through the score endpoint.
With no rung covering a label, the gate now passes it only where
a PreferredFormats token names it, as canary did, and refuses it
otherwise. The new tests were written first and seen failing without
this change. The gate's comments are updated to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ewest one

AddQualityProfileUpgradeAllowed was scaffolded as 20261018212549, which now sorts
before 20261021212418_AddAutomaticSearchTiming. It still applies to an existing
database, but the newest migration's target model has no UpgradeAllowed column,
so a later `dotnet ef migrations remove` restores a snapshot without it and the
next scaffolded migration adds the column again.

Regenerate it with `dotnet ef migrations add` against the current snapshot, so
its Designer carries the full current model and its ID sorts after the newest
one. Up and Down are unchanged and the snapshot is unchanged. The startup
backfill gates on the new ID, the schema test lists it after
AutomaticSearchTiming, and the migration test starts from AutomaticSearchTiming,
which is now the migration immediately before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant