// 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/recsettings" "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, 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, recSettings: recSettings, 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) // 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 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 }