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, song.Uuid, songs[0].SongUuid)
assert.Equal(t, bird.Uuid, songs[0].SoundtrackUuid) 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) never, err := stats.NeverPlayedSoundtracks(ctx)
require.NoError(t, err) require.NoError(t, err)
for _, st := range never { for _, st := range never {
+7 -1
View File
@@ -59,7 +59,8 @@ type soundtrackRow interface {
repository.GetMostPlayedSoundtracksWithSongsRow | repository.GetMostPlayedSoundtracksWithSongsRow |
repository.GetLeastPlayedSoundtracksWithSongsRow | repository.GetLeastPlayedSoundtracksWithSongsRow |
repository.GetLastPlayedSoundtracksRow | repository.GetLastPlayedSoundtracksRow |
repository.GetOldestPlayedSoundtracksRow repository.GetOldestPlayedSoundtracksRow |
repository.GetAllSoundtracksWithSongsRow
} }
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) { func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
@@ -106,6 +107,11 @@ func parseSongs(data []byte) []SongInfoForStats {
return songs 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 // MostPlayedSoundtracks returns the top N most played soundtracks with their songs
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) { func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit)) 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 COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
FROM soundtrack FROM soundtrack
WHERE deleted IS NULL; 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) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error) GetActiveSongByUUID(ctx context.Context, argUuid uuid.UUID) (GetActiveSongByUUIDRow, error)
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, 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) GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
// Last played soundtracks (most recently played) // Last played soundtracks (most recently played)
GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error)
+64
View File
@@ -12,6 +12,70 @@ import (
"github.com/google/uuid" "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 const getLastPlayedSoundtracks = `-- name: GetLastPlayedSoundtracks :many
SELECT SELECT
g.id as soundtrack_id, g.id as soundtrack_id,
+19
View File
@@ -260,6 +260,17 @@ func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit i
}}, s.err }}, 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) { func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err 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) 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 var summary backend.StatisticsSummary
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary)) 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) 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.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")})
e.handler = e.server.RegisterRoutes() 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/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/soundtracks/{uuid}",
"GET /api/v1/statistics/songs/least-played", "GET /api/v1/statistics/songs/least-played",
"GET /api/v1/statistics/songs/most-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/last-played",
"GET /api/v1/statistics/soundtracks/least-played", "GET /api/v1/statistics/soundtracks/least-played",
"GET /api/v1/statistics/soundtracks/most-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"+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/never-played", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/statistics/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Sync, including the locked state and the result after it finishes. // 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() stats.handler = stats.server.RegisterRoutes()
vs := newSpecValidator(t, stats) vs := newSpecValidator(t, stats)
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK) 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) vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Every documented operation was checked at least once (statistics and tokens use other environments). // 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", songs("get-least-played-songs", "/songs/least-played", "Least played songs",
"Returns the least played songs with their soundtrack.", st.LeastPlayedSongs) "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", 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."), "Returns all soundtracks that have never been played, with their songs."),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) { 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 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) { func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) {
return nil, s.Err return nil, s.Err
} }