Skip to content

feat(indexer): normalise the audiobook size bonus by runtime and cap grabs (#2740) - #2750

Merged
vavallee merged 1 commit into
vavallee:mainfrom
tunglambk:feat/2740-audiobook-size-per-minute
Oct 2, 2026
Merged

vavallee merged 1 commit into
vavallee:mainfrom
tunglambk:feat/2740-audiobook-size-per-minute

Conversation

@tunglambk

@tunglambk tunglambk commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A book's runtime now weights two existing ranking terms, so a long audiobook stops being rewarded for being long and a very popular release can't dominate the format it carries. This is the smaller shape @vavallee described rather than the per-profile audiobookScoring block: no setting, no migration, no per-codec UI, and a book with no stored runtime scores exactly what it scored before.

Part of #2740.

The size bonus used to be min(size, 1024 MiB) / 100, which grows with the file. When the book has a stored durationSeconds, an audio release is now scored by its density instead, in MiB per minute, at the same points-per-MiB the flat term used for a ten-hour book and capped where its 1024 MiB cap lands. The ten-hour reference is deliberate: it is the length that cap implies, so a ten-hour book keeps the numbers it had. Against an ordered audiobook profile (m4b second of five, so 400) the 1,200 / 600 / 300 MiB releases score 440.24 / 436.00 / 433.00, and with no profile, where the format term is QualityRank, 940.24 / 936.00 / 933.00. What changes is that length no longer skews the term: 300 MiB over 5 h, 600 MiB over 10 h and 1,200 MiB over 20 h are all 1.0 MiB/min and all score 436 against that profile.

The popularity bonus is capped at 100 grabs, where log10(grabs+1)*10 reaches about 20 points. Popularity is still rewarded, but a release with thousands of downloads can no longer outweigh the format and size terms together. This one applies whether or not a runtime is known.

Two limits are worth stating plainly. The runtime describes the book, not the release, so an abridged or differently narrated release is still scored against the full book's runtime; there is no per-candidate runtime to use yet. And books.duration_seconds is only written from Hardcover, Audible, Audnexus gated on a non-empty ASIN, and the Audiobookshelf importer. Nothing derives it from a file, and metadata_provider defaults to OpenLibrary, which supplies neither runtime nor ASIN. On a default install the normalised path is rarely reached and the zero-runtime fallback is the one that runs, so a test pins that fallback bit for bit against the old formula on both ranking bases.

The per-codec targets, the settings UI and the score breakdown from the review are what this shape drops. The pending-release ranking path is still untouched: it needs a decision on where the ranking belongs, so it is not in here.

Test plan

  • gofmt -l . — clean
  • go build ./... — clean
  • go vet ./internal/indexer/... ./internal/models/... ./internal/api/... ./internal/db/... ./internal/scheduler/... — clean
  • go test -count=1 ./internal/indexer/... ./internal/models/... ./internal/scheduler/... — pass. internal/api, internal/db and internal/importer fail the same twelve hardlink/rename/filesystem tests on this branch and on unmodified main, checked side by side in two worktrees.
  • cd web && npm run typecheck && npm run lint && npm test && npm run build (Node 24) — pass, and the web side is back to main's sources.

golangci-lint couldn't run here: the workspace v2.12.2 build panics under Go 1.27 before it reaches a file, and CI pins v2.11.4 on Go 1.26.6, which isn't installed. I'm not claiming it passed.

@github-actions github-actions Bot added the bindery-notified Discord notification already sent for this PR label Sep 23, 2026
Comment thread web/src/pages/settings/QualityTab.tsx Fixed
Comment thread web/src/pages/settings/QualityTab.tsx Fixed
Comment thread web/src/pages/settings/QualityTab.tsx Fixed
Comment thread web/src/pages/settings/QualityTab.tsx Fixed
Comment thread web/src/pages/settings/QualityTab.tsx Fixed
@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@magrhino

Copy link
Copy Markdown
Contributor

Thanks @tunglambk for taking this on. The opt-in profile settings, tolerance band, and adjustable grabs weight provide a useful foundation. I appreciate the focused tests and documentation, and keeping existing ranking unchanged when scoring is disabled. The new controls also saved and reopened correctly during testing and worked across desktop/mobile layouts and both themes.

There are two behavior issues I noticed when reviewing this:

  1. Keep audiobook settings from affecting ebooks. The configured grabs weight currently applies to ebook results too, including the ebook side of dual-format searches. Setting audiobook grabs weight to zero changed the winning EPUB in a focused test. Please scope these preferences to audiobook ranking and cover ebook-only and dual-format searches.

  2. Apply scoring when selecting eligible pending releases. When no fresh result is approved, the pending-release path takes the first eligible entry in newest-first order without applying the profile’s scoring. This is a pre-existing path that needs integration with the feature. I’d appreciate input from @vavallee here, but would suggest ranking eligible pending candidates using the same preferences before selecting the winner.

A few clarifications on the intended scope:

  • Format vs codec targets. Separate M4B/MP3/FLAC targets work for what I’m requesting, consistent with the approach you proposed in feat(quality): configurable audiobook scoring by codec, size per minute, and grabs #2740. Reliable detection of the internal audio codec isn’t required. I would suggest we call these “audio format targets” in the UI and documentation so the terminology matches the implementation.
  • The book-runtime limitation is understood. You disclosed that the calculation uses the stored book duration. Please reuse the existing stored runtime and label the density calculation as an estimate based on book metadata, explaining that another edition, abridgment, or narration may have a different runtime. When available release metadata clearly identifies a different edition or narration, please skip the density adjustment. Reliable candidate-specific runtime selection can follow later.
  • There’s also a related, pre-existing runtime bug. I opened [#2755](Hardcover edition audio_seconds is dropped during audiobook hydration #2755) because Hardcover edition audio_seconds is fetched but dropped during conversion. In a focused reproduction, an edition supplied a 36,000-second runtime, but the stored book duration remained zero. This needs fixing so the calculation can use runtime data already available from the provider; it can be handled as a separate linked fix.
  • I recognize that score explanations were explicitly deferred. A basic breakdown showing estimated MiB/min, the target/tolerance, density and grabs contributions, and whether fallback applied would make the settings easier to understand. If that belongs in a follow-up, I’m comfortable agreeing on partial delivery and keeping feat(quality): configurable audiobook scoring by codec, size per minute, and grabs #2740 open rather than automatically closing it with this PR.

These two items are non-blocking and can be fixed here or explicitly deferred:

  • Associate the Tolerance, Size weight, and Grabs weight inputs with their visible labels so assistive technology can identify them.
  • Adjust the fallback wording: the size term falls back, but the configured grabs weight still applies, so “keeps the current ranking” overstates what is preserved.

One merge-order note: migration 090 overlaps another open change identified during review, so its numbering will probably need coordination depending on which PR merges first.

@vavallee

Copy link
Copy Markdown
Owner

Heads up on a migration number collision, not a review.

This PR adds a migration numbered 090, and so do two others that are open right now: #2761 and #2713 and #2750 all claim 090. assertUniqueMigrationVersions (internal/db/db.go:685) refuses to boot when two files share a numeric prefix, so whichever of the three merges second breaks startup for anyone who has both.

Main tops out at 089. Current claim map across the open PRs:

The usual gotcha here is that each of us computes "next free" against a main that does not yet contain anyone else's unpushed work, so this keeps happening. Worth renaming to something above 093 and picking by checking open PRs rather than main. No rush on my side, just flagging it before two of these land in the same window.

tunglambk pushed a commit to tunglambk/bindery that referenced this pull request Sep 25, 2026
…avallee#2718)

Renames 090_requests_auto_approve.sql to 093. Main tops out at 089 and
both vavallee#2750 and vavallee#2713 claim 090, so whichever of the three landed second
would trip assertUniqueMigrationVersions at boot.

Auto-approval removed the pending cap, because that cap counts pending
rows and an auto-approved request never sits in pending. The claim now
also carries a per-account daily quota, drawn from the same
requests.max_pending_per_user limit (25 by default). It is enforced
inside the claim statement, the way pendingBelowCap guards Create, so a
burst of creates cannot all pass a separate count. Once the account has
had that many requests auto-approved since midnight UTC, the next one is
left pending for a human, exactly as it is with the setting off.

An auto-approved request no longer sends requestCreated, so an admin is
not pinged for an item that was added without them. A request that stays
pending, whether the add failed or the day's quota is spent, still
notifies.

The admin user routes move into registerUserAdminRoutes so the route
level test drives the same registration the server uses. Moving
/auth/users/{id}/auto-approve out of the RequireAdmin group failed
nothing before this test; an unknown user id on that route is now a 404
rather than a silent success, and the Users page renders the
autoApproveHint key that was already in en.json.

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>
@vavallee

Copy link
Copy Markdown
Owner

Two things on this PR, one of them my own mistake.

Migration number. I told four PRs that 093 was the first free number, and two took it, so I created a fresh collision. Assigning distinct numbers instead:

Migration PR
086 #2633
090 #2713
091 #2714
092 #2737
093 #2761
094 #2673
095 #2750, this one

Please rename to 095_quality_profile_audiobook_scoring.sql. #2713 keeps 090 because it claimed it first, not because its claim is better.

The larger issue, and it is worth knowing before you spend more time here. This PR and #2737 both rewrite scoreResult in internal/indexer/searcher.go, and both also touch internal/models/settings.go, internal/api/quality_profiles.go, internal/api/indexers.go, internal/scheduler/scheduler.go and web/src/pages/settings/QualityTab.tsx. Both being green says nothing about the pair.

More specifically, #2737 replaces the base term this PR's arithmetic is derived from. Today format contributes models.QualityRank[quality] * 100; #2737 replaces that with the profile's own ordering. So the worked example in your body, 940.24 against 936.00 for the 1,200 MiB versus 600 MiB ten hour audiobook, stops holding once #2737 lands. #2737 is going in first, so this will want a rebase and the numbers re derived against the new base.

Two substantive points while you are rebasing, both about the feature rather than the code.

codecTargets keys on the container token, and the issue this closes explicitly says an M4B extension alone does not establish the codec. There is no codec, bitrate, sample rate or channel field anywhere in ParsedRelease (internal/indexer/release.go:16-26) and Format is just the first match in formatTokens order. So as written the field claims something the parser cannot see. Renaming it to container targets would at least be honest about what it matches.

The duration the size per minute term needs is usually zero. books.duration_seconds is only ever written from Hardcover, Audible, Audnexus gated on a non empty ASIN, and Audiobookshelf import. Nothing computes it from a file and there is no duration probe in the tree, while metadata_provider defaults to openlibrary, which supplies neither runtime nor ASIN. On a default install the whole term silently does nothing.

The version of this I would take: normalise the existing size term by duration when duration is known, and cap the grabs term, as weight changes inside scoreResult. No profile block, no migration, no per codec UI, and it degrades to today's behaviour when duration is zero. That also sidesteps the migration question entirely.

vavallee pushed a commit that referenced this pull request Sep 27, 2026
* feat(api): auto-approve requests per requester account (#2718)

A requester can only ask, and every ask waits for an admin. With the lean
requester UI that turns into a stream of approvals, which is the opposite
of what the role is for.

Add a per-account setting, off by default so no existing install changes
behaviour. It lives on the user row (migration 090) rather than in the
settings table because it is a per-account permission like role, and it is
set from the Users page or PUT /auth/users/{id}/auto-approve.

When it is on, Create claims the new request and runs the same add an
admin's Approve runs, with no deciding user recorded. The
already-in-the-library check, the stored-payload revalidation and the
pending cap all still apply. A book request searches on add; an author
request runs the ordinary catalogue sync. If the add fails the request
stays pending, so nothing is lost. The switch only affects the next
request; anything already queued stays for a human.

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>

* fix(api): rename migration 090 to 093 and cap auto-approve per day (#2718)

Renames 090_requests_auto_approve.sql to 093. Main tops out at 089 and
both #2750 and #2713 claim 090, so whichever of the three landed second
would trip assertUniqueMigrationVersions at boot.

Auto-approval removed the pending cap, because that cap counts pending
rows and an auto-approved request never sits in pending. The claim now
also carries a per-account daily quota, drawn from the same
requests.max_pending_per_user limit (25 by default). It is enforced
inside the claim statement, the way pendingBelowCap guards Create, so a
burst of creates cannot all pass a separate count. Once the account has
had that many requests auto-approved since midnight UTC, the next one is
left pending for a human, exactly as it is with the setting off.

An auto-approved request no longer sends requestCreated, so an admin is
not pinged for an item that was added without them. A request that stays
pending, whether the add failed or the day's quota is spent, still
notifies.

The admin user routes move into registerUserAdminRoutes so the route
level test drives the same registration the server uses. Moving
/auth/users/{id}/auto-approve out of the RequireAdmin group failed
nothing before this test; an unknown user id on that route is now a 404
rather than a silent success, and the Users page renders the
autoApproveHint key that was already in en.json.

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>

---------

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>
Co-authored-by: Tung Lam <lamphamabtung96@gmail.com>
@tunglambk
tunglambk force-pushed the feat/2740-audiobook-size-per-minute branch 2 times, most recently from bc79a28 to c3fc10c Compare September 27, 2026 03:01
…grabs (vavallee#2740)

The size term rewarded total bytes, so of two releases of the same book the
larger file always gained more, whatever its bitrate. When the book has a
stored runtime an audio release is now scored by its density in MiB per
minute instead, at the points-per-MiB the flat term used for a ten-hour book
and capped where its 1024 MiB cap lands. A ten-hour book therefore keeps the
numbers it had, and a shorter or longer one stops being rewarded or punished
for its length. A book with no stored runtime keeps the flat, capped bonus
exactly as it was.

The popularity term is capped at 100 grabs, where log10(grabs+1)*10 reaches
about 20 points, so a release with thousands of downloads can no longer
outweigh the format and size terms together.

Both are weight changes inside scoreResult. There is no profile block, no
setting to turn on, no migration and no per-codec UI.

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>
@tunglambk
tunglambk force-pushed the feat/2740-audiobook-size-per-minute branch from c3fc10c to 6bfc260 Compare September 27, 2026 03:11
@tunglambk tunglambk changed the title feat(indexer): rank audiobooks by size per minute when a profile asks (#2740) feat(indexer): normalise the audiobook size bonus by runtime and cap grabs (#2740) Sep 27, 2026
@vavallee
vavallee merged commit 0a4858a into vavallee:main Oct 2, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bindery-notified Discord notification already sent for this PR needs-triage New issue, not yet reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants