5 Commits

Author SHA1 Message Date
Sansan c63202242b feat: Complete DI cleanup - migrate test helpers to Database struct
- Update internal/db/test_helpers.go to use Database struct instead of globals
- Update internal/server/test_helpers.go to use TestDatabase.Pool
- Add TODO comment to old Dbpool/Ctx globals in dbHelper.go
- Remove db.Testf() usage from production code (kept for deprecated /dbtest endpoint)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-01 20:06:47 +02:00
Sansan 3418f492f5 feat: Add deprecation middleware for legacy endpoints
- Create middleware/deprecation.go with DeprecationMiddleware
- Adds Warning and Deprecation headers to old endpoints
- Apply middleware to all non-/api/v1 routes:
  /version, /dbtest, /health, /character*, /download*, /sync/*,
  /music/*
- Message: 'Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead.'

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-01 19:41:17 +02:00
Sansan f4d1c3cf28 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 <vibe@mistral.ai>
2026-06-01 19:40:22 +02:00
Sansan 98c1948eff feat: Remove global db.Dbpool with dependency injection (Phase 0)
- Add Database struct in internal/db/database.go with Pool, Ctx, and RunMigrations()
- Update server.go to use Database struct with NewServerInstance()
- Add backend.go with InitBackend(), BackendRepo(), BackendCtx(), BackendPool()
- Update music.go and sync.go to use BackendRepo() and BackendCtx() instead of db.Dbpool/db.Ctx
- Update token_handler.go to accept pool parameter
- Update routes.go to use s.db.Pool for middleware
- Update cmd/main.go to use NewServerInstance() and HTTPServer()
- Update test_helpers.go to initialize backend with test database
- Update test files to use backend.BackendPool() and backend.BackendCtx()

Benefits:
- Easier to mock database for unit tests
- Follows Go best practices (dependency injection)
- Better architecture with explicit dependencies
- RunMigrations() replaces old Migrate_db() function

Note: Global db.Dbpool and db.Ctx still exist in dbHelper.go for backward compatibility
with test_helpers.go, but production code no longer uses them.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-01 18:50:05 +02:00
Sansan 3e37303979 feat: Implement Session Token System with /api/v1 base path
- Add migration 000004 for sessions table and performance indexes
- Create session.sql queries for CRUD operations
- Generate session repository code with sqlc
- Create token auth middleware for Echo framework
- Create token handler with create/delete/cleanup endpoints
- Add /api/v1 router with token authentication infrastructure
- Update dbHelper.go to use Up() instead of Migrate(2)
- Update server.go to initialize token handler
- Existing endpoints remain functional (to be deprecated)

New endpoints:
- POST /api/v1/token - Create new session token
- DELETE /api/v1/token - Invalidate token
- POST /api/v1/token/cleanup - Remove expired sessions

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-01 18:07:28 +02:00
25 changed files with 2071 additions and 141 deletions
+14 -8
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"music-server/internal/db"
"music-server/internal/logging" "music-server/internal/logging"
"music-server/internal/server" "music-server/internal/server"
"net/http" "net/http"
@@ -19,9 +18,11 @@ import (
// @description This is a sample server Petstore server. // @description This is a sample server Petstore server.
// @termsOfService http://swagger.io/terms/ // @termsOfService http://swagger.io/terms/
//
// @contact.name Sebastian Olsson // @contact.name Sebastian Olsson
// @contact.email zarnor91@gmail.com // @contact.email zarnor91@gmail.com
//
// @license.name Apache 2.0 // @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html // @license.url http://www.apache.org/licenses/LICENSE-2.0.html
@@ -34,16 +35,17 @@ func main() {
pprof.StartCPUProfile(f) pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()*/ defer pprof.StopCPUProfile()*/
server := server.NewServer() appServer := server.NewServerInstance()
httpServer := appServer.HTTPServer()
// Create a done channel to signal when the shutdown is complete // Create a done channel to signal when the shutdown is complete
done := make(chan bool, 1) done := make(chan bool, 1)
// Run graceful shutdown in a separate goroutine // Run graceful shutdown in a separate goroutine
go gracefulShutdown(server, done) go gracefulShutdown(appServer, httpServer, done)
logging.GetLogger().Info("Server starting", zap.String("address", server.Addr)) logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr))
err := server.ListenAndServe() err := httpServer.ListenAndServe()
if err != nil && err != http.ErrServerClosed { if err != nil && err != http.ErrServerClosed {
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error())) logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
} }
@@ -53,7 +55,7 @@ func main() {
logging.GetLogger().Info("Graceful shutdown complete") logging.GetLogger().Info("Graceful shutdown complete")
} }
func gracefulShutdown(apiServer *http.Server, done chan bool) { func gracefulShutdown(appServer *server.Server, httpServer *http.Server, done chan bool) {
// Create context that listens for the interrupt signal from the OS. // Create context that listens for the interrupt signal from the OS.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
@@ -62,13 +64,17 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
<-ctx.Done() <-ctx.Done()
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force") logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
db.CloseDb()
// Close database connection
if appServer != nil && appServer.DB() != nil {
appServer.DB().Close()
}
// The context is used to inform the server it has 5 seconds to finish // The context is used to inform the server it has 5 seconds to finish
// the request it is currently handling // the request it is currently handling
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := apiServer.Shutdown(ctx); err != nil { if err := httpServer.Shutdown(ctx); err != nil {
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error())) logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
} }
+40
View File
@@ -0,0 +1,40 @@
package backend
import (
"context"
"music-server/internal/db/repository"
"github.com/jackc/pgx/v5/pgxpool"
)
// Global variables - these are initialized by InitBackend
var (
backendPool *pgxpool.Pool
repo *repository.Queries
backendCtx context.Context = context.Background()
)
// InitBackend initializes the backend package with the database pool.
// This should be called once at application startup.
func InitBackend(pool *pgxpool.Pool) {
backendPool = pool
repo = repository.New(pool)
backendCtx = context.Background()
}
// BackendCtx returns the context used by backend operations.
// This is exposed for use by the backend functions.
func BackendCtx() context.Context {
return backendCtx
}
// BackendRepo returns the repository queries instance.
// This is exposed for use by the backend functions.
func BackendRepo() *repository.Queries {
return repo
}
// BackendPool returns the underlying database pool.
// This is exposed for test utilities that need direct pool access.
func BackendPool() *pgxpool.Pool {
return backendPool
}
+17 -16
View File
@@ -2,7 +2,6 @@ package backend
import ( import (
"math/rand" "math/rand"
"music-server/internal/db"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"os" "os"
@@ -28,18 +27,20 @@ var gamesNew []repository.Game
var songQueNew []repository.Song var songQueNew []repository.Song
var lastFetchedNew repository.Song var lastFetchedNew repository.Song
var repo *repository.Queries
func initRepo() { func initRepo() {
if repo == nil { // This function is kept for backward compatibility
repo = repository.New(db.Dbpool) // but now uses the backend package's initialized repo
// If not initialized, this will panic intentionally
if BackendRepo() == nil {
panic("backend not initialized - call backend.InitBackend() first")
} }
} }
func getAllGames() []repository.Game { func getAllGames() []repository.Game {
if len(gamesNew) == 0 { if len(gamesNew) == 0 {
initRepo() initRepo()
gamesNew, _ = repo.FindAllGames(db.Ctx) gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
} }
return gamesNew return gamesNew
@@ -58,7 +59,7 @@ func Reset() {
songQueNew = nil songQueNew = nil
currentSong = -1 currentSong = -1
initRepo() initRepo()
gamesNew, _ = repo.FindAllGames(db.Ctx) gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
} }
func AddLatestToQue() { func AddLatestToQue() {
@@ -76,8 +77,8 @@ func AddLatestPlayed() {
currentSongData := songQueNew[currentSong] currentSongData := songQueNew[currentSong]
initRepo() initRepo()
repo.AddGamePlayed(db.Ctx, currentSongData.GameID) BackendRepo().AddGamePlayed(BackendCtx(), currentSongData.GameID)
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName}) BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
} }
func SetPlayed(songNumber int) { func SetPlayed(songNumber int) {
@@ -86,8 +87,8 @@ func SetPlayed(songNumber int) {
} }
songData := songQueNew[songNumber] songData := songQueNew[songNumber]
initRepo() initRepo()
repo.AddGamePlayed(db.Ctx, songData.GameID) BackendRepo().AddGamePlayed(BackendCtx(), songData.GameID)
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName}) BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
} }
func GetRandomSong() string { func GetRandomSong() string {
@@ -130,7 +131,7 @@ func GetRandomSongClassic() string {
var listOfAllSongs []repository.Song var listOfAllSongs []repository.Song
for _, game := range gamesNew { for _, game := range gamesNew {
songList, _ := repo.FindSongsFromGame(db.Ctx, game.ID) songList, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID)
listOfAllSongs = append(listOfAllSongs, songList...) listOfAllSongs = append(listOfAllSongs, songList...)
} }
@@ -138,10 +139,10 @@ func GetRandomSongClassic() string {
var song repository.Song var song repository.Song
for !songFound { for !songFound {
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))] song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
gameData, err := repo.GetGameById(db.Ctx, song.GameID) gameData, err := BackendRepo().GetGameById(BackendCtx(), song.GameID)
if err != nil { if err != nil {
repo.RemoveBrokenSong(db.Ctx, song.Path) BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", gameData.GameName), zap.String("game", gameData.GameName),
@@ -153,7 +154,7 @@ func GetRandomSongClassic() string {
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) { if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) {
//File not found //File not found
repo.RemoveBrokenSong(db.Ctx, song.Path) BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", gameData.GameName), zap.String("game", gameData.GameName),
@@ -270,7 +271,7 @@ func getSongFromList(games []repository.Game) repository.Song {
var song repository.Song var song repository.Song
for !songFound { for !songFound {
game := getRandomGame(games) game := getRandomGame(games)
songs, _ := repo.FindSongsFromGame(db.Ctx, game.ID) songs, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID)
if len(songs) == 0 { if len(songs) == 0 {
continue continue
} }
@@ -281,7 +282,7 @@ func getSongFromList(games []repository.Game) repository.Song {
openFile, err := os.Open(song.Path) openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) { if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
//File not found //File not found
repo.RemoveBrokenSong(db.Ctx, song.Path) BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
logging.GetLogger().Warn("Song not found, removed from database", logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName), zap.String("song", song.SongName),
zap.String("game", game.GameName), zap.String("game", game.GameName),
+277
View File
@@ -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()))
}
}
+23 -24
View File
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"music-server/internal/db"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"music-server/internal/logging" "music-server/internal/logging"
"os" "os"
@@ -80,8 +79,8 @@ func (gs GameStatus) String() string {
} }
func ResetDB() { func ResetDB() {
repo.ClearSongs(db.Ctx) repo.ClearSongs(BackendCtx())
repo.ClearGames(db.Ctx) repo.ClearGames(BackendCtx())
} }
func SyncProgress() ProgressResponse { func SyncProgress() ProgressResponse {
@@ -206,13 +205,13 @@ func syncGamesNew(full bool) {
catchedErrors = nil catchedErrors = nil
brokenSongs = nil brokenSongs = nil
gamesBeforeSync, err = repo.FindAllGames(db.Ctx) gamesBeforeSync, err = repo.FindAllGames(BackendCtx())
handleError("FindAllGames Before", err, "") handleError("FindAllGames Before", err, "")
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync))) logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
allGames, err = repo.GetAllGamesIncludingDeleted(db.Ctx) allGames, err = repo.GetAllGamesIncludingDeleted(BackendCtx())
handleError("GetAllGamesIncludingDeleted", err, "") handleError("GetAllGamesIncludingDeleted", err, "")
err = repo.SetGameDeletionDate(db.Ctx) err = repo.SetGameDeletionDate(BackendCtx())
handleError("SetGameDeletionDate", err, "") handleError("SetGameDeletionDate", err, "")
directories, err := os.ReadDir(musicPath) directories, err := os.ReadDir(musicPath)
@@ -237,7 +236,7 @@ func syncGamesNew(full bool) {
syncWg.Wait() syncWg.Wait()
checkBrokenSongsNew() checkBrokenSongsNew()
gamesAfterSync, err = repo.FindAllGames(db.Ctx) gamesAfterSync, err = repo.FindAllGames(BackendCtx())
handleError("FindAllGames After", err, "") handleError("FindAllGames After", err, "")
finished := time.Now() finished := time.Now()
@@ -249,7 +248,7 @@ func syncGamesNew(full bool) {
} }
func checkBrokenSongsNew() { func checkBrokenSongsNew() {
allSongs, err := repo.FetchAllSongs(db.Ctx) allSongs, err := repo.FetchAllSongs(BackendCtx())
handleError("FetchAllSongs", err, "") handleError("FetchAllSongs", err, "")
var brokenWg sync.WaitGroup var brokenWg sync.WaitGroup
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true)) poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
@@ -263,7 +262,7 @@ func checkBrokenSongsNew() {
}) })
} }
brokenWg.Wait() brokenWg.Wait()
err = repo.RemoveBrokenSongs(db.Ctx, brokenSongs) err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs)
handleError("RemoveBrokenSongs", err, "") handleError("RemoveBrokenSongs", err, "")
} }
@@ -336,7 +335,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
break break
} }
} }
err = repo.InsertGameWithExistingId(db.Ctx, repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash}) err = repo.InsertGameWithExistingId(BackendCtx(), repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
handleError("InsertGameWithExistingId", err, "") handleError("InsertGameWithExistingId", err, "")
if err != nil { if err != nil {
logging.GetLogger().Debug("Game already exists, removing old ID file", logging.GetLogger().Debug("Game already exists, removing old ID file",
@@ -370,7 +369,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
zap.String("game", file.Name()), zap.String("game", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.UpdateGameHash(db.Ctx, repository.UpdateGameHashParams{Hash: dirHash, ID: id}) err = repo.UpdateGameHash(BackendCtx(), repository.UpdateGameHashParams{Hash: dirHash, ID: id})
handleError("UpdateGameHash", err, "") handleError("UpdateGameHash", err, "")
gamesChangedContent = append(gamesChangedContent, file.Name()) gamesChangedContent = append(gamesChangedContent, file.Name())
newCheckSongs(entries, gameDir, id) newCheckSongs(entries, gameDir, id)
@@ -381,7 +380,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
zap.String("newName", file.Name()), zap.String("newName", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.UpdateGameName(db.Ctx, repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id}) err = repo.UpdateGameName(BackendCtx(), repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
handleError("UpdateGameName", err, "") handleError("UpdateGameName", err, "")
newCheckSongs(entries, gameDir, id) newCheckSongs(entries, gameDir, id)
if gamesChangedTitle == nil { if gamesChangedTitle == nil {
@@ -416,7 +415,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
zap.String("game", file.Name()), zap.String("game", file.Name()),
zap.String("hash", dirHash), zap.String("hash", dirHash),
zap.String("status", status.String())) zap.String("status", status.String()))
err = repo.RemoveDeletionDate(db.Ctx, id) err = repo.RemoveDeletionDate(BackendCtx(), id)
handleError("RemoveDeletionDate", err, "") handleError("RemoveDeletionDate", err, "")
} }
foldersSynced++ foldersSynced++
@@ -428,13 +427,13 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
func insertGameNew(name string, path string, hash string) int32 { func insertGameNew(name string, path string, hash string) int32 {
var duplicateError = errors.New("ERROR: duplicate key value violates unique") var duplicateError = errors.New("ERROR: duplicate key value violates unique")
id, err := repo.InsertGame(db.Ctx, repository.InsertGameParams{GameName: name, Path: path, Hash: hash}) id, err := repo.InsertGame(BackendCtx(), repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
handleError("InsertGame", err, "") handleError("InsertGame", err, "")
if err != nil { if err != nil {
logging.GetLogger().Warn("ID collision detected, resetting sequence") logging.GetLogger().Warn("ID collision detected, resetting sequence")
if strings.HasPrefix(err.Error(), duplicateError.Error()) { if strings.HasPrefix(err.Error(), duplicateError.Error()) {
logging.GetLogger().Debug("Resetting game ID sequence") logging.GetLogger().Debug("Resetting game ID sequence")
_, err = repo.ResetGameIdSeq(db.Ctx) _, err = repo.ResetGameIdSeq(BackendCtx())
handleError("ResetGameIdSeq", err, "") handleError("ResetGameIdSeq", err, "")
id = insertGameNew(name, path, hash) id = insertGameNew(name, path, hash)
} }
@@ -478,7 +477,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
fileName := entry.Name() fileName := entry.Name()
songName, _ := strings.CutSuffix(fileName, ".mp3") songName, _ := strings.CutSuffix(fileName, ".mp3")
song, err := repo.GetSongWithHash(db.Ctx, songHash) song, err := repo.GetSongWithHash(BackendCtx(), songHash)
handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
if err == nil { if err == nil {
if song.SongName == songName && song.Path == path { if song.SongName == songName && song.Path == path {
@@ -491,31 +490,31 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
zap.String("song_name", songName), zap.String("song_name", songName),
zap.String("song_hash", songHash)) zap.String("song_hash", songHash))
count, err := repo.CheckSongWithHash(db.Ctx, songHash) count, err := repo.CheckSongWithHash(BackendCtx(), songHash)
handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
if err != nil { if err != nil {
count2, err := repo.CheckSong(db.Ctx, path) count2, err := repo.CheckSong(BackendCtx(), path)
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
if count2 > 0 { if count2 > 0 {
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path}) err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
count, err = repo.CheckSongWithHash(db.Ctx, songHash) count, err = repo.CheckSongWithHash(BackendCtx(), songHash)
handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
} }
} }
//count, _ := repo.CheckSong(ctx, path) //count, _ := repo.CheckSong(ctx, path)
if count > 0 { if count > 0 {
err = repo.UpdateSong(db.Ctx, repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash}) err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
handleError("UpdateSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("UpdateSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
} else { } else {
count2, err := repo.CheckSong(db.Ctx, path) count2, err := repo.CheckSong(BackendCtx(), path)
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
if count2 > 0 { if count2 > 0 {
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path}) err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
} else { } else {
err = repo.AddSong(db.Ctx, repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash}) err = repo.AddSong(BackendCtx(), repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
} }
+121
View File
@@ -0,0 +1,121 @@
package db
import (
"context"
"database/sql"
"fmt"
"music-server/internal/logging"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/lib/pq"
"go.uber.org/zap"
)
// Database holds the database connection pool and context
type Database struct {
Pool *pgxpool.Pool
Ctx context.Context
}
// NewDatabase creates a new Database instance with connection pool
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
ctx := context.Background()
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
logging.GetLogger().Debug("Database connection info",
zap.String("host", host),
zap.String("port", port),
zap.String("dbname", dbname))
pool, err := pgxpool.New(ctx, psqlInfo)
if err != nil {
return nil, fmt.Errorf("unable to connect to database: %w", err)
}
// Test connection
var success string
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
if err != nil {
pool.Close()
return nil, fmt.Errorf("database query failed: %w", err)
}
logging.GetLogger().Info("Database connected", zap.String("status", success))
return &Database{Pool: pool, Ctx: ctx}, nil
}
// Close closes the database connection pool
func (db *Database) Close() {
if db.Pool != nil {
logging.GetLogger().Info("Closing database connection")
db.Pool.Close()
}
}
// RunMigrations runs all pending database migrations to the latest version.
// Uses the existing pool to extract connection details.
func (db *Database) RunMigrations() error {
// Extract connection info from pool config
connConfig := db.Pool.Config().ConnConfig
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
connConfig.User,
connConfig.Password,
connConfig.Host,
connConfig.Port,
connConfig.Database)
logging.GetLogger().Debug("Migration info", zap.String("url", migrationURL))
sqlDb, err := sql.Open("postgres", migrationURL)
if err != nil {
return fmt.Errorf("failed to open database for migration: %w", err)
}
defer sqlDb.Close()
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
if err != nil {
return fmt.Errorf("failed to create migration driver: %w", err)
}
files, err := iofs.New(MigrationsFs, "migrations")
if err != nil {
return fmt.Errorf("failed to create migration files: %w", err)
}
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
if err != nil {
return fmt.Errorf("failed to create migrator: %w", err)
}
// Get current version for logging
version, _, err := m.Version()
if err != nil {
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
// Run all pending migrations to latest version
err = m.Up()
if err != nil {
if err == migrate.ErrNoChange {
logging.GetLogger().Info("Database already up to date")
} else {
return fmt.Errorf("migration failed: %w", err)
}
} else {
// Get new version after migration
versionAfter, _, _ := m.Version()
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
}
logging.GetLogger().Info("Migration completed")
return nil
}
+10 -5
View File
@@ -20,6 +20,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// TODO: Remove these global variables once test_helpers.go is fully migrated to use Database struct
var Dbpool *pgxpool.Pool var Dbpool *pgxpool.Pool
var Ctx = context.Background() var Ctx = context.Background()
@@ -136,18 +137,22 @@ func Migrate_db(host string, port string, user string, password string, dbname s
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error())) // logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
//} //}
err = m.Migrate(2) // Use Up() to apply all pending migrations instead of Migrate(2)
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run err = m.Up()
if err != nil { if err != nil {
if err == migrate.ErrNoChange {
logging.GetLogger().Info("Database already up to date")
} else {
logging.GetLogger().Error("Migration error", zap.String("error", err.Error())) logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
} }
} else {
versionAfter, _, err := m.Version() versionAfter, _, err := m.Version()
if err != nil { if err != nil {
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error())) logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
} else {
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
}
} }
logging.GetLogger().Info("Migration version after", zap.Uint("version", versionAfter))
logging.GetLogger().Info("Migration completed") logging.GetLogger().Info("Migration completed")
@@ -0,0 +1,24 @@
-- Drop indexes for sessions table
DROP INDEX IF EXISTS idx_sessions_expires;
DROP INDEX IF EXISTS idx_sessions_token;
DROP INDEX IF EXISTS idx_sessions_ip;
DROP INDEX IF EXISTS idx_sessions_created;
-- Drop sessions table
DROP TABLE IF EXISTS sessions;
-- Drop performance indexes for song_list
DROP INDEX IF EXISTS idx_song_list_match_date;
DROP INDEX IF EXISTS idx_song_list_match_id;
-- Drop performance indexes for song
DROP INDEX IF EXISTS idx_song_hash;
DROP INDEX IF EXISTS idx_song_path;
DROP INDEX IF EXISTS idx_song_game_id;
DROP INDEX IF EXISTS idx_song_game_id_song_name;
-- Drop performance indexes for game
DROP INDEX IF EXISTS idx_game_deleted;
DROP INDEX IF EXISTS idx_game_hash;
DROP INDEX IF EXISTS idx_game_path;
DROP INDEX IF EXISTS idx_game_name;
@@ -0,0 +1,39 @@
-- ============================================
-- PERFORMANCE INDEXES FOR EXISTING TABLES
-- ============================================
-- Game table indexes
CREATE INDEX IF NOT EXISTS idx_game_deleted ON game(deleted) WHERE deleted IS NULL;
CREATE INDEX IF NOT EXISTS idx_game_hash ON game(hash);
CREATE INDEX IF NOT EXISTS idx_game_path ON game(path);
CREATE INDEX IF NOT EXISTS idx_game_name ON game(game_name);
-- Song table indexes
CREATE INDEX IF NOT EXISTS idx_song_hash ON song(hash);
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
CREATE INDEX IF NOT EXISTS idx_song_game_id ON song(game_id);
CREATE INDEX IF NOT EXISTS idx_song_game_id_song_name ON song(game_id, song_name);
-- Song_list table indexes
CREATE INDEX IF NOT EXISTS idx_song_list_match_date ON song_list(match_date);
CREATE INDEX IF NOT EXISTS idx_song_list_match_id ON song_list(match_id);
-- ============================================
-- SESSIONS TABLE FOR TOKEN MANAGEMENT
-- ============================================
-- Create sessions table for tracking client tokens
CREATE TABLE sessions (
token VARCHAR(64) PRIMARY KEY,
ip_address VARCHAR(45) NOT NULL,
user_agent TEXT NOT NULL,
client_type VARCHAR(20) DEFAULT 'web',
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for fast lookup and cleanup
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
CREATE INDEX idx_sessions_token ON sessions(token);
CREATE INDEX idx_sessions_ip ON sessions(ip_address);
CREATE INDEX idx_sessions_created ON sessions(created_at);
+23
View File
@@ -0,0 +1,23 @@
-- name: CreateSession :one
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at;
-- name: GetSession :one
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
FROM sessions
WHERE token = $1
LIMIT 1;
-- name: DeleteSession :exec
DELETE FROM sessions
WHERE token = $1;
-- name: DeleteExpiredSessions :exec
DELETE FROM sessions
WHERE expires_at < NOW();
-- name: ListSessions :many
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
FROM sessions
ORDER BY created_at DESC;
+148
View File
@@ -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;
+11
View File
@@ -6,6 +6,8 @@ package repository
import ( import (
"time" "time"
"github.com/jackc/pgx/v5/pgtype"
) )
type Game struct { type Game struct {
@@ -21,6 +23,15 @@ type Game struct {
Hash string `json:"hash"` Hash string `json:"hash"`
} }
type Session struct {
Token string `json:"token"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
ClientType *string `json:"client_type"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Song struct { type Song struct {
GameID int32 `json:"game_id"` GameID int32 `json:"game_id"`
SongName string `json:"song_name"` SongName string `json:"song_name"`
+120
View File
@@ -0,0 +1,120 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: session.sql
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createSession = `-- name: CreateSession :one
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at
`
type CreateSessionParams struct {
Token string `json:"token"`
IpAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
ClientType *string `json:"client_type"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRow(ctx, createSession,
arg.Token,
arg.IpAddress,
arg.UserAgent,
arg.ClientType,
arg.ExpiresAt,
)
var i Session
err := row.Scan(
&i.Token,
&i.IpAddress,
&i.UserAgent,
&i.ClientType,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
DELETE FROM sessions
WHERE expires_at < NOW()
`
func (q *Queries) DeleteExpiredSessions(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteExpiredSessions)
return err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM sessions
WHERE token = $1
`
func (q *Queries) DeleteSession(ctx context.Context, token string) error {
_, err := q.db.Exec(ctx, deleteSession, token)
return err
}
const getSession = `-- name: GetSession :one
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
FROM sessions
WHERE token = $1
LIMIT 1
`
func (q *Queries) GetSession(ctx context.Context, token string) (Session, error) {
row := q.db.QueryRow(ctx, getSession, token)
var i Session
err := row.Scan(
&i.Token,
&i.IpAddress,
&i.UserAgent,
&i.ClientType,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}
const listSessions = `-- name: ListSessions :many
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
FROM sessions
ORDER BY created_at DESC
`
func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
rows, err := q.db.Query(ctx, listSessions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Session
for rows.Next() {
var i Session
if err := rows.Scan(
&i.Token,
&i.IpAddress,
&i.UserAgent,
&i.ClientType,
&i.ExpiresAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+435
View File
@@ -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
}
+22 -6
View File
@@ -1,6 +1,7 @@
package db package db
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"log" "log"
@@ -16,6 +17,8 @@ var (
testDBUser string testDBUser string
testDBPassword string testDBPassword string
testDBName string testDBName string
// TestDatabase is the database instance for tests
TestDatabase *Database
) )
// TestSetupDB initializes the test database using existing functions // TestSetupDB initializes the test database using existing functions
@@ -44,9 +47,17 @@ func TestSetupDB(t *testing.T) {
// Create the database first (testuser is a superuser in the container) // Create the database first (testuser is a superuser in the container)
createTestDatabase(host, port, dbname, user, password) createTestDatabase(host, port, dbname, user, password)
// Now run migrations using the existing function // Create database instance and run migrations
Migrate_db(host, port, user, password, dbname) var err error
InitDB(host, port, user, password, dbname) TestDatabase, err = NewDatabase(host, port, user, password, dbname)
if err != nil {
t.Fatalf("Failed to initialize test database: %v", err)
}
// Run migrations
if err := TestDatabase.RunMigrations(); err != nil {
t.Fatalf("Failed to run migrations: %v", err)
}
}) })
} }
@@ -86,12 +97,16 @@ func createTestDatabase(host, port, dbname, user, password string) {
// "closed pool" errors when tests run sequentially // "closed pool" errors when tests run sequentially
func TestTearDownDB(t *testing.T) { func TestTearDownDB(t *testing.T) {
// CloseDb() // Disabled to prevent pool closure between sequential tests // CloseDb() // Disabled to prevent pool closure between sequential tests
if TestDatabase != nil {
TestDatabase.Close()
TestDatabase = nil
}
} }
// TestClearDatabase clears all data from the test database // TestClearDatabase clears all data from the test database
// Useful for running tests with a clean slate // Useful for running tests with a clean slate
func TestClearDatabase(t *testing.T) { func TestClearDatabase(t *testing.T) {
if Dbpool == nil { if TestDatabase == nil || TestDatabase.Pool == nil {
t.Skip("Database not initialized") t.Skip("Database not initialized")
} }
@@ -103,15 +118,16 @@ func TestClearDatabase(t *testing.T) {
"game", "game",
} }
ctx := context.Background()
for _, table := range tables { for _, table := range tables {
_, err := Dbpool.Exec(Ctx, "TRUNCATE TABLE "+table+" CASCADE") _, err := TestDatabase.Pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
if err != nil { if err != nil {
t.Logf("Failed to truncate table %s: %v", table, err) t.Logf("Failed to truncate table %s: %v", table, err)
} }
} }
// Reset sequences // Reset sequences
_, err := Dbpool.Exec(Ctx, "SELECT setval('game_id_seq', 1, false)") _, err := TestDatabase.Pool.Exec(ctx, "SELECT setval('game_id_seq', 1, false)")
if err != nil { if err != nil {
t.Logf("Failed to reset game_id_seq: %v", err) t.Logf("Failed to reset game_id_seq: %v", err)
} }
+16
View File
@@ -0,0 +1,16 @@
package middleware
import (
"github.com/labstack/echo/v5"
)
// DeprecationMiddleware adds deprecation warning to responses
// for old endpoints that are being phased out in favor of /api/v1/*
func DeprecationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Add deprecation warning header
c.Response().Header().Add("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
c.Response().Header().Add("Deprecation", "true")
return next(c)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package middleware provides Echo middleware for the MusicServer application.
package middleware
+77
View File
@@ -0,0 +1,77 @@
package middleware
import (
"net/http"
"strings"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// TokenAuthMiddleware returns an Echo middleware that validates session tokens
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Extract token from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
}
// Bearer token format
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
}
token := parts[1]
queries := repository.New(pool)
session, err := queries.GetSession(c.Request().Context(), token)
if err != nil {
logging.GetLogger().Warn("Invalid token attempt",
zap.String("token", token),
zap.String("ip", c.RealIP()),
zap.String("error", err.Error()),
)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
}
// Check if token is expired
if time.Now().After(session.ExpiresAt.Time) {
// Clean up expired session in background
go func() {
queries.DeleteSession(c.Request().Context(), token)
}()
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
}
// Add session to request context for potential use by handlers
c.Set("session", session)
return next(c)
}
}
}
// TokenIPCheckMiddleware checks if the request IP matches the session IP
func TokenIPCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
sessionVal := c.Get("session")
if sessionVal == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "No session in context"})
}
session := sessionVal.(repository.Session)
if session.IpAddress != c.RealIP() {
logging.GetLogger().Warn("Token IP mismatch",
zap.String("token_ip", session.IpAddress),
zap.String("request_ip", c.RealIP()),
)
}
return next(c)
}
}
+97 -36
View File
@@ -2,15 +2,16 @@ package server
import ( import (
"music-server/cmd/web" "music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware" echoMiddleware "github.com/labstack/echo/v5/middleware"
echoSwagger "github.com/swaggo/echo-swagger/v2" echoSwagger "github.com/swaggo/echo-swagger/v2"
"music-server/internal/logging"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -36,9 +37,9 @@ func (s *Server) RegisterRoutes() http.Handler {
http.ServeFile(w, r, "cmd/docs/swagger.json") http.ServeFile(w, r, "cmd/docs/swagger.json")
}))) })))
e.Use(logging.RequestLogger()) e.Use(logging.RequestLogger())
e.Use(middleware.Recover()) e.Use(echoMiddleware.Recover())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"https://*", "http://*"}, AllowOrigins: []string{"https://*", "http://*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
@@ -57,47 +58,107 @@ func (s *Server) RegisterRoutes() http.Handler {
// Swagger UI // Swagger UI
e.GET("/swagger/*", echoSwagger.WrapHandler) e.GET("/swagger/*", echoSwagger.WrapHandler)
// ============================================
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
// ============================================
deprecatedMiddleware := middleware.DeprecationMiddleware
index := NewIndexHandler() index := NewIndexHandler()
e.GET("/version", index.GetVersion) e.GET("/version", deprecatedMiddleware(index.GetVersion))
e.GET("/dbtest", index.GetDBTest) e.GET("/dbtest", deprecatedMiddleware(index.GetDBTest))
e.GET("/health", index.HealthCheck) e.GET("/health", deprecatedMiddleware(index.HealthCheck))
e.GET("/character", index.GetCharacter) e.GET("/character", deprecatedMiddleware(index.GetCharacter))
e.GET("/characters", index.GetCharacterList) e.GET("/characters", deprecatedMiddleware(index.GetCharacterList))
download := NewDownloadHandler() download := NewDownloadHandler()
e.GET("/download", download.checkLatest) e.GET("/download", deprecatedMiddleware(download.checkLatest))
e.GET("/download/list", download.listAssetsOfLatest) e.GET("/download/list", deprecatedMiddleware(download.listAssetsOfLatest))
e.GET("/download/windows", download.downloadLatestWindows) e.GET("/download/windows", deprecatedMiddleware(download.downloadLatestWindows))
e.GET("/download/linux", download.downloadLatestLinux) e.GET("/download/linux", deprecatedMiddleware(download.downloadLatestLinux))
sync := NewSyncHandler() sync := NewSyncHandler()
syncGroup := e.Group("/sync") syncGroup := e.Group("/sync")
syncGroup.GET("", sync.SyncGamesNewOnlyChanges) syncGroup.GET("", deprecatedMiddleware(sync.SyncGamesNewOnlyChanges))
syncGroup.GET("/progress", sync.SyncProgress) syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
syncGroup.GET("/new", sync.SyncGamesNewOnlyChanges) syncGroup.GET("/new", deprecatedMiddleware(sync.SyncGamesNewOnlyChanges))
syncGroup.GET("/full", sync.SyncGamesNewFull) syncGroup.GET("/full", deprecatedMiddleware(sync.SyncGamesNewFull))
syncGroup.GET("/new/full", sync.SyncGamesNewFull) syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncGamesNewFull))
syncGroup.GET("/quick", sync.SyncGamesNewOnlyChanges) syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncGamesNewOnlyChanges))
syncGroup.GET("/reset", sync.ResetGames) syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetGames))
music := NewMusicHandler() music := NewMusicHandler()
musicGroup := e.Group("/music") musicGroup := e.Group("/music")
musicGroup.GET("", music.GetSong) musicGroup.GET("", deprecatedMiddleware(music.GetSong))
musicGroup.GET("/soundTest", music.GetSoundCheckSong) musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong))
musicGroup.GET("/reset", music.ResetMusic) musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic))
musicGroup.GET("/rand", music.GetRandomSong) musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong))
musicGroup.GET("/rand/low", music.GetRandomSongLowChance) musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance))
musicGroup.GET("/rand/classic", music.GetRandomSongClassic) musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic))
musicGroup.GET("/info", music.GetSongInfo) musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
musicGroup.GET("/list", music.GetPlayedSongs) musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
musicGroup.GET("/next", music.GetNextSong) musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong))
musicGroup.GET("/previous", music.GetPreviousSong) musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong))
musicGroup.GET("/all", music.GetAllGamesRandom) musicGroup.GET("/all", deprecatedMiddleware(music.GetAllGamesRandom))
musicGroup.GET("/all/order", music.GetAllGames) musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllGames))
musicGroup.GET("/all/random", music.GetAllGamesRandom) musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllGamesRandom))
musicGroup.PUT("/played", music.PutPlayed) musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed))
musicGroup.GET("/addQue", music.AddLatestToQue) musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue))
musicGroup.GET("/addPlayed", music.AddLatestPlayed) musicGroup.GET("/addPlayed", deprecatedMiddleware(music.AddLatestPlayed))
// ============================================
// API v1 Routes with Token Authentication
// ============================================
// Create /api/v1 group
apiV1 := e.Group("/api/v1")
// Public endpoints - no token required
apiV1.POST("/token", func(c *echo.Context) error {
return s.tokenHandler.CreateTokenHandler(c)
})
apiV1.DELETE("/token", func(c *echo.Context) error {
return s.tokenHandler.DeleteTokenHandler(c)
})
apiV1.POST("/token/cleanup", func(c *echo.Context) error {
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
})
// Protected endpoints - require valid token
// Create token auth middleware with pool access
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
// Protected group with token authentication
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
// 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() routes := e.Router().Routes()
sort.Slice(routes, func(i, j int) bool { sort.Slice(routes, func(i, j int) bool {
+65 -21
View File
@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"time" "time"
"music-server/internal/backend"
"music-server/internal/db" "music-server/internal/db"
"music-server/internal/logging" "music-server/internal/logging"
"net/http" "net/http"
@@ -15,6 +16,10 @@ import (
type Server struct { type Server struct {
port int port int
db *db.Database
tokenHandler *TokenHandler
statisticsHandler *StatisticsHandler
httpServer *http.Server
} }
var ( var (
@@ -29,7 +34,9 @@ var (
logJSON = os.Getenv("LOG_JSON") == "true" logJSON = os.Getenv("LOG_JSON") == "true"
) )
func NewServer() *http.Server { // NewServerInstance creates a new Server instance with all dependencies initialized.
// Use this for dependency injection and proper lifecycle management.
func NewServerInstance() *Server {
// Initialize logger // Initialize logger
if logLevel == "" { if logLevel == "" {
logLevel = "info" logLevel = "info"
@@ -39,8 +46,47 @@ func NewServer() *http.Server {
logger := logging.GetLogger() logger := logging.GetLogger()
port, _ := strconv.Atoi(os.Getenv("PORT")) port, _ := strconv.Atoi(os.Getenv("PORT"))
NewServer := &Server{
// Validate required environment variables
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
}
// Create database instance
database, err := db.NewDatabase(host, dbPort, username, password, dbName)
if err != nil {
logging.GetLogger().Fatal("Failed to initialize database", zap.String("error", err.Error()))
}
// Run migrations using the new method
if err := database.RunMigrations(); err != nil {
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
}
// Initialize backend package with database pool
backend.InitBackend(database.Pool)
// Initialize token handler with database pool
tokenHandler := NewTokenHandler(database.Pool)
// Initialize statistics handler
statisticsHandler := NewStatisticsHandler()
// Create the server instance
appServer := &Server{
port: port, port: port,
db: database,
tokenHandler: tokenHandler,
statisticsHandler: statisticsHandler,
}
// Create the HTTP server
appServer.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: appServer.RegisterRoutes(),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
} }
logger.Info("Starting server", logger.Info("Starting server",
@@ -55,23 +101,21 @@ func NewServer() *http.Server {
zap.String("charactersPath", charactersPath), zap.String("charactersPath", charactersPath),
) )
//conf.SetupDb() return appServer
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" { }
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
} // HTTPServer returns the underlying http.Server for serving HTTP requests.
func (s *Server) HTTPServer() *http.Server {
db.Migrate_db(host, dbPort, username, password, dbName) return s.httpServer
}
db.InitDB(host, dbPort, username, password, dbName)
// DB returns the database instance for dependency injection.
// Declare Server config func (s *Server) DB() *db.Database {
server := &http.Server{ return s.db
Addr: fmt.Sprintf(":%d", NewServer.port), }
Handler: NewServer.RegisterRoutes(),
IdleTimeout: time.Minute, // NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
ReadTimeout: 10 * time.Second, // This function is kept for backward compatibility.
WriteTimeout: 30 * time.Second, func NewServer() *http.Server {
} return NewServerInstance().HTTPServer()
return server
} }
+275
View File
@@ -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)
}
+15 -15
View File
@@ -75,8 +75,8 @@ func TestSyncPopulatesDatabase(t *testing.T) {
db.TestClearDatabase(t) db.TestClearDatabase(t)
// Before sync - should have no games // Before sync - should have no games
repo := repository.New(db.Dbpool) repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllGames(db.Ctx) gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
beforeCount := len(gamesBefore) beforeCount := len(gamesBefore)
t.Logf("Games before sync: %d", beforeCount) t.Logf("Games before sync: %d", beforeCount)
@@ -92,7 +92,7 @@ func TestSyncPopulatesDatabase(t *testing.T) {
} }
// After sync - should have games // After sync - should have games
gamesAfter, err := repo.FindAllGames(db.Ctx) gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
afterCount := len(gamesAfter) afterCount := len(gamesAfter)
t.Logf("Games after sync: %d", afterCount) t.Logf("Games after sync: %d", afterCount)
@@ -112,8 +112,8 @@ func TestSyncMakesDifference(t *testing.T) {
db.TestClearDatabase(t) db.TestClearDatabase(t)
// Before sync - should have no games // Before sync - should have no games
repo := repository.New(db.Dbpool) repo := repository.New(backend.BackendPool())
gamesBefore, err := repo.FindAllGames(db.Ctx) gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync") assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync")
@@ -127,7 +127,7 @@ func TestSyncMakesDifference(t *testing.T) {
} }
// After sync - should have games // After sync - should have games
gamesAfter, err := repo.FindAllGames(db.Ctx) gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, len(gamesAfter) > 0, "Should have games after sync") assert.True(t, len(gamesAfter) > 0, "Should have games after sync")
} }
@@ -199,8 +199,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
} }
// Get initial count // Get initial count
repo := repository.New(db.Dbpool) repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllGames(db.Ctx) gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
beforeCount := len(gamesBefore) beforeCount := len(gamesBefore)
// Run incremental sync (should not change count if nothing changed) // Run incremental sync (should not change count if nothing changed)
@@ -211,7 +211,7 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
// Count should be the same // Count should be the same
gamesAfter, _ := repo.FindAllGames(db.Ctx) gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
afterCount := len(gamesAfter) afterCount := len(gamesAfter)
// Note: This might not be exactly equal due to timing, but should be close // Note: This might not be exactly equal due to timing, but should be close
@@ -227,8 +227,8 @@ func TestResetGames(t *testing.T) {
e := StartTestServer(t) e := StartTestServer(t)
// First ensure we have data // First ensure we have data
repo := repository.New(db.Dbpool) repo := repository.New(backend.BackendPool())
gamesBefore, _ := repo.FindAllGames(db.Ctx) gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
beforeCount := len(gamesBefore) beforeCount := len(gamesBefore)
if beforeCount == 0 { if beforeCount == 0 {
@@ -238,7 +238,7 @@ func TestResetGames(t *testing.T) {
t.Error("Sync did not complete within timeout") t.Error("Sync did not complete within timeout")
return return
} }
gamesBefore, _ = repo.FindAllGames(db.Ctx) gamesBefore, _ = repo.FindAllGames(backend.BackendCtx())
beforeCount = len(gamesBefore) beforeCount = len(gamesBefore)
} }
@@ -253,7 +253,7 @@ func TestResetGames(t *testing.T) {
// Note: reset might take a moment to propagate // Note: reset might take a moment to propagate
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
gamesAfter, _ := repo.FindAllGames(db.Ctx) gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
afterCount := len(gamesAfter) afterCount := len(gamesAfter)
t.Logf("Games after reset: %d", afterCount) t.Logf("Games after reset: %d", afterCount)
@@ -281,8 +281,8 @@ func TestSyncGamesNewFull(t *testing.T) {
} }
// Verify database is populated // Verify database is populated
repo := repository.New(db.Dbpool) repo := repository.New(backend.BackendPool())
games, err := repo.FindAllGames(db.Ctx) games, err := repo.FindAllGames(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, len(games) > 0, "Database should be populated after full sync") assert.True(t, len(games) > 0, "Database should be populated after full sync")
t.Logf("Full sync populated %d games", len(games)) t.Logf("Full sync populated %d games", len(games))
+16 -1
View File
@@ -8,6 +8,9 @@ import (
"testing" "testing"
"time" "time"
"music-server/internal/backend"
"music-server/internal/db"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
@@ -45,8 +48,20 @@ func StartTestServer(t *testing.T) *echo.Echo {
os.Setenv("LOG_JSON", "false") os.Setenv("LOG_JSON", "false")
} }
// Initialize database for tests
db.TestSetupDB(t)
// Initialize backend with test database pool
// This ensures BackendRepo() and BackendCtx() are available
if db.TestDatabase != nil && db.TestDatabase.Pool != nil {
backend.InitBackend(db.TestDatabase.Pool)
}
// Create a Server instance and get its routes // Create a Server instance and get its routes
s := &Server{} s := &Server{
db: db.TestDatabase,
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
}
handler := s.RegisterRoutes() handler := s.RegisterRoutes()
// Wrap the http.Handler in an echo.Echo // Wrap the http.Handler in an echo.Echo
+175
View File
@@ -0,0 +1,175 @@
package server
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// TokenRequest represents a request to generate a new token
type TokenRequest struct {
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
}
// TokenResponse represents the response with a new token
type TokenResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
ClientType string `json:"client_type"`
}
// TokenHandler contains the database pool for token operations
type TokenHandler struct {
pool *pgxpool.Pool
}
// NewTokenHandler creates a new token handler with database pool
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
return &TokenHandler{
pool: pool,
}
}
// generateToken creates a new cryptographically secure token
func (h *TokenHandler) generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// CreateTokenHandler creates a new session token
// POST /api/v1/token
//
// @Summary Create session token
// @Description Returns a new session token for API access
// @Tags auth
// @Accept json
// @Produce json
// @Param request body TokenRequest true "Client type"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [post]
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
var req TokenRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
}
if req.ClientType == "" {
req.ClientType = "web"
}
// Generate token
token, err := h.generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
}
// Set expiration (24 hours from now)
expiresAt := time.Now().Add(24 * time.Hour)
clientType := req.ClientType
// Store in database using sqlc-generated repository
queries := repository.New(h.pool)
session, err := queries.CreateSession(c.Request().Context(), repository.CreateSessionParams{
Token: token,
IpAddress: c.RealIP(),
UserAgent: c.Request().UserAgent(),
ClientType: &clientType,
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
}
response := TokenResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
ClientType: *session.ClientType,
}
return c.JSON(http.StatusOK, response)
}
// DeleteTokenHandler invalidates a session token
// DELETE /api/v1/token
//
// @Summary Invalidate session token
// @Description Deletes the current session token
// @Tags auth
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [delete]
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
}
token := parts[1]
// Delete session using sqlc-generated repository
queries := repository.New(h.pool)
err := queries.DeleteSession(c.Request().Context(), token)
if err != nil {
logging.GetLogger().Error("Failed to delete session", zap.String("error", err.Error()))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
}
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
}
// CleanupExpiredSessionsHandler removes all expired sessions
// POST /api/v1/token/cleanup
//
// @Summary Cleanup expired sessions
// @Description Removes all expired session tokens from the database
// @Tags auth
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token/cleanup [post]
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
// Verify token is valid first (using existing middleware)
// The middleware will have already validated the token
queries := repository.New(h.pool)
err := queries.DeleteExpiredSessions(c.Request().Context())
if err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.String("error", err.Error()))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
}
// Get count of deleted rows (DeleteExpiredSessions doesn't return count in the generated code)
// So we just return success
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "cleanup complete",
})
}
+2 -2
View File
@@ -15,8 +15,8 @@ import (
// ensureSyncRan ensures that sync has been run before testing music endpoints // ensureSyncRan ensures that sync has been run before testing music endpoints
func ensureSyncRan(t *testing.T, e *echo.Echo) { func ensureSyncRan(t *testing.T, e *echo.Echo) {
repo := repository.New(db.Dbpool) repo := repository.New(backend.BackendPool())
games, err := repo.FindAllGames(db.Ctx) games, err := repo.FindAllGames(backend.BackendCtx())
assert.NoError(t, err) assert.NoError(t, err)
if len(games) == 0 { if len(games) == 0 {