Skip to content

fix(metadata): keep a locked language through Hardcover edition hydration (#2757) - #2760

Merged
vavallee merged 1 commit into
vavallee:mainfrom
tunglambk:fix/2757-locked-language-hardcover-hydration
Sep 24, 2026
Merged

vavallee merged 1 commit into
vavallee:mainfrom
tunglambk:fix/2757-locked-language-hardcover-hydration

Conversation

@tunglambk

@tunglambk tunglambk commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

deriveAudiobookMetadataFromEdition only checked book.Language == "" before filling the language from the chosen Hardcover audio edition. A manual edit that clears the language locks the field (internal/api/books.go:483), and locked has to mean locked even when the value the user saved is empty — clearing a wrong language is a decision, not a gap for the provider to fill. Hydration wrote the edition's language straight over the lock, and HydrateHardcoverEditions persisted it because BookUpdater.Update writes language unconditionally.

The language branch now also tests book.IsFieldLocked(models.BookFieldLanguage), the same guard internal/calibre/importer.go:971 and internal/abs/import_upserts.go:1046 already use. That is the whole fix. Language is the only lockable field this package writes: MediaType, ImageURL and the ASIN promotion in the same function are not in models.LockableBookFields, so there is no matching omission for them and I left them alone. internal/bookhydrate/ has one non-test source file, so this branch is the whole extent of the mistake there.

Closes #2757.

Checklist

  • Commits signed off with git commit -s
  • Tests added or updated
  • docs/DEPLOYMENT.md updated if env vars, config, or upgrade path changed
  • Added a changelog fragment under changelog.d/
  • Wiki pages updated if user-facing behaviour changed

No wiki change: docs/Metadata-Editing-Wiki.md already says an edited field is kept instead of overwritten by every automatic refresh, and that promise is what this restores. Nothing on the page became inaccurate.

Test plan

The reproduction is a table in internal/bookhydrate/hardcover_locked_language_test.go, using the package's existing newHydrateBook fixture and a stubbed edition fetcher. With the fix reverted, only the locked-empty case fails:

--- FAIL: TestHydrateHardcoverEditionsRespectsLanguageLock (0.36s)
    --- FAIL: TestHydrateHardcoverEditionsRespectsLanguageLock/locked_empty_survives (0.09s)
    hardcover_locked_language_test.go:68: language after hydration = "ger", want ""
    hardcover_locked_language_test.go:75: persisted language = "ger", want ""
    --- PASS: TestHydrateHardcoverEditionsRespectsLanguageLock/locked_non-empty_survives (0.09s)
    --- PASS: TestHydrateHardcoverEditionsRespectsLanguageLock/unlocked_empty_is_filled (0.09s)
    --- PASS: TestHydrateHardcoverEditionsRespectsLanguageLock/unlocked_non-empty_is_kept (0.10s)

The other three cases pass before and after, which is the point of them: a locked non-empty value survives, an unlocked empty language is still filled from the edition, and an unlocked non-empty value is left as it is. Every case promotes the edition's ASIN, so a pass cannot come from hydration not running at all.

With the fix in place:

  • gofmt -l . — clean
  • go build ./... — exit 0
  • go vet ./internal/bookhydrate/ ./internal/api/ — exit 0
  • go test -count=1 ./internal/bookhydrate/ — ok
  • go test -count=1 ./cmd/... ./internal/... — 12 failures: 2 in internal/api, 1 in internal/db, 9 in internal/importer. The same 12 fail on unmodified main in this sandbox (hardlink and rename on a filesystem that does not support them); I diffed the two failure lists and they are identical.

golangci-lint cannot run in this environment: the available binary is v1.64.8 and the repo config is v2, so it exits with you are using a configuration file for golangci-lint v2 with golangci-lint v1. CI pins v2.11.4, which I could not reproduce here.

…tion (vavallee#2757)

deriveAudiobookMetadataFromEdition filled an empty Book.Language from the
chosen Hardcover audio edition without checking whether the field was
locked. Clearing a book's language by hand locks it (books.go), so
hydration wrote the provider value over a decision the user had made, and
persisted it through BookUpdater.Update, which writes language
unconditionally.

The language branch now checks IsFieldLocked, the same guard the Calibre
and ABS import paths already use. Language is the only lockable field this
package writes: MediaType, ImageURL and the ASIN promotion are not in
models.LockableBookFields.

Signed-off-by: Tung Lam <lamphamabtung96@gmail.com>
@github-actions github-actions Bot added the bindery-notified Discord notification already sent for this PR label Sep 24, 2026
@codecov

codecov Bot commented Sep 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@vavallee
vavallee merged commit 8e023eb into vavallee:main Sep 24, 2026
43 checks passed
vavallee added a commit that referenced this pull request Sep 24, 2026
models.Book documents the contract that every refresh, enrichment and merge
path must check IsFieldLocked before overwriting a lockable field. No path
under internal/bookhydrate or internal/metadata did, until #2760 added the
first check for Language. This generalises that fix to the rest.

The live one is Description. HydrateHardcoverEditions hands the stored book
to Aggregator.EnrichAudiobook and then persists it through Books.Update, so
a description the user cleared and locked was refilled from the Audnex
summary on the next hydrate and written to the database. That is #2757 one
field over, reached by the same call path.

The enrichment merge itself is the other half: enrichBook replaces
Description whenever a provider string is longer and replaces Genres
outright for Hardcover, with no emptiness gate to hide behind.
applyEnrichmentSnapshot replays both decisions from the cache, so a guard on
only the live path would reopen the bug on the next cache hit.

The snapshot is the post merge book rather than the provider payload, and
its key is (provider, foreignID), which every book for that work shares. Now
that a locked field keeps the user's value, caching from a locked book would
serve that value to another library enriching the same work, so a locked
book no longer seeds the cache at all.

Book.CanWrite is the one way this question is asked in these two packages,
so there is a single name to grep for. It answers ownership only. Emptiness
stays a separate test at each site, because fill when empty and overwrite
unconditionally are different merge rules and a lock has to stop both.
Conflating them is what produced #2757.

ImageURL stays out of LockableBookFields and stays unguarded. There is no
cover field in the edit dialog, so nothing can lock it today, and adding it
would be a user visible capability change needing the edit UI and
docs/Metadata-Editing-Wiki.md to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
vavallee added a commit that referenced this pull request Sep 24, 2026
… (#2773)

models.Book documents the contract that every refresh, enrichment and merge
path must check IsFieldLocked before overwriting a lockable field. No path
under internal/bookhydrate or internal/metadata did, until #2760 added the
first check for Language. This generalises that fix to the rest.

The live one is Description. HydrateHardcoverEditions hands the stored book
to Aggregator.EnrichAudiobook and then persists it through Books.Update, so
a description the user cleared and locked was refilled from the Audnex
summary on the next hydrate and written to the database. That is #2757 one
field over, reached by the same call path.

The enrichment merge itself is the other half: enrichBook replaces
Description whenever a provider string is longer and replaces Genres
outright for Hardcover, with no emptiness gate to hide behind.
applyEnrichmentSnapshot replays both decisions from the cache, so a guard on
only the live path would reopen the bug on the next cache hit.

The snapshot is the post merge book rather than the provider payload, and
its key is (provider, foreignID), which every book for that work shares. Now
that a locked field keeps the user's value, caching from a locked book would
serve that value to another library enriching the same work, so a locked
book no longer seeds the cache at all.

Book.CanWrite is the one way this question is asked in these two packages,
so there is a single name to grep for. It answers ownership only. Emptiness
stays a separate test at each site, because fill when empty and overwrite
unconditionally are different merge rules and a lock has to stop both.
Conflating them is what produced #2757.

ImageURL stays out of LockableBookFields and stays unguarded. There is no
cover field in the edit dialog, so nothing can lock it today, and adding it
would be a user visible capability change needing the edit UI and
docs/Metadata-Editing-Wiki.md to match.


Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

Signed-off-by: vavallee <vavallee@protonmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hardcover edition hydration overwrites a manually locked empty language

2 participants