// 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 }