Repository navigation
fix(hardcover): don't treat translated default editions as work language - #2754
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
vavallee
left a comment
There was a problem hiding this comment.
Thanks for this, and for the detail in #2753. The bug is real and the architecture is the right one: an author catalogue really does need per work language evidence rather than trusting the default edition, and one batched request for it is the right cost. The alias table handling is the best part of the PR (more on that below). Two things block it though, and both make an author look empty rather than mislabelled.
1. A failed evidence lookup empties the whole catalogue under a strict profile.
GetAuthorWorkLanguageEvidence seeds every work as Indeterminate before issuing the query, then returns that fully seeded map alongside the error. The caller at internal/api/authors.go:1881-1886 assigns it regardless of the error and only logs a warning. authorWorkPassesLanguageFilter then takes the default: arm for every work, blanks book.Language, and returns !unknownFail.
A four work author, three eng and one blank, provider returning that map plus an error, profile eng with unknown = fail:
stored = map[] // 0 of 4 works persisted
On main those three English works are kept. So a token expiry or a single 502 now reads as "this author has no books". Your TestFetchAuthorBooks_LanguageEvidenceFailureRemainsUnknown only covers a Portuguese work, which main skips too, which is why the suite reports this as correct.
Either return nil on error so the caller falls through to the existing behaviour, or have the caller ignore a map it did not successfully obtain.
2. The indeterminate arm discards the majority language fallback and the #891 edition sampling.
Ordering: the evidence query runs at authors.go:1881, FillMissingAuthorWorkLanguages at :1907, and applyAuthorMajorityLanguageFallback at :1925. Whatever those two resolve is then overwritten or blanked by authorWorkPassesLanguageFilter in the candidate loop at :2211.
I reproduced TestFetchAuthorBooks_MajorityLanguageFallbackRescuesUnresolvedWork against an evidence supplying provider, three allowed works and one indeterminate, unknown = fail, and the rescued work was dropped. That is the exact case the test was written to guard; it does not catch it because its stub does not implement the evidence interface.
Either run the evidence query after those two fills, or make the indeterminate arm fall through to the scalar language instead of blanking it.
3. Medium, and I could not verify this one. The new query is the only one in the client without an explicit limit (compare client.go:246, 356, 410, 493, 588). If Hasura applies a default row cap, works past the cap silently become NotAllowed, which is the rejection path rather than the safe one. A zero row response is also indistinguishable from a schema mismatch on code2, code3 or book.slug. Worth a limit plus treating a truncated response as indeterminate.
4. Medium. authors.go:3462 rewrites book.Language to the allowed edition's language, so a work shown as por becomes eng once any English edition exists. Defensible, but it is user visible and neither the PR body nor the wiki edit mentions it.
The alias table, which is the thing I most wanted to check, you got right. hardcoverLanguageName now falls back to models.NormalizeLanguageCode on a miss instead of returning the raw lowercased string, and that does strip subtags, so en-US becomes eng rather than en-us. I ran all forty hardcoverLanguageAliases entries through NormalizeLanguageCode and they agree exactly, which means the duplicate table is now provably redundant and a follow up can delete it outright. That is a nice result and it was not the point of the PR.
Also checked and fine: the central guard is pinned, one request per catalogue as claimed, and the distinct_on column ordering matches the order_by the way Hasura requires.
Tiny one: the changelog fragment has an em dash in it. House style here is no dashes in release notes. Several existing fragments have them too so this is drift rather than something you introduced.
Main has moved under you since you opened this, so a rebase is needed regardless: #2760, #2773 and #2774 all landed, and #2773 touches hardcoverLanguageName's neighbourhood in the same file.
Happy to re review as soon as 1 and 2 are addressed.
…allee#2753) Signed-off-by: kevinatlee <kevin.atlee@live.ca>
Signed-off-by: kevinatlee <kevin.atlee@live.ca>
Signed-off-by: kevinatlee <kevin.atlee@live.ca>
178a1d8 to
1bb2a7f
Compare
|
Thanks, these were good catches. Addressed both blockers and the two medium items:
Added regressions for the four-work strict-profile failure, evidence-capable majority fallback, #891 edition sampling, indeterminate scalar fallback including unknown pass/fail, explicit bounded query behavior, and reconciliation failure/indeterminate safety. The translated-default/English-edition, The rebase incorporates #2760/#2773/#2774 without dropping their language-lock changes. Focused tests, coverage, vet, the exact requested v2.11.4 lint scope, and the pinned vulnerability scan pass; CI is running on Ready for re-review. |
vavallee
left a comment
There was a problem hiding this comment.
Both blockers fixed, and the way you fixed the second one is better than either option I suggested. Making evidence filter only, so it can rescue a work but never rewrite or blank a language, means a query that silently matches nothing now degrades to exactly what main does rather than to something worse. That also closes my point 4 for free.
What I checked, merged onto current main (it merges clean, builds, vets, and `./internal/api` plus `./internal/metadata/...` pass):
- Blocker 2. Putting the old `default:` arm back fails four tests, including `TestFetchAuthorBooks_MajorityLanguageFallbackRescuesUnresolvedWork`, which is the one that couldn't see it before. That's the evidence I wanted.
- Blocker 1. Returning the seeded map on error fails `TestGetAuthorWorkLanguageEvidence_FailureReturnsNoEvidence`, so the client contract is pinned. Non blocking gap: nothing at the API layer drives a provider that returns a map and an error, so the caller's `languageEvidence = nil` is untested. With both halves reverted `./internal/api` stays green. A stub doing that under unknown = fail with three English works and one blank would pin the exact case from my review. Happy to take it as a follow up.
- The limit. `limit` equal to the unique work count with `distinct_on: [book_id]` is exactly one row per work, so a truncated response can't happen. Nice.
Resolves the one conflict in internal/api/authors.go: this branch adds resolveMinEditionCount and main (vavallee#2754) adds the author work language evidence fetch at the same point in runCatalogueSync. Independent, so both are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
#3091) With OpenLibrary primary and Hardcover supplementing, Hardcover's translated works reached the language filter with no language and passed under the default unknown "pass", or were stamped with the author's majority language and passed even under "fail". - GetAuthorWorkLanguageEvidence now asks every enricher that implements the evidence capability, not only the primary. A supplement's failure or missing token costs only its own evidence. - Hardcover evidence follows up on works left indeterminate only because they carry no language of their own: one bounded distinct_on query for any edition with a language. The first query has already proved there is no allowed edition, so a known language marks the work not allowed. Best effort; a failure leaves them indeterminate as before. - OpenLibrary edition sampling skips works it does not own (hc:, audible:, dnb:), which were a guaranteed "not found" request each. - The author majority fallback skips works whose title is in a different script from the majority's titles, and works already proved not allowed. Indeterminate evidence still gets the fallback, as #2754 intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
… cover_edition, rebind editions (#3102) * fix(metadata): copy and scope cached ISBN and ASIN lookups (#2869) GetBookByISBNWithOutcome and GetCanonicalBookByASIN cached the enriched book for 24 hours under a key with no provider scope and returned the cached pointer on a hit, so a caller editing its book rewrote the entry for every later caller, and a provider configuration change kept serving the old answer. Both now bind the provider snapshot, key on its scope, fetch through the bound providers, and clone on store and on hit. enrichBook now reports whether its enrichment was complete. A record built while an enricher (or the full-record fetch) failed goes in the five minute cache instead of the 24 hour one, matching what #2615 did for GetBook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): resolve supplement translations for the language filter (#3091) With OpenLibrary primary and Hardcover supplementing, Hardcover's translated works reached the language filter with no language and passed under the default unknown "pass", or were stamped with the author's majority language and passed even under "fail". - GetAuthorWorkLanguageEvidence now asks every enricher that implements the evidence capability, not only the primary. A supplement's failure or missing token costs only its own evidence. - Hardcover evidence follows up on works left indeterminate only because they carry no language of their own: one bounded distinct_on query for any edition with a language. The first query has already proved there is no allowed edition, so a known language marks the work not allowed. Best effort; a failure leaves them indeterminate as before. - OpenLibrary edition sampling skips works it does not own (hc:, audible:, dnb:), which were a guaranteed "not found" request each. - The author majority fallback skips works whose title is in a different script from the majority's titles, and works already proved not allowed. Indeterminate evidence still gets the fallback, as #2754 intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): derive OpenLibrary work language and cover from cover_edition (#1779) The cover and language samplers read the first five entries of /works/{id}/editions.json, whose order means nothing, so a work whose first few entries were translations was read as foreign language or got a translated cover. #2197 fixed the truncated GetEditions half; this is the sampler half. sampleWorkEditions now reads the work record and, when it names a cover_edition, that edition: the one OpenLibrary's own work page features. Only what it lacks comes from the editions sample, as before, and a sampled cover now prefers an edition in the sampled language, then one with no language, then any. A work-level cover from the record is preferred to a sampled one. Cost: a work whose featured edition carries both costs two plain document fetches instead of one editions sample; any other work costs the work record plus the sample. The round trip test is updated to pin that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(rebind): drop the previous work's provider editions (#2781) Rebind changed the book's identity and hydrated the new work but left every edition persisted for the old one attached. Search loads all of a book's editions and CriteriaISBN takes the selected or first usable ISBN, so the corrected book could keep searching on the wrong work's ISBN, and the unique foreign_id kept the old edition from its rightful book. After the guarded identity update, rebind now deletes the book's provider editions when the identity actually changed. Kept: Calibre and Audiobookshelf importer editions (calibre:, abs:), the selected edition, and any edition a download or Calibre delivery references. The delete is conditioned on the book already carrying its new identity, and UpsertMetadata refuses old-identity writes once that identity has changed, so an earlier in-flight edition write is removed and a later one cannot land. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): read Japanese titles as one script for the majority language (#3091) titleScript told Han, Hiragana and Katakana apart, so a katakana heavy Japanese title such as "ノルウェイの森" was judged foreign to an author whose other titles are mostly kanji, lost the majority language and was rejected under unknown_language_behavior = fail. The three now share one CJK bucket. Hangul stays separate, since Korean titles are written almost entirely in it and a Japanese or Chinese translation in a Korean catalogue is plainly foreign. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(hardcover): don't reject a blank work for an allowed known language (#3091) The follow up query for blank-language works rejected the work on any edition language it found, on the reasoning that the allowed-language query had already ruled allowed languages out. But that query matches code2 and code3 only, while the follow up also normalises the language name, so an edition recorded as "English" with no codes was missed by the first and then rejected by the second. The language found is now checked against the profile, and an allowed one keeps the work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> * fix(metadata): use cover_edition in OpenLibrary GetBook, state sampler cost (#1779) GetBook now takes the featured cover_edition's cover when the work record has no cover of its own: one extra request, only in that case, best effort. The work's own covers[0] still wins when present. The sampler doc comment and changelog understated the cost. Against the single editions sample it replaced, it is up to two extra requests per sampled work: two when the featured edition carries both a language and a cover, three when it lacks either, two when there is no featured edition. A test now pins the three request path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com> --------- Signed-off-by: vavallee <vavallee@protonmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Hardcover author works now use profile-specific edition evidence instead of treating the default edition's language as authoritative work-level language. This preserves the strict-profile fix from #2246 / #2241 while preventing translated defaults from hiding legitimate English works. Failed evidence lookups leave the existing language pipeline authoritative, and catalogue reconciliation remains conservative. Closes #2753.
#2247 exposed the regression safely: its preview offered a metadata-only Audible row for removal, but no files were deleted and its apply-time safeguards remain unchanged.
Reproduction and root cause
With Penelope Douglas as Hardcover primary and an English-only profile (
eng, unknown = fail), 49 of 78 works were skipped, includingCorrupt (por),Hideaway (por),Kill Switch (por),Bully (Spanish), andNightfall (Spanish).An existing English Audible
Corruptrow (B01CZ0WTEM) was offered by Reconcile catalogue asRejected by the language filter.The current path was:
bookshas no work-level language.toBookfilledBook.Languagefromdefault_ebook_edition, then audio.language_not_alloweddeletion evidence.A translated default edition therefore classified the whole work as Portuguese or Spanish.
Implementation notes
The primary Hardcover client performs one profile-specific batch query after the author catalogue is fetched. It filters editions by all known ISO 639-1, 639-2/B, and 639-2/T aliases and uses
distinct_on: [book_id], returning at most one small row per work.language_not_allowedEvidence is filter-only. It does not rewrite
Book.Language: the user-visible value remains the provider's preferred/default language or a user-locked value. This keeps filtering evidence separate from display metadata and preserves theBook.CanWritelock protections from #2760 / #2773.No schema or dependency changes were needed.
API and performance impact
limitequal to the unique applicable work count, capped by the existing 500-work Hardcover author catalogue bound.limit: 78.Reconciliation safety
Existing metadata-only Wanted, file-bearing, imported, excluded, ownership, status, apply-time recomputation, and no-filesystem-deletion safeguards are unchanged. Failed evidence lookups cannot become removal evidence. A successful indeterminate result only becomes
language_not_allowedwhen the existing scalar pipeline independently resolves a known non-allowed language.Checklist
git commit -sdocs/User-Guide-Wiki.md)changelog.d/mainwith fix(metadata): keep a locked language through Hardcover edition hydration (#2757) #2760 / fix(metadata): honour field locks on every Hardcover write path (#2767) #2773 / fix(importlist): scope the Hardcover list sync's create decision to the list owner #2774 lock and tenancy changes preservedTest plan
Passed:
go test ./internal/models ./internal/metadata/hardcover ./internal/metadata ./internal/bookhydrate -count=1internal/apiregressions for evidence failure, OpenLibrary edition-sampling for work-level language so the allowed_languages filter catches the OL tail #891 sampling, majority fallback, translated defaults, non-allowed evidence, unknown pass/fail, reconciliation failure/indeterminate behavior, and field locksinternal/api,internal/metadata, andinternal/metadata/hardcoverwith focused coveragego vet ./...golangci-lint run --timeout=5m ./internal/models ./internal/metadata/... ./internal/api(v2.11.4; 0 issues)govulncheck ./...(0 called vulnerabilities)gofmt -l .git diff --checkEnvironment / upstream Windows limitations:
go test ./cmd/... ./internal/...passes all changed packages but retains unrelated Windows/environment failures incmd/telemetry-server,internal/abs, andinternal/importer(path separator, symlink/permission, timing/state, and filesystem semantics).golangci-lint run --timeout=5mreaches the unrelated Windows-onlyinternal/importer/samedevice_windows.go: sameDevice is unused; the exact requested changed-package lint scope is clean.makeis unavailable in this environment.