Files
minstrel/internal/api/admin_status.go
T
bvandeusenandClaude Opus 5.5 edcb3a57ef
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
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
feat: the library's folders are set in Admin › Library, and changing a folder moves its tracks with it, keeping their plays, likes and playlists (#6129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:03 -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 (M504): 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
}