Vellum/cmd/vellum/main.go
mithral 82ded6a9c7 Add a ThePornDB provider for the adult kind
The adult kind scanned and browsed but could never be enriched, because no
provider claimed it. ThePornDB does: it is the catalogue the Stash ecosystem
uses for scenes and movies, and it is keyed the same way IGDB and TMDB are, so
it registers only when VELLUM_TPDB_API_KEY is set and otherwise logs the env
var at boot.

The API shape came from the live OpenAPI spec rather than the docs, which is
where the search parameter lives: it is `parse`, not `q` or `title`, and the
endpoint is /scenes with a `data` envelope.

Not verified against the live API: it needs a token I do not have, so the
tests run against httptest only. The field names are taken from the published
schema, which is the best available substitute.

The provider also does not use ThePornDB's hash lookup, which is the more
accurate way to match: it needs a file hash computed at scan time, and
matching by parsed title and date works today without that.
2026-10-12 11:30:58 -04:00

244 lines
8.6 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")
}
if cfg.TPDBAPIKey != "" {
providers.Register(providers.NewThePornDB(cfg.TPDBAPIKey))
} else {
log.Info("adult metadata disabled: set VELLUM_TPDB_API_KEY to enable ThePornDB 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
}