Vellum/internal/metadata/provider.go
mithral df740d3e1a Phase 5: job queue and metadata providers
- jobs: a table-backed queue in the same SQLite file. No Redis, no second
  process, included in the same backup. claim() is a single UPDATE...RETURNING
  so a job cannot run twice even with several workers. Failures back off
  exponentially; a missing handler fails permanently rather than retrying a
  bug.
- metadata: Provider interface plus Open Library and Google Books (both
  keyless). The enricher tries providers in registration order, falls back on
  empty, and continues past a provider error.
- migration 0004: metadata_fetch records the attempt, not just the match, so a
  rescan does not re-hammer a rate-limited API for an item that will never
  match. Errors are always retried; ok/notfound are cached for 30 days.
- enrichment writes provider_ids, updates title/year, links authors through
  people/item_people, and downloads the cover as an asset. The cover download
  runs outside the transaction: a slow image host must not hold a write lock.
- endpoints: queue enrichment per library, and inspect an item's matches.
- web: Enrich button per library.

Verified live against Open Library: "The Hobbit" matched /works/OL27482W,
title and year 1937 written, a 301x500 JPEG cover downloaded and served.
2026-10-09 21:16:07 -04:00

73 lines
1.9 KiB
Go

// Package metadata resolves media items against external catalogues.
//
// Providers are optional by design: a library with no network still browses,
// because a failed lookup is recorded and skipped, never surfaced as a scan or
// request failure.
package metadata
import (
"context"
"strings"
"sync"
)
// Query is what a provider is asked to match.
type Query struct {
Title string
Author string
ISBN string
Kind string
}
// Candidate is one possible match returned by a provider.
type Candidate struct {
Provider string `json:"provider"`
ExternalID string `json:"external_id"`
Title string `json:"title"`
Authors []string `json:"authors"`
Year int `json:"year"`
Publisher string `json:"publisher"`
Language string `json:"language"`
Description string `json:"description"`
ISBN string `json:"isbn"`
CoverURL string `json:"cover_url"`
}
// Provider is one external catalogue.
type Provider interface {
// Name is the stable identifier stored in provider_ids.provider.
Name() string
// Kinds lists the media kinds this provider can match.
Kinds() []string
// Search returns candidates, best first. An empty result is not an error.
Search(ctx context.Context, q Query) ([]Candidate, error)
}
var (
mu sync.RWMutex
providers []Provider
)
// Register adds a provider. Later registrations are consulted later, so
// registering a primary provider before a fallback gives the intended order.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
providers = append(providers, p)
}
// For returns the providers that handle a media kind, in registration order.
func For(kind string) []Provider {
mu.RLock()
defer mu.RUnlock()
var out []Provider
for _, p := range providers {
for _, k := range p.Kinds() {
if strings.EqualFold(k, kind) {
out = append(out, p)
break
}
}
}
return out
}