Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ Repeated provider searches use a bounded five-minute cache, and identical in-fli
| [Hardcover.app](https://hardcover.app) | API token (free) | Search enrichment + community ratings, series, wishlist; can be promoted to **primary** for a curated, translation-free catalogue. Token required for **all** queries — without it Hardcover is silently skipped ([troubleshooting](docs/Troubleshooting-Wiki.md#a-book-is-on-hardcoverapp-but-doesnt-show-up-in-the-add-to-library-search)) |
| [DNB](https://www.dnb.de/) | None (public SRU) | German-language descriptions, language, year, publisher; can be promoted to **primary** |
| [Nasjonalbiblioteket](https://www.nb.no/) | None (public API) | Norwegian catalogues under their original titles (legal deposit), with series and series numbers; opt-in **primary** only, never queried otherwise |
| [BnF](https://catalogue.bnf.fr/) | None (public SRU) | French editions under their French titles (legal deposit), with translator, publisher, year and ISBN; opt-in **primary** only, never queried otherwise; no covers. Data under the Licence Ouverte 2.0 ([credit and terms](docs/third-party-data.md#bibliothèque-nationale-de-france)) |
| [Audnex](https://api.audnex.us) | None | Audiobook narrator, duration, cover by ASIN |
| [Audible](https://audible.com) | None | Supplemental audiobook author lookup — pulls ASINs OL/Hardcover miss |

Expand Down
2 changes: 2 additions & 0 deletions changelog.d/3144-metadata-bnf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Added
- **Bibliothèque nationale de France metadata provider** (#3144). The BnF can be chosen as the primary metadata provider in Settings, Metadata Profiles. Legal deposit puts nearly every book published in France in its catalogue under the French edition title, so a French library whose indexers name releases after the French edition ("Alice au pays des merveilles") gets that title instead of the original English one other providers list. Books carry their language, publisher, year, ISBNs and page count, and the original title of a translation; translators and illustrators are never taken for the author. It reads only BnF's printed book records, under the Licence Ouverte 2.0, credited in Settings and, with the date each record was retrieved, on every BnF book and author page, and fetches no covers. It is opt in: an install that does not select it never contacts the BnF, and when selected it sends at most one request a second. Thanks to mnivoliez for the request.
18 changes: 3 additions & 15 deletions cmd/bindery/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import (
"github.com/vavallee/bindery/internal/metadata/dnb"
"github.com/vavallee/bindery/internal/metadata/googlebooks"
"github.com/vavallee/bindery/internal/metadata/hardcover"
"github.com/vavallee/bindery/internal/metadata/nb"
"github.com/vavallee/bindery/internal/metadata/openlibrary"
"github.com/vavallee/bindery/internal/metrics"
"github.com/vavallee/bindery/internal/models"
Expand Down Expand Up @@ -311,6 +310,8 @@ func main() {
// where OpenLibrary coverage is too thin for German-language books.
// - "nb" is the same for Norwegian ones: original titles where
// OpenLibrary has the English translation.
// - "bnf" is the same for French editions, translations included: the
// French title an indexer names a release after (#3144).
// - "hardcover" trades breadth for a cleaner, editorially curated
// catalogue: no translation editions masquerading as separate works, no
// omnibus bundles, no non-book merchandise rows (#2040).
Expand All @@ -319,20 +320,7 @@ func main() {
configuredPrimary = s.Value
}
primaryName := resolveMetadataPrimaryProvider(configuredPrimary, api.GetHardcoverAPIToken(ctxBoot, settingsRepo) != "")
var primaryProvider metadata.Provider
switch primaryName {
case "dnb":
primaryProvider = dnbClient
case "nb":
// Opt-in only: unlike the others, NB is never added as an enricher,
// so an install that did not choose it sends NB no traffic. NB
// publishes no rate limits for a fleet of independent installs.
primaryProvider = nb.New()
case "hardcover":
primaryProvider = hcClient
default:
primaryProvider = olClient
}
primaryProvider := primaryMetadataProvider(primaryName, olClient, dnbClient, hcClient)
slog.Info("metadata primary provider", "provider", primaryName)

var enrichers []metadata.Provider
Expand Down
28 changes: 28 additions & 0 deletions cmd/bindery/metadata_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,36 @@ import (
"log/slog"

"github.com/vavallee/bindery/internal/api"
"github.com/vavallee/bindery/internal/metadata"
"github.com/vavallee/bindery/internal/metadata/bnf"
"github.com/vavallee/bindery/internal/metadata/nb"
)

// primaryMetadataProvider is the provider wired as primary for name, a value
// resolveMetadataPrimaryProvider returned. The clients that are also
// enrichers are passed in so primary and enricher share one instance; NB and
// BnF are built here because they are never enrichers.
func primaryMetadataProvider(name string, openLibrary, dnbClient, hardcover metadata.Provider) metadata.Provider {
switch name {
case "dnb":
return dnbClient
case "nb":
// Opt-in only: unlike the others, NB is never added as an enricher,
// so an install that did not choose it sends NB no traffic. NB
// publishes no rate limits for a fleet of independent installs.
return nb.New()
case "bnf":
// Opt-in only, like NB: never an enricher, so an install that did not
// choose it sends BnF no traffic. BnF publishes no rate limits; the
// client paces itself at one request a second (internal/metadata/bnf).
return bnf.New()
case "hardcover":
return hardcover
default:
return openLibrary
}
}

// metadataPrimaryProviderDefault is the provider used when
// metadata.primary_provider is empty, unknown, or unusable.
const metadataPrimaryProviderDefault = "openlibrary"
Expand Down
29 changes: 28 additions & 1 deletion cmd/bindery/metadata_providers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
package main

import "testing"
import (
"testing"

"github.com/vavallee/bindery/internal/metadata/bnf"
"github.com/vavallee/bindery/internal/metadata/dnb"
"github.com/vavallee/bindery/internal/metadata/hardcover"
"github.com/vavallee/bindery/internal/metadata/openlibrary"
)

// TestPrimaryMetadataProvider covers the wiring after resolution: each
// accepted value builds that provider, so a value the settings accept never
// silently boots OpenLibrary instead.
func TestPrimaryMetadataProvider(t *testing.T) {
ol, dnbClient, hc := openlibrary.New(), dnb.New(), hardcover.New()
for _, name := range []string{"openlibrary", "dnb", "nb", "bnf", "hardcover"} {
if got := primaryMetadataProvider(name, ol, dnbClient, hc); got == nil || got.Name() != name {
t.Errorf("primaryMetadataProvider(%q) = %v, want the %s provider", name, got, name)
}
}
if _, ok := primaryMetadataProvider("bnf", ol, dnbClient, hc).(*bnf.Client); !ok {
t.Error("bnf is not wired to the BnF client")
}
if got := primaryMetadataProvider("dnb", ol, dnbClient, hc); got != dnbClient {
t.Error("dnb primary is a second DNB client, want the enricher's instance")
}
}

// TestResolveMetadataPrimaryProvider covers the boot-time provider selection:
// openlibrary stays the default for every unset / unusable value, dnb and
Expand All @@ -17,6 +42,8 @@ func TestResolveMetadataPrimaryProvider(t *testing.T) {
{"unset defaults to openlibrary even with a token", "", true, "openlibrary"},
{"explicit openlibrary", "openlibrary", false, "openlibrary"},
{"explicit dnb", "dnb", false, "dnb"},
{"explicit nb", "nb", false, "nb"},
{"explicit bnf", "bnf", false, "bnf"},
{"hardcover with a token", "hardcover", true, "hardcover"},
{"hardcover without a token falls back", "hardcover", false, "openlibrary"},
{"unknown value falls back", "goodreads", true, "openlibrary"},
Expand Down
11 changes: 6 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ OpenLibrary Google Books, Hardcover.app, Audnex, Audible

Exactly one provider is *primary* — it defines what an author's catalogue is.
`metadata.primary_provider` selects OpenLibrary (default), DNB, Nasjonalbiblioteket
(`nb`), or Hardcover (token required); every provider that isn't primary is wired
as an enricher, except Nasjonalbiblioteket, which is wired only when it is primary.
Switching the primary away from it therefore leaves `nb:` authors with no provider
to sync from until they are relinked.
(`nb`), the Bibliothèque nationale de France (`bnf`), or Hardcover (token required);
every provider that isn't primary is wired as an enricher, except Nasjonalbiblioteket
and BnF, which are wired only when primary. Switching the primary away from either
therefore leaves its `nb:` or `bnf:` authors with no provider to sync from until
they are relinked.

## Components

Expand All @@ -51,7 +52,7 @@ The `internal/` tree is organised by domain, not by layer:
| `db` | Connection pooling, transaction helpers, repository interfaces, and the embedded schema migrations under `db/migrations/` (`NNN_description.sql`, applied idempotently at boot). |
| `migrate` | Bulk-import of authors and related records from a `readarr.db` or a Goodreads CSV export. |
| `models` | Domain types (Author, Book, Edition, Series, Indexer, etc.) shared across handlers, repos, and pipelines. |
| `metadata` | OpenLibrary, Google Books, Hardcover, DNB, Nasjonalbiblioteket, Audnex, Audible: fetchers and unifying interfaces. The HTTP providers share one request loop (`metadata/providerhttp`): a 429, 502, 503, 504 or transport error is retried up to 3 times, `Retry-After` is honoured up to 30 seconds with jittered backoff otherwise, and a refusal holds every request that client makes, failing one at once when the hold would outlast its deadline, reported as rate limited after a 429 and as unavailable after a 5xx. A Google Books daily quota refusal (`dailyLimitExceeded`, `quotaExceeded`) is not retried. The CSV, Readarr and Goodreads importers wait out a primary's hold and ask again (up to 3 times per lookup, and at most 10 minutes of waiting per run) instead of failing the row. A lookup still refused after that counts toward their primary outage breaker, so a primary that keeps refusing stops the import after 3 such lookups and the remaining rows fail with a "try again later" reason. The Add Book ISBN and ASIN lookups run under one 20 second deadline. Hardcover paces itself adaptively instead, and treats a 200 whose GraphQL errors report a rate limit as a refusal ([#2369](https://github.com/vavallee/bindery/issues/2369), [#2791](https://github.com/vavallee/bindery/issues/2791)). |
| `metadata` | OpenLibrary, Google Books, Hardcover, DNB, Nasjonalbiblioteket, BnF, Audnex, Audible: fetchers and unifying interfaces. The HTTP providers share one request loop (`metadata/providerhttp`): a 429, 502, 503, 504 or transport error is retried up to 3 times, `Retry-After` is honoured up to 30 seconds with jittered backoff otherwise, and a refusal holds every request that client makes, failing one at once when the hold would outlast its deadline, reported as rate limited after a 429 and as unavailable after a 5xx. A Google Books daily quota refusal (`dailyLimitExceeded`, `quotaExceeded`) is not retried. The CSV, Readarr and Goodreads importers wait out a primary's hold and ask again (up to 3 times per lookup, and at most 10 minutes of waiting per run) instead of failing the row. A lookup still refused after that counts toward their primary outage breaker, so a primary that keeps refusing stops the import after 3 such lookups and the remaining rows fail with a "try again later" reason. The Add Book ISBN and ASIN lookups run under one 20 second deadline. BnF also paces itself at a fixed one request a second, two in flight, for the whole process, because BnF publishes no limit (`metadata/bnf/pacer.go`). Hardcover paces itself adaptively instead, and treats a 200 whose GraphQL errors report a rate limit as a refusal ([#2369](https://github.com/vavallee/bindery/issues/2369), [#2791](https://github.com/vavallee/bindery/issues/2791)). |
| `indexer` | Newznab/Torznab clients, query builder, four-tier fallback, per-indexer query deduplication, result deduplication, ranking. |
| `decision` | Quality profiles, language filter, custom formats, delay profiles, blocklist consultation. |
| `downloader` | SABnzbd, NZBGet, qBittorrent, Transmission, Deluge, rTorrent clients (queue/history polling, submission, deletion). |
Expand Down
27 changes: 22 additions & 5 deletions docs/User-Guide-Wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,26 @@ like everything else on that tab that names server paths.
(Settings → Metadata Profiles → Library Defaults) offers OpenLibrary,
**DNB** (German National Library), **Nasjonalbiblioteket** (National Library
of Norway: Norwegian books under their original titles, only used when chosen
as primary), and **Hardcover**. Switching the primary away from
Nasjonalbiblioteket switches it off entirely, so authors linked to it stop
syncing until you relink each one with "Link metadata" on their page.
as primary), **BnF** (Bibliothèque nationale de France: French editions under
their French titles, translations included, only used when chosen as primary),
and **Hardcover**. Switching the primary away from Nasjonalbiblioteket or BnF
switches it off entirely, so authors linked to it stop syncing until you
relink each one with "Link metadata" on their page.
- **BnF** suits a library of French editions whose indexers name releases
after the French title ("Alice au pays des merveilles", not "Alice's
Adventures in Wonderland"). Its data is under the Licence Ouverte / Open
Licence 2.0, credited in Settings where you choose it, and each BnF book and
author page shows the credit with the date its record was retrieved;
Bindery fetches no covers from BnF, so covers come from the other
providers. BnF is moving its catalogue to a new record model until the end
of 2026, and records created since June 2026 are not yet linked to their
author, so Bindery finds those by the author's name, only when no other
person in BnF's authority file has the same name, and treats an author's
BnF catalogue as possibly
incomplete: catalogue reconciliation never offers to remove a book because
BnF did not list it. BnF publishes no rate limit, so Bindery sends it at most
one request a second; refreshing an author with a long catalogue can take
around fifteen seconds.
- **Hardcover** is an enricher by default — it improves search results,
ratings, and series data, and powers import lists and the Discover wishlist
row. **Without an API token (Settings → API Keys) Hardcover is silently
Expand Down Expand Up @@ -898,8 +915,8 @@ bound to with a copy button, and lists any other provider ids the same book is
known by. That is the thing to check before deciding a book needs re-binding,
and the id is what to quote in a bug report. Hover or activate **Links** while
confirming a book in the Add to library dialog or in the book header to open
trustworthy upstream pages for OpenLibrary, Google Books, Hardcover, DNB and
Nasjonalbiblioteket records.
trustworthy upstream pages for OpenLibrary, Google Books, Hardcover, DNB,
Nasjonalbiblioteket and BnF records.
Calibre and Audiobookshelf ids remain visible only under **Metadata source**
because they do not map to stable public pages.

Expand Down
Loading
Loading