From 080bacae8053e32756863c21ef5e60dc30dc06e0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Oct 2026 23:14:49 +0200 Subject: [PATCH] Add GET /api/v1/statistics/soundtracks with every soundtrack and its songs 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. --- internal/backend/library_integration_test.go | 16 +++++ internal/backend/statistics.go | 8 ++- internal/db/queries/statistics.sql | 27 +++++++++ internal/db/repository/querier.go | 2 + internal/db/repository/statistics.sql.go | 64 ++++++++++++++++++++ internal/server/handlers_test.go | 19 ++++++ internal/server/openapi_test.go | 3 + internal/server/statistics_handler.go | 5 ++ internal/testutil/fakedb/fakedb.go | 4 ++ 9 files changed, 147 insertions(+), 1 deletion(-) diff --git a/internal/backend/library_integration_test.go b/internal/backend/library_integration_test.go index 2fa7604..4ccbf81 100644 --- a/internal/backend/library_integration_test.go +++ b/internal/backend/library_integration_test.go @@ -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 { diff --git a/internal/backend/statistics.go b/internal/backend/statistics.go index d3b2dbd..f61301f 100644 --- a/internal/backend/statistics.go +++ b/internal/backend/statistics.go @@ -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)) diff --git a/internal/db/queries/statistics.sql b/internal/db/queries/statistics.sql index 97c1f23..590966e 100644 --- a/internal/db/queries/statistics.sql +++ b/internal/db/queries/statistics.sql @@ -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; diff --git a/internal/db/repository/querier.go b/internal/db/repository/querier.go index f734c63..1148fa6 100644 --- a/internal/db/repository/querier.go +++ b/internal/db/repository/querier.go @@ -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) diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go index c257802..04731f0 100644 --- a/internal/db/repository/statistics.sql.go +++ b/internal/db/repository/statistics.sql.go @@ -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, diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index c2a0b67..03575b8 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -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) } diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go index d241f2a..7b20761 100644 --- a/internal/server/openapi_test.go +++ b/internal/server/openapi_test.go @@ -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). diff --git a/internal/server/statistics_handler.go b/internal/server/statistics_handler.go index a7563cc..c693896 100644 --- a/internal/server/statistics_handler.go +++ b/internal/server/statistics_handler.go @@ -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) { diff --git a/internal/testutil/fakedb/fakedb.go b/internal/testutil/fakedb/fakedb.go index 10c8cc0..533d4bd 100644 --- a/internal/testutil/fakedb/fakedb.go +++ b/internal/testutil/fakedb/fakedb.go @@ -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 }