release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the operator. Most are safe to settle, and Lidarr defines what safe means: it maps one file to each track of the release it monitors and downloads any mapped file that disappears. Deleting a mapped copy opens exactly the hole the operator saw Lidarr fill. Classify (#5435) - Migration 0075: duplicate_groups.class (same_release, cross_release, mismatch, review), resolve_note, resolved_automatically; duplicate_group_members.lidarr_state (tracked, unmapped); fingerprint_settings.auto_resolve; notification kind duplicates_resolved with both kind CHECKs swapped (rule 36). - library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring credits, remaster notes and video-rip markers, and keeping live, demo, remix and instrumental. The rip markers move from api to library. Choose the copy to keep (#5436) - ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a clash-free track number, no rip marker in the name, an MBID), then the quality rules. File size picked the wrong Humanz copy in 6 of 21 groups. Act (#5437) - An hourly resolver pass reads Lidarr's unmapped files, matched by the last three path components, and records each copy's state. - Same album, with at most one copy mapped: merged into the mapped copy. The merge is guarded, so a mapped copy can never be removed (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr). - Same album, every copy mapped: the monitored release lists the song twice (Humanz's 14x12" box set). The pass moves Lidarr to the release that lists each song once and best covers what is on disk. It never picks one covering less, and is capped at 10 albums per pass. - Fixed point (lesson #4183): the chosen release no longer repeats. - The album is left alone for 24h while Lidarr rescans, so "every copy unmapped" mid-rescan is never read as licence to merge. - Both actions are audited with no actor and summarised to admins. The operator can switch them off in the Fingerprinting card (rule 25). - Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503 lidarr_unavailable when Lidarr cannot say. Web - Duplicates gets tabs: Needs review, Across releases, Resolved automatically. Each loads as you scroll (rule 172), replacing the pager. - Each copy says whether Lidarr uses it. - The resolver's note shows on each group. - The merge confirm blocks, before sending, a merge that would remove the copy Lidarr uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
14 KiB
Go
303 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"time"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
"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/subsonic"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
|
"git.fabledsword.com/bvandeusen/minstrel/web"
|
|
)
|
|
|
|
// lidarrUnmonitorAdapter wires the per-call lidarrClientFn factory pattern
|
|
// (used elsewhere in this package so admin Lidarr config edits take effect
|
|
// without restart) into the tracks.LidarrUnmonitorer interface that
|
|
// internal/tracks expects. When the factory returns nil (Lidarr disabled)
|
|
// we surface a sentinel error — tracks.Service treats UnmonitorTrack
|
|
// failures as non-fatal, so this collapses to a `lidarr_unmonitor_failed`
|
|
// flag in the response and the destructive part still runs.
|
|
type lidarrUnmonitorAdapter struct {
|
|
fn func() *lidarr.Client
|
|
}
|
|
|
|
var errLidarrDisabled = errors.New("lidarr disabled")
|
|
|
|
func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error {
|
|
c := a.fn()
|
|
if c == nil {
|
|
return errLidarrDisabled
|
|
}
|
|
return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
|
|
}
|
|
|
|
// ListUnmappedTrackFiles lets the duplicate merge refuse to remove a copy
|
|
// Lidarr maps (M498). tracks.ErrLidarrDisabled tells it there is no Lidarr to
|
|
// protect.
|
|
func (a lidarrUnmonitorAdapter) ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error) {
|
|
c := a.fn()
|
|
if c == nil {
|
|
return nil, tracks.ErrLidarrDisabled
|
|
}
|
|
return c.ListUnmappedTrackFiles(ctx)
|
|
}
|
|
|
|
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
|
|
// interface so tests can stub it without touching the DB. The progressCb
|
|
// parameter (added in m7-scan-progress) lets the orchestrator drive partial-
|
|
// tally writes; the HTTP handler passes nil for fire-and-forget triggers.
|
|
type ScanTrigger interface {
|
|
Scan(ctx context.Context, progressCb func(library.Stats)) (library.Stats, error)
|
|
}
|
|
|
|
type Server struct {
|
|
Logger *slog.Logger
|
|
Pool *pgxpool.Pool
|
|
Scanner ScanTrigger
|
|
SubsonicCfg subsonic.Config
|
|
EventsCfg config.EventsConfig
|
|
RecommendationCfg config.RecommendationConfig
|
|
// DataDir is the on-disk root for cached artifacts (currently
|
|
// playlist cover collages under <DataDir>/playlist_covers/). Empty
|
|
// strings are tolerated by tests that don't exercise persisted-cover
|
|
// codepaths; production callers should pass a writable directory.
|
|
DataDir string
|
|
BrandingCfg config.BrandingConfig
|
|
CoverEnricher *coverart.Enricher
|
|
CoverSettings *coverart.SettingsService
|
|
TagSettings *tags.SettingsService
|
|
LibraryScanner *library.Scanner
|
|
ScanCfg library.RunScanConfig
|
|
// Bus is the live-event bus shared with background workers (the
|
|
// lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go.
|
|
// When nil, Router() constructs a local fallback (test contexts).
|
|
Bus *eventbus.Bus
|
|
// PlaylistScheduler fires per-user daily system-playlist builds at
|
|
// 03:00 in each user's stored timezone (#392 Half B). Constructed
|
|
// in cmd/minstrel/main.go and threaded into the API handlers so
|
|
// PUT /api/me/timezone and POST /api/auth/register can call
|
|
// Refresh synchronously.
|
|
PlaylistScheduler *playlists.Scheduler
|
|
// RecSettings is the DB-backed recommendation tuning lab (#1250):
|
|
// scoring-weight profiles + taste-build knobs. Constructed in
|
|
// cmd/minstrel/main.go (it pushes daily-mix weights into package
|
|
// playlists at boot); the API layer reads radio weights per request
|
|
// and serves the admin tuning endpoints from it. Router() constructs
|
|
// a fallback when nil (tests).
|
|
RecSettings *recsettings.Service
|
|
// FingerprintSettings is the DB-backed fingerprinting policy (M400 #3913).
|
|
// Constructed in cmd/minstrel/main.go and shared with the scanner and the
|
|
// fingerprint workers, so a save from the admin card reaches them without a
|
|
// restart. Router() constructs a fallback when nil (tests).
|
|
FingerprintSettings *library.FingerprintSettingsService
|
|
// LoudnessSettings is the DB-backed loudness analysis policy (M464 #4995),
|
|
// shared with the loudness backfill. Nil makes the router load its own.
|
|
LoudnessSettings *library.LoudnessSettingsService
|
|
// AcoustIDLookup is the AcoustID lookup worker (M401), whose settings,
|
|
// status and "look up now" the admin card drives. Nil in test contexts;
|
|
// one is built (and never run) so the admin routes still answer.
|
|
AcoustIDLookup *library.AcoustIDLookupWorker
|
|
// ReacqSettings is the DB-backed missing-file re-acquisition policy
|
|
// (milestone #290) — the same instance the sweeper in cmd/minstrel/main.go
|
|
// reads, so a save from the admin card reaches it without a restart
|
|
// (#3936). Router() constructs a fallback when nil (tests).
|
|
ReacqSettings *reacquisition.SettingsService
|
|
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
|
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
|
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
|
// leave it nil leave the cookie path intact and reject all signed
|
|
// tokens (HMAC of empty key matches nothing a client could mint).
|
|
StreamSecret []byte
|
|
}
|
|
|
|
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
|
|
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
|
|
}
|
|
|
|
func (s *Server) Router() http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
// Built before the router because the access log needs it, and the access
|
|
// log covers /healthz and the SPA — which exist whether or not there's a
|
|
// pool. netsettings.New handles a nil pool by returning a default-valued
|
|
// service, so this needs no branch and no later reassignment; hoisting it
|
|
// here keeps the accessor a plain method value instead of a closure over
|
|
// a variable mutated after the middleware is already registered.
|
|
netSettings, nsErr := netsettings.New(context.Background(), s.Pool, s.Logger)
|
|
if nsErr != nil {
|
|
s.Logger.Error("server: netsettings boot failed, using default hops", "err", nsErr)
|
|
}
|
|
|
|
r.Use(middleware.RequestID)
|
|
r.Use(requestLog(s.Logger, netSettings.Hops))
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(securityHeaders(netSettings.Hops))
|
|
r.Use(limitRequestBody)
|
|
r.Use(requireJSONForCookieWrites)
|
|
|
|
r.Get("/healthz", s.handleHealthz)
|
|
|
|
if s.Pool != nil {
|
|
writer := playevents.NewWriter(
|
|
s.Pool, s.Logger,
|
|
time.Duration(s.EventsCfg.SessionTimeoutMinutes)*time.Minute,
|
|
s.EventsCfg.SkipMaxCompletionRatio,
|
|
s.EventsCfg.SkipMaxDurationPlayedMs,
|
|
)
|
|
lidarrCfg := lidarrconfig.New(s.Pool)
|
|
// Per-call client factory: re-reads config so an admin save in
|
|
// /admin/integrations takes effect immediately without restart.
|
|
// Returns nil when Lidarr is disabled, which the Service-layer
|
|
// methods translate to ErrLidarrDisabled.
|
|
lidarrClientFn := func() *lidarr.Client {
|
|
cfg, err := lidarrCfg.Get(context.Background())
|
|
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
|
|
return nil
|
|
}
|
|
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
|
}
|
|
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
|
|
reacqSettings := s.ReacqSettings
|
|
if reacqSettings == nil {
|
|
// Test contexts construct Server without main.go's boot wiring.
|
|
// Always usable even when the load fails — it falls back to the
|
|
// shipped defaults rather than leaving the admin card unable to
|
|
// render (same posture as netsettings above).
|
|
var raErr error
|
|
reacqSettings, raErr = reacquisition.NewSettingsService(
|
|
context.Background(), s.Pool, s.Logger)
|
|
if raErr != nil {
|
|
s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr)
|
|
}
|
|
}
|
|
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn, s.DataDir)
|
|
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
|
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
|
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
|
|
// Live-event bus for SSE subscribers (#392). Constructed per-process;
|
|
// producers in playevents / lidarrrequests / scanner publish into
|
|
// the same instance. Background workers (lidarr reconciler, scan
|
|
// scheduler) live in cmd/minstrel/main.go where they're constructed
|
|
// before Router() runs; they read s.Bus directly so they share this
|
|
// process's bus. When Router() runs before main set the bus (tests,
|
|
// or future contexts) we fall back to a fresh local instance.
|
|
bus := s.Bus
|
|
if bus == nil {
|
|
bus = eventbus.New()
|
|
}
|
|
recSettings := s.RecSettings
|
|
if recSettings == nil {
|
|
// Test contexts construct Server directly without main.go's
|
|
// boot wiring; reconcile here so radio + the admin tuning
|
|
// endpoints work against the same pool.
|
|
var err error
|
|
recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger)
|
|
if err != nil {
|
|
s.Logger.Error("server: recsettings boot failed", "err", err)
|
|
}
|
|
}
|
|
fpSettings := s.FingerprintSettings
|
|
if fpSettings == nil {
|
|
// Test contexts construct Server without main.go's wiring. Always
|
|
// usable: a failed load serves the defaults.
|
|
var err error
|
|
fpSettings, err = library.NewFingerprintSettingsService(context.Background(), s.Pool)
|
|
if err != nil {
|
|
s.Logger.Warn("fingerprint settings unavailable; serving defaults", "err", err)
|
|
}
|
|
}
|
|
loudSettings := s.LoudnessSettings
|
|
if loudSettings == nil {
|
|
var err error
|
|
loudSettings, err = library.NewLoudnessSettingsService(context.Background(), s.Pool)
|
|
if err != nil {
|
|
s.Logger.Warn("loudness settings unavailable; serving defaults", "err", err)
|
|
}
|
|
}
|
|
acoustIDLookup := s.AcoustIDLookup
|
|
if acoustIDLookup == nil {
|
|
acoustIDSettings, err := library.NewAcoustIDSettingsService(context.Background(), s.Pool)
|
|
if err != nil {
|
|
s.Logger.Warn("acoustid settings unavailable; serving defaults", "err", err)
|
|
}
|
|
acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil)
|
|
}
|
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
|
|
// /api/admin/scan is the only admin route owned by the server package
|
|
// (it needs the Scanner). Register it as a single inline-middleware
|
|
// route — using r.Route("/api/admin", ...) here would create a second
|
|
// subtree that shadows every admin route registered by api.Mount.
|
|
if s.Scanner != nil {
|
|
r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()).
|
|
Post("/api/admin/scan", s.handleAdminScan)
|
|
}
|
|
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer, netSettings.Hops)
|
|
}
|
|
|
|
spa := web.Handler(s.BrandingCfg)
|
|
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
|
p := req.URL.Path
|
|
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
|
|
return
|
|
}
|
|
spa.ServeHTTP(w, req)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "ok",
|
|
"version": ServerVersion,
|
|
"channel": ServerChannel,
|
|
"min_client_version": MinClientVersion,
|
|
})
|
|
}
|
|
|
|
// handleAdminScan runs a scan synchronously and returns the resulting stats.
|
|
// Kept synchronous for v1: libraries are small and the operator can tell when
|
|
// it's done. Async jobs with status polling are a later-milestone concern.
|
|
func (s *Server) handleAdminScan(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := s.Scanner.Scan(r.Context(), nil)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err != nil {
|
|
s.Logger.Error("admin scan failed", "err", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error(), "stats": stats})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(stats)
|
|
}
|