From c1305a547d5337a7ee46d20af48756607afd0ef3 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 11 Oct 2026 14:50:40 -0400
Subject: [PATCH] feat: the admin Overview shows who is listening, downloads,
the library and its disk, and the server's version, uptime and recent errors;
a download waiting on you, a client not answering or a full disk count in its
verdict (M504 #5627 #5628 #5629 #5630)
The version moves to internal/buildinfo, so /rest/ping reports the build
instead of "dev".
Co-Authored-By: Claude Opus 5.5
---
Dockerfile | 4 +-
internal/api/admin_status.go | 229 ++++++++++++++++++
internal/api/admin_status_test.go | 89 +++++++
internal/api/api.go | 1 +
internal/buildinfo/buildinfo.go | 33 +++
internal/db/dbq/admin_status.sql.go | 168 +++++++++++++
.../0092_play_events_started_idx.down.sql | 1 +
.../0092_play_events_started_idx.up.sql | 4 +
internal/db/queries/admin_status.sql | 54 +++++
internal/logging/logging.go | 3 +-
internal/logging/recent.go | 122 ++++++++++
internal/logging/recent_test.go | 49 ++++
internal/server/server.go | 5 +-
internal/server/version.go | 26 +-
internal/subsonic/envelope.go | 7 +-
web/src/lib/api/admin.ts | 16 ++
web/src/lib/api/queries.ts | 1 +
web/src/lib/api/types.ts | 46 ++++
web/src/lib/utils/bytes.test.ts | 12 +
web/src/lib/utils/bytes.ts | 14 ++
web/src/routes/admin/+page.svelte | 201 ++++++++++++++-
web/src/routes/admin/admin.test.ts | 101 +++++++-
22 files changed, 1148 insertions(+), 38 deletions(-)
create mode 100644 internal/api/admin_status.go
create mode 100644 internal/api/admin_status_test.go
create mode 100644 internal/buildinfo/buildinfo.go
create mode 100644 internal/db/dbq/admin_status.sql.go
create mode 100644 internal/db/migrations/0092_play_events_started_idx.down.sql
create mode 100644 internal/db/migrations/0092_play_events_started_idx.up.sql
create mode 100644 internal/db/queries/admin_status.sql
create mode 100644 internal/logging/recent.go
create mode 100644 internal/logging/recent_test.go
create mode 100644 web/src/lib/utils/bytes.test.ts
create mode 100644 web/src/lib/utils/bytes.ts
diff --git a/Dockerfile b/Dockerfile
index 91903306..0af41d99 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,8 +28,8 @@ ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \
-ldflags="-s -w \
- -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
- -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
+ -X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=${MINSTREL_VERSION}' \
+ -X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Channel=${MINSTREL_CHANNEL}'" \
-o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim
diff --git a/internal/api/admin_status.go b/internal/api/admin_status.go
new file mode 100644
index 00000000..3a80d418
--- /dev/null
+++ b/internal/api/admin_status.go
@@ -0,0 +1,229 @@
+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
+}
diff --git a/internal/api/admin_status_test.go b/internal/api/admin_status_test.go
new file mode 100644
index 00000000..8ead2f1c
--- /dev/null
+++ b/internal/api/admin_status_test.go
@@ -0,0 +1,89 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/logging"
+)
+
+// openPlay records a play by user of track that started ago and has not
+// ended.
+func openPlay(t *testing.T, h *handlers, user, track pgtype.UUID, ago string) {
+ t.Helper()
+ ctx := context.Background()
+ var session pgtype.UUID
+ if err := h.pool.QueryRow(ctx,
+ `INSERT INTO play_sessions (user_id, started_at, last_event_at) VALUES ($1, now(), now()) RETURNING id`,
+ user).Scan(&session); err != nil {
+ t.Fatalf("session: %v", err)
+ }
+ if _, err := h.pool.Exec(ctx,
+ `INSERT INTO play_events (user_id, track_id, session_id, started_at, client_id)
+ VALUES ($1, $2, $3, now() - $4::interval, 'web')`, user, track, session, ago); err != nil {
+ t.Fatalf("play: %v", err)
+ }
+}
+
+// The Overview's status (M504): a play still inside its track's length is
+// listening now; one long past it, never closed by its client, is not.
+func TestAdminStatus_ListeningAndServer(t *testing.T) {
+ h, pool := testHandlers(t)
+ truncateLibrary(t, pool)
+ admin := seedUser(t, pool, "admin", "pw", true)
+
+ fresh, _ := seedTrackOnDisk(t, h, "statusfresh") // 30 s long
+ stale, _ := seedTrackOnDisk(t, h, "statusstale")
+ openPlay(t, h, admin.ID, fresh.ID, "10 seconds")
+ openPlay(t, h, admin.ID, stale.ID, "1 hour")
+
+ req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/status", nil), admin)
+ w := httptest.NewRecorder()
+ h.handleGetAdminStatus(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ var got adminStatusView
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ var sawFresh, sawStale bool
+ for _, p := range got.Listening.Now {
+ switch p.TrackID {
+ case uuidToString(fresh.ID):
+ sawFresh = true
+ if p.Username != admin.Username || p.Client != "web" || p.Title != "AdmQ Track statusfresh" {
+ t.Errorf("listening row = %+v", p)
+ }
+ case uuidToString(stale.ID):
+ sawStale = true
+ }
+ }
+ if !sawFresh || sawStale {
+ t.Errorf("listening now: fresh %v (want true), stale %v (want false)", sawFresh, sawStale)
+ }
+ if got.Listening.PlaysDay < 2 || got.Listening.LastPlayAt == nil {
+ t.Errorf("last day: plays %d, last %v", got.Listening.PlaysDay, got.Listening.LastPlayAt)
+ }
+
+ if got.Library.Tracks != 2 || got.Library.Albums != 2 || got.Library.Bytes != 10 {
+ t.Errorf("library = %+v, want 2 tracks, 2 albums, 10 bytes", got.Library)
+ }
+ if got.Downloads.Live == nil || got.Downloads.ClientsDown == nil || got.Library.Disks == nil {
+ t.Error("lists must be [], never null")
+ }
+
+ if got.Server.Version != buildinfo.Version || got.Server.StartedAt == "" || got.Server.Migration < 92 {
+ t.Errorf("server = %+v", got.Server)
+ }
+ if _, total := logging.Errors.Snapshot(); got.Server.ErrorCount != total {
+ t.Errorf("error count = %d, ring says %d", got.Server.ErrorCount, total)
+ }
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index 1ffdaeef..bdb6ad8e 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -238,6 +238,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
// The queues that wait on an admin, for the rail and the Overview (#5605).
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)
diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go
new file mode 100644
index 00000000..04461efc
--- /dev/null
+++ b/internal/buildinfo/buildinfo.go
@@ -0,0 +1,33 @@
+// Package buildinfo says which build is running and since when. It is the
+// one place the version lives, so /healthz, the Subsonic envelope and the
+// admin Overview cannot disagree about it.
+package buildinfo
+
+import "time"
+
+// Version is the build's own version name — YYYY.MM.DD.HHMM, derived from
+// the commit it was built from by ci/version.sh. Defaults to "dev" for local
+// builds; overridden at link time via:
+//
+// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=2026.09.10.1449'"
+//
+// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
+// ldflag.
+//
+// This carried the literal strings "main" and "dev" until 2026-09-10, which
+// made every image on a channel report the same thing forever. It stopped
+// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule
+// 145): this is now the ONLY thing that says which build is running.
+var Version = "dev"
+
+// Channel is which line this build came off — "stable" or "dev", or "local"
+// for a plain `docker build`.
+//
+// A SIBLING FIELD, never a suffix inside Version (family rule 149). The same
+// commit built on both lanes reports the same version and differs only here;
+// folding the two together is what makes a version string stop being
+// comparable.
+var Channel = "local"
+
+// Started is when this process started, for the Overview's uptime (M504).
+var Started = time.Now()
diff --git a/internal/db/dbq/admin_status.sql.go b/internal/db/dbq/admin_status.sql.go
new file mode 100644
index 00000000..4738dbdc
--- /dev/null
+++ b/internal/db/dbq/admin_status.sql.go
@@ -0,0 +1,168 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: admin_status.sql
+
+package dbq
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const downloadsStatus = `-- name: DownloadsStatus :one
+SELECT COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('', 'downloading', 'waiting', 'importing'))::bigint AS running,
+ COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS needs_you,
+ MIN(COALESCE(import_updated_at, grabbed_at)) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::timestamptz AS needs_you_since,
+ COUNT(*) FILTER (WHERE imported_at > now() - INTERVAL '24 hours')::bigint AS imported_day,
+ COUNT(*) FILTER (WHERE ended_at > now() - INTERVAL '24 hours'
+ AND end_reason IN ('stalled', 'failed', 'gone', 'abandoned'))::bigint AS given_up_day
+ FROM grabs
+ WHERE ended_at IS NULL
+ OR ended_at > now() - INTERVAL '24 hours'
+ OR imported_at > now() - INTERVAL '24 hours'
+`
+
+type DownloadsStatusRow struct {
+ Running int64
+ NeedsYou int64
+ NeedsYouSince pgtype.Timestamptz
+ ImportedDay int64
+ GivenUpDay int64
+}
+
+// Downloads at a glance: running, waiting on a person (and since when), and
+// the last day's imports and give-ups.
+func (q *Queries) DownloadsStatus(ctx context.Context) (DownloadsStatusRow, error) {
+ row := q.db.QueryRow(ctx, downloadsStatus)
+ var i DownloadsStatusRow
+ err := row.Scan(
+ &i.Running,
+ &i.NeedsYou,
+ &i.NeedsYouSince,
+ &i.ImportedDay,
+ &i.GivenUpDay,
+ )
+ return i, err
+}
+
+const libraryStatus = `-- name: LibraryStatus :one
+SELECT COUNT(*)::bigint AS tracks,
+ COUNT(DISTINCT album_id)::bigint AS albums,
+ COUNT(DISTINCT artist_id)::bigint AS artists,
+ COALESCE(SUM(file_size), 0)::bigint AS bytes
+ FROM tracks
+ WHERE missing_since IS NULL
+`
+
+type LibraryStatusRow struct {
+ Tracks int64
+ Albums int64
+ Artists int64
+ Bytes int64
+}
+
+// What the library holds on disk: tracks whose files are present, the
+// albums and artists they make, and their size.
+func (q *Queries) LibraryStatus(ctx context.Context) (LibraryStatusRow, error) {
+ row := q.db.QueryRow(ctx, libraryStatus)
+ var i LibraryStatusRow
+ err := row.Scan(
+ &i.Tracks,
+ &i.Albums,
+ &i.Artists,
+ &i.Bytes,
+ )
+ return i, err
+}
+
+const listListeningNow = `-- name: ListListeningNow :many
+
+SELECT pe.started_at, pe.client_id, pe.device_class,
+ u.username,
+ t.id AS track_id, t.title AS track_title, t.duration_ms,
+ ar.name AS artist_name,
+ al.id AS album_id, al.title AS album_title
+ FROM play_events pe
+ JOIN users u ON u.id = pe.user_id
+ JOIN tracks t ON t.id = pe.track_id
+ JOIN albums al ON al.id = t.album_id
+ JOIN artists ar ON ar.id = t.artist_id
+ WHERE pe.ended_at IS NULL
+ AND pe.started_at > now() - INTERVAL '24 hours'
+ AND pe.started_at + make_interval(secs => t.duration_ms / 1000.0) + INTERVAL '5 minutes' > now()
+ ORDER BY pe.started_at DESC
+ LIMIT 20
+`
+
+type ListListeningNowRow struct {
+ StartedAt pgtype.Timestamptz
+ ClientID *string
+ DeviceClass *string
+ Username string
+ TrackID pgtype.UUID
+ TrackTitle string
+ DurationMs int32
+ ArtistName string
+ AlbumID pgtype.UUID
+ AlbumTitle string
+}
+
+// The admin Overview's status sections (M504): who is listening, what is
+// downloading, what the library holds. Each is one cheap read.
+// Plays in progress: open play events still inside their track's length,
+// with a few minutes' grace for a pause. A client that never says a play
+// ended would otherwise read as listening until the 24-hour sweep closes it.
+func (q *Queries) ListListeningNow(ctx context.Context) ([]ListListeningNowRow, error) {
+ rows, err := q.db.Query(ctx, listListeningNow)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListListeningNowRow
+ for rows.Next() {
+ var i ListListeningNowRow
+ if err := rows.Scan(
+ &i.StartedAt,
+ &i.ClientID,
+ &i.DeviceClass,
+ &i.Username,
+ &i.TrackID,
+ &i.TrackTitle,
+ &i.DurationMs,
+ &i.ArtistName,
+ &i.AlbumID,
+ &i.AlbumTitle,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const playsLastDay = `-- name: PlaysLastDay :one
+SELECT COUNT(*)::bigint AS plays,
+ COUNT(DISTINCT user_id)::bigint AS listeners,
+ MAX(started_at)::timestamptz AS last_at
+ FROM play_events
+ WHERE started_at > now() - INTERVAL '24 hours'
+`
+
+type PlaysLastDayRow struct {
+ Plays int64
+ Listeners int64
+ LastAt pgtype.Timestamptz
+}
+
+// The last day's listening, for the line under Listening now.
+func (q *Queries) PlaysLastDay(ctx context.Context) (PlaysLastDayRow, error) {
+ row := q.db.QueryRow(ctx, playsLastDay)
+ var i PlaysLastDayRow
+ err := row.Scan(&i.Plays, &i.Listeners, &i.LastAt)
+ return i, err
+}
diff --git a/internal/db/migrations/0092_play_events_started_idx.down.sql b/internal/db/migrations/0092_play_events_started_idx.down.sql
new file mode 100644
index 00000000..62eafa51
--- /dev/null
+++ b/internal/db/migrations/0092_play_events_started_idx.down.sql
@@ -0,0 +1 @@
+DROP INDEX IF EXISTS play_events_started_idx;
diff --git a/internal/db/migrations/0092_play_events_started_idx.up.sql b/internal/db/migrations/0092_play_events_started_idx.up.sql
new file mode 100644
index 00000000..c72abf73
--- /dev/null
+++ b/internal/db/migrations/0092_play_events_started_idx.up.sql
@@ -0,0 +1,4 @@
+-- The admin Overview (M504) reads plays across every user by time: the ones
+-- in progress and the last day's count. The only index on play_events leads
+-- with user_id, so without this every Overview load scanned the table.
+CREATE INDEX play_events_started_idx ON play_events (started_at);
diff --git a/internal/db/queries/admin_status.sql b/internal/db/queries/admin_status.sql
new file mode 100644
index 00000000..f0d73827
--- /dev/null
+++ b/internal/db/queries/admin_status.sql
@@ -0,0 +1,54 @@
+-- The admin Overview's status sections (M504): who is listening, what is
+-- downloading, what the library holds. Each is one cheap read.
+
+-- name: ListListeningNow :many
+-- Plays in progress: open play events still inside their track's length,
+-- with a few minutes' grace for a pause. A client that never says a play
+-- ended would otherwise read as listening until the 24-hour sweep closes it.
+SELECT pe.started_at, pe.client_id, pe.device_class,
+ u.username,
+ t.id AS track_id, t.title AS track_title, t.duration_ms,
+ ar.name AS artist_name,
+ al.id AS album_id, al.title AS album_title
+ FROM play_events pe
+ JOIN users u ON u.id = pe.user_id
+ JOIN tracks t ON t.id = pe.track_id
+ JOIN albums al ON al.id = t.album_id
+ JOIN artists ar ON ar.id = t.artist_id
+ WHERE pe.ended_at IS NULL
+ AND pe.started_at > now() - INTERVAL '24 hours'
+ AND pe.started_at + make_interval(secs => t.duration_ms / 1000.0) + INTERVAL '5 minutes' > now()
+ ORDER BY pe.started_at DESC
+ LIMIT 20;
+
+-- name: PlaysLastDay :one
+-- The last day's listening, for the line under Listening now.
+SELECT COUNT(*)::bigint AS plays,
+ COUNT(DISTINCT user_id)::bigint AS listeners,
+ MAX(started_at)::timestamptz AS last_at
+ FROM play_events
+ WHERE started_at > now() - INTERVAL '24 hours';
+
+-- name: DownloadsStatus :one
+-- Downloads at a glance: running, waiting on a person (and since when), and
+-- the last day's imports and give-ups.
+SELECT COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('', 'downloading', 'waiting', 'importing'))::bigint AS running,
+ COUNT(*) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS needs_you,
+ MIN(COALESCE(import_updated_at, grabbed_at)) FILTER (WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::timestamptz AS needs_you_since,
+ COUNT(*) FILTER (WHERE imported_at > now() - INTERVAL '24 hours')::bigint AS imported_day,
+ COUNT(*) FILTER (WHERE ended_at > now() - INTERVAL '24 hours'
+ AND end_reason IN ('stalled', 'failed', 'gone', 'abandoned'))::bigint AS given_up_day
+ FROM grabs
+ WHERE ended_at IS NULL
+ OR ended_at > now() - INTERVAL '24 hours'
+ OR imported_at > now() - INTERVAL '24 hours';
+
+-- name: LibraryStatus :one
+-- What the library holds on disk: tracks whose files are present, the
+-- albums and artists they make, and their size.
+SELECT COUNT(*)::bigint AS tracks,
+ COUNT(DISTINCT album_id)::bigint AS albums,
+ COUNT(DISTINCT artist_id)::bigint AS artists,
+ COALESCE(SUM(file_size), 0)::bigint AS bytes
+ FROM tracks
+ WHERE missing_since IS NULL;
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
index d0561466..e0dc48b0 100644
--- a/internal/logging/logging.go
+++ b/internal/logging/logging.go
@@ -23,7 +23,8 @@ func New(w io.Writer, level, format string) (*slog.Logger, error) {
default:
return nil, fmt.Errorf("unknown log format %q (want text|json)", format)
}
- return slog.New(h), nil
+ // Errors are also kept for the admin Overview (M504).
+ return slog.New(newRingHandler(h, Errors)), nil
}
func parseLevel(s string) (slog.Level, error) {
diff --git a/internal/logging/recent.go b/internal/logging/recent.go
new file mode 100644
index 00000000..0aee1681
--- /dev/null
+++ b/internal/logging/recent.go
@@ -0,0 +1,122 @@
+package logging
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+ "time"
+)
+
+// The server's recent errors, kept in memory for the admin Overview (M504):
+// what has gone wrong since the process started, without reading the
+// container's logs. Only ERROR and above, and only the newest few; the full
+// record is still the log stream.
+
+// RecentErrorsKept is how many recent errors are kept.
+const RecentErrorsKept = 50
+
+// LoggedError is one error the server logged.
+type LoggedError struct {
+ At time.Time
+ Message string
+ // Attrs is the record's attributes as key=value, the way the text log
+ // writes them.
+ Attrs string
+}
+
+// ErrorRing keeps the newest errors and counts all of them.
+type ErrorRing struct {
+ mu sync.Mutex
+ buf []LoggedError
+ next int
+ total int64
+}
+
+// Errors is the process's ring, filled by every logger New returns.
+var Errors = NewErrorRing(RecentErrorsKept)
+
+// NewErrorRing returns a ring that keeps the newest n errors.
+func NewErrorRing(n int) *ErrorRing {
+ return &ErrorRing{buf: make([]LoggedError, 0, n)}
+}
+
+func (r *ErrorRing) add(e LoggedError) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.total++
+ if len(r.buf) < cap(r.buf) {
+ r.buf = append(r.buf, e)
+ return
+ }
+ r.buf[r.next] = e
+ r.next = (r.next + 1) % len(r.buf)
+}
+
+// Snapshot returns the kept errors, newest first, and how many have been
+// logged in all since the process started.
+func (r *ErrorRing) Snapshot() ([]LoggedError, int64) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ out := make([]LoggedError, 0, len(r.buf))
+ for i := len(r.buf) - 1; i >= 0; i-- {
+ out = append(out, r.buf[(r.next+i)%len(r.buf)])
+ }
+ return out, r.total
+}
+
+// ringHandler passes every record on, and copies the errors into a ring.
+type ringHandler struct {
+ inner slog.Handler
+ ring *ErrorRing
+ // attrs are the logger's own attributes (component=… and the like),
+ // already rendered, group-qualified.
+ attrs []string
+ groups []string
+}
+
+func newRingHandler(inner slog.Handler, ring *ErrorRing) *ringHandler {
+ return &ringHandler{inner: inner, ring: ring}
+}
+
+func (h *ringHandler) Enabled(ctx context.Context, l slog.Level) bool {
+ return h.inner.Enabled(ctx, l)
+}
+
+func (h *ringHandler) Handle(ctx context.Context, rec slog.Record) error {
+ if rec.Level >= slog.LevelError {
+ parts := append([]string(nil), h.attrs...)
+ rec.Attrs(func(a slog.Attr) bool {
+ parts = append(parts, h.render(a))
+ return true
+ })
+ h.ring.add(LoggedError{At: rec.Time, Message: rec.Message, Attrs: strings.Join(parts, " ")})
+ }
+ return h.inner.Handle(ctx, rec)
+}
+
+func (h *ringHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ next := *h
+ next.inner = h.inner.WithAttrs(attrs)
+ next.attrs = append([]string(nil), h.attrs...)
+ for _, a := range attrs {
+ next.attrs = append(next.attrs, h.render(a))
+ }
+ return &next
+}
+
+func (h *ringHandler) WithGroup(name string) slog.Handler {
+ next := *h
+ next.inner = h.inner.WithGroup(name)
+ next.groups = append(append([]string(nil), h.groups...), name)
+ return &next
+}
+
+func (h *ringHandler) render(a slog.Attr) string {
+ key := a.Key
+ if len(h.groups) > 0 {
+ key = strings.Join(h.groups, ".") + "." + key
+ }
+ return fmt.Sprintf("%s=%v", key, a.Value.Resolve())
+}
diff --git a/internal/logging/recent_test.go b/internal/logging/recent_test.go
new file mode 100644
index 00000000..dacbbbb7
--- /dev/null
+++ b/internal/logging/recent_test.go
@@ -0,0 +1,49 @@
+package logging
+
+import (
+ "bytes"
+ "log/slog"
+ "testing"
+)
+
+func TestRingHandler_KeepsErrorsNewestFirst(t *testing.T) {
+ ring := NewErrorRing(3)
+ var out bytes.Buffer
+ log := slog.New(newRingHandler(slog.NewTextHandler(&out, nil), ring)).With("component", "scan")
+
+ log.Info("fine")
+ log.Warn("odd")
+ for _, msg := range []string{"one", "two", "three", "four"} {
+ log.WithGroup("job").Error(msg, "n", len(msg))
+ }
+
+ got, total := ring.Snapshot()
+ if total != 4 {
+ t.Errorf("total = %d, want 4 (info and warn are not errors)", total)
+ }
+ if len(got) != 3 {
+ t.Fatalf("kept %d, want 3", len(got))
+ }
+ for i, want := range []string{"four", "three", "two"} {
+ if got[i].Message != want {
+ t.Errorf("kept[%d] = %q, want %q", i, got[i].Message, want)
+ }
+ }
+ if got[0].Attrs != "component=scan job.n=4" {
+ t.Errorf("attrs = %q", got[0].Attrs)
+ }
+ // Every record still reaches the log stream.
+ if n := bytes.Count(out.Bytes(), []byte("\n")); n != 6 {
+ t.Errorf("log lines = %d, want 6", n)
+ }
+}
+
+func TestErrorRing_BeforeItFills(t *testing.T) {
+ ring := NewErrorRing(5)
+ ring.add(LoggedError{Message: "a"})
+ ring.add(LoggedError{Message: "b"})
+ got, total := ring.Snapshot()
+ if total != 2 || len(got) != 2 || got[0].Message != "b" || got[1].Message != "a" {
+ t.Errorf("got %+v total %d, want b, a", got, total)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index b96c420a..17d51891 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -15,6 +15,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -232,8 +233,8 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
- "version": ServerVersion,
- "channel": ServerChannel,
+ "version": buildinfo.Version,
+ "channel": buildinfo.Channel,
"min_client_version": MinClientVersion,
})
}
diff --git a/internal/server/version.go b/internal/server/version.go
index efc2399e..5c120a0c 100644
--- a/internal/server/version.go
+++ b/internal/server/version.go
@@ -5,27 +5,5 @@ package server
// older clients see version_too_old at /healthz and refuse to operate.
const MinClientVersion = "0.1.0"
-// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived
-// from the commit it was built from by ci/version.sh. Defaults to "dev" for
-// local builds; overridden at link time via:
-//
-// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'"
-//
-// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
-// ldflag. Surfaced at /healthz so operators can verify which image their
-// container is running without exec'ing into it.
-//
-// This carried the literal strings "main" and "dev" until 2026-09-10, which
-// made every image on a channel report the same thing forever. It stopped
-// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule
-// 145): this is now the ONLY thing that says which build is running.
-var ServerVersion = "dev"
-
-// ServerChannel is which line this build came off — "stable" or "dev", or
-// "local" for a plain `docker build`.
-//
-// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The
-// same commit built on both lanes reports the same version and differs only
-// here; folding the two together is what makes a version string stop being
-// comparable.
-var ServerChannel = "local"
+// The build's version and channel live in internal/buildinfo, shared with
+// the Subsonic envelope and the admin Overview (M504).
diff --git a/internal/subsonic/envelope.go b/internal/subsonic/envelope.go
index ffbf4255..4bcd20ed 100644
--- a/internal/subsonic/envelope.go
+++ b/internal/subsonic/envelope.go
@@ -7,6 +7,8 @@ import (
"io"
"net/http"
"strings"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
)
// APIVersion is the Subsonic REST version we advertise. 1.16.1 is the last
@@ -18,9 +20,6 @@ const (
XMLNS = "http://subsonic.org/restapi"
)
-// ServerVersion identifies this build. Overwritten by cmd/minstrel at startup.
-var ServerVersion = "dev"
-
// Envelope holds the attributes present on every Subsonic response. Concrete
// response types embed it and add endpoint-specific fields with json+xml tags.
type Envelope struct {
@@ -48,7 +47,7 @@ func NewEnvelope(status string) Envelope {
Status: status,
Version: APIVersion,
Type: ServerType,
- ServerVersion: ServerVersion,
+ ServerVersion: buildinfo.Version,
OpenSubsonic: true,
}
}
diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts
index 2acddc22..4f6217ee 100644
--- a/web/src/lib/api/admin.ts
+++ b/web/src/lib/api/admin.ts
@@ -12,6 +12,7 @@ import type {
ResolvedDuplicatesResponse,
AdminPlaybackError,
AdminQuarantineRow,
+ AdminStatus,
AdminSummary,
QuarantineActionRow,
MusicRequest,
@@ -36,6 +37,21 @@ export function createAdminSummaryQuery() {
});
}
+// Admin status (M504): who is listening, downloads, the library and disk,
+// and the server, for the Overview below its verdict. Polled while open.
+
+export async function getAdminStatus(): Promise {
+ return api.get('/api/admin/status');
+}
+
+export function createAdminStatusQuery() {
+ return createQuery({
+ queryKey: qk.adminStatus(),
+ queryFn: getAdminStatus,
+ refetchInterval: 15_000
+ });
+}
+
// Admin request queue -----------------------------------------------------
export async function listAdminRequests(
diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts
index 16a2d521..09706f4a 100644
--- a/web/src/lib/api/queries.ts
+++ b/web/src/lib/api/queries.ts
@@ -60,6 +60,7 @@ export const qk = {
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
scanStatus: () => ['scanStatus'] as const,
adminSummary: () => ['adminSummary'] as const,
+ adminStatus: () => ['adminStatus'] as const,
coverage: () => ['coverage'] as const,
fingerprintCoverage: () => ['fingerprintCoverage'] as const,
coverProviders: () => ['coverProviders'] as const,
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 2981ee08..af84ca84 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -879,3 +879,49 @@ export type AdminSummary = {
suspect: AdminQueue;
missing: AdminQueue;
};
+
+// What GET /api/admin/status returns (M504): the Overview's status sections.
+export type AdminListening = {
+ username: string;
+ track_id: string;
+ title: string;
+ artist_name: string;
+ album_id: string;
+ album_title: string;
+ duration_ms: number;
+ started_at: string;
+ client: string;
+ device_class: string;
+};
+// data marks Minstrel's own data folder, beside the library's folders.
+export type AdminDisk = { path: string; data?: boolean; total_bytes: number; free_bytes: number; error?: string };
+export type AdminServerError = { at: string; message: string; attrs: string };
+export type AdminStatus = {
+ listening: {
+ now: AdminListening[];
+ plays_day: number;
+ listeners_day: number;
+ last_play_at: string | null;
+ };
+ downloads: {
+ running: number;
+ needs_you: number;
+ needs_you_since: string | null;
+ imported_day: number;
+ given_up_day: number;
+ live: Download[];
+ clients_down: { client: string; detail: string; since: string }[];
+ };
+ library: { tracks: number; albums: number; artists: number; bytes: number; disks: AdminDisk[] };
+ server: {
+ version: string;
+ channel: string;
+ started_at: string;
+ // The database schema's version, and whether its last migration stopped
+ // part way.
+ migration: number;
+ dirty: boolean;
+ errors: AdminServerError[];
+ error_count: number;
+ };
+};
diff --git a/web/src/lib/utils/bytes.test.ts b/web/src/lib/utils/bytes.test.ts
new file mode 100644
index 00000000..32ed3b21
--- /dev/null
+++ b/web/src/lib/utils/bytes.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, test } from 'vitest';
+import { formatBytes } from './bytes';
+
+describe('formatBytes', () => {
+ test('picks the unit that keeps the figure above one', () => {
+ expect(formatBytes(0)).toBe('0 B');
+ expect(formatBytes(999)).toBe('999 B');
+ expect(formatBytes(12_300)).toBe('12.3 KB');
+ expect(formatBytes(820_000_000_000)).toBe('820 GB');
+ expect(formatBytes(1_400_000_000_000)).toBe('1.4 TB');
+ });
+});
diff --git a/web/src/lib/utils/bytes.ts b/web/src/lib/utils/bytes.ts
new file mode 100644
index 00000000..dddd313d
--- /dev/null
+++ b/web/src/lib/utils/bytes.ts
@@ -0,0 +1,14 @@
+// A size in bytes, in the largest unit that keeps it above one: "1.4 TB",
+// "820 GB", "12 KB". Decimal units, as drives are sold and as `df -H` shows.
+const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
+
+export function formatBytes(n: number): string {
+ let v = n;
+ let u = 0;
+ while (v >= 1000 && u < UNITS.length - 1) {
+ v /= 1000;
+ u++;
+ }
+ const digits = u === 0 || v >= 100 ? 0 : 1;
+ return `${v.toFixed(digits)} ${UNITS[u]}`;
+}
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte
index 3b77b7a9..99f7f11f 100644
--- a/web/src/routes/admin/+page.svelte
+++ b/web/src/routes/admin/+page.svelte
@@ -1,13 +1,21 @@
{pageTitle('Admin · Overview')}
@@ -36,7 +98,7 @@
summaryQ.refetch()}>Try again
{:else}
- {@const problems = waiting.length + (scan?.error_message ? 1 : 0)}
+ {@const problems = waiting.length + (scan?.error_message ? 1 : 0) + statusProblems.length}
{#if problems === 0}
Nothing needs you.
@@ -69,6 +131,17 @@
{/each}
+ {#each statusProblems as p (p.key)}
+
+
+ {p.text}
+ {#if p.since}
+ since {relativeTime(p.since)}
+ {/if}
+ Open
+
+
+ {/each}
{/if}
{#if clear.length > 0}
@@ -78,5 +151,129 @@
{/if}
{/if}
+
+ {#if statusQ.isError}
+
+ Couldn't read the server's status.
+ statusQ.refetch()}>Try again
+
+ {:else if status}
+ {@const l = status.listening}
+ {@const dl = status.downloads}
+ {@const lib = status.library}
+ {@const srv = status.server}
+
+ Listening now
+ {#if l.now.length === 0}
+ Nobody is listening.
+ {:else}
+
+ {#each l.now as p (p.username + p.started_at)}
+
+ {p.username}
+ {p.title} · {p.artist_name}
+ {p.client || p.device_class || 'a client'} · started {relativeTime(p.started_at)}
+
+ {/each}
+
+ {/if}
+
+ {#if l.plays_day > 0}
+ {plural(l.plays_day, 'play', 'plays')} by {plural(l.listeners_day, 'person', 'people')} in the last day{#if l.last_play_at},
+ the latest {relativeTime(l.last_play_at)}{/if}.
+ {:else}
+ No plays in the last day.
+ {/if}
+
+
+
+
+ Downloads
+
+ {dl.running === 0 ? 'Nothing downloading' : `${dl.running} downloading`}.
+ In the last day, {dl.imported_day} imported{dl.given_up_day ? ` and ${dl.given_up_day} given up` : ''}.
+ Activity
+
+ {#if dl.live.length > 0}
+
+ {#each dl.live as d (d.id)}
+
+
+ {d.want_title ? `${d.want_title} · ${d.want_artist}` : d.name}
+
+ {downloadLine(d)} · {relativeTime(d.grabbed_at)}
+
+ {/each}
+
+ {/if}
+
+
+
+ Library
+
+ {plural(lib.tracks, 'track', 'tracks')} on {plural(lib.albums, 'album', 'albums')} by {plural(lib.artists, 'artist', 'artists')},
+ {formatBytes(lib.bytes)}.
+ {#if scan?.in_flight}
+ Scanning now.
+ {:else if scan?.finished_at}
+ Last scanned {relativeTime(scan.finished_at)}.
+ {/if}
+ Library
+
+ {#each lib.disks as d (d.path)}
+ {#if !d.error}
+ {@const used = 1 - freeShare(d)}
+
+
+ {#if d.data}Minstrel's data · {/if}{d.path}
+ {formatBytes(d.free_bytes)} free of {formatBytes(d.total_bytes)}
+
+
+
+ {/if}
+ {/each}
+
+
+
+ Server
+
+ Version {srv.version} ({srv.channel}), database schema {srv.migration}, started {relativeTime(srv.started_at)}.
+ {#if srv.error_count === 0}
+ No errors since.
+ {:else}
+ {plural(srv.error_count, 'error', 'errors')} logged since{srv.error_count > srv.errors.length
+ ? `; the latest ${srv.errors.length}:`
+ : ':'}
+ {/if}
+
+ {#if srv.errors.length > 0}
+
+ {/if}
+
+ {/if}
diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts
index 0fddd500..0c020bb2 100644
--- a/web/src/routes/admin/admin.test.ts
+++ b/web/src/routes/admin/admin.test.ts
@@ -20,6 +20,7 @@ vi.mock('#lib/api/admin.js', async () => {
const { readable } = await import('svelte/store');
return {
createAdminSummaryQuery: vi.fn(),
+ createAdminStatusQuery: vi.fn(),
createScanStatusQuery: vi.fn(() =>
readable({
data: { id: '', started_at: '', finished_at: null, in_flight: false },
@@ -65,8 +66,8 @@ describe('admin/+layout.ts load gate', () => {
// where the page picked up empty data despite `mockReturnValue` being set.
// Importing at module load means the mocks attach to the same handles the
// page binds against.
-import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
-import type { AdminSummary } from '#lib/api/types.js';
+import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
+import type { AdminStatus, AdminSummary } from '#lib/api/types.js';
import OverviewPage from './+page.svelte';
import AdminLayout from './+layout.svelte';
import { createRawSnippet } from 'svelte';
@@ -86,14 +87,40 @@ function summary(over: Partial = {}): AdminSummary {
};
}
-function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn()) {
+function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn(), st?: AdminStatus) {
(createAdminSummaryQuery as ReturnType).mockReturnValue(
data
? mockQuery({ data, refetch })
: mockQuery({ isError: true, error: new Error('boom'), refetch })
);
+ (createAdminStatusQuery as ReturnType).mockReturnValue(
+ st ? mockQuery({ data: st }) : mockQuery({ isPending: true })
+ );
}
+const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
+
+function status(over: Partial = {}): AdminStatus {
+ return {
+ listening: { now: [], plays_day: 0, listeners_day: 0, last_play_at: null },
+ downloads: {
+ running: 0, needs_you: 0, needs_you_since: null, imported_day: 0, given_up_day: 0, live: [], clients_down: []
+ },
+ library: {
+ tracks: 41_000, albums: 3_200, artists: 900, bytes: 1_400_000_000_000,
+ disks: [{ path: '/music', total_bytes: 4_000_000_000_000, free_bytes: 2_000_000_000_000 }]
+ },
+ server: {
+ version: '2026.10.11.1840', channel: 'dev', started_at: minutesAgo(180), migration: 92, dirty: false,
+ errors: [], error_count: 0
+ },
+ ...over
+ };
+}
+
+// The text of a testid'd element with its whitespace folded.
+const textOf = (id: string) => (screen.getByTestId(id).textContent ?? '').replace(/\s+/g, ' ').trim();
+
describe('admin Overview page', () => {
test('with every queue empty, the verdict says nothing needs you, and names what is clear', () => {
setupOverview();
@@ -154,6 +181,74 @@ describe('admin Overview page', () => {
});
});
+// Below the verdict, the server's state (M504).
+describe('admin Overview status', () => {
+ test('who is listening, and the last day of plays', () => {
+ setupOverview(summary(), vi.fn(), status({
+ listening: {
+ now: [{
+ username: 'bryan', track_id: 't1', title: 'Avril 14th', artist_name: 'Aphex Twin', album_id: 'al1',
+ album_title: 'Drukqs', duration_ms: 125_000, started_at: minutesAgo(1), client: 'android', device_class: 'phone'
+ }],
+ plays_day: 37, listeners_day: 2, last_play_at: minutesAgo(1)
+ }
+ }));
+ render(OverviewPage);
+ const rows = screen.getAllByTestId('listening-row');
+ expect(rows).toHaveLength(1);
+ expect(rows[0].textContent?.replace(/\s+/g, ' ')).toMatch(/bryan Avril 14th · Aphex Twin android · started 1m ago/);
+ expect(textOf('listening-day')).toBe('37 plays by 2 people in the last day, the latest 1m ago.');
+ });
+
+ test('nobody listening says so', () => {
+ setupOverview(summary(), vi.fn(), status());
+ render(OverviewPage);
+ expect(screen.getByText('Nobody is listening.')).toBeTruthy();
+ expect(textOf('listening-day')).toBe('No plays in the last day.');
+ });
+
+ test('the library, its size, and the room left on its disk', () => {
+ setupOverview(summary(), vi.fn(), status());
+ render(OverviewPage);
+ expect(textOf('library-line')).toMatch(/^41,000 tracks on 3,200 albums by 900 artists, 1.4 TB\./);
+ expect(textOf('disk')).toBe('/music 2.0 TB free of 4.0 TB');
+ });
+
+ test('the server says its version, when it started, and its recent errors', () => {
+ setupOverview(summary(), vi.fn(), status({
+ server: {
+ version: '2026.10.11.1840', channel: 'dev', started_at: minutesAgo(180), migration: 92, dirty: false, error_count: 3,
+ errors: [{ at: minutesAgo(5), message: 'admin: summary', attrs: 'err=boom' }]
+ }
+ }));
+ render(OverviewPage);
+ expect(textOf('server-line')).toBe('Version 2026.10.11.1840 (dev), database schema 92, started 3h ago. 3 errors logged since; the latest 1:');
+ expect(screen.getAllByTestId('server-error')[0].textContent).toMatch(/admin: summary[\s\S]*err=boom/);
+ });
+
+ test('a download waiting on you, a client not answering and a nearly full disk count in the verdict', () => {
+ setupOverview(summary(), vi.fn(), status({
+ downloads: {
+ running: 1, needs_you: 2, needs_you_since: minutesAgo(90), imported_day: 4, given_up_day: 0, live: [],
+ clients_down: [{ client: 'qbittorrent', detail: 'connection refused', since: minutesAgo(30) }]
+ },
+ library: {
+ tracks: 1, albums: 1, artists: 1, bytes: 1,
+ disks: [{ path: '/music', total_bytes: 1_000, free_bytes: 10 }]
+ }
+ }));
+ render(OverviewPage);
+ expect(screen.getByTestId('verdict').textContent).toMatch(/3 things need you/);
+ const needs = screen.getAllByTestId('needs-you').map((li) => li.textContent?.replace(/\s+/g, ' ').trim());
+ expect(needs).toEqual([
+ '2 downloads need you since 1h ago Open',
+ "qbittorrent isn't answering: connection refused since 30m ago Open",
+ '/music is nearly full: 10 B free Open'
+ ]);
+ expect(textOf('downloads-line')).toBe('1 downloading. In the last day, 4 imported. Activity');
+ });
+});
+
describe('admin layout', () => {
test('the sections run down a rail, each queue carrying how many wait on you', () => {
setupOverview(summary({ requests: { count: 2, since: ago(1) }, duplicates: { count: 5, since: ago(4) } }));