Expose Prometheus metrics on a separate port
CI / integration (push) Failing after 14s
CI / docker (push) Failing after 20s
CI / test (push) Successful in 1m10s
CI / publish (push) Skipped

Metrics are served on METRICS_PORT (default 2112, "off" to disable), never on
the public port. Besides the Go runtime and process metrics this adds:

- http_requests_total and http_request_duration_seconds per route pattern,
  from an Echo middleware.
- musicserver_songs_requested_total and musicserver_songs_played_total by
  pick mode (random, low, classic). The mode is now stored with each queued
  song (migration 000006, not released yet).
- Library size, sync duration, last successful sync, broken songs from the
  last full sync, and the database connection pool.

Refs #35, #36, #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-11 21:57:58 +02:00
1 parent c62b65d7cd
commit f79f51ea4c
28 files changed
+864 -70

No files matched your search

@@ -29,6 +29,11 @@ func TestLibraryAndStatisticsWithPostgres(t *testing.T) {
s.Wait()
require.Empty(t, s.Result().CatchedErrors)
count, err := q.CountLibrary(ctx)
require.NoError(t, err)
assert.Equal(t, int64(4), count.Soundtracks)
require.NoError(t, s.UpdateLibraryMetrics(ctx))
library := NewLibraryService(q)
soundtracks, err := library.Soundtracks(ctx)
require.NoError(t, err)
+108
View File
@@ -0,0 +1,108 @@
package backend
import (
"context"
"path/filepath"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/metrics"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func requested(mode PickMode) float64 {
return testutil.ToFloat64(metrics.SongsRequested.WithLabelValues(string(mode)))
}
func played(mode PickMode) float64 {
return testutil.ToFloat64(metrics.SongsPlayed.WithLabelValues(string(mode)))
}
func syncCount(syncType string) uint64 {
m := &dto.Metric{}
_ = metrics.SyncDuration.WithLabelValues(syncType).(prometheus.Metric).Write(m)
return m.GetHistogram().GetSampleCount()
}
func TestSongMetricsByPickMode(t *testing.T) {
store := fakedb.New()
addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song 1")
m := NewMusicService(store, t.TempDir())
ctx := context.Background()
q := newQueue(t, m)
before := map[PickMode][2]float64{}
for _, mode := range []PickMode{PickRandom, PickLow, PickClassic} {
before[mode] = [2]float64{requested(mode), played(mode)}
}
pick := map[PickMode]func(context.Context, uuid.UUID) (string, error){
PickRandom: func(ctx context.Context, id uuid.UUID) (string, error) { return m.RandomSong(ctx, id) },
PickLow: func(ctx context.Context, id uuid.UUID) (string, error) { return m.RandomSongLowChance(ctx, id) },
PickClassic: func(ctx context.Context, id uuid.UUID) (string, error) { return m.RandomSongClassic(ctx, id) },
}
for songNo, mode := range []PickMode{PickRandom, PickLow, PickClassic} {
_, err := pick[mode](ctx, q)
require.NoError(t, err)
require.NoError(t, m.AddLatestToQueue(ctx, q))
require.NoError(t, m.SetPlayed(ctx, q, songNo))
}
// Fetched but never added: requested, not played.
_, err := m.RandomSong(ctx, q)
require.NoError(t, err)
require.NoError(t, m.AddLatestPlayed(ctx, q), "the current song is the classic one")
assert.Equal(t, before[PickRandom][0]+2, requested(PickRandom))
assert.Equal(t, before[PickLow][0]+1, requested(PickLow))
assert.Equal(t, before[PickClassic][0]+1, requested(PickClassic))
assert.Equal(t, before[PickRandom][1]+1, played(PickRandom))
assert.Equal(t, before[PickLow][1]+1, played(PickLow))
assert.Equal(t, before[PickClassic][1]+2, played(PickClassic))
}
func TestSongsPlayedIgnoresSongsOutsideTheQueue(t *testing.T) {
m := NewMusicService(fakedb.New(), t.TempDir())
before := played(PickRandom)
require.NoError(t, m.SetPlayed(context.Background(), newQueue(t, m), 3))
assert.Equal(t, before, played(PickRandom))
}
func TestSyncMetrics(t *testing.T) {
f := newSyncFixture(t)
f.write("Game A/01.mp3", "a1")
f.write("Game A/02.mp3", "a2")
f.write("Game B/01.mp3", "b1")
changesBefore, fullBefore := syncCount("changes"), syncCount("full")
metrics.SyncLastSuccess.Set(0)
f.run(false)
assert.Equal(t, changesBefore+1, syncCount("changes"))
assert.Equal(t, 2.0, testutil.ToFloat64(metrics.LibrarySoundtracks))
assert.Equal(t, 3.0, testutil.ToFloat64(metrics.LibrarySongs))
assert.InDelta(t, float64(time.Now().Unix()), testutil.ToFloat64(metrics.SyncLastSuccess), 5)
f.store.AddTestSong(repository.Song{SoundtrackID: 999, SongName: "gone", Path: "/does/not/exist.mp3"})
f.run(true)
assert.Equal(t, fullBefore+1, syncCount("full"))
assert.Equal(t, 1.0, testutil.ToFloat64(metrics.BrokenSongs))
assert.Equal(t, 3.0, testutil.ToFloat64(metrics.LibrarySongs), "the broken song is removed")
}
func TestSyncWithErrorsIsNotALastSuccess(t *testing.T) {
metrics.SyncLastSuccess.Set(0)
s := NewSyncService(fakedb.New(), filepath.Join(t.TempDir(), "missing"), nil)
require.True(t, s.Start(false))
s.Wait()
require.NotEmpty(t, s.Result().CatchedErrors)
assert.Zero(t, testutil.ToFloat64(metrics.SyncLastSuccess))
}
+39 -9
View File
@@ -12,6 +12,7 @@ import (
"music-server/internal/db/repository"
"music-server/internal/logging"
"music-server/internal/metrics"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -21,6 +22,24 @@ import (
// maxPickAttempts bounds how many broken songs are skipped before giving up.
const maxPickAttempts = 100
// PickMode is how a random song was picked. It is stored with queued songs and used as
// the mode label of the song metrics.
type PickMode string
const (
PickRandom PickMode = "random"
PickLow PickMode = "low"
PickClassic PickMode = "classic"
)
func init() {
// Start every mode at 0, so the series exist before the first song.
for _, mode := range []PickMode{PickRandom, PickLow, PickClassic} {
metrics.SongsRequested.WithLabelValues(string(mode))
metrics.SongsPlayed.WithLabelValues(string(mode))
}
}
type SongInfo struct {
SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"`
Soundtrack string `json:"Soundtrack"`
@@ -145,7 +164,11 @@ func (m *MusicService) markPlayed(ctx context.Context, songs []repository.ListQu
if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
return err
}
return m.q.AddSongPlayedByUUID(ctx, song.SongUuid)
if err := m.q.AddSongPlayedByUUID(ctx, song.SongUuid); err != nil {
return err
}
metrics.SongsPlayed.WithLabelValues(song.Mode).Inc()
return nil
}
func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) {
@@ -167,7 +190,7 @@ func (m *MusicService) RandomSong(ctx context.Context, queueID uuid.UUID) (strin
for i, s := range soundtracks {
candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1}
}
return m.pickAndRemember(ctx, queueID, candidates)
return m.pickAndRemember(ctx, queueID, candidates, PickRandom)
}
// RandomSongLowChance picks a random song, favouring soundtracks played less than average.
@@ -176,23 +199,30 @@ func (m *MusicService) RandomSongLowChance(ctx context.Context, queueID uuid.UUI
if err != nil {
return "", err
}
return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks))
return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks), PickLow)
}
func (m *MusicService) pickAndRemember(ctx context.Context, queueID uuid.UUID, candidates []weightedSoundtrack) (string, error) {
func (m *MusicService) pickAndRemember(
ctx context.Context, queueID uuid.UUID, candidates []weightedSoundtrack, mode PickMode,
) (string, error) {
song, err := m.pickSong(ctx, candidates)
if err != nil {
return "", err
}
return m.remember(ctx, queueID, song)
return m.remember(ctx, queueID, song, mode)
}
// remember stores the song as the queue's last fetched song, for AddLatestToQueue.
func (m *MusicService) remember(ctx context.Context, queueID uuid.UUID, song repository.Song) (string, error) {
err := m.q.SetQueueLastFetched(ctx, repository.SetQueueLastFetchedParams{Uuid: queueID, LastFetchedSongUuid: song.Uuid})
// remember stores the song and how it was picked as the queue's last fetched song,
// for AddLatestToQueue.
func (m *MusicService) remember(ctx context.Context, queueID uuid.UUID, song repository.Song, mode PickMode) (string, error) {
modeName := string(mode)
err := m.q.SetQueueLastFetched(ctx, repository.SetQueueLastFetchedParams{
Uuid: queueID, LastFetchedSongUuid: song.Uuid, LastFetchedMode: &modeName,
})
if err != nil {
return "", err
}
metrics.SongsRequested.WithLabelValues(modeName).Inc()
return song.Path, nil
}
@@ -212,7 +242,7 @@ func (m *MusicService) RandomSongClassic(ctx context.Context, queueID uuid.UUID)
}
continue
}
return m.remember(ctx, queueID, row.Song)
return m.remember(ctx, queueID, row.Song, PickClassic)
}
return "", ErrNoSongs
}
+26 -2
View File
@@ -47,7 +47,31 @@ func TestQueueWithPostgres(t *testing.T) {
assert.NotEqual(t, uuid.Nil, song.SoundtrackUuid)
}
path, err := m.PreviousSong(ctx, queueID)
var modes []string
rows, err := database.Pool.Query(ctx, "SELECT mode FROM queue_song WHERE queue_uuid = $1 ORDER BY song_no", queueID)
require.NoError(t, err)
for rows.Next() {
var mode string
require.NoError(t, rows.Scan(&mode))
modes = append(modes, mode)
}
require.NoError(t, rows.Err())
assert.Equal(t, []string{"random", "random", "random"}, modes)
_, err = m.RandomSongClassic(ctx, queueID)
require.NoError(t, err)
require.NoError(t, m.AddLatestToQueue(ctx, queueID))
var classic string
require.NoError(t, database.Pool.QueryRow(ctx, "SELECT mode FROM queue_song WHERE queue_uuid = $1 AND song_no = 3", queueID).Scan(&classic))
assert.Equal(t, "classic", classic)
played, err = m.PlayedSongs(ctx, queueID)
require.NoError(t, err)
require.Len(t, played, 4)
path, err := m.Song(ctx, queueID, 2)
require.NoError(t, err)
assert.Equal(t, paths[2], path)
path, err = m.PreviousSong(ctx, queueID)
require.NoError(t, err)
assert.Equal(t, paths[1], path)
require.NoError(t, m.AddLatestPlayed(ctx, queueID))
@@ -82,7 +106,7 @@ func TestQueueWithPostgres(t *testing.T) {
require.NoError(t, q.ClearSongs(ctx))
played, err = m.PlayedSongs(ctx, queueID)
require.NoError(t, err)
require.Len(t, played, 3)
require.Len(t, played, 4)
assert.NotEmpty(t, played[0].Song)
_, err = m.Song(ctx, queueID, 0)
assert.ErrorIs(t, err, ErrNotFound)
+39
View File
@@ -19,6 +19,7 @@ import (
"music-server/internal/db/repository"
"music-server/internal/logging"
"music-server/internal/metrics"
"github.com/MShekow/directory-checksum/directory_checksum"
"github.com/google/uuid"
@@ -199,6 +200,7 @@ func (s *SyncService) run(ctx context.Context, full bool) {
run := &syncRun{q: s.q, musicPath: s.musicPath, full: full, progress: func() { s.foldersSynced.Add(1) }}
result := run.execute(ctx, func(total int) { s.foldersTotal.Store(int64(total)) })
result.TotalTime = formatDuration(time.Since(start))
s.recordMetrics(ctx, full, time.Since(start), len(result.CatchedErrors) == 0)
if s.onComplete != nil {
s.onComplete(ctx)
@@ -218,6 +220,42 @@ func (s *SyncService) run(ctx context.Context, full bool) {
zap.Int("errors", len(result.CatchedErrors)))
}
func init() {
// Start both sync types at 0, so the series exist before the first sync.
metrics.SyncDuration.WithLabelValues(syncType(false))
metrics.SyncDuration.WithLabelValues(syncType(true))
}
// syncType is the type label of the sync metrics.
func syncType(full bool) string {
if full {
return "full"
}
return "changes"
}
// recordMetrics updates the sync and library metrics after a sync.
func (s *SyncService) recordMetrics(ctx context.Context, full bool, took time.Duration, success bool) {
metrics.SyncDuration.WithLabelValues(syncType(full)).Observe(took.Seconds())
if success {
metrics.SyncLastSuccess.SetToCurrentTime()
}
if err := s.UpdateLibraryMetrics(ctx); err != nil {
logging.GetLogger().Error("Failed to count the library", zap.Error(err))
}
}
// UpdateLibraryMetrics sets the library size metrics from the database.
func (s *SyncService) UpdateLibraryMetrics(ctx context.Context) error {
count, err := s.q.CountLibrary(ctx)
if err != nil {
return err
}
metrics.LibrarySoundtracks.Set(float64(count.Soundtracks))
metrics.LibrarySongs.Set(float64(count.Songs))
return nil
}
func (r *syncRun) execute(ctx context.Context, setTotal func(int)) SyncResponse {
var err error
r.existing, err = r.q.GetAllSoundtracksIncludingDeleted(ctx)
@@ -623,6 +661,7 @@ func (r *syncRun) removeBrokenSongs(ctx context.Context) {
}
_ = g.Wait()
metrics.BrokenSongs.Set(float64(len(broken)))
if len(broken) > 0 {
if err := r.q.RemoveBrokenSongs(ctx, broken); err != nil {
r.addError("RemoveBrokenSongs", err, "")
+26
View File
@@ -17,6 +17,9 @@ const DefaultReleasesURL = "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicP
// Config holds all settings read from the environment.
type Config struct {
Port int
// MetricsPort serves Prometheus metrics on a port of its own, so /metrics is never
// reachable through the public port. 0 means metrics are not served.
MetricsPort int
DBHost string
DBPort string
@@ -77,6 +80,11 @@ func load(getenv func(string) string) (Config, error) {
}
cfg.Port = port
cfg.MetricsPort, err = parseMetricsPort(getenv("METRICS_PORT"))
if err != nil {
errs = append(errs, fmt.Errorf("METRICS_PORT: %w", err))
}
for name, value := range map[string]string{
"DB_HOST": cfg.DBHost,
"DB_PORT": cfg.DBPort,
@@ -120,6 +128,24 @@ func parseCIDRs(value string) ([]*net.IPNet, error) {
return nets, nil
}
// DefaultMetricsPort is used when METRICS_PORT is not set.
const DefaultMetricsPort = 2112
// parseMetricsPort reads METRICS_PORT: empty for the default, "off" for no metrics, or a port.
func parseMetricsPort(value string) (int, error) {
switch value {
case "":
return DefaultMetricsPort, nil
case "off":
return 0, nil
}
port, err := strconv.Atoi(value)
if err != nil || port < 1 || port > 65535 {
return 0, fmt.Errorf("%q is not a port or \"off\"", value)
}
return port, nil
}
// parseOrigins parses a comma-separated list of origins (scheme://host[:port], nothing else).
func parseOrigins(value string) ([]string, error) {
var origins []string
+20
View File
@@ -32,6 +32,26 @@ func TestLoadDefaults(t *testing.T) {
assert.Equal(t, DefaultReleasesURL, cfg.ReleasesURL)
assert.Empty(t, cfg.TrustedProxies)
assert.Empty(t, cfg.CORSAllowedOrigins)
assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort)
}
func TestLoadMetricsPort(t *testing.T) {
values := validEnv()
values["METRICS_PORT"] = "9100"
cfg, err := load(env(values))
require.NoError(t, err)
assert.Equal(t, 9100, cfg.MetricsPort)
values["METRICS_PORT"] = "off"
cfg, err = load(env(values))
require.NoError(t, err)
assert.Zero(t, cfg.MetricsPort)
for _, invalid := range []string{"abc", "0", "70000", "-1", "OFF"} {
values["METRICS_PORT"] = invalid
_, err = load(env(values))
assert.ErrorContains(t, err, "METRICS_PORT", invalid)
}
}
func TestLoadMissingRequired(t *testing.T) {
@@ -7,8 +7,9 @@ ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
CREATE TABLE queue (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
current_song INT4 NOT NULL DEFAULT -1,
-- The last random song, added to the queue by /music/addQue.
-- The last random song, added to the queue by /music/addQue, and how it was picked.
last_fetched_song_uuid UUID NULL,
last_fetched_mode VARCHAR(10) NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ NULL
);
@@ -21,6 +22,8 @@ CREATE TABLE queue_song (
song_uuid UUID NOT NULL,
soundtrack_name VARCHAR NOT NULL,
song_name VARCHAR NOT NULL,
-- How the song was picked: random, low or classic.
mode VARCHAR(10) NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (queue_uuid, song_no)
);
+5 -5
View File
@@ -16,7 +16,7 @@ UPDATE queue SET ended_at = NOW()
WHERE ended_at IS NULL;
-- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2
UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
WHERE uuid = $1;
-- name: SetQueueCurrentSong :exec
@@ -27,23 +27,23 @@ WHERE uuid = $1;
-- Appends the queue's last fetched song and makes it the current song.
-- Does nothing when no song has been fetched since the last call.
WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name)
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
s.uuid, st.soundtrack_name, s.song_name
s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1
RETURNING queue_uuid, song_no
)
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
FROM added
WHERE queue.uuid = added.queue_uuid;
-- name: ListQueueSongs :many
-- The song columns are NULL when the song has been removed from the library since.
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name,
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid
+6
View File
@@ -207,3 +207,9 @@ LEFT JOIN song s ON g.id = s.soundtrack_id
WHERE g.deleted IS NULL
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
ORDER BY g.soundtrack_name;
-- name: CountLibrary :one
-- Soundtracks and songs in the library, without deleted soundtracks.
SELECT
(SELECT COUNT(*) FROM soundtrack WHERE deleted IS NULL) AS soundtracks,
(SELECT COUNT(*) FROM song s JOIN soundtrack st ON st.id = s.soundtrack_id WHERE st.deleted IS NULL) AS songs;
+2
View File
@@ -15,6 +15,7 @@ type Queue struct {
Uuid uuid.UUID `json:"uuid"`
CurrentSong int32 `json:"current_song"`
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
LastFetchedMode *string `json:"last_fetched_mode"`
StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
@@ -25,6 +26,7 @@ type QueueSong struct {
SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Mode string `json:"mode"`
AddedAt pgtype.Timestamptz `json:"added_at"`
}
+2
View File
@@ -26,6 +26,8 @@ type Querier interface {
ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) error
// Soundtracks and songs in the library, without deleted soundtracks.
CountLibrary(ctx context.Context) (CountLibraryRow, error)
CreateQueue(ctx context.Context) (Queue, error)
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteExpiredSessions(ctx context.Context) error
+13 -8
View File
@@ -13,17 +13,17 @@ import (
const addLastFetchedToQueue = `-- name: AddLastFetchedToQueue :exec
WITH added AS (
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name)
INSERT INTO queue_song (queue_uuid, song_no, song_uuid, soundtrack_name, song_name, mode)
SELECT q.uuid,
(SELECT COALESCE(MAX(qs.song_no), -1) + 1 FROM queue_song qs WHERE qs.queue_uuid = q.uuid),
s.uuid, st.soundtrack_name, s.song_name
s.uuid, st.soundtrack_name, s.song_name, COALESCE(q.last_fetched_mode, 'unknown')
FROM queue q
JOIN song s ON s.uuid = q.last_fetched_song_uuid
JOIN soundtrack st ON st.id = s.soundtrack_id
WHERE q.uuid = $1
RETURNING queue_uuid, song_no
)
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL
UPDATE queue SET current_song = added.song_no, last_fetched_song_uuid = NULL, last_fetched_mode = NULL
FROM added
WHERE queue.uuid = added.queue_uuid
`
@@ -37,7 +37,7 @@ func (q *Queries) AddLastFetchedToQueue(ctx context.Context, argUuid uuid.UUID)
const createQueue = `-- name: CreateQueue :one
INSERT INTO queue DEFAULT VALUES
RETURNING uuid, current_song, last_fetched_song_uuid, started_at, ended_at
RETURNING uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
`
func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) {
@@ -47,6 +47,7 @@ func (q *Queries) CreateQueue(ctx context.Context) (Queue, error) {
&i.Uuid,
&i.CurrentSong,
&i.LastFetchedSongUuid,
&i.LastFetchedMode,
&i.StartedAt,
&i.EndedAt,
)
@@ -74,7 +75,7 @@ func (q *Queries) EndQueue(ctx context.Context, argUuid uuid.UUID) error {
}
const getQueue = `-- name: GetQueue :one
SELECT uuid, current_song, last_fetched_song_uuid, started_at, ended_at
SELECT uuid, current_song, last_fetched_song_uuid, last_fetched_mode, started_at, ended_at
FROM queue
WHERE uuid = $1
`
@@ -86,6 +87,7 @@ func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error
&i.Uuid,
&i.CurrentSong,
&i.LastFetchedSongUuid,
&i.LastFetchedMode,
&i.StartedAt,
&i.EndedAt,
)
@@ -93,7 +95,7 @@ func (q *Queries) GetQueue(ctx context.Context, argUuid uuid.UUID) (Queue, error
}
const listQueueSongs = `-- name: ListQueueSongs :many
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name,
SELECT qs.song_no, qs.song_uuid, qs.soundtrack_name, qs.song_name, qs.mode,
s.path, s.times_played, s.soundtrack_uuid
FROM queue_song qs
LEFT JOIN song s ON s.uuid = qs.song_uuid
@@ -106,6 +108,7 @@ type ListQueueSongsRow struct {
SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Mode string `json:"mode"`
Path *string `json:"path"`
TimesPlayed *int32 `json:"times_played"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
@@ -126,6 +129,7 @@ func (q *Queries) ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]Li
&i.SongUuid,
&i.SoundtrackName,
&i.SongName,
&i.Mode,
&i.Path,
&i.TimesPlayed,
&i.SoundtrackUuid,
@@ -156,16 +160,17 @@ func (q *Queries) SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSo
}
const setQueueLastFetched = `-- name: SetQueueLastFetched :exec
UPDATE queue SET last_fetched_song_uuid = $2
UPDATE queue SET last_fetched_song_uuid = $2, last_fetched_mode = $3
WHERE uuid = $1
`
type SetQueueLastFetchedParams struct {
Uuid uuid.UUID `json:"uuid"`
LastFetchedSongUuid uuid.UUID `json:"last_fetched_song_uuid"`
LastFetchedMode *string `json:"last_fetched_mode"`
}
func (q *Queries) SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error {
_, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid)
_, err := q.db.Exec(ctx, setQueueLastFetched, arg.Uuid, arg.LastFetchedSongUuid, arg.LastFetchedMode)
return err
}
+19
View File
@@ -12,6 +12,25 @@ import (
"github.com/google/uuid"
)
const countLibrary = `-- name: CountLibrary :one
SELECT
(SELECT COUNT(*) FROM soundtrack WHERE deleted IS NULL) AS soundtracks,
(SELECT COUNT(*) FROM song s JOIN soundtrack st ON st.id = s.soundtrack_id WHERE st.deleted IS NULL) AS songs
`
type CountLibraryRow struct {
Soundtracks int64 `json:"soundtracks"`
Songs int64 `json:"songs"`
}
// Soundtracks and songs in the library, without deleted soundtracks.
func (q *Queries) CountLibrary(ctx context.Context) (CountLibraryRow, error) {
row := q.db.QueryRow(ctx, countLibrary)
var i CountLibraryRow
err := row.Scan(&i.Soundtracks, &i.Songs)
return i, err
}
const getAllSoundtracksWithSongs = `-- name: GetAllSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
+53
View File
@@ -0,0 +1,53 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// MusicServer's own metrics. Labels only take values from small fixed sets: never song
// names, IDs, tokens or IPs, since every value creates a new time series.
var (
// SongsRequested counts random songs handed out, by how they were picked.
SongsRequested = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "musicserver_songs_requested_total",
Help: "Random songs handed out, by how they were picked (random, low, classic).",
}, []string{"mode"})
// SongsPlayed counts songs marked as played, by how they were picked.
SongsPlayed = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "musicserver_songs_played_total",
Help: "Songs marked as played, by how they were picked (random, low, classic).",
}, []string{"mode"})
// LibrarySoundtracks is the number of soundtracks in the library.
LibrarySoundtracks = promauto.NewGauge(prometheus.GaugeOpts{
Name: "musicserver_library_soundtracks",
Help: "Soundtracks in the library, counted at startup and after each sync.",
})
// LibrarySongs is the number of songs in the library.
LibrarySongs = promauto.NewGauge(prometheus.GaugeOpts{
Name: "musicserver_library_songs",
Help: "Songs in the library, counted at startup and after each sync.",
})
// SyncDuration is how long syncs take, by type.
SyncDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "musicserver_sync_duration_seconds",
Help: "How long a sync took, by type (full, changes).",
Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600, 1200, 1800, 3600},
}, []string{"type"})
// SyncLastSuccess is when the last sync without errors finished.
SyncLastSuccess = promauto.NewGauge(prometheus.GaugeOpts{
Name: "musicserver_sync_last_success_timestamp_seconds",
Help: "Unix time when the last sync without errors finished.",
})
// BrokenSongs is the number of songs the last full sync found missing and removed.
BrokenSongs = promauto.NewGauge(prometheus.GaugeOpts{
Name: "musicserver_broken_songs",
Help: "Songs whose file was missing in the last full sync. They are removed from the library.",
})
)
+49
View File
@@ -0,0 +1,49 @@
package metrics
import (
"strconv"
"time"
"github.com/labstack/echo/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "HTTP requests by method, route and status code.",
}, []string{"method", "route", "code"})
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency by method and route. Song downloads include the transfer.",
// Up to 30 s, since songs can take long to send to slow clients.
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 30},
}, []string{"method", "route"})
)
// unmatchedRoute labels requests that matched no route, so raw URLs never become labels.
const unmatchedRoute = "unmatched"
// Middleware records the count and latency of requests per route. The route label is the
// registered pattern, like /api/v1/songs/:uuid, never the URL with its IDs and query.
// Register it outside Recover, so requests that panic are counted as 500.
func Middleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
start := time.Now()
err := next(c)
route := c.Path()
if route == "" {
route = unmatchedRoute
}
_, status := echo.ResolveResponseStatus(c.Response(), err)
method := c.Request().Method
httpRequests.WithLabelValues(method, route, strconv.Itoa(status)).Inc()
httpDuration.WithLabelValues(method, route).Observe(time.Since(start).Seconds())
return err
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package metrics
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/assert"
)
func newEcho() *echo.Echo {
e := echo.New()
e.Use(Middleware())
e.Use(echoMiddleware.Recover())
e.GET("/items/:id", func(c *echo.Context) error { return c.String(http.StatusOK, "item") })
e.GET("/fail", func(c *echo.Context) error { return errors.New("broken") })
e.GET("/panic", func(c *echo.Context) error { panic("boom") })
return e
}
func serve(e *echo.Echo, method, target string) {
e.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(method, target, nil))
}
func requests(method, route, code string) float64 {
return testutil.ToFloat64(httpRequests.WithLabelValues(method, route, code))
}
func TestMiddlewareCountsRequestsByRoutePattern(t *testing.T) {
e := newEcho()
before := requests("GET", "/items/:id", "200")
serve(e, http.MethodGet, "/items/1?x=secret")
serve(e, http.MethodGet, "/items/2")
assert.Equal(t, before+2, requests("GET", "/items/:id", "200"))
assert.Equal(t, 0.0, requests("GET", "/items/1?x=secret", "200"), "the URL is never a label")
assert.Equal(t, 0.0, requests("GET", "/items/1", "200"))
}
func TestMiddlewareRecordsErrorStatus(t *testing.T) {
e := newEcho()
failBefore := requests("GET", "/fail", "500")
panicBefore := requests("GET", "/panic", "500")
missingBefore := requests("GET", unmatchedRoute, "404")
serve(e, http.MethodGet, "/fail")
serve(e, http.MethodGet, "/panic")
serve(e, http.MethodGet, "/no/such/page?q=1")
assert.Equal(t, failBefore+1, requests("GET", "/fail", "500"))
assert.Equal(t, panicBefore+1, requests("GET", "/panic", "500"), "Recover turns the panic into a 500")
assert.Equal(t, missingBefore+1, requests("GET", unmatchedRoute, "404"))
}
func observations(method, route string) uint64 {
m := &dto.Metric{}
_ = httpDuration.WithLabelValues(method, route).(prometheus.Metric).Write(m)
return m.GetHistogram().GetSampleCount()
}
func TestMiddlewareRecordsLatency(t *testing.T) {
e := newEcho()
before := observations("GET", "/items/:id")
serve(e, http.MethodGet, "/items/1")
assert.Equal(t, before+1, observations("GET", "/items/:id"))
}
+70
View File
@@ -0,0 +1,70 @@
package metrics
import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus"
)
// poolCollector reports the database connection pool's statistics on every scrape.
type poolCollector struct {
pool *pgxpool.Pool
acquiredConns *prometheus.Desc
idleConns *prometheus.Desc
totalConns *prometheus.Desc
maxConns *prometheus.Desc
acquires *prometheus.Desc
acquireWait *prometheus.Desc
emptyAcquires *prometheus.Desc
canceledAcquires *prometheus.Desc
newConns *prometheus.Desc
maxLifetimeDestroyed *prometheus.Desc
maxIdleDestroyed *prometheus.Desc
}
// NewPoolCollector returns a collector for the musicserver_db_pool_* metrics of pool.
// Register it once, for example with prometheus.MustRegister.
func NewPoolCollector(pool *pgxpool.Pool) prometheus.Collector {
desc := func(name, help string) *prometheus.Desc {
return prometheus.NewDesc("musicserver_db_pool_"+name, help, nil, nil)
}
return &poolCollector{
pool: pool,
acquiredConns: desc("acquired_conns", "Connections in use."),
idleConns: desc("idle_conns", "Idle connections."),
totalConns: desc("total_conns", "Open connections, in use, idle and being opened."),
maxConns: desc("max_conns", "Most connections the pool opens."),
acquires: desc("acquires_total", "Connections handed out."),
acquireWait: desc("acquire_wait_seconds_total", "Total time spent waiting for a connection."),
emptyAcquires: desc("empty_acquires_total", "Acquires that had to wait because no connection was idle."),
canceledAcquires: desc("canceled_acquires_total", "Acquires canceled while waiting, for example by a timeout."),
newConns: desc("new_conns_total", "Connections opened."),
maxLifetimeDestroyed: desc("max_lifetime_destroyed_total", "Connections closed for reaching their maximum lifetime."),
maxIdleDestroyed: desc("max_idle_destroyed_total", "Connections closed for being idle too long."),
}
}
func (c *poolCollector) Describe(ch chan<- *prometheus.Desc) {
prometheus.DescribeByCollect(c, ch)
}
func (c *poolCollector) Collect(ch chan<- prometheus.Metric) {
s := c.pool.Stat()
gauge := func(d *prometheus.Desc, v float64) {
ch <- prometheus.MustNewConstMetric(d, prometheus.GaugeValue, v)
}
counter := func(d *prometheus.Desc, v float64) {
ch <- prometheus.MustNewConstMetric(d, prometheus.CounterValue, v)
}
gauge(c.acquiredConns, float64(s.AcquiredConns()))
gauge(c.idleConns, float64(s.IdleConns()))
gauge(c.totalConns, float64(s.TotalConns()))
gauge(c.maxConns, float64(s.MaxConns()))
counter(c.acquires, float64(s.AcquireCount()))
counter(c.acquireWait, s.AcquireDuration().Seconds())
counter(c.emptyAcquires, float64(s.EmptyAcquireCount()))
counter(c.canceledAcquires, float64(s.CanceledAcquireCount()))
counter(c.newConns, float64(s.NewConnsCount()))
counter(c.maxLifetimeDestroyed, float64(s.MaxLifetimeDestroyCount()))
counter(c.maxIdleDestroyed, float64(s.MaxIdleDestroyCount()))
}
+33
View File
@@ -0,0 +1,33 @@
package metrics
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPoolCollector(t *testing.T) {
// The pool connects lazily, so no database is needed to read its statistics.
pool, err := pgxpool.New(context.Background(), "postgres://user:pass@127.0.0.1:1/music?pool_max_conns=7")
require.NoError(t, err)
t.Cleanup(pool.Close)
registry := prometheus.NewRegistry()
registry.MustRegister(NewPoolCollector(pool))
assert.Equal(t, 11, testutil.CollectAndCount(registry))
require.NoError(t, testutil.GatherAndCompare(registry, strings.NewReader(`
# HELP musicserver_db_pool_max_conns Most connections the pool opens.
# TYPE musicserver_db_pool_max_conns gauge
musicserver_db_pool_max_conns 7
# HELP musicserver_db_pool_acquired_conns Connections in use.
# TYPE musicserver_db_pool_acquired_conns gauge
musicserver_db_pool_acquired_conns 0
`), "musicserver_db_pool_max_conns", "musicserver_db_pool_acquired_conns"))
}
+60
View File
@@ -0,0 +1,60 @@
// Package metrics exposes Prometheus metrics: the Go runtime and process metrics that the
// default registry collects, HTTP request metrics and MusicServer's own metrics.
package metrics
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"music-server/internal/logging"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
)
// Server serves /metrics on a port of its own, so it is never reachable through the
// public port.
type Server struct {
httpServer *http.Server
}
// NewServer creates a metrics server for the given port.
func NewServer(port int) *Server {
mux := http.NewServeMux()
mux.Handle("GET /metrics", promhttp.Handler())
return &Server{httpServer: &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}}
}
// Handler returns the handler that serves /metrics.
func (s *Server) Handler() http.Handler {
return s.httpServer.Handler
}
// Start listens on the port and serves metrics in the background. It returns an error
// when the port cannot be opened.
func (s *Server) Start() error {
ln, err := net.Listen("tcp", s.httpServer.Addr)
if err != nil {
return err
}
logging.GetLogger().Info("Metrics server starting", zap.String("address", s.httpServer.Addr))
go func() {
if err := s.httpServer.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
logging.GetLogger().Error("Metrics server error", zap.Error(err))
}
}()
return nil
}
// Shutdown stops the metrics server.
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
+63
View File
@@ -0,0 +1,63 @@
package metrics
import (
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"testing"
"music-server/internal/logging"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
logging.Init("fatal", false)
os.Exit(m.Run())
}
func TestServerServesRuntimeMetrics(t *testing.T) {
rec := httptest.NewRecorder()
NewServer(0).Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "go_goroutines")
assert.Contains(t, rec.Body.String(), "process_start_time_seconds")
}
func TestServerServesOnlyMetrics(t *testing.T) {
rec := httptest.NewRecorder()
NewServer(0).Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestServerStartAndShutdown(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
s := NewServer(port)
require.NoError(t, s.Start())
resp, err := http.Get("http://" + ln.Addr().String() + "/metrics")
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
assert.Contains(t, string(body), "go_goroutines")
require.NoError(t, s.Shutdown(context.Background()))
_, err = http.Get("http://" + ln.Addr().String() + "/metrics")
assert.Error(t, err, "the port is closed after shutdown")
}
func TestServerStartFailsWhenPortIsTaken(t *testing.T) {
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err)
defer ln.Close()
assert.Error(t, NewServer(ln.Addr().(*net.TCPAddr).Port).Start())
}
+3
View File
@@ -6,6 +6,7 @@ import (
"music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/metrics"
"music-server/internal/server/middleware"
"github.com/a-h/templ"
@@ -39,6 +40,8 @@ func (s *Server) routes() (*echo.Echo, huma.API) {
}
e.Use(logging.RequestLogger())
// Outside Recover, so requests that panic are counted with status 500.
e.Use(metrics.Middleware())
e.Use(echoMiddleware.Recover())
// Without allowed origins no CORS headers are sent, so only same-origin browser requests work.
// The API uses Bearer tokens, not cookies, so credentials are not allowed.
+30
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
@@ -223,3 +224,32 @@ func TestCORSWithoutAllowedOrigins(t *testing.T) {
assert.Equal(t, http.StatusOK, rec.Code, "the request itself works; the browser blocks reading it")
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
}
func TestMetricsAreNotServedOnTheMainPort(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
require.NotNil(t, e.server.metrics)
for _, path := range []string{"/metrics", "/api/v1/metrics"} {
rec := e.do(http.MethodGet, path)
assert.Equal(t, http.StatusNotFound, rec.Code, path)
assert.NotContains(t, rec.Body.String(), "go_goroutines", path)
}
}
func TestMetricsOff(t *testing.T) {
assert.Nil(t, newTestEnv(t).server.metrics, "MetricsPort 0 means no metrics server")
}
func TestRequestMetricsUseRoutePatterns(t *testing.T) {
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
song := e.addSong("Game A", "Song", "x")
e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String())
e.do(http.MethodGet, "/no/such/page?secret=1")
rec := httptest.NewRecorder()
e.server.metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := rec.Body.String()
assert.Contains(t, body, `http_requests_total{code="200",method="GET",route="/api/v1/songs/:uuid"}`)
assert.NotContains(t, body, song.Uuid.String(), "IDs never become labels")
assert.NotContains(t, body, "secret", "query strings never become labels")
assert.NotContains(t, body, "/no/such/page", "unknown URLs count under the frontend's /* route")
}
+21 -1
View File
@@ -12,7 +12,9 @@ import (
"music-server/internal/db"
"music-server/internal/db/repository"
"music-server/internal/logging"
"music-server/internal/metrics"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
)
@@ -34,6 +36,8 @@ type Server struct {
stats *backend.StatisticsService
search *backend.SearchCache
httpServer *http.Server
// metrics serves /metrics on its own port; nil when metrics are off.
metrics *metrics.Server
}
// New creates a Server with all services wired up.
@@ -56,6 +60,9 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
if cfg.MetricsPort > 0 {
s.metrics = metrics.NewServer(cfg.MetricsPort)
}
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: s.RegisterRoutes(),
@@ -111,11 +118,21 @@ func NewServerInstance() *Server {
if err != nil {
logger.Fatal("Failed to create server", zap.Error(err))
}
prometheus.MustRegister(metrics.NewPoolCollector(database.Pool))
if err := s.sync.UpdateLibraryMetrics(ctx); err != nil {
logger.Error("Failed to count the library", zap.Error(err))
}
return s
}
// ListenAndServe starts the HTTP server. It returns nil after a graceful shutdown.
// ListenAndServe starts the HTTP server and the metrics server. It returns nil after a
// graceful shutdown. The server keeps running without metrics if their port cannot be opened.
func (s *Server) ListenAndServe() error {
if s.metrics != nil {
if err := s.metrics.Start(); err != nil {
logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err))
}
}
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
@@ -126,6 +143,9 @@ func (s *Server) ListenAndServe() error {
// Shutdown stops accepting requests, waits for running requests and then closes the database.
func (s *Server) Shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
if s.metrics != nil {
err = errors.Join(err, s.metrics.Shutdown(ctx))
}
s.sync.Wait()
s.characters.Close()
s.db.Close()
+32 -3
View File
@@ -431,7 +431,9 @@ func (s *Store) EndActiveQueues(context.Context) error {
}
func (s *Store) SetQueueLastFetched(_ context.Context, arg repository.SetQueueLastFetchedParams) error {
return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.LastFetchedSongUuid = arg.LastFetchedSongUuid })
return s.updateQueue(arg.Uuid, func(q *repository.Queue) {
q.LastFetchedSongUuid, q.LastFetchedMode = arg.LastFetchedSongUuid, arg.LastFetchedMode
})
}
func (s *Store) SetQueueCurrentSong(_ context.Context, arg repository.SetQueueCurrentSongParams) error {
@@ -457,6 +459,10 @@ func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error {
if st := s.soundtrackIndex(song.SoundtrackID); st >= 0 {
soundtrackName = s.Soundtracks[st].SoundtrackName
}
mode := "unknown"
if queue.LastFetchedMode != nil {
mode = *queue.LastFetchedMode
}
songNo := int32(0)
for _, qs := range s.QueueSongs {
if qs.QueueUuid == id {
@@ -465,9 +471,10 @@ func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error {
}
s.QueueSongs = append(s.QueueSongs, repository.QueueSong{
QueueUuid: id, SongNo: songNo, SongUuid: song.Uuid, SoundtrackName: soundtrackName, SongName: song.SongName,
Mode: mode,
})
queue.CurrentSong = songNo
queue.LastFetchedSongUuid = uuid.Nil
queue.LastFetchedSongUuid, queue.LastFetchedMode = uuid.Nil, nil
return nil
}
@@ -481,7 +488,9 @@ func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.Li
if qs.QueueUuid != id {
continue
}
row := repository.ListQueueSongsRow{SongNo: qs.SongNo, SongUuid: qs.SongUuid, SoundtrackName: qs.SoundtrackName, SongName: qs.SongName}
row := repository.ListQueueSongsRow{
SongNo: qs.SongNo, SongUuid: qs.SongUuid, SoundtrackName: qs.SoundtrackName, SongName: qs.SongName, Mode: qs.Mode,
}
if i := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == qs.SongUuid }); i >= 0 {
song := s.Songs[i]
row.Path, row.TimesPlayed, row.SoundtrackUuid = &song.Path, &song.TimesPlayed, song.SoundtrackUuid
@@ -492,6 +501,26 @@ func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.Li
return rows, nil
}
func (s *Store) CountLibrary(context.Context) (repository.CountLibraryRow, error) {
defer s.lock()()
if s.Err != nil {
return repository.CountLibraryRow{}, s.Err
}
var count repository.CountLibraryRow
for _, st := range s.Soundtracks {
if st.Deleted != nil {
continue
}
count.Soundtracks++
for _, song := range s.Songs {
if song.SoundtrackID == st.ID {
count.Songs++
}
}
}
return count, nil
}
// Statistics queries return empty results; tests that need data use their own fakes.
func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {