Files
MusicServer/internal/backend/statistics.go
T
Sansan 973ebf224b
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00

166 lines
5.9 KiB
Go

package backend
import (
"context"
"encoding/json"
"time"
"music-server/internal/db/repository"
"github.com/google/uuid"
)
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
type SoundtrackWithSongs 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,omitempty"`
Songs []SongInfoForStats `json:"songs"`
}
// SongInfoForStats represents a song with soundtrack info for statistics.
// The field order matches the generated query rows so they can be converted directly.
type SongInfoForStats struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SongUuid uuid.UUID `json:"song_uuid"`
SoundtrackName string `json:"soundtrack_name"`
SongName string `json:"song_name"`
Path string `json:"path"`
TimesPlayed int32 `json:"times_played"`
FileName *string `json:"file_name,omitempty"`
}
// StatisticsSummary holds overall statistics
type StatisticsSummary struct {
TotalSoundtracks int64 `json:"total_soundtracks"`
PlayedSoundtracks int64 `json:"played_soundtracks"`
NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
}
// StatisticsService reads play statistics.
type StatisticsService struct {
q repository.Querier
}
// NewStatisticsService creates a StatisticsService.
func NewStatisticsService(q repository.Querier) *StatisticsService {
return &StatisticsService{q: q}
}
// soundtrackRow covers the generated rows that share the soundtrack-with-songs shape.
type soundtrackRow interface {
repository.GetMostPlayedSoundtracksWithSongsRow |
repository.GetLeastPlayedSoundtracksWithSongsRow |
repository.GetLastPlayedSoundtracksRow |
repository.GetOldestPlayedSoundtracksRow
}
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
if err != nil {
return nil, err
}
result := []SoundtrackWithSongs{}
for _, r := range rows {
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: parseSongs(row.Songs),
})
}
return result, nil
}
type songRow interface {
repository.GetMostPlayedSongsWithSoundtrackRow | repository.GetLeastPlayedSongsWithSoundtrackRow
}
func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
if err != nil {
return nil, err
}
result := []SongInfoForStats{}
for _, r := range rows {
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
}
return result, nil
}
func parseSongs(data []byte) []SongInfoForStats {
songs := []SongInfoForStats{}
if data != nil {
if err := json.Unmarshal(data, &songs); err != nil {
return []SongInfoForStats{}
}
}
return songs
}
// 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))
}
// LeastPlayedSoundtracks returns the top N least played soundtracks with their songs
func (s *StatisticsService) LeastPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetLeastPlayedSoundtracksWithSongs(ctx, limit))
}
// LastPlayedSoundtracks returns the most recently played soundtracks
func (s *StatisticsService) LastPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetLastPlayedSoundtracks(ctx, limit))
}
// OldestPlayedSoundtracks returns the least recently played soundtracks
func (s *StatisticsService) OldestPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetOldestPlayedSoundtracks(ctx, limit))
}
// NeverPlayedSoundtracks returns soundtracks that have never been played
func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]SoundtrackWithSongs, error) {
rows, err := s.q.GetNeverPlayedSoundtracks(ctx)
if err != nil {
return nil, err
}
result := []SoundtrackWithSongs{}
for _, row := range rows {
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
Songs: parseSongs(row.Songs),
})
}
return result, nil
}
// MostPlayedSongs returns the top N most played songs with their soundtrack info
func (s *StatisticsService) MostPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
return toSongs(s.q.GetMostPlayedSongsWithSoundtrack(ctx, limit))
}
// LeastPlayedSongs returns the top N least played songs with their soundtrack info
func (s *StatisticsService) LeastPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
return toSongs(s.q.GetLeastPlayedSongsWithSoundtrack(ctx, limit))
}
// Summary returns overall statistics
func (s *StatisticsService) Summary(ctx context.Context) (StatisticsSummary, error) {
row, err := s.q.GetStatisticsSummary(ctx)
if err != nil {
return StatisticsSummary{}, err
}
return StatisticsSummary(row), nil
}