Vellum/internal/metadata/enrich.go
mithral 1fcc7344cb Separate video libraries by kind: movies, TV, adult, hentai
One "video" kind meant every video library behaved the same, so a film shelf
tried to parse episode numbers and a TV library could not be told apart from
it. The kinds are now a declaration rather than a special case.

Five video kinds, all owning the same containers and differing only in how
their files become items:

  movies  flat                    tv      series/season/episode
  adult   flat                    hentai  series/season/episode
  video   series/season/episode   (kept: existing libraries already use it)

The scanner dispatches on library.GroupModeFor(lib.Kind) instead of comparing
the kind to "video", so a new episodic kind is a registry entry and nothing in
the scanner changes.

The decision worth recording, because it could have gone the other way:
media_items.kind stays STRUCTURAL. Every video library stores 'video' on its
rows, not its own kind. media_items.kind answers "is this a playable video",
which is what the playback, catalog and Jellyfin paths actually ask, and
migration 0013 already committed to that rule when it decided an episode would
stay kind='video'.

Storing the library kind instead would have meant teaching every query that
says kind='video' about five new values - catalog's episode and next-episode
reads, Jellyfin's episodes-only filter, the item-type mapping, the web
player's routing - and every kind added later would have had to do it again.

So the two jobs are split: the ROW kind says what a thing is, the LIBRARY kind
says how it is classified, and the library kind is what routes metadata. The
enricher now reads the library to choose providers, because a movies library
and an adult library hold identical rows and must not query the same
catalogue.

The adult kinds have no metadata provider. There is no mainstream catalogue
for that content - the ecosystem uses StashDB or local NFO files - so they
scan and browse and stay unenriched until someone registers one. That is
stated in the registry rather than left as a surprise.

Verified end-to-end: the same tree under a movies library stays flat, under a
tv library builds series/season/episodes, and every row in both is stored as
kind='video'. The UI needs no change - it reads /api/v1/media-types, so the
new kinds appeared in the picker by themselves.

Red-green: dispatching on the literal kind fails the tv and hentai cases;
storing the library kind on rows fails the structural-kind assertion.
2026-10-12 11:20:51 -04:00

438 lines
13 KiB
Go

// Package metadata enriches catalogue items from external providers.
//
// It owns the policy: which provider to try, whether a fuzzy candidate is
// actually the item being asked about, and what gets written back. The HTTP
// clients live in internal/providers, which knows nothing about the database.
package metadata
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/providers"
)
// JobKind is the queue job that enriches a single item.
const JobKind = "enrich_item"
// retryAfter is how long a failed or empty lookup is trusted before the
// provider is asked again. It stops a rescan from re-hammering a rate-limited
// API for an item that will never match.
const retryAfter = 30 * 24 * time.Hour
// maxCoverBytes caps a downloaded cover.
const maxCoverBytes = 8 << 20
// Enricher matches items against providers and writes the results back.
type Enricher struct {
db *sql.DB
log *slog.Logger
cacheDir string
client *http.Client
// providers resolves the provider list for a media kind. It defaults to
// the global registry; tests override it to avoid network and global state.
providers func(kind string) []providers.Provider
}
// NewEnricher builds the enricher.
func NewEnricher(db *sql.DB, log *slog.Logger, cacheDir string) *Enricher {
return &Enricher{
db: db,
log: log,
cacheDir: cacheDir,
client: &http.Client{Timeout: 20 * time.Second},
providers: providers.For,
}
}
type enrichPayload struct {
ItemID int64 `json:"item_id"`
}
// Handle is the queue handler for JobKind.
func (e *Enricher) Handle(ctx context.Context, payload json.RawMessage) error {
var p enrichPayload
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("metadata: bad payload: %w", err)
}
if p.ItemID <= 0 {
return errors.New("metadata: payload has no item_id")
}
return e.EnrichItem(ctx, p.ItemID)
}
// EnrichItem tries each provider for the item's kind until one matches.
//
// A provider error is recorded and the next provider is tried. The function
// returns an error only for a database failure, because a missing match is a
// normal outcome and must not fail the job.
func (e *Enricher) EnrichItem(ctx context.Context, itemID int64) error {
// This runs as a background job, not a request, so there is no user and no
// grant to check. catalog.Load is the unchecked read, which is correct
// here: the job acts on an item the scanner already admitted to the
// catalogue, and a job that silently skipped its work because of a grant
// check it cannot perform would be a bug.
it, err := catalog.LoadUnchecked(ctx, e.db, itemID)
if err != nil {
if errors.Is(err, catalog.ErrNotFound) {
return nil // deleted since the job was queued
}
return err
}
// Already matched by a previous run.
var matched int
if err := e.db.QueryRowContext(ctx,
`SELECT count(*) FROM provider_ids WHERE item_id = ?`, itemID).Scan(&matched); err != nil {
return err
}
if matched > 0 {
return nil
}
// Providers are chosen by the LIBRARY's kind, not the row's. Every video kind
// stores kind='video' (see library.MediaType.ItemKind), so a movies library
// and an adult library hold identical rows; only the library says which
// catalogue is allowed to look at them. Routing on the row kind would send
// every video item to TMDB, adult content included.
kind := it.Kind
if lib, err := library.Get(ctx, e.db, it.LibraryID); err == nil {
kind = lib.Kind
}
provs := e.providers(kind)
if len(provs) == 0 {
return nil
}
// Year is what disambiguates a remake from the original, and it is the one
// hint every provider that matches titles can use. Providers that cannot
// ignore it.
q := providers.Query{Title: searchTitle(it.Title), Year: it.Year, Author: artistHint(it), Kind: kind}
for _, p := range provs {
if e.triedRecently(ctx, itemID, p.Name()) {
continue
}
candidates, err := p.Search(ctx, q)
if err != nil {
e.log.Warn("metadata provider failed", "provider", p.Name(), "item", itemID, "err", err)
if recErr := e.record(ctx, itemID, p.Name(), "error", err.Error()); recErr != nil {
return recErr
}
continue
}
if len(candidates) == 0 {
if recErr := e.record(ctx, itemID, p.Name(), "notfound", ""); recErr != nil {
return recErr
}
continue
}
// Providers return fuzzy matches. Taking the first blindly is how you
// end up labelling Daft Punk's album as Franz Kirmann's. Require the
// title to actually correspond before accepting.
best, ok := pickBest(candidates, it.Title)
if !ok {
e.log.Info("metadata: no close match", "item", itemID, "provider", p.Name(), "title", it.Title)
if recErr := e.record(ctx, itemID, p.Name(), "notfound", "no close title match"); recErr != nil {
return recErr
}
continue
}
if err := e.apply(ctx, it, best); err != nil {
return err
}
if err := e.record(ctx, itemID, p.Name(), "ok", ""); err != nil {
return err
}
e.log.Info("metadata matched", "item", itemID, "provider", p.Name(), "title", best.Title)
return nil
}
return nil
}
// artistHint recovers the artist or author from the directory structure.
//
// Directory-grouped kinds store the item as "Artist/Album" or "Author/Book",
// so the parent directory is a strong signal that the provider search should
// use to disambiguate. Without it, "Random Access Memories" matches a release
// by an unrelated artist.
func artistHint(it catalog.Item) string {
if library.GroupModeFor(it.Kind) != library.GroupByDirectory {
return ""
}
dir := path.Dir(it.Path)
if dir == "." || dir == "/" || dir == "" {
return ""
}
return path.Base(dir)
}
// searchTitle strips the region, language, and version tags that ROM and
// release filenames carry — "(USA)", "[!]", "(Rev 1)", "(En,Fr,De)" — because
// they are noise to a metadata search. "Sonic the Hedgehog (USA)" matches
// nothing; "Sonic the Hedgehog" matches immediately.
func searchTitle(s string) string {
s = strings.TrimSpace(s)
for {
trimmed := strings.TrimSpace(s)
var cut int
switch {
case strings.HasSuffix(trimmed, ")"):
cut = strings.LastIndexByte(trimmed, '(')
case strings.HasSuffix(trimmed, "]"):
cut = strings.LastIndexByte(trimmed, '[')
default:
return trimmed
}
// cut <= 0 would mean the whole title is a bracketed group; leave it.
if cut <= 0 {
return trimmed
}
s = strings.TrimSpace(trimmed[:cut])
if s == "" {
return trimmed
}
}
}
// pickBest returns the first candidate whose title corresponds to want.
//
// The bar is deliberately loose (normalised equality or containment in either
// direction) but not absent: it accepts "The Hobbit" against "The Hobbit, or
// There and Back Again" and rejects "Random Access Memories" against an
// unrelated album that merely shares a word.
func pickBest(candidates []providers.Candidate, want string) (providers.Candidate, bool) {
w := normalizeTitle(want)
if len(w) < 3 {
return providers.Candidate{}, false
}
for _, c := range candidates {
got := normalizeTitle(c.Title)
if len(got) < 3 {
continue
}
if got == w || strings.Contains(got, w) || strings.Contains(w, got) {
return c, true
}
}
return providers.Candidate{}, false
}
// normalizeTitle lowercases and strips punctuation so titles can be compared
// regardless of formatting differences.
func normalizeTitle(s string) string {
var b strings.Builder
lastSpace := true
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastSpace = false
case r >= 'à' && r <= 'ÿ':
// Keep accented letters as-is; providers and files usually agree.
b.WriteRune(r)
lastSpace = false
default:
if !lastSpace {
b.WriteByte(' ')
lastSpace = true
}
}
}
return strings.TrimSpace(b.String())
}
// apply writes a chosen candidate back to the database.
func (e *Enricher) apply(ctx context.Context, it catalog.Item, c providers.Candidate) error {
tx, err := e.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
if _, err := tx.ExecContext(ctx, `
INSERT INTO provider_ids (item_id, provider, external_id) VALUES (?, ?, ?)
ON CONFLICT (item_id, provider) DO UPDATE SET external_id = excluded.external_id`,
it.ID, c.Provider, c.ExternalID); err != nil {
return fmt.Errorf("metadata: store provider id: %w", err)
}
// Only overwrite the scanner's title when the catalogue gave us one.
title := it.Title
if strings.TrimSpace(c.Title) != "" {
title = strings.TrimSpace(c.Title)
}
if _, err := tx.ExecContext(ctx, `
UPDATE media_items SET title = ?, sort_title = ?, year = ? WHERE id = ?`,
title, strings.ToLower(title), nullableYear(c.Year), it.ID); err != nil {
return fmt.Errorf("metadata: update item: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM item_people WHERE item_id = ? AND role = 'author'`, it.ID); err != nil {
return err
}
for i, name := range c.Authors {
name = strings.TrimSpace(name)
if name == "" {
continue
}
var personID int64
if err := tx.QueryRowContext(ctx, `
INSERT INTO people (name) VALUES (?)
ON CONFLICT (name) DO UPDATE SET name = excluded.name
RETURNING id`, name).Scan(&personID); err != nil {
return fmt.Errorf("metadata: upsert person: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', ?)
ON CONFLICT (item_id, person_id, role) DO UPDATE SET ord = excluded.ord`,
it.ID, personID, i); err != nil {
return fmt.Errorf("metadata: link author: %w", err)
}
}
if err := tx.Commit(); err != nil {
return err
}
// Cover download is best-effort and outside the transaction: a slow image
// host must not hold a write lock.
if c.CoverURL != "" {
if err := e.downloadCover(ctx, it, c); err != nil {
e.log.Warn("cover download failed", "item", it.ID, "err", err)
}
}
return nil
}
// downloadCover fetches a cover into the cache and records it as an asset.
func (e *Enricher) downloadCover(ctx context.Context, it catalog.Item, c providers.Candidate) error {
var existing int
if err := e.db.QueryRowContext(ctx,
`SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&existing); err != nil {
return err
}
if existing > 0 {
return nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.CoverURL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Vellum/0.1 (self-hosted media server)")
resp, err := e.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cover status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxCoverBytes))
if err != nil {
return err
}
if len(data) == 0 {
return errors.New("empty cover")
}
dir, err := media.CoverCacheDir(e.cacheDir, it.Kind)
if err != nil {
return err
}
ext := coverExt(resp.Header.Get("Content-Type"), c.CoverURL)
outPath := filepath.Join(dir, fmt.Sprintf("%d%s", it.ID, ext))
tmp := outPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o640); err != nil {
return err
}
if err := os.Rename(tmp, outPath); err != nil {
_ = os.Remove(tmp)
return err
}
_, err = e.db.ExecContext(ctx, `
INSERT INTO assets (item_id, role, path, bytes) VALUES (?, 'cover', ?, ?)
ON CONFLICT (item_id, role, path) DO NOTHING`, it.ID, outPath, len(data))
return err
}
// triedRecently reports whether a provider was already consulted for an item.
func (e *Enricher) triedRecently(ctx context.Context, itemID int64, provider string) bool {
var status, fetchedAt string
err := e.db.QueryRowContext(ctx,
`SELECT status, fetched_at FROM metadata_fetch WHERE item_id = ? AND provider = ?`,
itemID, provider).Scan(&status, &fetchedAt)
if err != nil {
return false
}
if status != "ok" && status != "notfound" {
return false // errors are always worth retrying
}
when, err := time.Parse(time.RFC3339Nano, fetchedAt)
if err != nil {
return false
}
return time.Since(when) < retryAfter
}
func (e *Enricher) record(ctx context.Context, itemID int64, provider, status, detail string) error {
_, err := e.db.ExecContext(ctx, `
INSERT INTO metadata_fetch (item_id, provider, status, detail, fetched_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (item_id, provider)
DO UPDATE SET status = excluded.status, detail = excluded.detail, fetched_at = excluded.fetched_at`,
itemID, provider, status, detail, core.Now())
return err
}
func coverExt(contentType, url string) string {
switch {
case strings.Contains(contentType, "png"):
return ".png"
case strings.Contains(contentType, "webp"):
return ".webp"
case strings.Contains(contentType, "gif"):
return ".gif"
case strings.Contains(contentType, "jpeg"), strings.Contains(contentType, "jpg"):
return ".jpg"
}
lower := strings.ToLower(url)
for _, ext := range []string{".png", ".webp", ".gif", ".jpeg", ".jpg"} {
if strings.Contains(lower, ext) {
return ext
}
}
return ".jpg"
}
// nullableYear stores 0 as NULL rather than a literal year zero.
func nullableYear(year int) any {
if year <= 0 {
return nil
}
return year
}