release / govulncheck (push) Successful in 29s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m26s
release / android (push) Successful in 5m27s
release / Build signed APK (push) Successful in 5m44s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A new server (no accounts, no library) offers "Restore a backup" beside "Create account". The file goes up as the request body to POST /api/auth/restore with the setup token from the log in X-Setup-Token, since the backup's admins become this server's. It loads in one transaction, then a library scan starts and finds each file at its path. The same load runs offline as `minstrel restore FILE`, which migrates the schema first. That path alone gets a 1 GiB, 15-minute body allowance instead of 4 MiB and 30s, and skips the JSON-only check for cookie writes: a stale cookie from the old server would otherwise refuse the upload, and the custom header already rules out a cross-site form. The library sync answers 410 cursor_ahead to a cursor past the last one the server has handed out, read from the change sequence, so an app synced with the old server after the backup was made starts over instead of skipping the ids the restored server reuses. Restore now leaves each sequence as having issued the restored maximum, so that reading holds; a compacted log still counts its handed-out cursors. backup.Read keeps the underlying error in its chain, so an oversized upload answers 413 rather than "not a backup". The hosting docs say how to put the music back first and what is entered again afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
522 lines
27 KiB
Go
522 lines
27 KiB
Go
// Package api implements Minstrel's native JSON surface under /api. It is
|
|
// consumed by the built-in web SPA and the Android app.
|
|
// 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/acquire"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/backup"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
|
"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/importer"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
|
"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/requests"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
|
)
|
|
|
|
// 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, requestSvc *requests.Service, quarantineSvc *quarantine.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, acoustIDLookup *library.AcoustIDLookupWorker, backups *backup.Service) {
|
|
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)
|
|
cat := catalogue.New(pool, nil, logger)
|
|
h := &handlers{
|
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
|
recSettings: recSettings,
|
|
rng: rng.Float64,
|
|
requests: requestSvc,
|
|
quarantine: quarantineSvc,
|
|
tracks: tracksSvc,
|
|
playlists: playlistsSvc,
|
|
coverart: coverEnricher,
|
|
coverSettings: coverSettings,
|
|
tagSettings: tagSettings,
|
|
scanner: scanner,
|
|
scanCfg: scanCfg,
|
|
dataDir: dataDir,
|
|
mailer: sender,
|
|
eventbus: bus,
|
|
notifier: notifications.New(pool, bus, logger.With("component", "notifications")),
|
|
playlistScheduler: playlistScheduler,
|
|
streamSecret: streamSecret,
|
|
netSettings: netSettings,
|
|
reacqSettings: reacqSettings,
|
|
fingerprintSettings: fpSettings,
|
|
loudnessSettings: loudSettings,
|
|
acoustIDLookup: acoustIDLookup,
|
|
backups: backups,
|
|
acquisition: acquire.NewStore(pool),
|
|
catalogue: cat,
|
|
wanted: wanted.New(pool, cat, logger),
|
|
searcher: wanted.NewSearcher(pool, cat, logger),
|
|
importer: importer.New(pool, scanner, cat, nil, scannerRoots(scanner), logger),
|
|
lifecycle: lifecycle.New(pool, logger),
|
|
catalogueSearches: newSearchCache(),
|
|
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
|
|
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/restore", h.handleRestore)
|
|
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.
|
|
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}).
|
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
|
// The leveled stream for Sonos/UPnP: 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("/me/notifications", h.handleListMyNotifications)
|
|
authed.Get("/me/notifications/unread-count", h.handleMyUnreadNotificationCount)
|
|
authed.Post("/me/notifications/read-all", h.handleMarkAllMyNotificationsRead)
|
|
authed.Post("/me/notifications/{id}/read", h.handleMarkMyNotificationRead)
|
|
authed.Get("/me/notification-settings", h.handleGetMyNotificationSettings)
|
|
authed.Put("/me/notification-settings", h.handlePutMyNotificationSettings)
|
|
|
|
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. 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.
|
|
// 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 casting: issue a short-lived HMAC stream URL the
|
|
// speaker can fetch without the user's session.
|
|
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)
|
|
|
|
// The release catalogue: request search asks
|
|
// MusicBrainz, and art for what isn't held comes through
|
|
// Minstrel from the enabled cover-art sources.
|
|
authed.Get("/catalogue/search", h.handleCatalogueSearch)
|
|
authed.Get("/catalogue/art/{kind}/{mbid}", h.handleCatalogueArt)
|
|
|
|
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. 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. 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("/acquisition/services", h.handleListAcquisitionServices)
|
|
admin.Post("/acquisition/services", h.handleCreateAcquisitionService)
|
|
admin.Put("/acquisition/services/{code}", h.handleUpdateAcquisitionService)
|
|
admin.Delete("/acquisition/services/{code}", h.handleDeleteAcquisitionService)
|
|
admin.Post("/acquisition/services/{code}/test", h.handleTestAcquisitionService)
|
|
admin.Put("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
|
|
admin.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
|
|
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
|
|
admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
|
|
// The queues that wait on an admin, for the rail and the
|
|
// Overview.
|
|
admin.Get("/summary", h.handleGetAdminSummary)
|
|
admin.Get("/status", h.handleGetAdminStatus)
|
|
admin.Get("/requests", h.handleListAdminRequests)
|
|
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
|
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
|
|
|
// The wanted list: what Minstrel is looking
|
|
// for, the artists it follows, and the release-type
|
|
// profiles that decide which of their releases count.
|
|
admin.Get("/wanted", h.handleListWants)
|
|
admin.Get("/wanted/artists", h.handleListWantArtists)
|
|
admin.Get("/wanted/unmatched", h.handleListUnmatched)
|
|
admin.Post("/wanted/unmatched/{id}/match", h.handleMatchUnmatched)
|
|
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
|
|
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
|
|
admin.Delete("/wanted/{id}", h.handleRemoveWant)
|
|
// Searching and grabbing.
|
|
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
|
|
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
|
|
admin.Get("/wanted/{id}/candidates", h.handleListCandidates)
|
|
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
|
|
admin.Post("/wanted/{id}/search", h.handleSearchWant)
|
|
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
|
|
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
|
|
admin.Delete("/release-blocks/{hash}", h.handleUnblockRelease)
|
|
admin.Get("/release-blocks", h.handleListReleaseBlocks)
|
|
admin.Get("/downloads", h.handleListDownloads)
|
|
admin.Post("/downloads/{id}/import", h.handleImportAnyway)
|
|
admin.Post("/downloads/{id}/retry", h.handleRetryImport)
|
|
admin.Post("/downloads/{id}/refuse", h.handleRefuseDownload)
|
|
admin.Post("/downloads/{id}/abandon", h.handleAbandonDownload)
|
|
admin.Get("/follows", h.handleListFollows)
|
|
admin.Get("/follows/{mbid}", h.handleGetFollow)
|
|
admin.Put("/follows/{mbid}", h.handlePutFollow)
|
|
admin.Delete("/follows/{mbid}", h.handleDeleteFollow)
|
|
admin.Get("/release-profiles", h.handleListReleaseProfiles)
|
|
admin.Post("/release-profiles", h.handleCreateReleaseProfile)
|
|
admin.Put("/release-profiles/{id}", h.handleUpdateReleaseProfile)
|
|
admin.Delete("/release-profiles/{id}", h.handleDeleteReleaseProfile)
|
|
|
|
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.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 wanting back an album whose files went missing
|
|
// Sits beside the missing-files list it governs, because an
|
|
// admin meets it on that surface.
|
|
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
|
|
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
|
|
|
|
// Where the library lives. A changed folder moves its
|
|
// tracks rather than re-adding them.
|
|
admin.Get("/library/folders", h.handleGetLibraryFolders)
|
|
admin.Put("/library/folders", h.handlePutLibraryFolders)
|
|
|
|
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.
|
|
admin.Get("/library/missing", h.handleListMissingTracks)
|
|
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
|
|
admin.Put("/library/suspect-sources/{id}", h.handleSetSuspectSourceVerdict)
|
|
|
|
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)
|
|
admin.Get("/library/acoustid", h.handleGetAcoustID)
|
|
admin.Put("/library/acoustid-settings", h.handleUpdateAcoustIDSettings)
|
|
admin.Post("/library/acoustid/run", h.handleRunAcoustIDLookup)
|
|
admin.Get("/library/acoustid/unsettled", h.handleListUnsettledAcoustID)
|
|
// Duplicates report: proposals from the duplicate sweep, a
|
|
// trigger to sweep now, dismissal, and the merge, which deletes
|
|
// the removed copies' files after moving their history onto the
|
|
// kept one.
|
|
admin.Get("/library/duplicates", h.handleListDuplicates)
|
|
admin.Get("/library/duplicates/resolved", h.handleListResolvedDuplicates)
|
|
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
|
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
|
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
|
admin.Post("/library/duplicates/{id}/refetch", h.handleRefetchDuplicateGroup)
|
|
|
|
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.
|
|
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("/backups", h.handleGetBackups)
|
|
admin.Put("/backups", h.handleUpdateBackups)
|
|
admin.Post("/backups/test", h.handleTestBackups)
|
|
admin.Post("/backups/run", h.handleRunBackup)
|
|
|
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
|
admin.Get("/notification-email", h.handleGetNotificationEmail)
|
|
admin.Put("/notification-email", h.handleUpdateNotificationEmail)
|
|
|
|
// Recommendation tuning: 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.
|
|
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
|
|
// 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
|
|
requests *requests.Service
|
|
quarantine *quarantine.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
|
|
// notifier writes the notifications inbox. Nil-safe: a nil
|
|
// notifier records nothing, which is what most handler tests want.
|
|
notifier *notifications.Notifier
|
|
playlistScheduler *playlists.Scheduler
|
|
// reacqSettings is the DB-backed policy for auto re-acquisition of
|
|
// missing files — 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, 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, the same
|
|
// instance the loudness backfill reads. Nil serves the defaults.
|
|
loudnessSettings *library.LoudnessSettingsService
|
|
// acoustIDLookup is the AcoustID lookup worker: its settings, its
|
|
// status and "look up now". Nil serves the defaults (off).
|
|
acoustIDLookup *library.AcoustIDLookupWorker
|
|
// backups is the server's own backup: settings, status, test and run.
|
|
backups *backup.Service
|
|
// acquisition is the configured indexers and download clients.
|
|
acquisition *acquire.Store
|
|
// catalogue is the release catalogue read from MusicBrainz, and
|
|
// catalogueArt the images for what isn't held.
|
|
catalogue *catalogue.Service
|
|
catalogueArt *catalogue.Art
|
|
// wanted is the wanted list: follows, release-type profiles and wants.
|
|
// Its worker runs in main; the API reads and edits.
|
|
wanted *wanted.Service
|
|
// searcher searches for wants and grabs releases for them.
|
|
searcher *wanted.Searcher
|
|
// importer refuses a download at a person's word. The import
|
|
// loop itself runs in main; this one only acts on a request.
|
|
importer *importer.Importer
|
|
// lifecycle gives up on a download at a person's word; its loop
|
|
// runs in main.
|
|
lifecycle *lifecycle.Service
|
|
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
|
|
// time, which is what handler tests get.
|
|
catalogueSearches *searchCache
|
|
// leveled renders the leveled streams handed to Sonos/UPnP speakers. 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), from config.Config.StreamSecret. 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).
|
|
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
|
|
}
|
|
|
|
// artSources hands the art cache the cover-art settings, or nothing when
|
|
// there are none: a nil *SettingsService inside the interface would not
|
|
// compare equal to nil and would be called.
|
|
func artSources(s *coverart.SettingsService) catalogue.ArtSources {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// scannerRoots reads the library folders, or none when the API runs without
|
|
// a scanner (tests).
|
|
func scannerRoots(s *library.Scanner) func() []string {
|
|
return func() []string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
return s.Roots()
|
|
}
|
|
}
|