Add GET /api/v1/statistics/soundtracks with every soundtrack and its songs
CI / integration (push) Successful in 18s
CI / test (push) Successful in 22s
CI / publish (push) Skipped
CI / docker (push) Successful in 41s

The other soundtrack statistics are capped at 100, so a client that shows every soundtrack (the MusicPlayer statistics tab) had no way to get them all. This one has no limit and is sorted by name.
This commit is contained in:
Sansan committed 2026-10-06 23:14:49 +02:00
1 parent fc7d787014
commit 080bacae80
9 files changed
+147 -1

No files matched your search

@@ -70,6 +70,22 @@ func TestLibraryAndStatisticsWithPostgres(t *testing.T) {
assert.Equal(t, song.Uuid, songs[0].SongUuid)
assert.Equal(t, bird.Uuid, songs[0].SoundtrackUuid)
all, err := stats.AllSoundtracks(ctx)
require.NoError(t, err)
require.NotEmpty(t, all)
for i := 1; i < len(all); i++ {
assert.LessOrEqual(t, all[i-1].SoundtrackName, all[i].SoundtrackName, "sorted by name")
}
for _, st := range all {
if st.SoundtrackUuid == bird.Uuid {
assert.Equal(t, int32(1), st.SoundtrackPlayed)
assert.Len(t, st.Songs, 6)
}
if st.SoundtrackName == "Empty Soundtrack" {
assert.Empty(t, st.Songs, "a soundtrack without songs has an empty song list")
}
}
never, err := stats.NeverPlayedSoundtracks(ctx)
require.NoError(t, err)
for _, st := range never {
+7 -1
View File
@@ -59,7 +59,8 @@ type soundtrackRow interface {
repository.GetMostPlayedSoundtracksWithSongsRow |
repository.GetLeastPlayedSoundtracksWithSongsRow |
repository.GetLastPlayedSoundtracksRow |
repository.GetOldestPlayedSoundtracksRow
repository.GetOldestPlayedSoundtracksRow |
repository.GetAllSoundtracksWithSongsRow
}
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
@@ -106,6 +107,11 @@ func parseSongs(data []byte) []SongInfoForStats {
return songs
}
// AllSoundtracks returns every soundtrack with its songs, sorted by name
func (s *StatisticsService) AllSoundtracks(ctx context.Context) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetAllSoundtracksWithSongs(ctx))
}
// MostPlayedSoundtracks returns the top N most played soundtracks with their songs
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit))
+27
View File
@@ -180,3 +180,30 @@ SELECT
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
FROM soundtrack
WHERE deleted IS NULL;
-- Every soundtrack with its songs, by name
-- name: GetAllSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
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;
+2
View File
@@ -31,6 +31,8 @@ type Querier interface {
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error)
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error)
// Every soundtrack with its songs, by name
GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSoundtracksWithSongsRow, error)
GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
// Last played soundtracks (most recently played)
GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error)
+64
View File
@@ -12,6 +12,70 @@ import (
"github.com/google/uuid"
)
const getAllSoundtracksWithSongs = `-- name: GetAllSoundtracksWithSongs :many
SELECT
g.id as soundtrack_id,
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
json_build_object(
'song_uuid', s.uuid,
'soundtrack_uuid', g.uuid,
'song_name', s.song_name,
'path', s.path,
'times_played', s.times_played,
'file_name', s.file_name
)
) FILTER (WHERE s.uuid IS NOT NULL),
'[]'
)::json as songs
FROM soundtrack g
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
`
type GetAllSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
}
// Every soundtrack with its songs, by name
func (q *Queries) GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSoundtracksWithSongsRow, error) {
rows, err := q.db.Query(ctx, getAllSoundtracksWithSongs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllSoundtracksWithSongsRow
for rows.Next() {
var i GetAllSoundtracksWithSongsRow
if err := rows.Scan(
&i.SoundtrackID,
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
&i.SoundtrackLastPlayed,
&i.Songs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT
g.id as soundtrack_id,
+19
View File
@@ -260,6 +260,17 @@ func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit i
}}, s.err
}
func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.GetAllSoundtracksWithSongsRow, error) {
return []repository.GetAllSoundtracksWithSongsRow{{
SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A",
SoundtrackPlayed: 4,
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
}, {
SoundtrackName: "Game B",
}}, s.err
}
func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err
}
@@ -288,6 +299,13 @@ func TestStatisticsEndpoints(t *testing.T) {
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
}
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Body.Bytes(), &soundtracks))
require.Len(t, soundtracks, 2, "every soundtrack, no limit")
assert.Equal(t, "Game A", soundtracks[0].SoundtrackName)
assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed)
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list")
var summary backend.StatisticsSummary
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary))
assert.Equal(t, backend.StatisticsSummary{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, summary)
@@ -295,4 +313,5 @@ func TestStatisticsEndpoints(t *testing.T) {
e.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")})
e.handler = e.server.RegisterRoutes()
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/summary").Code)
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Code)
}
+3
View File
@@ -103,6 +103,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"GET /api/v1/soundtracks/{uuid}",
"GET /api/v1/statistics/songs/least-played",
"GET /api/v1/statistics/songs/most-played",
"GET /api/v1/statistics/soundtracks",
"GET /api/v1/statistics/soundtracks/last-played",
"GET /api/v1/statistics/soundtracks/least-played",
"GET /api/v1/statistics/soundtracks/most-played",
@@ -225,6 +226,7 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK)
}
v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/statistics/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Sync, including the locked state and the result after it finishes.
@@ -254,6 +256,7 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
stats.handler = stats.server.RegisterRoutes()
vs := newSpecValidator(t, stats)
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK)
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks", "", http.StatusOK)
vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Every documented operation was checked at least once (statistics and tokens use other environments).
+5
View File
@@ -59,6 +59,11 @@ func (s *Server) registerStatistics(a apis) {
songs("get-least-played-songs", "/songs/least-played", "Least played songs",
"Returns the least played songs with their soundtrack.", st.LeastPlayedSongs)
register(s, a, op("get-all-soundtrack-statistics", "/soundtracks", "All soundtracks with statistics",
"Returns every soundtrack with its songs and play counts, sorted by name."),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
return ok(st.AllSoundtracks(ctx))
})
register(s, a, op("get-never-played-soundtracks", "/soundtracks/never-played", "Never played soundtracks",
"Returns all soundtracks that have never been played, with their songs."),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
+4
View File
@@ -391,6 +391,10 @@ func (s *Store) GetOldestPlayedSoundtracks(context.Context, int32) ([]repository
return nil, s.Err
}
func (s *Store) GetAllSoundtracksWithSongs(context.Context) ([]repository.GetAllSoundtracksWithSongsRow, error) {
return nil, s.Err
}
func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) {
return nil, s.Err
}