fix(metadata): keep a locked language through Hardcover edition hydration (#2757) - #2760
Merged
vavallee merged 1 commit intoSep 24, 2026
Conversation
…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>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
2 tasks
9 tasks
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>
This was referenced Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
deriveAudiobookMetadataFromEditiononly checkedbook.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, andHydrateHardcoverEditionspersisted it becauseBookUpdater.Updatewriteslanguageunconditionally.The language branch now also tests
book.IsFieldLocked(models.BookFieldLanguage), the same guardinternal/calibre/importer.go:971andinternal/abs/import_upserts.go:1046already use. That is the whole fix. Language is the only lockable field this package writes:MediaType,ImageURLand the ASIN promotion in the same function are not inmodels.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
git commit -sdocs/DEPLOYMENT.mdupdated if env vars, config, or upgrade path changedchangelog.d/No wiki change:
docs/Metadata-Editing-Wiki.mdalready 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 existingnewHydrateBookfixture and a stubbed edition fetcher. With the fix reverted, only the locked-empty case fails: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 .— cleango build ./...— exit 0go vet ./internal/bookhydrate/ ./internal/api/— exit 0go test -count=1 ./internal/bookhydrate/— okgo test -count=1 ./cmd/... ./internal/...— 12 failures: 2 ininternal/api, 1 ininternal/db, 9 ininternal/importer. The same 12 fail on unmodifiedmainin this sandbox (hardlink and rename on a filesystem that does not support them); I diffed the two failure lists and they are identical.golangci-lintcannot run in this environment: the available binary is v1.64.8 and the repo config is v2, so it exits withyou are using a configuration file for golangci-lint v2 with golangci-lint v1. CI pins v2.11.4, which I could not reproduce here.