Files
minstrel/internal/api/admin_status.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

230 lines
7.5 KiB
Go

package api
import (
"net/http"
"syscall"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
// The admin Overview's status sections: after the verdict, who is
// listening, what is downloading, what the library holds and how much room
// it has, and how the server itself is doing. One read for the page, polled
// while it is open.
type listeningView struct {
Username string `json:"username"`
TrackID string `json:"track_id"`
Title string `json:"title"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
DurationMs int32 `json:"duration_ms"`
StartedAt string `json:"started_at"`
Client string `json:"client"`
DeviceClass string `json:"device_class"`
}
type listeningStatusView struct {
Now []listeningView `json:"now"`
// The last day: how many plays, by how many people, the latest when.
PlaysDay int64 `json:"plays_day"`
ListenersDay int64 `json:"listeners_day"`
LastPlayAt *string `json:"last_play_at"`
}
type downloadsStatusView struct {
Running int64 `json:"running"`
NeedsYou int64 `json:"needs_you"`
NeedsYouSince *string `json:"needs_you_since"`
ImportedDay int64 `json:"imported_day"`
GivenUpDay int64 `json:"given_up_day"`
// Live is the downloads not yet ended, the ones needing a person first.
Live []downloadView `json:"live"`
ClientsDown []clientDownView `json:"clients_down"`
}
// diskView is the filesystem one library folder, or the data folder, sits
// on.
type diskView struct {
Path string `json:"path"`
// Data marks Minstrel's own data folder (covers, fingerprints), beside
// the library's.
Data bool `json:"data,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
// Error says why the folder could not be read, which is itself the
// news: a drive that did not mount.
Error string `json:"error,omitempty"`
}
type libraryStatusView struct {
Tracks int64 `json:"tracks"`
Albums int64 `json:"albums"`
Artists int64 `json:"artists"`
Bytes int64 `json:"bytes"`
Disks []diskView `json:"disks"`
}
type serverErrorView struct {
At string `json:"at"`
Message string `json:"message"`
Attrs string `json:"attrs"`
}
type serverStatusView struct {
Version string `json:"version"`
Channel string `json:"channel"`
StartedAt string `json:"started_at"`
// Migration is the database schema's version, and Dirty whether its
// last migration stopped part way.
Migration int64 `json:"migration"`
Dirty bool `json:"dirty"`
// Errors is the newest few errors logged since the start, newest first;
// ErrorCount is all of them.
Errors []serverErrorView `json:"errors"`
ErrorCount int64 `json:"error_count"`
}
type adminStatusView struct {
Listening listeningStatusView `json:"listening"`
Downloads downloadsStatusView `json:"downloads"`
Library libraryStatusView `json:"library"`
Server serverStatusView `json:"server"`
}
// overviewLiveDownloads is how many running downloads the Overview lists;
// Activity has the rest.
const overviewLiveDownloads = 5
// overviewErrors is how many recent errors the Overview lists.
const overviewErrors = 10
// handleGetAdminStatus implements GET /api/admin/status.
func (h *handlers) handleGetAdminStatus(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
ctx := r.Context()
var out adminStatusView
now, err := q.ListListeningNow(ctx)
if err != nil {
h.logger.Error("admin status: listening", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out.Listening.Now = make([]listeningView, 0, len(now))
for _, p := range now {
out.Listening.Now = append(out.Listening.Now, listeningView{
Username: p.Username, TrackID: uuidToString(p.TrackID), Title: p.TrackTitle,
ArtistName: p.ArtistName, AlbumID: uuidToString(p.AlbumID), AlbumTitle: p.AlbumTitle,
DurationMs: p.DurationMs, StartedAt: formatTimestamp(p.StartedAt),
Client: deref(p.ClientID), DeviceClass: deref(p.DeviceClass),
})
}
day, err := q.PlaysLastDay(ctx)
if err != nil {
h.logger.Error("admin status: plays", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out.Listening.PlaysDay, out.Listening.ListenersDay = day.Plays, day.Listeners
if day.LastAt.Valid {
at := formatTimestamp(day.LastAt)
out.Listening.LastPlayAt = &at
}
if err := h.downloadsStatus(r, q, &out.Downloads); err != nil {
h.logger.Error("admin status: downloads", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
lib, err := q.LibraryStatus(ctx)
if err != nil {
h.logger.Error("admin status: library", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists,
Bytes: lib.Bytes, Disks: []diskView{}}
if h.importer != nil {
for _, root := range h.importer.Roots() {
out.Library.Disks = append(out.Library.Disks, diskOf(root))
}
}
if h.dataDir != "" {
d := diskOf(h.dataDir)
d.Data = true
out.Library.Disks = append(out.Library.Disks, d)
}
out.Server = serverStatus(logging.Errors)
// golang-migrate's own table, outside sqlc's schema.
if err := h.pool.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`).
Scan(&out.Server.Migration, &out.Server.Dirty); err != nil {
h.logger.Warn("admin status: migration version", "err", err)
}
writeJSON(w, http.StatusOK, out)
}
func (h *handlers) downloadsStatus(r *http.Request, q *dbq.Queries, out *downloadsStatusView) error {
st, err := q.DownloadsStatus(r.Context())
if err != nil {
return err
}
*out = downloadsStatusView{Running: st.Running, NeedsYou: st.NeedsYou,
ImportedDay: st.ImportedDay, GivenUpDay: st.GivenUpDay,
Live: []downloadView{}, ClientsDown: []clientDownView{}}
if st.NeedsYou > 0 && st.NeedsYouSince.Valid {
since := formatTimestamp(st.NeedsYouSince)
out.NeedsYouSince = &since
}
// ListGrabs puts the ones needing a person first, then the running ones.
rows, err := q.ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: overviewLiveDownloads})
if err != nil {
return err
}
for _, g := range rows {
if !g.EndedAt.Valid {
out.Live = append(out.Live, downloadFrom(g))
}
}
down, err := q.ListClientsDown(r.Context())
if err != nil {
return err
}
for _, c := range down {
out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
Since: formatTimestamp(c.FirstSeen)})
}
return nil
}
// diskOf reads the size and free space of the filesystem path is on.
func diskOf(path string) diskView {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return diskView{Path: path, Error: err.Error()}
}
bsize := uint64(st.Bsize)
return diskView{Path: path, TotalBytes: st.Blocks * bsize, FreeBytes: st.Bavail * bsize}
}
func serverStatus(ring *logging.ErrorRing) serverStatusView {
kept, total := ring.Snapshot()
v := serverStatusView{Version: buildinfo.Version, Channel: buildinfo.Channel,
StartedAt: buildinfo.Started.UTC().Format(time.RFC3339), ErrorCount: total,
Errors: make([]serverErrorView, 0, min(len(kept), overviewErrors))}
for i, e := range kept {
if i == overviewErrors {
break
}
v.Errors = append(v.Errors, serverErrorView{At: e.At.UTC().Format(time.RFC3339),
Message: e.Message, Attrs: e.Attrs})
}
return v
}