Vellum/cmd/vellum/main.go
mithral 3207b89205 Add a TMDB provider for video: films and television
Five providers existed and none covered the "video" kind, so every movie
and show got a title from its filename and nothing else - no poster, no
plot, no cast, no year. That is the gap between "a folder of videos with a
nice web UI" and a media server.

TMDB keeps films and television in separate namespaces behind separate
endpoints, and a Query does not say which one it is. Rather than guessing
from the title, Search asks both at once and merges by TMDB popularity,
which is the only ranking signal available without a second request per
item.

Speed is the point, so the two requests run concurrently over one derived
context on a single shared client, and whichever fails first cancels the
other: one dead endpoint cannot hold the result hostage while the healthy
one is waited out. Verified red-green - serialising the two fetches makes
TestTMDBSearchesConcurrently fail at 2s.

Two corrections made in review, both found by reading the code rather than
trusting the report:

1. ExternalID was the bare TMDB id, so film 603 and show 603 - different
   works - would have been stored identically in provider_ids. Namespaced
   as movie/603 and tv/603. Nothing resolves an item from an external_id
   today, so this was latent rather than broken, but the table is populated
   as enrichment runs and correcting it later would have needed a data
   migration. Pinned by TestTMDBExternalIDIsNamespaced.

2. Query.Year was added but never populated, so the year filter - the main
   way to tell a remake from the original - never applied. catalog.Item
   already carried Year; the enricher now passes it.

No cover bytes are fetched here: the provider returns image URLs and the
existing enricher caches what it uses. A missing key logs the env var and
skips, so the feature is discoverable from the boot log.

Not verified: the live TMDB API. Tests run against httptest servers only.
2026-10-12 01:17:48 -04:00

239 lines
8.4 KiB
Go

// Command vellum is the unified media server: one process, one database,
// every media type.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/cli"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/discovery"
"github.com/mithral/vellum/internal/home"
"github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/internal/jellyfin"
"github.com/mithral/vellum/internal/jobs"
"github.com/mithral/vellum/internal/library"
"github.com/mithral/vellum/internal/media"
"github.com/mithral/vellum/internal/metadata"
"github.com/mithral/vellum/internal/modules/comics"
"github.com/mithral/vellum/internal/modules/ebooks"
"github.com/mithral/vellum/internal/modules/games"
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
"github.com/mithral/vellum/internal/playback"
"github.com/mithral/vellum/internal/providers"
"github.com/mithral/vellum/internal/scanner"
"github.com/mithral/vellum/internal/search"
"github.com/mithral/vellum/internal/subsonic"
"github.com/mithral/vellum/migrations"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
cfg, err := core.LoadConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "vellum: fatal:", err)
os.Exit(1)
}
log := core.NewLogger(cfg.LogLevel, cfg.LogFormat)
// Administrative subcommands run instead of the server. This is the way
// back in when someone is locked out, and the way to take a backup while
// the server is stopped.
if handled, err := cli.Run(os.Args[1:], cfg, log); handled {
if err != nil {
fmt.Fprintln(os.Stderr, "vellum:", err)
os.Exit(1)
}
return
}
if err := run(cfg, log); err != nil {
fmt.Fprintln(os.Stderr, "vellum: fatal:", err)
os.Exit(1)
}
}
func run(cfg core.Config, log *slog.Logger) error {
log.Info("starting vellum", "version", version, "listen", cfg.Listen)
if err := cfg.Validate(); err != nil {
return err
}
db, err := core.OpenDB(cfg.DBPath)
if err != nil {
return err
}
defer db.Close()
migCtx, cancelMig := context.WithTimeout(context.Background(), 60*time.Second)
err = core.Migrate(migCtx, db, migrations.FS, log)
cancelMig()
if err != nil {
return err
}
api := httpapi.New(db, log, version)
authenticator := auth.NewAuthenticator(db, cfg.CookieName, cfg.SessionTTL, cfg.CookieSecure, log)
authenticator.TrustedProxies = cfg.TrustedProxies
if !cfg.CookieSecure {
log.Warn("VELLUM_COOKIE_SECURE is false; session cookies will be sent over plain HTTP. " +
"Set it to true when serving over TLS.")
}
if err := auth.EnsureAdminFromEnv(context.Background(), db, log, cfg.AdminUser, cfg.AdminPassword); err != nil {
return fmt.Errorf("bootstrap admin: %w", err)
}
authHandlers := auth.NewHandlers(authenticator, log)
authHandlers.Register(api.Mux())
api.SetAuth(authenticator)
// Background work: a table-backed queue in the same database. Created
// before the modules so a module's handlers can enqueue work.
queue := jobs.New(db, log)
library.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
// The landing screen is an aggregate over items, not an item resource, so it
// is mounted by its own package.
home.NewHandlers(db, log, authenticator).Register(api.Mux())
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
games.NewHandlers(db, log, authenticator).Register(api.Mux())
search.NewHandlers(db, log, authenticator).Register(api.Mux())
// Playback engine registry + selection. Vellum never transcodes, so the
// server reports what is inside a file and matches it against the engines the
// CLIENT declares — a browser offers "html5", a native client can offer mpv.
playback.NewHandlers(catalog.New(db), log, authenticator).Register(api.Mux())
// Client compatibility layers. Neither speaks the session cookie; both
// authenticate with an api_tokens credential and then go through the same
// grant chokepoint as the web app. They mount at the root because that is
// where the real clients look, and both report SupportsTranscoding=false:
// this server has no CPU for it, so a client that demands a transcode is
// refused rather than left waiting for one that will never start.
jellyfin.NewHandlers(db, log, authenticator).Register(api.Mux())
subsonic.NewHandlers(db, log, authenticator).Register(api.Mux())
// Scans run on the queue rather than inside the request: a real library
// takes longer than any sensible timeout, and a hanging request is
// indistinguishable from a broken server. The result is written back so
// the UI can read progress.
queue.Register(library.ScanJobKind, func(ctx context.Context, payload json.RawMessage) error {
var p library.ScanPayload
if err := json.Unmarshal(payload, &p); err != nil {
return fmt.Errorf("scan: bad payload: %w", err)
}
lib, err := library.Get(ctx, db, p.LibraryID)
if err != nil {
return nil // deleted since the job was queued
}
res, err := scanner.Scan(ctx, db, lib, log)
if jobID, ok := jobs.JobID(ctx); ok {
if serr := jobs.SetResult(ctx, db, jobID, res); serr != nil {
log.Warn("record scan result", "err", serr)
}
}
return err
})
// Providers are registered primary-first; a fallback is only consulted
// when the primary returns nothing.
providers.Register(providers.NewOpenLibrary())
providers.Register(providers.NewGoogleBooks())
providers.Register(providers.NewMusicBrainz())
if cfg.ComicVineAPIKey != "" {
providers.Register(providers.NewComicVine(cfg.ComicVineAPIKey))
} else {
log.Info("comic metadata disabled: set VELLUM_COMICVINE_API_KEY to enable ComicVine lookups")
}
if cfg.IGDBClientID != "" && cfg.IGDBClientSecret != "" {
providers.Register(providers.NewIGDB(cfg.IGDBClientID, cfg.IGDBClientSecret))
} else {
log.Info("game metadata disabled: set VELLUM_IGDB_CLIENT_ID and VELLUM_IGDB_CLIENT_SECRET to enable IGDB lookups")
}
if cfg.TMDBAPIKey != "" {
providers.Register(providers.NewTMDB(cfg.TMDBAPIKey))
} else {
log.Info("video metadata disabled: set VELLUM_TMDB_API_KEY to enable TMDB movie and TV lookups")
}
enricher := metadata.NewEnricher(db, log, cfg.ConfigDir)
queue.Register(metadata.JobKind, enricher.Handle)
metadata.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
// Anything left 'running' belongs to a previous process that died mid-job.
// Requeue before the workers start, or it stays 'running' forever and the
// UI polls a job that will never move.
if n, err := queue.RecoverStale(context.Background()); err != nil {
log.Warn("recover stale jobs", "err", err)
} else if n > 0 {
log.Info("requeued jobs left running by a previous process", "count", n)
}
jobCtx, cancelJobs := context.WithCancel(context.Background())
defer cancelJobs()
go queue.Run(jobCtx)
// mDNS: advertise on the LAN so clients browse to this server instead of
// being handed a URL. Cancelled with the job queue so the records are
// withdrawn before the process exits.
kinds := make([]string, 0)
for _, mt := range library.SupportedTypes() {
kinds = append(kinds, mt.Kind)
}
go discovery.Start(jobCtx, cfg, version, kinds, log)
srv := &http.Server{
Addr: cfg.Listen,
Handler: api.Handler(),
// ReadHeaderTimeout protects against slowloris. WriteTimeout is
// deliberately zero: streaming large media files must not be cut off.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
ErrorLog: nil,
}
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Listen, "data_dir", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
cancelJobs()
log.Info("stopped")
return nil
}