release / govulncheck (push) Successful in 17s
release / web (push) Failing after 17s
release / go (push) Successful in 1m11s
release / integration (push) Successful in 4m50s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Ported from Fabled Librarian's System 104. A new lifecycle poller, every ten minutes: - a download the client fails is given up at once; - one nobody shares, or still fetching its file list, past the stall setting (6 h) is asked for again three times, then given up; - giving up removes the download with its files, holds the release back for a week (release_blocks.expires_at), ends the grab and searches the album again, with no limit per album; - a running download the client stops listing for half an hour ends and is searched again, not held back; an empty list is not taken as gone; - an imported download seeds to ratio 2.0 or 14 days, then is removed; - a download the client would not remove is asked again every pass. Every condition and act is one open row per (client, download, condition) in download_interventions. Activity shows what is wrong with a download and how often it was retried, why one ended, its seeding, and a banner for a client that does not answer. A running download can be given up by hand (set aside for good). The seed ratio, seed days and stall hours are on the search settings card. The download client lookup moves to acquire.DownloadClientFor, shared by the importer and the lifecycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
495 lines
20 KiB
Go
495 lines
20 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||
)
|
||
|
||
func main() {
|
||
if len(os.Args) > 1 {
|
||
switch os.Args[1] {
|
||
case "admin":
|
||
if err := runAdmin(os.Args[2:]); err != nil {
|
||
fmt.Fprintf(os.Stderr, "minstrel admin: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
return
|
||
case "migrate":
|
||
if err := runMigrate(os.Args[2:]); err != nil {
|
||
fmt.Fprintf(os.Stderr, "minstrel migrate: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
if err := run(); err != nil {
|
||
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
|
||
// full delta walk, catching anything the watcher missed (inotify watch-limit
|
||
// exhaustion, dropped events). Fixed — there is no operator configuration.
|
||
const safetyNetScanInterval = 12 * time.Hour
|
||
|
||
// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx
|
||
// is cancelled, deferring to TryStartScan's in-flight guard so it never
|
||
// collides with a manual, startup, or watcher-driven scan.
|
||
func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner,
|
||
enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig,
|
||
) {
|
||
ticker := time.NewTicker(safetyNetScanInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg)
|
||
if err != nil {
|
||
logger.Error("safety-net scan: try start failed", "err", err)
|
||
} else if !started && existing != nil {
|
||
logger.Info("safety-net scan skipped — prior still in flight",
|
||
"in_flight_id", existing.ID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func run() error {
|
||
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||
flag.Parse()
|
||
|
||
cfg, err := config.Load(*configPath)
|
||
if err != nil {
|
||
return fmt.Errorf("load config: %w", err)
|
||
}
|
||
|
||
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
|
||
if err != nil {
|
||
return fmt.Errorf("init logger: %w", err)
|
||
}
|
||
|
||
logger.Info("minstrel starting",
|
||
"address", cfg.Server.Address,
|
||
"log_level", cfg.Log.Level,
|
||
"log_format", cfg.Log.Format,
|
||
)
|
||
|
||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
|
||
return fmt.Errorf("migrate: %w", err)
|
||
}
|
||
|
||
pool, err := db.Open(ctx, cfg.Database.URL)
|
||
if err != nil {
|
||
return fmt.Errorf("open db: %w", err)
|
||
}
|
||
defer pool.Close()
|
||
|
||
// Fingerprinting settings (M400 #3913): one instance, shared by the scanner,
|
||
// the fingerprint backfill, the duplicate sweep and the admin API, so a save
|
||
// reaches all of them without a restart. A load failure is logged, not fatal:
|
||
// the service falls back to the shipped defaults.
|
||
fpSettings, fpErr := library.NewFingerprintSettingsService(ctx, pool)
|
||
if fpErr != nil {
|
||
logger.Warn("fingerprint settings: using defaults", "err", fpErr)
|
||
}
|
||
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
|
||
|
||
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
|
||
// and the admin API, and falls back to the defaults like the above.
|
||
loudSettings, loudErr := library.NewLoudnessSettingsService(ctx, pool)
|
||
if loudErr != nil {
|
||
logger.Warn("loudness settings: using defaults", "err", loudErr)
|
||
}
|
||
|
||
// AcoustID lookup settings (M401): shared by the lookup worker and the
|
||
// admin API. A load failure leaves the lookup off.
|
||
acoustIDSettings, acoustIDErr := library.NewAcoustIDSettingsService(ctx, pool)
|
||
if acoustIDErr != nil {
|
||
logger.Warn("acoustid settings: using defaults (lookup off)", "err", acoustIDErr)
|
||
}
|
||
// One AcoustID client, so the lookup worker and the import's check share
|
||
// its pace (AcoustID allows three requests a second per key).
|
||
acoustIDClient := acoustid.New(acoustid.DefaultBaseURL)
|
||
acoustIDLookup := library.NewAcoustIDLookupWorker(pool, logger.With("component", "acoustid_lookup"),
|
||
acoustIDSettings, acoustIDClient)
|
||
|
||
contact := cfg.Library.ContactEmail
|
||
if contact == "" {
|
||
contact = "https://git.fabledsword.com/bvandeusen/minstrel"
|
||
}
|
||
// Swap in the production User-Agent + MinPeriod on the registered
|
||
// MBCAA provider. init() registered it with default config; this
|
||
// updates the running instance.
|
||
coverart.NewMBCAAProviderFromConfig(coverart.FetcherConfig{
|
||
UserAgent: fmt.Sprintf("Minstrel/dev (%s)", contact),
|
||
MinPeriod: time.Second,
|
||
})
|
||
|
||
coverSettings, err := coverart.NewSettingsService(ctx, pool, logger.With("component", "coverart"))
|
||
if err != nil {
|
||
logger.Error("coverart settings service init failed", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
if newVer, bumped, berr := coverSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
|
||
logger.Warn("coverart: provider-hash boot check failed", "err", berr)
|
||
} else if bumped {
|
||
logger.Info("coverart: registered provider set changed; version bumped",
|
||
"new_version", newVer)
|
||
}
|
||
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
||
coverEnricher.DataDir = cfg.Storage.DataDir
|
||
|
||
// Recommendation tuning lab (#1250): seeds shipped defaults on first
|
||
// boot and pushes the daily_mix weights + taste config into package
|
||
// playlists — must precede the scheduler so the first builds score
|
||
// with the operator's tuned values, not the pre-push literals.
|
||
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
|
||
if err != nil {
|
||
logger.Error("recommendation settings service init failed", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||
// Boot-time scan and manual-trigger scans share this path; results land
|
||
// in scan_runs for the admin overview.
|
||
if cfg.Library.ScanOnStartup && len(cfg.Library.ScanPaths) > 0 {
|
||
go func() {
|
||
if _, err := library.RunScan(ctx, pool, scanner, coverEnricher,
|
||
logger.With("component", "scan_run"),
|
||
library.RunScanConfig{
|
||
BackfillCap: 5000,
|
||
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||
ArtistEnrichCap: -1,
|
||
DataDir: cfg.Storage.DataDir,
|
||
},
|
||
); err != nil {
|
||
logger.Warn("startup scan run failed", "err", err)
|
||
}
|
||
}()
|
||
} else {
|
||
// No startup file walk, but still run backfill + enrich so cover
|
||
// progress doesn't stall just because the operator disabled
|
||
// scan-on-startup.
|
||
go func() {
|
||
if _, err := library.RunScan(ctx, pool, nil, coverEnricher,
|
||
logger.With("component", "scan_run"),
|
||
library.RunScanConfig{
|
||
BackfillCap: 5000,
|
||
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||
ArtistEnrichCap: -1,
|
||
DataDir: cfg.Storage.DataDir,
|
||
},
|
||
); err != nil {
|
||
logger.Warn("boot-only scan run failed", "err", err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
// Start the ListenBrainz scrobble worker. Per spec §M4a, runs every 30s
|
||
// and drains up to 50 pending rows per tick. Per-user gating happens
|
||
// inside the worker (rows from disabled users are skipped).
|
||
scrobbleWorker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "scrobble"))
|
||
go scrobbleWorker.Run(ctx)
|
||
|
||
// Start the similarity ingest worker. Per spec §M4b, runs every 1h, drains
|
||
// up to 5 played tracks + 5 played artists per tick, weekly re-fetch cap
|
||
// per row. Public LB endpoints — no token required.
|
||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||
go similarityWorker.Run(ctx)
|
||
|
||
// Start the household co-play worker (#1533). Recomputes artist–artist
|
||
// co-occurrence edges (source='user_cooccurrence') from play_events every
|
||
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
|
||
// SQL, no external calls; empty on single-user servers.
|
||
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||
|
||
// Fingerprint backfill (M400 #3908): fingerprints the tracks the scan never
|
||
// will — everything imported before fingerprinting existed, and rows derived
|
||
// by an older method. A worker of its own rather than a scan stage; see
|
||
// internal/library/fingerprint_backfill.go for why.
|
||
go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx)
|
||
|
||
// Loudness backfill (M464 #4995): measures every track's loudness for
|
||
// normalization, new tracks included; the scan only drops a changed file's
|
||
// measurement. See internal/library/loudness_backfill.go.
|
||
go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx)
|
||
|
||
// AcoustID lookup (M401): fills the recording MBIDs untagged tracks lack,
|
||
// so the similarity arms can see them. Idle until the operator adds a key
|
||
// and switches it on. See internal/library/acoustid_lookup.go.
|
||
go acoustIDLookup.Run(ctx)
|
||
|
||
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one
|
||
// recording, from the fingerprints above. Sweeps only when fingerprints have
|
||
// changed since the last sweep.
|
||
go library.NewDuplicateSweepWorker(pool, logger.With("component", "duplicate_sweep"), fpSettings).Run(ctx)
|
||
|
||
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||
// tag providers with tag_provider_settings, bumps the sources version if
|
||
// the provider set changed (re-opening settled rows), then drains tracks
|
||
// needing folksonomy tags on a periodic tick. Standalone (not in the file
|
||
// scan chain) because tag lookups need only DB fields — MBID / artist /
|
||
// title — that a scan has already imported.
|
||
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
|
||
if err != nil {
|
||
logger.Error("tag settings service init failed", "err", err)
|
||
os.Exit(1)
|
||
}
|
||
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
|
||
logger.Warn("tags: provider-hash boot check failed", "err", berr)
|
||
} else if bumped {
|
||
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
|
||
}
|
||
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
|
||
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
|
||
|
||
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||
// that have no writer-side close path or retention policy:
|
||
// orphan play_events, stale play_sessions, expired
|
||
// scrobble_queue failures, stuck system_playlist_runs, expired
|
||
// password_resets. Each sweep is idempotent — a row that's
|
||
// already clean is a no-op. Addresses drift audit findings
|
||
// #565 #566 #567 #574 #575 (Scribe parent #552).
|
||
gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
|
||
go gcWorker.Run(ctx)
|
||
|
||
// The request reconciler completes approved requests once the library
|
||
// holds what they asked for (M502 #5520: wants, not Lidarr adds).
|
||
// Live-event bus shared between SSE subscribers (api.Mount) and
|
||
// background workers that publish (reconciler today; scanner later).
|
||
// Constructed before any service that publishes so they all share the
|
||
// same instance.
|
||
bus := eventbus.New()
|
||
// Scan-run lifecycle events use a package-level setter rather than
|
||
// threading the bus through RunScan + TryStartScan + the Scheduler
|
||
// + every test caller. Per-process singleton, set once at startup.
|
||
library.SetEventBus(bus)
|
||
// The notifications inbox's one writer (M489), shared by every
|
||
// background producer started here. The API builds its own over the
|
||
// same pool and bus.
|
||
notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
|
||
|
||
requestReconciler := requests.NewReconciler(pool, logger.With("component", "requests"), bus)
|
||
requestReconciler.SetNotifier(notifier)
|
||
library.SetNotifier(notifier)
|
||
go requestReconciler.Run(ctx)
|
||
|
||
// Release catalogue (M502 #5519): re-reads discographies older than a
|
||
// week from MusicBrainz, a batch an hour, through the process-wide
|
||
// MusicBrainz pacer it shares with tag enrichment.
|
||
cat := catalogue.New(pool, nil, logger.With("component", "catalogue"))
|
||
go cat.Run(ctx)
|
||
|
||
// The wanted list (M502 #5520): reads followed artists' discographies
|
||
// daily for new releases and marks wants held once the library has them.
|
||
go wanted.New(pool, cat, logger.With("component", "wanted")).Run(ctx)
|
||
|
||
// Search and grab (M502 #5521): every ten minutes, searches the
|
||
// configured indexers for wants due, paced by each source's
|
||
// searches-per-hour, and hands the best release to the download client
|
||
// when auto-grab is on.
|
||
go wanted.NewSearcher(pool, cat, logger.With("component", "search")).Run(ctx)
|
||
|
||
// Import with verification (M502 #5522): every minute, finished
|
||
// downloads are checked against their album (AcoustID, lengths, tags)
|
||
// and filed into the library with MusicBrainz tags, or refused.
|
||
var printer importer.Printer
|
||
if acoustIDSettings != nil {
|
||
printer = &importer.AcoustIDPrinter{Settings: acoustIDSettings, Client: acoustIDClient}
|
||
}
|
||
go importer.New(pool, scanner, cat, printer, cfg.Library.ScanPaths, logger.With("component", "import")).Run(ctx)
|
||
|
||
// Download lifecycle (M502 #5523): every ten minutes, a download that
|
||
// failed or that nobody shares is retried, then given up and its album
|
||
// searched again; an imported one seeds, then is removed.
|
||
go lifecycle.New(pool, logger.With("component", "lifecycle")).Run(ctx)
|
||
|
||
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with
|
||
// the operator's auto-resolve on, merges the copies that are safe to merge.
|
||
// Started after SetNotifier so its summaries reach admins. See
|
||
// internal/library/duplicate_resolve.go.
|
||
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
|
||
cfg.Storage.DataDir, fpSettings).Run(ctx)
|
||
|
||
// Missing-file re-acquisition (milestone #290, M502 #5520). Wants back
|
||
// albums whose files have been gone longer than the grace window; the
|
||
// search schedule looks for them. Hourly tick — the grace window is
|
||
// measured in hours.
|
||
//
|
||
// A settings-load failure is logged, not fatal: NewSettingsService always
|
||
// returns a usable service holding the defaults, and running on defaults
|
||
// is far better than dropping the feature because the database hiccuped
|
||
// during boot.
|
||
reacqSettings, reacqErr := reacquisition.NewSettingsService(
|
||
ctx, pool, logger.With("component", "reacquisition"))
|
||
if reacqErr != nil {
|
||
logger.Warn("reacquisition: using default settings", "err", reacqErr)
|
||
}
|
||
go reacquisition.NewSweeper(pool, reacqSettings, logger.With("component", "reacquisition")).Run(ctx)
|
||
|
||
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
||
// rows older than the configured retention so the change-log table
|
||
// doesn't grow unbounded. Clients that drop offline longer than
|
||
// retention hit the /api/library/sync 410 fallback and resync.
|
||
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
|
||
go libraryChangesCompactor.Run(ctx)
|
||
|
||
// Notifications inbox retention (M489). Daily: read rows go after 90
|
||
// days, anything at all after a year.
|
||
go notifications.NewRetention(pool, logger.With("component", "notifications_retention")).Run(ctx)
|
||
|
||
// Notifications by email (M489 #5346), grouped, never one per event: new
|
||
// music as a daily summary at a local hour, everything else batched an
|
||
// hour (admin-configurable) after the first item.
|
||
go notifications.NewDigest(
|
||
pool,
|
||
mailer.NewSMTPSender(pool, logger.With("component", "mailer")),
|
||
logger.With("component", "notification_digest"),
|
||
).Run(ctx)
|
||
|
||
// Per-user system-playlist scheduler (#392 Half B). Fires each
|
||
// active user's daily build at 03:00 in their stored timezone.
|
||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||
// of this arc).
|
||
playlistScheduler, err := playlists.NewScheduler(
|
||
pool,
|
||
logger.With("component", "playlist_scheduler"),
|
||
cfg.Storage.DataDir,
|
||
bus,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("init playlist scheduler: %w", err)
|
||
}
|
||
if err := playlistScheduler.Start(ctx); err != nil {
|
||
return fmt.Errorf("start playlist scheduler: %w", err)
|
||
}
|
||
defer playlistScheduler.Stop()
|
||
|
||
// Ensure DataDir exists before any service tries to write into it.
|
||
// Fatal on failure: a non-writable data_dir silently breaks every
|
||
// downstream cache (playlist covers, artist art, album-cover fallback)
|
||
// and produces "0 successes" symptoms that are hard to diagnose.
|
||
if cfg.Storage.DataDir != "" {
|
||
if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
|
||
logger.Error("data_dir create failed; refusing to start",
|
||
"path", cfg.Storage.DataDir, "err", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
scanCfg := library.RunScanConfig{
|
||
BackfillCap: 5000,
|
||
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||
ArtistEnrichCap: -1,
|
||
DataDir: cfg.Storage.DataDir,
|
||
}
|
||
// Filesystem watcher: near-instant pickup of new/changed files via
|
||
// fsnotify, scanning just the affected paths + enriching their albums
|
||
// inline. Replaces the removed configurable scan scheduler.
|
||
watcher := library.NewWatcher(scanner, coverEnricher,
|
||
logger.With("component", "watcher"), cfg.Library.ScanPaths)
|
||
go func() {
|
||
if werr := watcher.Run(ctx); werr != nil {
|
||
logger.Warn("library watcher exited", "err", werr)
|
||
}
|
||
}()
|
||
// Safety-net delta walk backstops the watcher (inotify limits / dropped
|
||
// events) at a fixed low frequency. No operator config.
|
||
go runSafetyNetScans(ctx, pool, scanner, coverEnricher,
|
||
logger.With("component", "scan_safetynet"), scanCfg)
|
||
|
||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||
srv.Bus = bus
|
||
srv.PlaylistScheduler = playlistScheduler
|
||
srv.RecSettings = recSettings
|
||
srv.TagSettings = tagSettings
|
||
srv.FingerprintSettings = fpSettings
|
||
srv.LoudnessSettings = loudSettings
|
||
srv.AcoustIDLookup = acoustIDLookup
|
||
// The sweeper above holds this same instance, so a save from the admin
|
||
// card changes what it does on its next tick (#3936).
|
||
srv.ReacqSettings = reacqSettings
|
||
srv.StreamSecret = cfg.StreamSecret
|
||
httpServer := &http.Server{
|
||
Addr: cfg.Server.Address,
|
||
Handler: srv.Router(),
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
// Closes keep-alive connections nobody is using. Deliberately no
|
||
// ReadTimeout or WriteTimeout: either would cut off audio streams and
|
||
// the SSE event stream. Request bodies get their own deadline in the
|
||
// server's limitRequestBody middleware instead.
|
||
IdleTimeout: 120 * time.Second,
|
||
}
|
||
|
||
errCh := make(chan error, 1)
|
||
go func() {
|
||
logger.Info("listening", "address", cfg.Server.Address)
|
||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
errCh <- err
|
||
}
|
||
close(errCh)
|
||
}()
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
logger.Info("shutdown signal received")
|
||
case err := <-errCh:
|
||
if err != nil {
|
||
return fmt.Errorf("server: %w", err)
|
||
}
|
||
}
|
||
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||
return fmt.Errorf("shutdown: %w", err)
|
||
}
|
||
logger.Info("minstrel stopped")
|
||
return nil
|
||
}
|