release / web (push) Failing after 15s
release / govulncheck (push) Successful in 16s
release / go (push) Failing after 41s
release / integration (push) Failing after 3m29s
release / android (push) Successful in 4m7s
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 / Build signed APK (push) Canceled after 5m34s
Admin › Backups shows a verdict first (off, not set up, waiting, backed up, overdue, failing since when), Back up now, Test destination and the latest runs, with the settings beside them. - backup.Service: one run at a time, bounded at 30 minutes; the schedule ticks every 5 minutes, catches up a slot missed while the server was down, and retries a failure after an hour and then after as long as it has been failing. Runs are recorded in backup_runs, pruned after 90 days; a run cut off by a shutdown is marked failed at the next start. - A folder destination writes database/minstrel-<UTC stamp>.tar and refuses a folder that isn't there. An S3 destination stages the file, hashes it, and uploads it under database/ through a throttled reader (4 MiB/s by default, 0 for no limit). - Test destination checks the form as typed and only lists. - The bucket secret is sealed like every stored credential; backup_settings and backup_runs are left out of the backup itself. - The panel polls only while a run is going, capped at the run limit plus five minutes. The music switch named in the task is left to #6774, so no control ships that does nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
521 lines
27 KiB
Go
521 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/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()
|
|
}
|
|
}
|