release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.
Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
20 KiB
Go
393 lines
20 KiB
Go
// Package api implements Minstrel's native JSON surface under /api. It is
|
|
// consumed by the built-in web SPA and (eventually) the Flutter client.
|
|
// Subsonic-compatible endpoints under /rest are intentionally separate —
|
|
// see internal/subsonic — and the two packages must not depend on each other.
|
|
package api
|
|
|
|
import (
|
|
"log/slog"
|
|
"math/rand"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"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/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/recommendation"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
|
)
|
|
|
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
|
// RequireUser; everything else is gated by the middleware. The events writer
|
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService) {
|
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
|
setupToken, err := auth.NewSetupToken()
|
|
if err != nil {
|
|
// crypto/rand failing means the platform can't make secrets at all;
|
|
// sessions would be minted from the same source. Nothing to degrade to.
|
|
panic("api: mint setup token: " + err.Error())
|
|
}
|
|
logSetupTokenIfNeeded(pool, logger, setupToken)
|
|
h := &handlers{
|
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
|
recSettings: recSettings,
|
|
rng: rng.Float64,
|
|
lidarrCfg: lidarrCfg,
|
|
lidarrRequests: lidarrReqs,
|
|
lidarrQuarantine: lidarrQuar,
|
|
tracks: tracksSvc,
|
|
playlists: playlistsSvc,
|
|
coverart: coverEnricher,
|
|
coverSettings: coverSettings,
|
|
tagSettings: tagSettings,
|
|
scanner: scanner,
|
|
scanCfg: scanCfg,
|
|
dataDir: dataDir,
|
|
mailer: sender,
|
|
eventbus: bus,
|
|
playlistScheduler: playlistScheduler,
|
|
streamSecret: streamSecret,
|
|
netSettings: netSettings,
|
|
reacqSettings: reacqSettings,
|
|
fingerprintSettings: fpSettings,
|
|
loudnessSettings: loudSettings,
|
|
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
|
librarySize: recommendation.NewLibrarySize(nil),
|
|
loginGuard: auth.NewLoginGuard(),
|
|
setupToken: setupToken,
|
|
requireSetupToken: true,
|
|
registerLimit: auth.NewAttemptLimiter(registerPerAddressMax, time.Hour),
|
|
forgotAddressLimit: auth.NewAttemptLimiter(forgotPerAddressMax, time.Hour),
|
|
forgotEmailLimit: auth.NewAttemptLimiter(forgotPerEmailMax, time.Hour),
|
|
resetLimit: auth.NewAttemptLimiter(resetFailuresPerAddressMax, 15*time.Minute),
|
|
}
|
|
|
|
r.Route("/api", func(api chi.Router) {
|
|
api.Post("/auth/login", h.handleLogin)
|
|
api.Post("/auth/register", h.handleRegister)
|
|
api.Get("/auth/setup-status", h.handleSetupStatus)
|
|
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
|
api.Post("/auth/reset-password", h.handleResetPassword)
|
|
|
|
// Stream lives outside authed.Group so it can accept EITHER a
|
|
// session (resolved by the OptionalUser middleware) OR a signed
|
|
// query token (UPnP / Sonos path; see streamAuthOk). The
|
|
// middleware attaches user to context when a valid cookie /
|
|
// bearer is present but does NOT 401 on absence; the handler's
|
|
// own streamAuthOk performs the actual auth check. See the
|
|
// design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
|
|
// Extension-bearing alias so Sonos's URL probe can identify the
|
|
// audio format from the path. The {ext} param is consumed by chi
|
|
// and ignored by the handler (which keys off {id}). See task #610.
|
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
|
// The leveled stream for Sonos/UPnP (M464 #5001): session or a
|
|
// leveled token, like the plain stream.
|
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/leveled.flac", h.handleGetLeveledStream)
|
|
|
|
api.Group(func(authed chi.Router) {
|
|
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
|
authed.Post("/auth/logout", h.handleLogout)
|
|
authed.Get("/me", h.handleGetMe)
|
|
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
|
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
|
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
|
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
|
authed.Get("/me/history", h.handleGetMyHistory)
|
|
authed.Put("/me/password", h.handleChangePassword)
|
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
|
authed.Get("/me/normalization", h.handleGetMyNormalization)
|
|
authed.Put("/me/normalization", h.handlePutMyNormalization)
|
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
|
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
|
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
|
authed.Delete("/me/subsonic-password", h.handleClearMySubsonicPassword)
|
|
authed.Get("/me/sessions", h.handleListMySessions)
|
|
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
|
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
|
|
|
authed.Get("/artists", h.handleListArtists)
|
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
|
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
|
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
|
|
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
|
|
authed.Get("/albums/{id}", h.handleGetAlbum)
|
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
|
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
|
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
|
// Browse indexes (#367). Genre filtering rides
|
|
// /library/albums?genre= rather than a path segment, because raw
|
|
// ID3 genres contain slashes ("Rock/Pop") that a path can't carry.
|
|
authed.Get("/library/genres", h.handleListGenres)
|
|
authed.Get("/library/years", h.handleListAlbumYears)
|
|
authed.Get("/library/sync", h.handleLibrarySync)
|
|
// Before /tracks/{id} for readability; chi prefers the static
|
|
// segment either way.
|
|
authed.Get("/tracks/replay-gain", h.handleGetReplayGain)
|
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
|
// /tracks/{id}/stream is mounted above with OptionalUser so
|
|
// it can accept either a session or a signed token.
|
|
authed.Get("/search", h.handleSearch)
|
|
authed.Get("/radio", h.handleRadio)
|
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
|
// Snooze = "not right now", time-boxed and self-expiring
|
|
// (#2374). Not a dislike — see the migration for why.
|
|
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
|
|
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
|
|
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
|
|
authed.Get("/home", h.handleGetHome)
|
|
authed.Get("/home/index", h.handleGetHomeIndex)
|
|
authed.Post("/events", h.handleEvents)
|
|
authed.Get("/events/stream", h.handleEventsStream)
|
|
// UPnP / Sonos cast slice: issue a short-lived HMAC stream URL
|
|
// the speaker can fetch without the user's session. See the
|
|
// design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
|
authed.Post("/cast/stream-token", h.handleCastStreamToken)
|
|
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
|
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
|
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
|
authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum)
|
|
authed.Post("/likes/artists/{id}", h.handleLikeArtist)
|
|
authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist)
|
|
authed.Get("/likes/tracks", h.handleListLikedTracks)
|
|
authed.Get("/likes/albums", h.handleListLikedAlbums)
|
|
authed.Get("/likes/artists", h.handleListLikedArtists)
|
|
authed.Get("/likes/ids", h.handleGetLikedIDs)
|
|
|
|
authed.Get("/lidarr/search", h.handleLidarrSearch)
|
|
|
|
authed.Post("/requests", h.handleCreateRequest)
|
|
authed.Get("/requests", h.handleListRequests)
|
|
authed.Get("/requests/{id}", h.handleGetRequest)
|
|
authed.Delete("/requests/{id}", h.handleCancelRequest)
|
|
|
|
authed.Post("/quarantine", h.handleFlag)
|
|
authed.Delete("/quarantine/{track_id}", h.handleUnflag)
|
|
authed.Get("/quarantine/mine", h.handleListMyQuarantine)
|
|
|
|
// Client-reported playback errors (zero-duration tracks,
|
|
// load failures). Admin-only inbox; any user can report.
|
|
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
|
|
|
// Device diagnostics ingest (M9). Any signed-in user can
|
|
// POST a batch, but events are only stored when the
|
|
// account's debug_mode_enabled flag is on (handler no-ops
|
|
// otherwise). Admin views live under /admin/diagnostics.
|
|
authed.Post("/diagnostics", h.handleReportDiagnostics)
|
|
|
|
// Self-hosted in-app update channel (#397). Auth-gated to
|
|
// prevent anonymous bandwidth abuse on the APK stream;
|
|
// /apk additionally per-user rate-limited.
|
|
authed.Get("/client/version", h.handleClientVersion)
|
|
authed.Get("/client/apk", h.handleClientAPK)
|
|
|
|
authed.Route("/admin", func(admin chi.Router) {
|
|
admin.Use(auth.RequireAdmin())
|
|
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
|
|
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
|
|
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
|
|
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
|
|
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
|
|
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
|
|
admin.Get("/requests", h.handleListAdminRequests)
|
|
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
|
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
|
|
|
admin.Get("/quarantine", h.handleListAdminQuarantine)
|
|
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
|
|
|
|
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
|
|
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
|
|
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
|
|
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
|
|
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
|
|
|
|
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
|
|
|
admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
|
|
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
|
|
|
|
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
|
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
|
|
|
// Policy for turning a missing file back into a Lidarr
|
|
// request (#290). Sits beside the missing-files list it
|
|
// governs rather than under /lidarr, because the operator
|
|
// meets it on the missing-files surface.
|
|
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
|
|
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
|
|
|
|
admin.Get("/scan/status", h.handleGetScanStatus)
|
|
admin.Post("/scan/run", h.handleTriggerScan)
|
|
// Sits under /library rather than /tracks because what it
|
|
// reports is a property of the library's relationship to disk,
|
|
// and because the destructive /tracks/{id} route above must
|
|
// not be mistaken for it (#2527).
|
|
admin.Get("/library/missing", h.handleListMissingTracks)
|
|
|
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
|
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
|
admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
|
|
admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings)
|
|
admin.Get("/library/loudness", h.handleGetLoudnessCoverage)
|
|
admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings)
|
|
admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings)
|
|
// Duplicates report (#3912): proposals from the duplicate sweep, a
|
|
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
|
// the removed copies' files after moving their history onto the kept one.
|
|
admin.Get("/library/duplicates", h.handleListDuplicates)
|
|
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
|
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
|
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
|
|
|
admin.Get("/invites", h.handleListInvites)
|
|
admin.Post("/invites", h.handleCreateInvite)
|
|
admin.Delete("/invites/{token}", h.handleDeleteInvite)
|
|
admin.Get("/users", h.handleAdminListUsers)
|
|
admin.Put("/users/{id}/admin", h.handleUpdateUserAdmin)
|
|
admin.Post("/users", h.handleAdminCreateUser)
|
|
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
|
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
|
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
|
admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle)
|
|
|
|
// Device diagnostics timeline + device overview (M9).
|
|
admin.Get("/diagnostics", h.handleListAdminDiagnostics)
|
|
admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices)
|
|
|
|
admin.Get("/cover-sources", h.handleListCoverSources)
|
|
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
|
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
|
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
|
|
|
admin.Get("/tag-sources", h.handleListTagSources)
|
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
|
admin.Post("/tag-sources/research", h.handleResearchTags)
|
|
|
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
|
|
|
// Recommendation tuning lab (#1250): scoring-weight
|
|
// profiles + taste-build knobs, DB-backed, live effect.
|
|
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
|
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
|
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
|
// Weekly outcome trends + knob-turn markers (#1251).
|
|
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
|
|
})
|
|
|
|
authed.Get("/playlists", h.handleListPlaylists)
|
|
authed.Post("/playlists", h.handleCreatePlaylist)
|
|
authed.Get("/playlists/{id}", h.handleGetPlaylist)
|
|
authed.Patch("/playlists/{id}", h.handleUpdatePlaylist)
|
|
authed.Delete("/playlists/{id}", h.handleDeletePlaylist)
|
|
authed.Post("/playlists/{id}/tracks", h.handleAppendTracks)
|
|
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
|
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
|
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
|
authed.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
|
|
authed.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
|
|
})
|
|
})
|
|
}
|
|
|
|
type handlers struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
events *playevents.Writer
|
|
recCfg config.RecommendationConfig
|
|
recSettings *recsettings.Service
|
|
rng func() float64
|
|
// librarySize memoises the track count that sizes the candidate pool
|
|
// (#3880). Held here rather than counted per request: the count is a
|
|
// full table scan, and library size only moves when a scan runs.
|
|
librarySize *recommendation.LibrarySize
|
|
lidarrCfg *lidarrconfig.Service
|
|
lidarrRequests *lidarrrequests.Service
|
|
lidarrQuarantine *lidarrquarantine.Service
|
|
tracks *tracks.Service
|
|
playlists *playlists.Service
|
|
coverart *coverart.Enricher
|
|
coverSettings *coverart.SettingsService
|
|
tagSettings *tags.SettingsService
|
|
scanner *library.Scanner
|
|
scanCfg library.RunScanConfig
|
|
dataDir string
|
|
mailer mailer.Sender
|
|
eventbus *eventbus.Bus
|
|
playlistScheduler *playlists.Scheduler
|
|
// reacqSettings is the DB-backed policy for auto re-acquisition of
|
|
// missing files (milestone #290) — grace window, backoff, attempt caps.
|
|
// Cached in the service, so the admin card reads it without a query.
|
|
reacqSettings *reacquisition.SettingsService
|
|
// fingerprintSettings is the fingerprinting policy (M400 #3913), the same
|
|
// instance the scanner and the fingerprint workers read, so a save from the
|
|
// admin card reaches them without a restart. Nil serves the defaults.
|
|
fingerprintSettings *library.FingerprintSettingsService
|
|
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
|
// instance the loudness backfill reads. Nil serves the defaults.
|
|
loudnessSettings *library.LoudnessSettingsService
|
|
// leveled renders the leveled streams handed to Sonos/UPnP speakers
|
|
// (M464 #5001). Nil when its cache directory cannot be made: a level
|
|
// request then gets the plain stream.
|
|
leveled *library.LeveledRenderer
|
|
// setupToken must accompany the first registration while no users exist
|
|
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
|
// production constructor; tests that build handlers directly leave it
|
|
// off unless they are testing it.
|
|
setupToken *auth.SetupToken
|
|
requireSetupToken bool
|
|
// loginGuard throttles failed logins per account and per address, and
|
|
// the limiters below cap the other unauthenticated auth routes. All are
|
|
// nil-safe, so tests that build handlers directly run unthrottled.
|
|
loginGuard *auth.LoginGuard
|
|
registerLimit *auth.AttemptLimiter
|
|
forgotAddressLimit *auth.AttemptLimiter
|
|
forgotEmailLimit *auth.AttemptLimiter
|
|
resetLimit *auth.AttemptLimiter
|
|
// netSettings caches the trusted reverse-proxy depth read by the auth
|
|
// middleware on every request and edited from the admin network card.
|
|
netSettings *netsettings.Service
|
|
// streamSecret is the HMAC key used by SignStreamToken /
|
|
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
|
// (see internal/api/stream_token.go and the design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
|
|
// nil in slice 1; slice 2 wires the env-var-with-app_preferences-
|
|
// fallback loader. A nil secret leaves the cookie path intact and
|
|
// makes the token path unreachable (HMAC of empty key won't match
|
|
// anything a client mints), which is the desired slice-1 default.
|
|
streamSecret []byte
|
|
}
|
|
|
|
// newLeveledRenderer makes the leveled-stream renderer, caching under the
|
|
// data directory. A failure is logged and leaves leveling off for speakers.
|
|
func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsService, logger *slog.Logger) *library.LeveledRenderer {
|
|
r, err := library.NewLeveledRenderer(filepath.Join(dataDir, "leveled-cache"), settings, logger)
|
|
if err != nil {
|
|
logger.Error("api: leveled streams unavailable", "err", err)
|
|
return nil
|
|
}
|
|
return r
|
|
}
|