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)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped

The version moves to internal/buildinfo, so /rest/ping reports the build
instead of "dev".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 14:51:41 -04:00
co-authored by Claude Opus 5.5
parent ead0abf0cf
commit c1305a547d
22 changed files with 1148 additions and 38 deletions
+229
View File
@@ -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
}
+89
View File
@@ -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)
}
}
+1
View File
@@ -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)
+33
View File
@@ -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()
+168
View File
@@ -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
}
@@ -0,0 +1 @@
DROP INDEX IF EXISTS play_events_started_idx;
@@ -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);
+54
View File
@@ -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;
+2 -1
View File
@@ -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) {
+122
View File
@@ -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())
}
+49
View File
@@ -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)
}
}
+3 -2
View File
@@ -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,
})
}
+2 -24
View File
@@ -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).
+3 -4
View File
@@ -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,
}
}