- 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.
73 lines
1.9 KiB
Go
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
|
|
}
|