Files
minstrel/internal/config/config.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

272 lines
9.1 KiB
Go

package config
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Storage StorageConfig `yaml:"storage"`
Database DatabaseConfig `yaml:"database"`
Log LogConfig `yaml:"log"`
Library LibraryConfig `yaml:"library"`
Subsonic SubsonicConfig `yaml:"subsonic"`
Events EventsConfig `yaml:"events"`
Recommendation RecommendationConfig `yaml:"recommendation"`
Branding BrandingConfig `yaml:"branding"`
// StreamSecret is the base64-decoded HMAC key used to sign UPnP /
// Sonos stream URLs (see internal/api/stream_token.go). Sourced from
// MINSTREL_STREAM_SECRET (base64-encoded), with a per-machine fallback
// persisted at <Storage.DataDir>/stream_secret. Not read from YAML: it
// is per-machine state, not a setting. Never log the contents.
StreamSecret []byte `yaml:"-"`
}
type ServerConfig struct {
Address string `yaml:"address"`
}
// StorageConfig points at the on-disk root for generated files: playlist
// cover collages, artwork, caches and the stream secret.
type StorageConfig struct {
// DataDir is the root for cached artifacts. Default: "./data" (relative
// to the working directory). Operators in production should set an
// absolute path persistent across restarts.
DataDir string `yaml:"data_dir"`
}
// BrandingConfig drives per-instance UI labelling: the app name shown
// in the header / tab title and the description used in OG share-preview
// metadata. Both default to sensible values when unset.
type BrandingConfig struct {
AppName string `yaml:"app_name"`
Description string `yaml:"description"`
}
type DatabaseConfig struct {
URL string `yaml:"url"`
}
type LogConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
type LibraryConfig struct {
ScanPaths []string `yaml:"scan_paths"`
ScanOnStartup bool `yaml:"scan_on_startup"`
CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true
ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA
}
// SubsonicConfig controls wire-format concessions on the /rest/* surface.
// AllowPlaintextPassword gates the `p=` parameter; the recommended posture is
// to leave it disabled and require apiKey (OpenSubsonic) or t+s.
type SubsonicConfig struct {
AllowPlaintextPassword bool `yaml:"allow_plaintext_password"`
}
// EventsConfig governs play-event ingestion: session window, skip rule.
type EventsConfig struct {
SessionTimeoutMinutes int `yaml:"session_timeout_minutes"`
SkipMaxCompletionRatio float64 `yaml:"skip_max_completion_ratio"`
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
}
// RecommendationConfig holds the radio path's operational knobs. The scoring
// weights are DB-backed admin settings (internal/recsettings), tuned in the
// UI with live effect.
type RecommendationConfig struct {
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
}
func Default() Config {
return Config{
Server: ServerConfig{Address: ":4533"},
Storage: StorageConfig{DataDir: "./data"},
Database: DatabaseConfig{URL: ""},
Log: LogConfig{Level: "info", Format: "text"},
Branding: BrandingConfig{
AppName: "Minstrel",
Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition.",
},
Events: EventsConfig{
SessionTimeoutMinutes: 30,
SkipMaxCompletionRatio: 0.5,
SkipMaxDurationPlayedMs: 30000,
},
Recommendation: RecommendationConfig{
RecentlyPlayedHours: 1,
RadioSize: 50,
RadioSizeMax: 200,
},
Library: LibraryConfig{
// On by default so a fresh install populates its library on first
// boot with no manual step. Scans are incremental (unchanged files
// skipped by mtime), so every later restart is just a cheap
// directory walk.
ScanOnStartup: true,
CoverArtFromMBCAA: true,
},
}
}
func Load(path string) (Config, error) {
cfg := Default()
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
return cfg, fmt.Errorf("read config %q: %w", path, err)
}
} else if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config %q: %w", path, err)
}
}
applyEnv(&cfg)
if err := resolveStreamSecret(&cfg); err != nil {
return cfg, err
}
return cfg, nil
}
// streamSecretBytes is the raw HMAC-key length; 64 bytes gives 512 bits
// of entropy, more than enough for HMAC-SHA256, and lands at a 86-char
// base64-url-no-padding string that fits cleanly in a single .env line.
const streamSecretBytes = 64
// streamSecretFile is the basename for the per-machine persisted fallback
// under <Storage.DataDir>. Kept as a constant so tests assert against the
// exact path and operators can find it during backup planning.
const streamSecretFile = "stream_secret"
// resolveStreamSecret populates cfg.StreamSecret using, in order:
// 1. MINSTREL_STREAM_SECRET env var (base64-url encoded — accepts either
// padded or raw form).
// 2. <Storage.DataDir>/stream_secret if present (auto-generated on a
// previous boot; survives restarts so signed URLs stay valid).
// 3. Auto-generate streamSecretBytes random bytes, persist them to that
// file at 0600, then use them.
//
// Logs (only) when an auto-generation happens, so it shows on first boot. Never
// logs the contents.
//
// The loader runs before slog is initialized in cmd/minstrel/main.go;
// log.Printf is the project's pre-logger convention.
func resolveStreamSecret(cfg *Config) error {
if raw := strings.TrimSpace(os.Getenv("MINSTREL_STREAM_SECRET")); raw != "" {
decoded, err := decodeBase64Secret(raw)
if err != nil {
return fmt.Errorf("decode MINSTREL_STREAM_SECRET: %w", err)
}
cfg.StreamSecret = decoded
return nil
}
if cfg.Storage.DataDir == "" {
return fmt.Errorf("stream secret: empty storage.data_dir; " +
"set MINSTREL_STREAM_SECRET or storage.data_dir")
}
path := filepath.Join(cfg.Storage.DataDir, streamSecretFile)
if buf, err := os.ReadFile(path); err == nil && len(buf) > 0 {
decoded, err := decodeBase64Secret(strings.TrimSpace(string(buf)))
if err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
cfg.StreamSecret = decoded
return nil
}
raw := make([]byte, streamSecretBytes)
if _, err := rand.Read(raw); err != nil {
return fmt.Errorf("auto-gen stream secret: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(raw)
if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
return fmt.Errorf("ensure data_dir for stream secret: %w", err)
}
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
return fmt.Errorf("persist stream secret: %w", err)
}
log.Printf("config: auto-generated MINSTREL_STREAM_SECRET (persisted to %s)", path)
cfg.StreamSecret = raw
return nil
}
// decodeBase64Secret accepts base64-url with OR without padding; the
// stream-secret bytes are opaque so callers shouldn't have to know which
// encoder produced their string. Returns an error on any other shape.
func decodeBase64Secret(s string) ([]byte, error) {
if buf, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return buf, nil
}
return base64.URLEncoding.DecodeString(s)
}
func applyEnv(cfg *Config) {
if v, ok := os.LookupEnv("MINSTREL_SERVER_ADDRESS"); ok {
cfg.Server.Address = v
}
if v, ok := os.LookupEnv("MINSTREL_STORAGE_DATA_DIR"); ok {
cfg.Storage.DataDir = v
}
if v, ok := os.LookupEnv("MINSTREL_DATABASE_URL"); ok {
cfg.Database.URL = v
}
if v, ok := os.LookupEnv("MINSTREL_LOG_LEVEL"); ok {
cfg.Log.Level = strings.ToLower(v)
}
if v, ok := os.LookupEnv("MINSTREL_LOG_FORMAT"); ok {
cfg.Log.Format = strings.ToLower(v)
}
if v, ok := os.LookupEnv("MINSTREL_BRANDING_APP_NAME"); ok && v != "" {
cfg.Branding.AppName = v
}
if v, ok := os.LookupEnv("MINSTREL_BRANDING_DESCRIPTION"); ok && v != "" {
cfg.Branding.Description = v
}
if v, ok := os.LookupEnv("MINSTREL_LIBRARY_SCAN_PATHS"); ok {
cfg.Library.ScanPaths = splitPaths(v)
}
if v, ok := os.LookupEnv("MINSTREL_LIBRARY_SCAN_ON_STARTUP"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Library.ScanOnStartup = b
}
}
if v, ok := os.LookupEnv("MINSTREL_LIBRARY_COVERART_FROM_MBCAA"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Library.CoverArtFromMBCAA = b
}
}
if v, ok := os.LookupEnv("MINSTREL_CONTACT_EMAIL"); ok {
cfg.Library.ContactEmail = v
}
if v, ok := os.LookupEnv("MINSTREL_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD"); ok {
if b, err := strconv.ParseBool(v); err == nil {
cfg.Subsonic.AllowPlaintextPassword = b
}
}
}
// splitPaths splits a colon-separated path list, dropping empty entries.
// Colon matches PATH-style Unix conventions; Minstrel ships Linux-only.
func splitPaths(s string) []string {
parts := strings.Split(s, ":")
out := parts[:0]
for _, p := range parts {
if p != "" {
out = append(out, p)
}
}
return out
}