Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
920f7fe
feat(metadata): add Nasjonalbiblioteket provider
francisrath Oct 4, 2026
efbfba3
feat(metadata): wire Nasjonalbiblioteket as an opt-in primary provider
francisrath Oct 4, 2026
e07291c
feat(web): offer Nasjonalbiblioteket in the primary metadata selector
francisrath Oct 4, 2026
17af033
fix(metadata): return the whole work from the NB book lookups
francisrath Oct 4, 2026
3af8ca8
feat(metadata): series and series numbers from Nasjonalbiblioteket
francisrath Oct 4, 2026
2d8b44a
docs(changelog): key the Nasjonalbiblioteket fragment to #2979
francisrath Oct 4, 2026
7b18423
fix(metadata): read NB volume titles and numbers from the part fields
francisrath Oct 4, 2026
1be16ef
fix(metadata): recover NB series volumes missing from the name index
francisrath Oct 4, 2026
807d9ba
fix(metadata): fold NB records whose title a cataloguing slip split
francisrath Oct 4, 2026
c46de99
docs(changelog): match the fragment style for #2979
francisrath Oct 4, 2026
f5a2ed3
fix(metadata): mark NB rate limits and outages as provider errors
francisrath Oct 4, 2026
4688ef7
fix(api): use NB works' own editions in catalogue reconciliation
francisrath Oct 4, 2026
8d08eda
fix(metadata): report an empty NB catalogue for a known author as par…
francisrath Oct 4, 2026
921c898
docs: record Nasjonalbiblioteket's data terms and what leaving it as …
francisrath Oct 4, 2026
0f394cb
fix(metadata): report a missing NB authority record as a partial cata…
francisrath Oct 4, 2026
7410331
fix(api): judge NB works on their own editions only on a complete cat…
francisrath Oct 4, 2026
7b0ce06
docs: cite the CC0 source for NB records and fix the GetEditions comment
francisrath Oct 4, 2026
02cd223
docs(changelog): drop the Norwegian language entry that #2998 now ships
francisrath Oct 5, 2026
a4ddca7
feat(metadata): narrator, running time, genres and name variants from NB
francisrath Oct 5, 2026
a25796f
test(metadata): cover NB edge cases and failure paths
francisrath Oct 5, 2026
0d28041
fix(metadata): fetch NB works from the creator index and count creato…
francisrath Oct 5, 2026
1321c9b
fix(metadata): merge NB series named two ways and keep translation na…
francisrath Oct 5, 2026
3c3e4b1
docs(metadata): say NB's search API states no licence; CC0 is the del…
francisrath Oct 6, 2026
89c6661
Merge remote-tracking branch 'upstream/main' into feat/metadata-nasjo…
francisrath Oct 6, 2026
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 @@ -152,6 +152,7 @@ Clean-room Go rewrite, modern React UI, MIT-licensed, actively developed.
| [Google Books](https://developers.google.com/books) | API key (free) | Enrichment: descriptions, ratings |
| [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 |
| [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/2979-metadata-nasjonalbiblioteket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Added
- **Nasjonalbiblioteket metadata provider** (#2979). The National Library of Norway can be chosen as the primary metadata provider in Settings → Metadata Profiles. It covers Norwegian publications by legal deposit under their original Norwegian titles, so a Norwegian library's files match the author's catalogue instead of the English translations other providers list. Books carry the author's series and their number in it, and the library's genres. Audiobooks carry their narrator and running time where the library records them. An author's other spellings, such as the name without its Norwegian letters, become aliases so release names still match. It is opt in: an install that does not select it never contacts the National Library.
8 changes: 8 additions & 0 deletions cmd/bindery/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ 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 @@ -306,6 +307,8 @@ func main() {
//
// - "dnb" is the recommended choice for German/Austrian/Swiss catalogues,
// 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.
// - "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 @@ -318,6 +321,11 @@ func main() {
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:
Expand Down
9 changes: 6 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,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, or Hardcover
(token required); every provider that isn't primary is wired as an enricher.
`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.

## Components

Expand All @@ -48,7 +51,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, Audnex, Audible — fetchers and unifying interfaces. |
| `metadata` | OpenLibrary, Google Books, Hardcover, DNB, Nasjonalbiblioteket, Audnex, Audible — fetchers and unifying interfaces. |
| `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
10 changes: 7 additions & 3 deletions docs/User-Guide-Wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,11 @@ like everything else on that tab that names server paths.
author's catalogue looks like. It is community data: expect occasional
duplicates, language mix-ups, and box-set entries. The "primary" selector
(Settings → Metadata Profiles → Library Defaults) offers OpenLibrary,
**DNB** (German National Library), and **Hardcover**.
**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.
- **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 @@ -839,8 +843,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, and DNB
records.
trustworthy upstream pages for OpenLibrary, Google Books, Hardcover, DNB and
Nasjonalbiblioteket records.
Calibre and Audiobookshelf ids remain visible only under **Metadata source**
because they do not map to stable public pages.

Expand Down
33 changes: 33 additions & 0 deletions docs/third-party-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,39 @@ Public domain catalogue data (CC0). Cover images come from
`covers.openlibrary.org` and are subject to their rate limits; Bindery serves
them through the same `/api/v1/images` cache.

## Nasjonalbiblioteket

Sources: <https://api.nb.no/catalog/v1/items/api-docs> (the catalogue search
API) and <https://authority.bibsys.no> (the Norwegian authority file). Reviewed
2026-10-04. Only contacted when Nasjonalbiblioteket is the primary provider.

- **The delivered records are CC0; the search API states no licence.** The
National Library publishes its catalogue records under CC0 through its
metadata delivery over OAI-PMH and SRU
(<https://doc.aja.bs.no/hente/bibliografiske-data/mlnb.html>). Bindery does
not use that delivery: it reads the catalogue search API, which publishes no
licence, terms or rate limits of its own. What Bindery stores from it is the
same bibliographic data (titles, authors, ISBNs, years, languages, series,
genres, audiobook narrators and running times). Bindery keeps NB opt in so
installs that do not choose it send no traffic.
- **Summaries are publisher copy.** A record's summary is usually the
publisher's own description of the book, not the library's cataloguing, so
CC0 does not reach it. Bindery stores it as the book description, the way it
stores descriptions from the other providers.
- **Covers are excluded.** NB's image service refuses in-copyright books, and
the cover images some records link to are licensed to library catalogues
only. Bindery fetches no cover from NB or from those links.
- **The authority file is Sikt's, under NLOD 2.0.** Bindery reads an author's
name heading from it to look up their catalogue, and the name's other forms,
which may become aliases. NLOD asks for attribution when the data is
redistributed, for example published as a dataset:

> Contains data under the Norwegian licence for Open Government data (NLOD)
> distributed by Sikt.

A self-hosted install that shows the names to its own users does not
redistribute them.

## Audible

`internal/metadata/audible` calls an unpublished Amazon endpoint, and Amazon's
Expand Down
14 changes: 14 additions & 0 deletions internal/api/catalogue_reconciliation.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,20 @@ func (h *AuthorHandler) buildCatalogueReconciliation(ctx context.Context, author
if provider == "dnb" {
return
}
// NB works come from the author catalogue with every edition
// already attached, and its per-work editions lookup is a full
// GetBook, so the work's own editions are the evidence. None
// attached is not evidence of no ISBN or too few pages, and on a
// partial catalogue a work may be missing editions, so then
// nothing is judged on them.
if provider == "nb" {
if snapshot.Complete && len(work.Editions) > 0 {
mu.Lock()
editions[work.ForeignID] = editionEvidence{editions: work.Editions, known: true}
mu.Unlock()
}
return
}
found, err := h.meta.GetEditionsFromProvider(ctx, provider, work.ForeignID)
mu.Lock()
defer mu.Unlock()
Expand Down
63 changes: 63 additions & 0 deletions internal/api/catalogue_reconciliation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,69 @@ func TestBuildCatalogueReconciliation_UsesConservativeEditionAndIdentityEvidence
}
}

// NB works arrive from the author catalogue with every edition attached, so
// the per-work editions lookup (a full GetBook each) is skipped and the
// work's own editions are the evidence. A work that somehow has none stays
// indeterminate: no editions is not proof of no ISBN or too few pages. On a
// partial catalogue (page cap, failed series recall) a work may be missing
// editions, so none of them is judged on that evidence.
func TestBuildCatalogueReconciliation_NBUsesTheWorksOwnEditions(t *testing.T) {
for _, complete := range []bool{true, false} {
pages100, pages300 := 100, 300
isbn := "9788200000011"
lookupErr := errors.New("per-work editions lookup must not run for nb works")
provider := &editionResultsProvider{
partialSnapshotProvider: partialSnapshotProvider{
stubMetaProvider: stubMetaProvider{name: "nb", works: []models.Book{
{ForeignID: "nb:long", Title: "Long Work", Language: "eng", MetadataProvider: "nb",
Editions: []models.Edition{{NumPages: &pages300, ISBN13: &isbn}}},
{ForeignID: "nb:short", Title: "Short Work", Language: "eng", MetadataProvider: "nb",
Editions: []models.Edition{{NumPages: &pages100, ISBN13: &isbn}}},
{ForeignID: "nb:bare", Title: "Bare Work", Language: "eng", MetadataProvider: "nb"},
}},
complete: complete,
},
errors: map[string]error{"nb:long": lookupErr, "nb:short": lookupErr, "nb:bare": lookupErr},
}
f := newReconciliationFixture(t, provider)
profile, err := f.profiles.GetByID(context.Background(), models.DefaultMetadataProfileID)
if err != nil || profile == nil {
t.Fatalf("load profile: profile=%+v err=%v", profile, err)
}
profile.MinPages = 200
if err := f.profiles.Update(context.Background(), profile); err != nil {
t.Fatal(err)
}
long := f.createBook(t, "nb:long", "Long Work", "nb", models.BookStatusWanted)
short := f.createBook(t, "nb:short", "Short Work", "nb", models.BookStatusWanted)
bare := f.createBook(t, "nb:bare", "Bare Work", "nb", models.BookStatusWanted)
f.author.ForeignID = "nb:author:10000001" // an NB author: the snapshot routes by prefix

ctx := auth.WithUserRole(auth.WithUserID(context.Background(), 7), "user")
got, err := f.handler.buildCatalogueReconciliation(ctx, f.author)
if err != nil {
t.Fatal(err)
}
assertIndeterminateRow(t, got, bare, reconcileIndeterminateReasonEditionUnavailable)
if !complete {
if len(got.Candidates) != 0 {
t.Errorf("partial catalogue: candidates = %+v, want none judged on its editions", got.Candidates)
}
assertIndeterminateRow(t, got, short, reconcileIndeterminateReasonEditionUnavailable)
assertIndeterminateRow(t, got, long, reconcileIndeterminateReasonEditionUnavailable)
continue
}
if len(got.Candidates) != 1 || got.Candidates[0].BookID != short.ID || got.Candidates[0].Reason != reconcileReasonPages {
t.Errorf("candidates = %+v, want only the short work, for pages", got.Candidates)
}
for _, row := range got.IndeterminateRows {
if row.BookID == long.ID || row.BookID == short.ID {
t.Errorf("%q is indeterminate (%s): its own editions were not used", row.Title, row.Reason)
}
}
}
}

func TestReconciliationRejectReason_ProfileReasonsAndIndeterminateEvidence(t *testing.T) {
pages100 := 100
pages250 := 250
Expand Down
4 changes: 2 additions & 2 deletions internal/api/settings_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const SettingDefaultAudiobookRootFolderID = "library.defaultAudiobookRootFolderI

// SettingMetadataPrimaryProvider is the KV key that selects the primary
// metadata provider used for author/book search and lookup. Valid values are
// "openlibrary" (default), "dnb", and "hardcover". Empty or unset falls back to
// "openlibrary" (default), "dnb", "nb", and "hardcover". Empty or unset falls back to
// "openlibrary" for backwards compatibility.
//
// "hardcover" requires a Hardcover API token (SettingHardcoverAPIToken) —
Expand All @@ -85,7 +85,7 @@ const SettingMetadataPrimaryProvider = "metadata.primary_provider"
// MetadataPrimaryProviders lists the accepted values of
// SettingMetadataPrimaryProvider, in the order the UI presents them. The first
// entry is the default used when the setting is empty or unset.
var MetadataPrimaryProviders = []string{"openlibrary", "dnb", "hardcover"}
var MetadataPrimaryProviders = []string{"openlibrary", "dnb", "nb", "hardcover"}

// IsMetadataPrimaryProviderValid reports whether value names a provider that
// may be promoted to primary. The empty string is valid and means "default".
Expand Down
Loading
Loading