4ed831d9c3
New diagnostic_events table + per-account users.debug_mode_enabled flag.
When an account's flag is on, its client(s) POST a batch timeseries of
connectivity / UPnP-sync / power / lifecycle events to /api/diagnostics
(no-op 204 when off, kind whitelist mirrors the CHECK constraint).
Admin surface: GET /api/admin/diagnostics (optional account/device/kind/
time-window filters, RFC3339-or-epoch-ms, export-sized paging) + a
/diagnostics/devices overview + PUT /api/admin/users/{id}/debug-mode to
flip an account remotely while a bug is live. debug_mode_enabled is now
exposed on /api/me (client gate) and the admin user views.
Retention: a 30-day gc-worker sweep (GcPruneDiagnostics), keyed on the
server clock so a skewed device clock can't keep rows alive.
Refs Scribe M9 (#119), tasks #1172 #1173.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
250 lines
12 KiB
Go
250 lines
12 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"
|
|
|
|
"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/playevents"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
"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, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
|
h := &handlers{
|
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
|
rng: rng.Float64,
|
|
lidarrCfg: lidarrCfg,
|
|
lidarrRequests: lidarrReqs,
|
|
lidarrQuarantine: lidarrQuar,
|
|
tracks: tracksSvc,
|
|
playlists: playlistsSvc,
|
|
coverart: coverEnricher,
|
|
coverSettings: coverSettings,
|
|
scanner: scanner,
|
|
scanCfg: scanCfg,
|
|
dataDir: dataDir,
|
|
mailer: sender,
|
|
eventbus: bus,
|
|
playlistScheduler: playlistScheduler,
|
|
streamSecret: streamSecret,
|
|
}
|
|
|
|
r.Route("/api", func(api chi.Router) {
|
|
api.Post("/auth/login", h.handleLogin)
|
|
api.Post("/auth/register", h.handleRegister)
|
|
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)
|
|
|
|
api.Group(func(authed chi.Router) {
|
|
authed.Use(auth.RequireUser(pool))
|
|
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/api-token", h.handleGetMyAPIToken)
|
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
|
|
|
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)
|
|
authed.Get("/library/sync", h.handleLibrarySync)
|
|
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)
|
|
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("/scan/status", h.handleGetScanStatus)
|
|
admin.Post("/scan/run", h.handleTriggerScan)
|
|
|
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
|
|
|
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("/smtp-config", h.handleGetSMTPConfig)
|
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
|
})
|
|
|
|
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
|
|
rng func() float64
|
|
lidarrCfg *lidarrconfig.Service
|
|
lidarrRequests *lidarrrequests.Service
|
|
lidarrQuarantine *lidarrquarantine.Service
|
|
tracks *tracks.Service
|
|
playlists *playlists.Service
|
|
coverart *coverart.Enricher
|
|
coverSettings *coverart.SettingsService
|
|
scanner *library.Scanner
|
|
scanCfg library.RunScanConfig
|
|
dataDir string
|
|
mailer mailer.Sender
|
|
eventbus *eventbus.Bus
|
|
playlistScheduler *playlists.Scheduler
|
|
// 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
|
|
}
|