From f4d1c3cf28c7e5f89eb68a0d462a25e4bb7242d1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 1 Jun 2026 19:40:22 +0200 Subject: [PATCH] feat: Implement Statistics API with 8 endpoints under /api/v1/statistics/ - Add statistics.sql with 8 SQL queries for play count statistics - Generate repository code via sqlc - Add backend/statistics.go with business logic - Add server/statistics_handler.go with Echo handlers - Register protected routes under /api/v1/statistics/ with token auth - Endpoints: games/most-played, games/least-played, games/never-played, games/last-played, games/oldest-played, songs/most-played, songs/least-played, summary Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- internal/backend/statistics.go | 277 +++++++++++++++ internal/db/queries/statistics.sql | 148 ++++++++ internal/db/repository/statistics.sql.go | 435 +++++++++++++++++++++++ internal/server/routes.go | 34 +- internal/server/server.go | 19 +- internal/server/statistics_handler.go | 275 ++++++++++++++ 6 files changed, 1178 insertions(+), 10 deletions(-) create mode 100644 internal/backend/statistics.go create mode 100644 internal/db/queries/statistics.sql create mode 100644 internal/db/repository/statistics.sql.go create mode 100644 internal/server/statistics_handler.go diff --git a/internal/backend/statistics.go b/internal/backend/statistics.go new file mode 100644 index 0000000..23fa4e6 --- /dev/null +++ b/internal/backend/statistics.go @@ -0,0 +1,277 @@ +package backend + +import ( + "encoding/json" + "time" + + "music-server/internal/logging" + + "go.uber.org/zap" +) + +// GameWithSongs represents a game with its songs for statistics +type GameWithSongs struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + GamePlayed int32 `json:"game_played"` + GameLastPlayed *time.Time `json:"game_last_played,omitempty"` + Songs []SongInfoForStats `json:"songs"` +} + +// SongInfoForStats represents a song with game info for statistics +type SongInfoForStats struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_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 { + TotalGames int64 `json:"total_games"` + PlayedGames int64 `json:"played_games"` + NeverPlayedGames int64 `json:"never_played_games"` + TotalGamePlays int64 `json:"total_game_plays"` + AvgGamePlays float64 `json:"avg_game_plays"` + MaxGamePlays int64 `json:"max_game_plays"` + MinGamePlays int64 `json:"min_game_plays"` +} + +// StatisticsHandler manages statistics operations +type StatisticsHandler struct { + // Uses the global backend repo initialized via InitBackend +} + +// NewStatisticsHandler creates a new StatisticsHandler +func NewStatisticsHandler() *StatisticsHandler { + return &StatisticsHandler{} +} + +// GetMostPlayedGamesWithSongs returns the top N most played games with their songs +func (h *StatisticsHandler) GetMostPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + // Get raw results + rows, err := queries.GetMostPlayedGamesWithSongs(ctx, limit) + if err != nil { + return nil, err + } + + // Convert to GameWithSongs + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + // Parse JSON songs array + if err := json.Unmarshal(row.Songs, &songs); err != nil { + // Fallback: if JSON parsing fails, create empty song entries + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + GameID: row.GameID, + GameName: row.GameName, + GamePlayed: row.GamePlayed, + GameLastPlayed: row.GameLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetLeastPlayedGamesWithSongs returns the top N least played games with their songs +func (h *StatisticsHandler) GetLeastPlayedGamesWithSongs(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetLeastPlayedGamesWithSongs(ctx, limit) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + GameID: row.GameID, + GameName: row.GameName, + GamePlayed: row.GamePlayed, + GameLastPlayed: row.GameLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetMostPlayedSongsWithGame returns the top N most played songs with their game info +func (h *StatisticsHandler) GetMostPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetMostPlayedSongsWithGame(ctx, limit) + if err != nil { + return nil, err + } + + var result []SongInfoForStats + for _, row := range rows { + result = append(result, SongInfoForStats{ + GameID: row.GameID, + GameName: row.GameName, + SongName: row.SongName, + Path: row.Path, + TimesPlayed: row.TimesPlayed, + FileName: row.FileName, + }) + } + return result, nil +} + +// GetLeastPlayedSongsWithGame returns the top N least played songs with their game info +func (h *StatisticsHandler) GetLeastPlayedSongsWithGame(limit int32) ([]SongInfoForStats, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetLeastPlayedSongsWithGame(ctx, limit) + if err != nil { + return nil, err + } + + var result []SongInfoForStats + for _, row := range rows { + result = append(result, SongInfoForStats{ + GameID: row.GameID, + GameName: row.GameName, + SongName: row.SongName, + Path: row.Path, + TimesPlayed: row.TimesPlayed, + FileName: row.FileName, + }) + } + return result, nil +} + +// GetNeverPlayedGames returns games that have never been played +func (h *StatisticsHandler) GetNeverPlayedGames() ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetNeverPlayedGames(ctx) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + GameID: row.GameID, + GameName: row.GameName, + GamePlayed: row.GamePlayed, + GameLastPlayed: nil, + Songs: songs, + }) + } + return result, nil +} + +// GetLastPlayedGames returns the most recently played games +func (h *StatisticsHandler) GetLastPlayedGames(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetLastPlayedGames(ctx, limit) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + GameID: row.GameID, + GameName: row.GameName, + GamePlayed: row.GamePlayed, + GameLastPlayed: row.GameLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetOldestPlayedGames returns the least recently played games +func (h *StatisticsHandler) GetOldestPlayedGames(limit int32) ([]GameWithSongs, error) { + queries := BackendRepo() + ctx := BackendCtx() + + rows, err := queries.GetOldestPlayedGames(ctx, limit) + if err != nil { + return nil, err + } + + var result []GameWithSongs + for _, row := range rows { + var songs []SongInfoForStats + if row.Songs != nil { + if err := json.Unmarshal(row.Songs, &songs); err != nil { + songs = make([]SongInfoForStats, 0) + } + } + result = append(result, GameWithSongs{ + GameID: row.GameID, + GameName: row.GameName, + GamePlayed: row.GamePlayed, + GameLastPlayed: row.GameLastPlayed, + Songs: songs, + }) + } + return result, nil +} + +// GetStatisticsSummary returns overall statistics +func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) { + queries := BackendRepo() + ctx := BackendCtx() + + row, err := queries.GetStatisticsSummary(ctx) + if err != nil { + return nil, err + } + + return &StatisticsSummary{ + TotalGames: int64(row.TotalGames), + PlayedGames: int64(row.PlayedGames), + NeverPlayedGames: int64(row.NeverPlayedGames), + TotalGamePlays: int64(row.TotalGamePlays), + AvgGamePlays: float64(row.AvgGamePlays), + MaxGamePlays: int64(row.MaxGamePlays), + MinGamePlays: int64(row.MinGamePlays), + }, nil +} + +// Log helper for statistics operations +func logStatisticsError(err error, operation string) { + if err != nil { + logging.GetLogger().Error("Statistics error", + zap.String("operation", operation), + zap.String("error", err.Error())) + } +} diff --git a/internal/db/queries/statistics.sql b/internal/db/queries/statistics.sql new file mode 100644 index 0000000..1dd7674 --- /dev/null +++ b/internal/db/queries/statistics.sql @@ -0,0 +1,148 @@ +-- Most played games with their songs +-- name: GetMostPlayedGamesWithSongs :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.times_played DESC, g.game_name +LIMIT $1; + +-- Least played games with their songs +-- name: GetLeastPlayedGamesWithSongs :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.times_played ASC, g.game_name +LIMIT $1; + +-- Most played songs with their game info +-- name: GetMostPlayedSongsWithGame :many +SELECT + s.game_id as game_id, + g.game_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN game g ON s.game_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played DESC, s.song_name +LIMIT $1; + +-- Least played songs with their game info +-- name: GetLeastPlayedSongsWithGame :many +SELECT + s.game_id as game_id, + g.game_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN game g ON s.game_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played ASC, s.song_name +LIMIT $1; + +-- Games that have never been played (times_played = 0) +-- name: GetNeverPlayedGames :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.added, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL AND g.times_played = 0 +GROUP BY g.id, g.game_name, g.times_played, g.added +ORDER BY g.game_name; + +-- Last played games (most recently played) +-- name: GetLastPlayedGames :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.last_played DESC +LIMIT $1; + +-- Oldest played games (least recently played, but has been played at least once) +-- name: GetOldestPlayedGames :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.last_played ASC +LIMIT $1; + +-- Get statistics summary +-- name: GetStatisticsSummary :one +SELECT + COUNT(*) as total_games, + SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_games, + SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_games, + COALESCE(SUM(times_played), 0)::bigint as total_game_plays, + COALESCE(AVG(times_played), 0)::float as avg_game_plays, + COALESCE(MAX(times_played), 0)::bigint as max_game_plays, + COALESCE(MIN(times_played), 0)::bigint as min_game_plays +FROM game +WHERE deleted IS NULL; diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go new file mode 100644 index 0000000..129b47d --- /dev/null +++ b/internal/db/repository/statistics.sql.go @@ -0,0 +1,435 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: statistics.sql + +package repository + +import ( + "context" + "time" +) + +const getLastPlayedGames = `-- name: GetLastPlayedGames :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.last_played DESC +LIMIT $1 +` + +type GetLastPlayedGamesRow struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + GamePlayed int32 `json:"game_played"` + GameLastPlayed *time.Time `json:"game_last_played"` + Songs []byte `json:"songs"` +} + +// Last played games (most recently played) +func (q *Queries) GetLastPlayedGames(ctx context.Context, limit int32) ([]GetLastPlayedGamesRow, error) { + rows, err := q.db.Query(ctx, getLastPlayedGames, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLastPlayedGamesRow + for rows.Next() { + var i GetLastPlayedGamesRow + if err := rows.Scan( + &i.GameID, + &i.GameName, + &i.GamePlayed, + &i.GameLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.times_played ASC, g.game_name +LIMIT $1 +` + +type GetLeastPlayedGamesWithSongsRow struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + GamePlayed int32 `json:"game_played"` + GameLastPlayed *time.Time `json:"game_last_played"` + Songs []byte `json:"songs"` +} + +// Least played games with their songs +func (q *Queries) GetLeastPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedGamesWithSongsRow, error) { + rows, err := q.db.Query(ctx, getLeastPlayedGamesWithSongs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLeastPlayedGamesWithSongsRow + for rows.Next() { + var i GetLeastPlayedGamesWithSongsRow + if err := rows.Scan( + &i.GameID, + &i.GameName, + &i.GamePlayed, + &i.GameLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :many +SELECT + s.game_id as game_id, + g.game_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN game g ON s.game_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played ASC, s.song_name +LIMIT $1 +` + +type GetLeastPlayedSongsWithGameRow struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + FileName *string `json:"file_name"` +} + +// Least played songs with their game info +func (q *Queries) GetLeastPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithGameRow, error) { + rows, err := q.db.Query(ctx, getLeastPlayedSongsWithGame, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetLeastPlayedSongsWithGameRow + for rows.Next() { + var i GetLeastPlayedSongsWithGameRow + if err := rows.Scan( + &i.GameID, + &i.GameName, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.FileName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMostPlayedGamesWithSongs = `-- name: GetMostPlayedGamesWithSongs :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.times_played DESC, g.game_name +LIMIT $1 +` + +type GetMostPlayedGamesWithSongsRow struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + GamePlayed int32 `json:"game_played"` + GameLastPlayed *time.Time `json:"game_last_played"` + Songs []byte `json:"songs"` +} + +// Most played games with their songs +func (q *Queries) GetMostPlayedGamesWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedGamesWithSongsRow, error) { + rows, err := q.db.Query(ctx, getMostPlayedGamesWithSongs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMostPlayedGamesWithSongsRow + for rows.Next() { + var i GetMostPlayedGamesWithSongsRow + if err := rows.Scan( + &i.GameID, + &i.GameName, + &i.GamePlayed, + &i.GameLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :many +SELECT + s.game_id as game_id, + g.game_name, + s.song_name, + s.path, + s.times_played, + s.file_name +FROM song s +JOIN game g ON s.game_id = g.id +WHERE g.deleted IS NULL +ORDER BY s.times_played DESC, s.song_name +LIMIT $1 +` + +type GetMostPlayedSongsWithGameRow struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + FileName *string `json:"file_name"` +} + +// Most played songs with their game info +func (q *Queries) GetMostPlayedSongsWithGame(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithGameRow, error) { + rows, err := q.db.Query(ctx, getMostPlayedSongsWithGame, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMostPlayedSongsWithGameRow + for rows.Next() { + var i GetMostPlayedSongsWithGameRow + if err := rows.Scan( + &i.GameID, + &i.GameName, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.FileName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getNeverPlayedGames = `-- name: GetNeverPlayedGames :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.added, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL AND g.times_played = 0 +GROUP BY g.id, g.game_name, g.times_played, g.added +ORDER BY g.game_name +` + +type GetNeverPlayedGamesRow struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + GamePlayed int32 `json:"game_played"` + Added time.Time `json:"added"` + Songs []byte `json:"songs"` +} + +// Games that have never been played (times_played = 0) +func (q *Queries) GetNeverPlayedGames(ctx context.Context) ([]GetNeverPlayedGamesRow, error) { + rows, err := q.db.Query(ctx, getNeverPlayedGames) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetNeverPlayedGamesRow + for rows.Next() { + var i GetNeverPlayedGamesRow + if err := rows.Scan( + &i.GameID, + &i.GameName, + &i.GamePlayed, + &i.Added, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getOldestPlayedGames = `-- name: GetOldestPlayedGames :many +SELECT + g.id as game_id, + g.game_name, + g.times_played as game_played, + g.last_played as game_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played + ) + ) as songs +FROM game g +LEFT JOIN song s ON g.id = s.game_id +WHERE g.deleted IS NULL AND g.last_played IS NOT NULL +GROUP BY g.id, g.game_name, g.times_played, g.last_played +ORDER BY g.last_played ASC +LIMIT $1 +` + +type GetOldestPlayedGamesRow struct { + GameID int32 `json:"game_id"` + GameName string `json:"game_name"` + GamePlayed int32 `json:"game_played"` + GameLastPlayed *time.Time `json:"game_last_played"` + Songs []byte `json:"songs"` +} + +// Oldest played games (least recently played, but has been played at least once) +func (q *Queries) GetOldestPlayedGames(ctx context.Context, limit int32) ([]GetOldestPlayedGamesRow, error) { + rows, err := q.db.Query(ctx, getOldestPlayedGames, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetOldestPlayedGamesRow + for rows.Next() { + var i GetOldestPlayedGamesRow + if err := rows.Scan( + &i.GameID, + &i.GameName, + &i.GamePlayed, + &i.GameLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getStatisticsSummary = `-- name: GetStatisticsSummary :one +SELECT + COUNT(*) as total_games, + SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_games, + SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_games, + COALESCE(SUM(times_played), 0)::bigint as total_game_plays, + COALESCE(AVG(times_played), 0)::float as avg_game_plays, + COALESCE(MAX(times_played), 0)::bigint as max_game_plays, + COALESCE(MIN(times_played), 0)::bigint as min_game_plays +FROM game +WHERE deleted IS NULL +` + +type GetStatisticsSummaryRow struct { + TotalGames int64 `json:"total_games"` + PlayedGames int64 `json:"played_games"` + NeverPlayedGames int64 `json:"never_played_games"` + TotalGamePlays int64 `json:"total_game_plays"` + AvgGamePlays float64 `json:"avg_game_plays"` + MaxGamePlays int64 `json:"max_game_plays"` + MinGamePlays int64 `json:"min_game_plays"` +} + +// Get statistics summary +func (q *Queries) GetStatisticsSummary(ctx context.Context) (GetStatisticsSummaryRow, error) { + row := q.db.QueryRow(ctx, getStatisticsSummary) + var i GetStatisticsSummaryRow + err := row.Scan( + &i.TotalGames, + &i.PlayedGames, + &i.NeverPlayedGames, + &i.TotalGamePlays, + &i.AvgGamePlays, + &i.MaxGamePlays, + &i.MinGamePlays, + ) + return i, err +} diff --git a/internal/server/routes.go b/internal/server/routes.go index aa9b6ba..bc33c17 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -122,10 +122,38 @@ func (s *Server) RegisterRoutes() http.Handler { // Create token auth middleware with pool access tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool) - // Protected group with token authentication - will be used by VGMQ and Statistics API - _ = apiV1.Group("", tokenAuthMiddleware) + // Protected group with token authentication + protectedV1 := apiV1.Group("", tokenAuthMiddleware) - // Note: Future protected endpoints (VGMQ, Statistics) will be added here + // Statistics API endpoints (protected by token auth) + statistics := s.statisticsHandler + protectedV1.GET("/statistics/games/most-played", func(c *echo.Context) error { + return statistics.GetMostPlayedGames(c) + }) + protectedV1.GET("/statistics/games/least-played", func(c *echo.Context) error { + return statistics.GetLeastPlayedGames(c) + }) + protectedV1.GET("/statistics/games/never-played", func(c *echo.Context) error { + return statistics.GetNeverPlayedGames(c) + }) + protectedV1.GET("/statistics/games/last-played", func(c *echo.Context) error { + return statistics.GetLastPlayedGames(c) + }) + protectedV1.GET("/statistics/games/oldest-played", func(c *echo.Context) error { + return statistics.GetOldestPlayedGames(c) + }) + protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error { + return statistics.GetMostPlayedSongs(c) + }) + protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error { + return statistics.GetLeastPlayedSongs(c) + }) + protectedV1.GET("/statistics/summary", func(c *echo.Context) error { + return statistics.GetStatisticsSummary(c) + }) + + // Future: VGMQ endpoints will be added to protectedV1 group + _ = protectedV1 // Use the variable to avoid unused variable error routes := e.Router().Routes() sort.Slice(routes, func(i, j int) bool { diff --git a/internal/server/server.go b/internal/server/server.go index b0e1b85..2c6fbc5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,10 +15,11 @@ import ( ) type Server struct { - port int - db *db.Database - tokenHandler *TokenHandler - httpServer *http.Server + port int + db *db.Database + tokenHandler *TokenHandler + statisticsHandler *StatisticsHandler + httpServer *http.Server } var ( @@ -67,12 +68,16 @@ func NewServerInstance() *Server { // Initialize token handler with database pool tokenHandler := NewTokenHandler(database.Pool) + + // Initialize statistics handler + statisticsHandler := NewStatisticsHandler() // Create the server instance appServer := &Server{ - port: port, - db: database, - tokenHandler: tokenHandler, + port: port, + db: database, + tokenHandler: tokenHandler, + statisticsHandler: statisticsHandler, } // Create the HTTP server diff --git a/internal/server/statistics_handler.go b/internal/server/statistics_handler.go new file mode 100644 index 0000000..614d247 --- /dev/null +++ b/internal/server/statistics_handler.go @@ -0,0 +1,275 @@ +package server + +import ( + "net/http" + "strconv" + + "music-server/internal/backend" + "music-server/internal/logging" + + "github.com/labstack/echo/v5" + "go.uber.org/zap" +) + +// StatisticsHandler handles statistics-related HTTP requests +type StatisticsHandler struct { + statsBackend *backend.StatisticsHandler +} + +// NewStatisticsHandler creates a new StatisticsHandler +func NewStatisticsHandler() *StatisticsHandler { + return &StatisticsHandler{ + statsBackend: backend.NewStatisticsHandler(), + } +} + +// GetMostPlayedGames returns top N most played games with songs +// GET /api/v1/statistics/games/most-played +// +// @Summary Get most played games +// @Description Returns the top N most played games with their songs +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/most-played [get] +func (h *StatisticsHandler) GetMostPlayedGames(ctx *echo.Context) error { + limit := 10 // default + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + // Cap at 100 for performance + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetMostPlayedGamesWithSongs(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get most played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetLeastPlayedGames returns top N least played games with songs +// GET /api/v1/statistics/games/least-played +// +// @Summary Get least played games +// @Description Returns the top N least played games with their songs +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/least-played [get] +func (h *StatisticsHandler) GetLeastPlayedGames(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetLeastPlayedGamesWithSongs(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get least played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetMostPlayedSongs returns top N most played songs with game info +// GET /api/v1/statistics/songs/most-played +// +// @Summary Get most played songs +// @Description Returns the top N most played songs with their game info +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.SongInfoForStats +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/songs/most-played [get] +func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + songs, err := h.statsBackend.GetMostPlayedSongsWithGame(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, songs) +} + +// GetLeastPlayedSongs returns top N least played songs with game info +// GET /api/v1/statistics/songs/least-played +// +// @Summary Get least played songs +// @Description Returns the top N least played songs with their game info +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.SongInfoForStats +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/songs/least-played [get] +func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + songs, err := h.statsBackend.GetLeastPlayedSongsWithGame(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, songs) +} + +// GetNeverPlayedGames returns games that have never been played +// GET /api/v1/statistics/games/never-played +// +// @Summary Get never played games +// @Description Returns all games that have never been played (times_played = 0) +// @Tags statistics +// @Accept json +// @Produce json +// @Success 200 {array} backend.GameWithSongs +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/never-played [get] +func (h *StatisticsHandler) GetNeverPlayedGames(ctx *echo.Context) error { + games, err := h.statsBackend.GetNeverPlayedGames() + if err != nil { + logging.GetLogger().Error("Failed to get never played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetLastPlayedGames returns most recently played games +// GET /api/v1/statistics/games/last-played +// +// @Summary Get last played games +// @Description Returns the most recently played games +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/last-played [get] +func (h *StatisticsHandler) GetLastPlayedGames(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetLastPlayedGames(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get last played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetOldestPlayedGames returns least recently played games +// GET /api/v1/statistics/games/oldest-played +// +// @Summary Get oldest played games +// @Description Returns the least recently played games (that have been played at least once) +// @Tags statistics +// @Accept json +// @Produce json +// @Param limit query int false "Number of results (default: 10)" +// @Success 200 {array} backend.GameWithSongs +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/games/oldest-played [get] +func (h *StatisticsHandler) GetOldestPlayedGames(ctx *echo.Context) error { + limit := 10 + limitStr := ctx.QueryParam("limit") + if limitStr != "" { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"}) + } + if limit > 100 { + limit = 100 + } + } + + games, err := h.statsBackend.GetOldestPlayedGames(int32(limit)) + if err != nil { + logging.GetLogger().Error("Failed to get oldest played games", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, games) +} + +// GetStatisticsSummary returns overall statistics +// GET /api/v1/statistics/summary +// +// @Summary Get statistics summary +// @Description Returns overall statistics about the music library +// @Tags statistics +// @Accept json +// @Produce json +// @Success 200 {object} backend.StatisticsSummary +// @Failure 500 {object} map[string]string +// @Router /api/v1/statistics/summary [get] +func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error { + summary, err := h.statsBackend.GetStatisticsSummary() + if err != nil { + logging.GetLogger().Error("Failed to get statistics summary", zap.String("error", err.Error())) + return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"}) + } + return ctx.JSON(http.StatusOK, summary) +}