Files
minstrel/internal/api/api.go
T
bvandeusen b6632136b7 feat(server/m7-cover-sources): admin cover-sources HTTP handlers
Three endpoints for the admin Settings UI:
- GET /api/admin/cover-sources — lists registered providers with
  current settings + capability badges + sources_version
- PATCH /api/admin/cover-sources/{provider_id} — updates enabled
  and/or api_key; returns version_bumped: true only when the
  enabled set changed
- POST /api/admin/cover-sources/{provider_id}/test — invokes
  TestableProvider.TestConnection; returns ok:bool + duration_ms +
  error string. Returns 200 in both success and failure cases (the
  operation completed; the test result is data, not an error).

api_key is never echoed in GET — replaced with api_key_set: bool
to follow the standard credential-display convention. PATCH
api_key: "" clears; omitting the field leaves it unchanged. PATCH
on an unknown provider_id returns 404.

All under existing RequireAdmin middleware. Routes registered
alongside the library/coverage endpoint in the /api/admin/ tree.

handlers struct gains a coverSettings *coverart.SettingsService
field; server.New + cmd/minstrel/main.go plumb the existing
coverSettings instance through.

coverart.ResetRegistryForTests() exported wrapper added so api
package tests can reset the registry without importing internals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:08:45 -04:00

157 lines
7.1 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/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/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, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string) {
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,
coverArtBackfillCap: coverBackfillCap,
coverSettings: coverSettings,
scanner: scanner,
scanCfg: scanCfg,
dataDir: dataDir,
}
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
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/listenbrainz", h.handleGetListenBrainz)
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
authed.Get("/me/history", h.handleGetMyHistory)
authed.Get("/artists", h.handleListArtists)
authed.Get("/artists/{id}", h.handleGetArtist)
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
authed.Get("/albums/{id}", h.handleGetAlbum)
authed.Get("/albums/{id}/cover", h.handleGetCover)
authed.Get("/library/albums", h.handleListLibraryAlbums)
authed.Get("/tracks/{id}", h.handleGetTrack)
authed.Get("/tracks/{id}/stream", h.handleGetStream)
authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio)
authed.Get("/discover/suggestions", h.handleListSuggestions)
authed.Get("/home", h.handleGetHome)
authed.Post("/events", h.handleEvents)
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)
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.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("/cover-sources", h.handleListCoverSources)
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
})
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)
})
})
}
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
coverArtBackfillCap int
coverSettings *coverart.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
}