Compare commits
8 Commits
develop
...
07e9fd6c56
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e9fd6c56 | |||
| d459d796cf | |||
| 90d621c195 | |||
| c63202242b | |||
| 3418f492f5 | |||
| f4d1c3cf28 | |||
| 98c1948eff | |||
| 3e37303979 |
+14
-8
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server"
|
||||
"net/http"
|
||||
@@ -19,9 +18,11 @@ import (
|
||||
// @description This is a sample server Petstore server.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
//
|
||||
// @contact.name Sebastian Olsson
|
||||
// @contact.email zarnor91@gmail.com
|
||||
|
||||
//
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
|
||||
@@ -34,16 +35,17 @@ func main() {
|
||||
pprof.StartCPUProfile(f)
|
||||
defer pprof.StopCPUProfile()*/
|
||||
|
||||
server := server.NewServer()
|
||||
appServer := server.NewServerInstance()
|
||||
httpServer := appServer.HTTPServer()
|
||||
|
||||
// Create a done channel to signal when the shutdown is complete
|
||||
done := make(chan bool, 1)
|
||||
|
||||
// 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))
|
||||
err := server.ListenAndServe()
|
||||
logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr))
|
||||
err := httpServer.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
||||
}
|
||||
@@ -53,7 +55,7 @@ func main() {
|
||||
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.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -62,13 +64,17 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
||||
<-ctx.Done()
|
||||
|
||||
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 request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
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()))
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ func FindGameWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func search(searchText string) {
|
||||
games_added = nil
|
||||
games := backend.GetAllGames()
|
||||
games := backend.GetAllSoundtracks()
|
||||
for _, game := range games {
|
||||
if is_match_exact(searchText, game) {
|
||||
add_game(game)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+34
-33
@@ -2,7 +2,6 @@ package backend
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"os"
|
||||
@@ -23,23 +22,25 @@ type SongInfo struct {
|
||||
|
||||
var currentSong = -1
|
||||
|
||||
var gamesNew []repository.Game
|
||||
var gamesNew []repository.Soundtrack
|
||||
|
||||
var songQueNew []repository.Song
|
||||
|
||||
var lastFetchedNew repository.Song
|
||||
var repo *repository.Queries
|
||||
|
||||
func initRepo() {
|
||||
if repo == nil {
|
||||
repo = repository.New(db.Dbpool)
|
||||
// This function is kept for backward compatibility
|
||||
// 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.Soundtrack {
|
||||
if len(gamesNew) == 0 {
|
||||
initRepo()
|
||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
||||
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||
}
|
||||
return gamesNew
|
||||
|
||||
@@ -58,7 +59,7 @@ func Reset() {
|
||||
songQueNew = nil
|
||||
currentSong = -1
|
||||
initRepo()
|
||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
||||
gamesNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
|
||||
}
|
||||
|
||||
func AddLatestToQue() {
|
||||
@@ -76,8 +77,8 @@ func AddLatestPlayed() {
|
||||
currentSongData := songQueNew[currentSong]
|
||||
|
||||
initRepo()
|
||||
repo.AddGamePlayed(db.Ctx, currentSongData.GameID)
|
||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
||||
BackendRepo().AddSoundtrackPlayed(BackendCtx(), currentSongData.SoundtrackID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: currentSongData.SoundtrackID, SongName: currentSongData.SongName})
|
||||
}
|
||||
|
||||
func SetPlayed(songNumber int) {
|
||||
@@ -86,8 +87,8 @@ func SetPlayed(songNumber int) {
|
||||
}
|
||||
songData := songQueNew[songNumber]
|
||||
initRepo()
|
||||
repo.AddGamePlayed(db.Ctx, songData.GameID)
|
||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
||||
BackendRepo().AddSoundtrackPlayed(BackendCtx(), songData.SoundtrackID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: songData.SoundtrackID, SongName: songData.SongName})
|
||||
}
|
||||
|
||||
func GetRandomSong() string {
|
||||
@@ -104,7 +105,7 @@ func GetRandomSong() string {
|
||||
func GetRandomSongLowChance() string {
|
||||
getAllGames()
|
||||
|
||||
var listOfGames []repository.Game
|
||||
var listOfGames []repository.Soundtrack
|
||||
|
||||
var averagePlayed = getAveragePlayed()
|
||||
|
||||
@@ -130,7 +131,7 @@ func GetRandomSongClassic() string {
|
||||
|
||||
var listOfAllSongs []repository.Song
|
||||
for _, game := range gamesNew {
|
||||
songList, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
||||
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
|
||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||
}
|
||||
|
||||
@@ -138,13 +139,13 @@ func GetRandomSongClassic() string {
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
||||
gameData, err := repo.GetGameById(db.Ctx, song.GameID)
|
||||
gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
|
||||
|
||||
if err != nil {
|
||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("game", gameData.GameName),
|
||||
zap.String("game", gameData.SoundtrackName),
|
||||
zap.String("filename", *song.FileName))
|
||||
continue
|
||||
}
|
||||
@@ -153,10 +154,10 @@ func GetRandomSongClassic() string {
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) {
|
||||
//File not found
|
||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("game", gameData.GameName),
|
||||
zap.String("game", gameData.SoundtrackName),
|
||||
zap.String("filename", *song.FileName))
|
||||
} else {
|
||||
songFound = true
|
||||
@@ -179,7 +180,7 @@ func GetSongInfo() SongInfo {
|
||||
currentGameData := getCurrentGame(currentSongData)
|
||||
|
||||
return SongInfo{
|
||||
Game: currentGameData.GameName,
|
||||
Game: currentGameData.SoundtrackName,
|
||||
GamePlayed: currentGameData.TimesPlayed,
|
||||
Song: currentSongData.SongName,
|
||||
SongPlayed: currentSongData.TimesPlayed,
|
||||
@@ -194,7 +195,7 @@ func GetPlayedSongs() []SongInfo {
|
||||
for i, song := range songQueNew {
|
||||
gameData := getCurrentGame(song)
|
||||
songList = append(songList, SongInfo{
|
||||
Game: gameData.GameName,
|
||||
Game: gameData.SoundtrackName,
|
||||
GamePlayed: gameData.TimesPlayed,
|
||||
Song: song.SongName,
|
||||
SongPlayed: song.TimesPlayed,
|
||||
@@ -216,22 +217,22 @@ func GetSong(song string) string {
|
||||
return songData.Path
|
||||
}
|
||||
|
||||
func GetAllGames() []string {
|
||||
func GetAllSoundtracks() []string {
|
||||
getAllGames()
|
||||
|
||||
var jsonArray []string
|
||||
for _, game := range gamesNew {
|
||||
jsonArray = append(jsonArray, game.GameName)
|
||||
jsonArray = append(jsonArray, game.SoundtrackName)
|
||||
}
|
||||
return jsonArray
|
||||
}
|
||||
|
||||
func GetAllGamesRandom() []string {
|
||||
func GetAllSoundtracksRandom() []string {
|
||||
getAllGames()
|
||||
|
||||
var jsonArray []string
|
||||
for _, game := range gamesNew {
|
||||
jsonArray = append(jsonArray, game.GameName)
|
||||
jsonArray = append(jsonArray, game.SoundtrackName)
|
||||
}
|
||||
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
|
||||
return jsonArray
|
||||
@@ -265,12 +266,12 @@ func GetPreviousSong() string {
|
||||
}
|
||||
}
|
||||
|
||||
func getSongFromList(games []repository.Game) repository.Song {
|
||||
func getSongFromList(games []repository.Soundtrack) repository.Song {
|
||||
songFound := false
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
game := getRandomGame(games)
|
||||
songs, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
||||
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), game.ID)
|
||||
if len(songs) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -281,10 +282,10 @@ func getSongFromList(games []repository.Game) repository.Song {
|
||||
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")) {
|
||||
//File not found
|
||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("game", game.GameName),
|
||||
zap.String("game", game.SoundtrackName),
|
||||
zap.Any("filename", song.FileName))
|
||||
} else {
|
||||
songFound = true
|
||||
@@ -298,13 +299,13 @@ func getSongFromList(games []repository.Game) repository.Song {
|
||||
return song
|
||||
}
|
||||
|
||||
func getCurrentGame(currentSongData repository.Song) repository.Game {
|
||||
func getCurrentGame(currentSongData repository.Song) repository.Soundtrack {
|
||||
for _, game := range gamesNew {
|
||||
if game.ID == currentSongData.GameID {
|
||||
if game.ID == currentSongData.SoundtrackID {
|
||||
return game
|
||||
}
|
||||
}
|
||||
return repository.Game{}
|
||||
return repository.Soundtrack{}
|
||||
}
|
||||
|
||||
func getAveragePlayed() int32 {
|
||||
@@ -316,6 +317,6 @@ func getAveragePlayed() int32 {
|
||||
return sum / int32(len(gamesNew))
|
||||
}
|
||||
|
||||
func getRandomGame(listOfGames []repository.Game) repository.Game {
|
||||
func getRandomGame(listOfGames []repository.Soundtrack) repository.Soundtrack {
|
||||
return listOfGames[rand.Intn(len(listOfGames))]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
SoundtrackID int32 `json:"game_id"`
|
||||
SoundtrackName string `json:"game_name"`
|
||||
SoundtrackPlayed int32 `json:"game_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"game_last_played,omitempty"`
|
||||
Songs []SongInfoForStats `json:"songs"`
|
||||
}
|
||||
|
||||
// SongInfoForStats represents a song with game info for statistics
|
||||
type SongInfoForStats struct {
|
||||
SoundtrackID int32 `json:"game_id"`
|
||||
SoundtrackName 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{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
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{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
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{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
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{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
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{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: 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{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
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{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
SoundtrackName: row.SoundtrackName,
|
||||
SoundtrackPlayed: row.SoundtrackPlayed,
|
||||
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
|
||||
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.TotalSoundtracks),
|
||||
PlayedGames: int64(row.PlayedSoundtracks),
|
||||
NeverPlayedGames: int64(row.NeverPlayedSoundtracks),
|
||||
TotalGamePlays: int64(row.TotalSoundtrackPlays),
|
||||
AvgGamePlays: float64(row.AvgSoundtrackPlays),
|
||||
MaxGamePlays: int64(row.MaxSoundtrackPlays),
|
||||
MinGamePlays: int64(row.MinSoundtrackPlays),
|
||||
}, 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()))
|
||||
}
|
||||
}
|
||||
+57
-58
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"os"
|
||||
@@ -31,9 +30,9 @@ var start time.Time
|
||||
var totalTime time.Duration
|
||||
var timeSpent time.Duration
|
||||
|
||||
var allGames []repository.Game
|
||||
var gamesBeforeSync []repository.Game
|
||||
var gamesAfterSync []repository.Game
|
||||
var allGames []repository.Soundtrack
|
||||
var gamesBeforeSync []repository.Soundtrack
|
||||
var gamesAfterSync []repository.Soundtrack
|
||||
var gamesAdded []string
|
||||
var gamesReAdded []string
|
||||
var gamesChangedTitle map[string]string
|
||||
@@ -80,8 +79,8 @@ func (gs GameStatus) String() string {
|
||||
}
|
||||
|
||||
func ResetDB() {
|
||||
repo.ClearSongs(db.Ctx)
|
||||
repo.ClearGames(db.Ctx)
|
||||
repo.ClearSongs(BackendCtx())
|
||||
repo.ClearSoundtracks(BackendCtx())
|
||||
}
|
||||
|
||||
func SyncProgress() ProgressResponse {
|
||||
@@ -125,13 +124,13 @@ func SyncResult() SyncResponse {
|
||||
for _, beforeGame := range gamesBeforeSync {
|
||||
var found = false
|
||||
for _, afterGame := range gamesAfterSync {
|
||||
if beforeGame.GameName == afterGame.GameName {
|
||||
if beforeGame.SoundtrackName == afterGame.SoundtrackName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.GameName)
|
||||
gamesRemovedTemp = append(gamesRemovedTemp, beforeGame.SoundtrackName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,12 +169,12 @@ func SyncResult() SyncResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func SyncGamesNewFull() {
|
||||
func SyncSoundtracksNewFull() {
|
||||
syncGamesNew(true)
|
||||
Reset()
|
||||
}
|
||||
|
||||
func SyncGamesNewOnlyChanges() {
|
||||
func SyncSoundtracksNewOnlyChanges() {
|
||||
syncGamesNew(false)
|
||||
Reset()
|
||||
}
|
||||
@@ -206,14 +205,14 @@ func syncGamesNew(full bool) {
|
||||
catchedErrors = nil
|
||||
brokenSongs = nil
|
||||
|
||||
gamesBeforeSync, err = repo.FindAllGames(db.Ctx)
|
||||
handleError("FindAllGames Before", err, "")
|
||||
gamesBeforeSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||
handleError("FindAllSoundtracks Before", err, "")
|
||||
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
||||
|
||||
allGames, err = repo.GetAllGamesIncludingDeleted(db.Ctx)
|
||||
handleError("GetAllGamesIncludingDeleted", err, "")
|
||||
err = repo.SetGameDeletionDate(db.Ctx)
|
||||
handleError("SetGameDeletionDate", err, "")
|
||||
allGames, err = repo.GetAllSoundtracksIncludingDeleted(BackendCtx())
|
||||
handleError("GetAllSoundtracksIncludingDeleted", err, "")
|
||||
err = repo.SetSoundtrackDeletionDate(BackendCtx())
|
||||
handleError("SetSoundtrackDeletionDate", err, "")
|
||||
|
||||
directories, err := os.ReadDir(musicPath)
|
||||
if err != nil {
|
||||
@@ -237,8 +236,8 @@ func syncGamesNew(full bool) {
|
||||
syncWg.Wait()
|
||||
checkBrokenSongsNew()
|
||||
|
||||
gamesAfterSync, err = repo.FindAllGames(db.Ctx)
|
||||
handleError("FindAllGames After", err, "")
|
||||
gamesAfterSync, err = repo.FindAllSoundtracks(BackendCtx())
|
||||
handleError("FindAllSoundtracks After", err, "")
|
||||
|
||||
finished := time.Now()
|
||||
totalTime = finished.Sub(start)
|
||||
@@ -249,7 +248,7 @@ func syncGamesNew(full bool) {
|
||||
}
|
||||
|
||||
func checkBrokenSongsNew() {
|
||||
allSongs, err := repo.FetchAllSongs(db.Ctx)
|
||||
allSongs, err := repo.FetchAllSongs(BackendCtx())
|
||||
handleError("FetchAllSongs", err, "")
|
||||
var brokenWg sync.WaitGroup
|
||||
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
||||
@@ -263,7 +262,7 @@ func checkBrokenSongsNew() {
|
||||
})
|
||||
}
|
||||
brokenWg.Wait()
|
||||
err = repo.RemoveBrokenSongs(db.Ctx, brokenSongs)
|
||||
err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs)
|
||||
handleError("RemoveBrokenSongs", err, "")
|
||||
}
|
||||
|
||||
@@ -289,28 +288,28 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
dirHash := getHashForDir(gameDir)
|
||||
|
||||
var status GameStatus = NewGame
|
||||
var oldGame repository.Game
|
||||
var oldGame repository.Soundtrack
|
||||
var id int32 = -1
|
||||
|
||||
//fmt.Printf("Games before: %d\n", len(gamesBeforeSync))
|
||||
|
||||
for _, currentGame := range allGames {
|
||||
oldGame = currentGame
|
||||
//fmt.Printf("%s | %s\n", oldGame.GameName, oldGame.Hash)
|
||||
if oldGame.GameName == file.Name() && oldGame.Hash == dirHash {
|
||||
//fmt.Printf("%s | %s\n", oldGame.SoundtrackName, oldGame.Hash)
|
||||
if oldGame.SoundtrackName == file.Name() && oldGame.Hash == dirHash {
|
||||
status = NotChanged
|
||||
id = oldGame.ID
|
||||
//fmt.Printf("Game not changed\n")
|
||||
break
|
||||
} else if oldGame.GameName == file.Name() && oldGame.Hash != dirHash {
|
||||
} else if oldGame.SoundtrackName == file.Name() && oldGame.Hash != dirHash {
|
||||
status = GameChanged
|
||||
id = oldGame.ID
|
||||
//fmt.Printf("Game changed\n")
|
||||
break
|
||||
} else if oldGame.GameName != file.Name() && oldGame.Hash == dirHash {
|
||||
} else if oldGame.SoundtrackName != file.Name() && oldGame.Hash == dirHash {
|
||||
status = TitleChanged
|
||||
id = oldGame.ID
|
||||
//fmt.Printf("GameName changed\n")
|
||||
//fmt.Printf("SoundtrackName changed\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -336,8 +335,8 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
break
|
||||
}
|
||||
}
|
||||
err = repo.InsertGameWithExistingId(db.Ctx, repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
||||
handleError("InsertGameWithExistingId", err, "")
|
||||
err = repo.InsertSoundtrackWithExistingId(BackendCtx(), repository.InsertSoundtrackWithExistingIdParams{ID: id, SoundtrackName: file.Name(), Path: gameDir, Hash: dirHash})
|
||||
handleError("InsertSoundtrackWithExistingId", err, "")
|
||||
if err != nil {
|
||||
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
||||
zap.Int32("id", id),
|
||||
@@ -370,24 +369,24 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
zap.String("game", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateGameHash(db.Ctx, repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
||||
handleError("UpdateGameHash", err, "")
|
||||
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
|
||||
handleError("UpdateSoundtrackHash", err, "")
|
||||
gamesChangedContent = append(gamesChangedContent, file.Name())
|
||||
newCheckSongs(entries, gameDir, id)
|
||||
case TitleChanged:
|
||||
logging.GetLogger().Debug("Game title changed",
|
||||
zap.Int32("id", id),
|
||||
zap.String("oldName", oldGame.GameName),
|
||||
zap.String("oldName", oldGame.SoundtrackName),
|
||||
zap.String("newName", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateGameName(db.Ctx, repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
||||
handleError("UpdateGameName", err, "")
|
||||
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
||||
handleError("UpdateSoundtrackName", err, "")
|
||||
newCheckSongs(entries, gameDir, id)
|
||||
if gamesChangedTitle == nil {
|
||||
gamesChangedTitle = make(map[string]string)
|
||||
}
|
||||
gamesChangedTitle[oldGame.GameName] = file.Name()
|
||||
gamesChangedTitle[oldGame.SoundtrackName] = file.Name()
|
||||
case NotChanged:
|
||||
var found bool = false
|
||||
for _, beforeGame := range gamesBeforeSync {
|
||||
@@ -416,8 +415,8 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
zap.String("game", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.RemoveDeletionDate(db.Ctx, id)
|
||||
handleError("RemoveDeletionDate", err, "")
|
||||
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
|
||||
handleError("RemoveSoundtrackDeletionDate", err, "")
|
||||
}
|
||||
foldersSynced++
|
||||
logging.GetLogger().Debug("Sync progress",
|
||||
@@ -428,14 +427,14 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
|
||||
func insertGameNew(name string, path string, hash string) int32 {
|
||||
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
||||
id, err := repo.InsertGame(db.Ctx, repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
|
||||
handleError("InsertGame", err, "")
|
||||
id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash})
|
||||
handleError("InsertSoundtrack", err, "")
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||
logging.GetLogger().Debug("Resetting game ID sequence")
|
||||
_, err = repo.ResetGameIdSeq(db.Ctx)
|
||||
handleError("ResetGameIdSeq", err, "")
|
||||
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
|
||||
handleError("ResetSoundtrackIdSeq", err, "")
|
||||
id = insertGameNew(name, path, hash)
|
||||
}
|
||||
}
|
||||
@@ -478,8 +477,8 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
||||
fileName := entry.Name()
|
||||
songName, _ := strings.CutSuffix(fileName, ".mp3")
|
||||
|
||||
song, err := repo.GetSongWithHash(db.Ctx, songHash)
|
||||
handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
song, err := repo.GetSongWithHash(BackendCtx(), songHash)
|
||||
handleError("GetSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
if err == nil {
|
||||
if song.SongName == songName && song.Path == path {
|
||||
return false
|
||||
@@ -491,32 +490,32 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
||||
zap.String("song_name", songName),
|
||||
zap.String("song_hash", songHash))
|
||||
|
||||
count, err := repo.CheckSongWithHash(db.Ctx, songHash)
|
||||
handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
count, err := repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||
handleError("CheckSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
if err != nil {
|
||||
count2, err := repo.CheckSong(db.Ctx, path)
|
||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
count2, err := repo.CheckSong(BackendCtx(), path)
|
||||
handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
if count2 > 0 {
|
||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
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)
|
||||
handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
count, err = repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||
handleError("CheckSongWithHash 2", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
}
|
||||
}
|
||||
|
||||
//count, _ := repo.CheckSong(ctx, path)
|
||||
if count > 0 {
|
||||
err = repo.UpdateSong(db.Ctx, 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))
|
||||
err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
||||
handleError("UpdateSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
} else {
|
||||
count2, err := repo.CheckSong(db.Ctx, path)
|
||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
count2, err := repo.CheckSong(BackendCtx(), path)
|
||||
handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
if count2 > 0 {
|
||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
} else {
|
||||
err = repo.AddSong(db.Ctx, 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))
|
||||
err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
||||
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -20,6 +20,7 @@ import (
|
||||
"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 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()))
|
||||
//}
|
||||
|
||||
err = m.Migrate(2)
|
||||
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
||||
// Use Up() to apply all pending migrations instead of Migrate(2)
|
||||
err = m.Up()
|
||||
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()))
|
||||
}
|
||||
|
||||
} else {
|
||||
versionAfter, _, err := m.Version()
|
||||
if err != nil {
|
||||
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")
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMigrationsStepByStep tests applying migrations incrementally
|
||||
// Then adding data manually, then completing migrations
|
||||
func TestMigrationsStepByStep(t *testing.T) {
|
||||
host := "localhost"
|
||||
port := "5432"
|
||||
user := "postgres"
|
||||
password := "postgres"
|
||||
// Use a unique database name for this test
|
||||
dbname := "music_server_migration_test"
|
||||
|
||||
// Clean up: drop database if it exists
|
||||
cleanupDB(t, host, port, user, password, dbname)
|
||||
defer cleanupDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Create the database
|
||||
createTestDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Step 1: Apply first 4 migrations (before soundtrack rename)
|
||||
// This creates: game, song, vgmq, song_list tables
|
||||
// And sessions table with indexes
|
||||
t.Run("ApplyFirst4Migrations", func(t *testing.T) {
|
||||
applyMigrations(t, host, port, user, password, dbname, 4)
|
||||
})
|
||||
|
||||
// Step 2: Add data manually to game and song tables
|
||||
t.Run("AddManualData", func(t *testing.T) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Insert 5 games manually
|
||||
for i := 1; i <= 5; i++ {
|
||||
gameName := fmt.Sprintf("Manual Game %d", i)
|
||||
path := fmt.Sprintf("/manual/path/game%d", i)
|
||||
hash := fmt.Sprintf("hash-%d", i)
|
||||
|
||||
_, err := db.Exec(`INSERT INTO game (game_name, path, hash, added)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
gameName, path, hash)
|
||||
require.NoError(t, err, "Failed to insert game %d", i)
|
||||
}
|
||||
|
||||
// Insert songs for each game
|
||||
songs := []struct {
|
||||
gameID int
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{1, "Song A", "/path/a.mp3"},
|
||||
{1, "Song B", "/path/b.mp3"},
|
||||
{2, "Song C", "/path/c.mp3"},
|
||||
{2, "Song D", "/path/d.mp3"},
|
||||
{3, "Song E", "/path/e.mp3"},
|
||||
{4, "Song F", "/path/f.mp3"},
|
||||
{4, "Song G", "/path/g.mp3"},
|
||||
{4, "Song H", "/path/h.mp3"},
|
||||
{5, "Song I", "/path/i.mp3"},
|
||||
}
|
||||
|
||||
for _, s := range songs {
|
||||
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path)
|
||||
VALUES ($1, $2, $3)`,
|
||||
s.gameID, s.name, s.path)
|
||||
require.NoError(t, err, "Failed to insert song %s", s.name)
|
||||
}
|
||||
|
||||
// Verify data was inserted
|
||||
var gameCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM game").Scan(&gameCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, gameCount, "Expected 5 games")
|
||||
|
||||
var songCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 8, songCount, "Expected 8 songs")
|
||||
|
||||
t.Log("✓ Manually inserted 5 games with 8 songs")
|
||||
})
|
||||
|
||||
// Step 3: Apply migration 5 (rename game→soundtrack)
|
||||
t.Run("ApplyMigration5", func(t *testing.T) {
|
||||
// Apply the remaining migrations (just migration 5)
|
||||
applyMigrations(t, host, port, user, password, dbname, 1)
|
||||
|
||||
// Verify tables were renamed
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Check that soundtrack table exists
|
||||
var soundtrackCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, soundtrackCount, "Expected 5 soundtracks after migration")
|
||||
|
||||
// Check that game table no longer exists
|
||||
_, err = db.Exec("SELECT 1 FROM game LIMIT 1")
|
||||
require.Error(t, err, "game table should not exist after migration")
|
||||
|
||||
// Check that song table has soundtrack_id column
|
||||
var songCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 8, songCount, "Expected 8 songs after migration")
|
||||
|
||||
// Verify data integrity: soundtrack_name values
|
||||
rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id")
|
||||
require.NoError(t, err)
|
||||
defer rows.Close()
|
||||
|
||||
expectedNames := []string{"Manual Game 1", "Manual Game 2", "Manual Game 3", "Manual Game 4", "Manual Game 5"}
|
||||
actualNames := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
err := rows.Scan(&name)
|
||||
require.NoError(t, err)
|
||||
actualNames = append(actualNames, name)
|
||||
}
|
||||
require.Equal(t, expectedNames, actualNames, "Soundtrack names should match original game names")
|
||||
|
||||
t.Log("✓ Migration 5 applied successfully, data preserved")
|
||||
})
|
||||
}
|
||||
|
||||
// cleanupDB drops the test database
|
||||
func cleanupDB(t *testing.T, host, port, user, password, dbname string) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||
host, port, user, password)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not connect to cleanup DB: %v", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check if database exists before dropping
|
||||
var exists int
|
||||
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Logf("Warning: could not check if DB exists: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists == 1 {
|
||||
_, err = db.Exec("DROP DATABASE " + dbname + " WITH (FORCE)")
|
||||
if err != nil {
|
||||
t.Logf("Warning: could not drop DB: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createTestDB creates a fresh test database
|
||||
func createTestDB(t *testing.T, host, port, user, password, dbname string) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||
host, port, user, password)
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Drop if exists
|
||||
cleanupDB(t, host, port, user, password, dbname)
|
||||
|
||||
// Create database
|
||||
_, err = db.Exec("CREATE DATABASE " + dbname)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Enable UUID extension if needed
|
||||
connStrDB := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
db2, err := sql.Open("postgres", connStrDB)
|
||||
require.NoError(t, err)
|
||||
defer db2.Close()
|
||||
|
||||
_, err = db2.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
|
||||
if err != nil {
|
||||
t.Logf("Note: uuid-ossp extension may not be available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyMigrations applies n migrations to the database
|
||||
func applyMigrations(t *testing.T, host, port, user, password, dbname string, steps int) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// This is a simplified version - in a real test you'd use the migrate library
|
||||
// For now, we'll just log that migrations should be applied
|
||||
t.Logf("Note: To fully test migrations, configure test DB and use migrate library")
|
||||
t.Logf("Would apply %d migration(s) to database: %s", steps, dbname)
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Revert: Rename soundtrack table back to game
|
||||
ALTER TABLE soundtrack RENAME TO game;
|
||||
|
||||
-- Revert primary key sequence
|
||||
ALTER SEQUENCE soundtrack_id_seq RENAME TO game_id_seq;
|
||||
|
||||
-- Revert columns in game table
|
||||
ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name;
|
||||
|
||||
-- Revert song table: rename soundtrack_id back to game_id
|
||||
ALTER TABLE song RENAME COLUMN soundtrack_id TO game_id;
|
||||
|
||||
-- Revert song primary key
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
|
||||
ALTER TABLE song ADD PRIMARY KEY (game_id, path);
|
||||
ALTER TABLE song RENAME CONSTRAINT song_pkey_soundtrack TO song_pkey;
|
||||
|
||||
-- Revert song_list table references
|
||||
ALTER TABLE song_list RENAME COLUMN soundtrack_name TO game_name;
|
||||
|
||||
-- Revert foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
||||
ALTER TABLE song ADD CONSTRAINT song_game_id_fkey
|
||||
FOREIGN KEY (game_id) REFERENCES game(id);
|
||||
|
||||
-- Revert indexes
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_deleted RENAME TO idx_game_deleted;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_hash RENAME TO idx_game_hash;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_path RENAME TO idx_game_path;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_name RENAME TO idx_game_name;
|
||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id RENAME TO idx_song_game_id;
|
||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id_song_name RENAME TO idx_song_game_id_song_name;
|
||||
ALTER INDEX IF EXISTS song_list_soundtrack_name_idx RENAME TO song_list_game_name_idx;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Rename game table to soundtrack
|
||||
ALTER TABLE game RENAME TO soundtrack;
|
||||
|
||||
-- Rename primary key sequence
|
||||
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
||||
|
||||
-- Rename columns in soundtrack table
|
||||
ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name;
|
||||
|
||||
-- Update song table: rename game_id to soundtrack_id
|
||||
ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id;
|
||||
|
||||
-- Update song primary key
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
|
||||
ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path);
|
||||
ALTER TABLE song RENAME CONSTRAINT song_pkey TO song_pkey_soundtrack;
|
||||
|
||||
-- Update song_list table references
|
||||
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
|
||||
|
||||
-- Rename foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
||||
|
||||
-- Rename indexes
|
||||
ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted;
|
||||
ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash;
|
||||
ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path;
|
||||
ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name;
|
||||
ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id;
|
||||
ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name;
|
||||
ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx;
|
||||
@@ -1,49 +0,0 @@
|
||||
-- name: ResetGameIdSeq :one
|
||||
SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1);
|
||||
|
||||
-- name: GetGameNameById :one
|
||||
SELECT game_name FROM game WHERE id = $1;
|
||||
|
||||
-- name: GetGameById :one
|
||||
SELECT *
|
||||
FROM game
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL;
|
||||
|
||||
-- name: SetGameDeletionDate :exec
|
||||
UPDATE game SET deleted=now() WHERE deleted IS NULL;
|
||||
|
||||
-- name: ClearGames :exec
|
||||
DELETE FROM game;
|
||||
|
||||
-- name: UpdateGameName :exec
|
||||
UPDATE game SET game_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: UpdateGameHash :exec
|
||||
UPDATE game SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: RemoveDeletionDate :exec
|
||||
UPDATE game SET deleted=NULL WHERE id=$1;
|
||||
|
||||
-- name: GetIdByGameName :one
|
||||
SELECT id FROM game WHERE game_name = $1;
|
||||
|
||||
-- name: InsertGame :one
|
||||
INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
|
||||
|
||||
-- name: InsertGameWithExistingId :exec
|
||||
INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
||||
|
||||
-- name: FindAllGames :many
|
||||
SELECT *
|
||||
FROM game
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY game_name;
|
||||
|
||||
-- name: GetAllGamesIncludingDeleted :many
|
||||
SELECT *
|
||||
FROM game
|
||||
ORDER BY game_name;
|
||||
|
||||
-- name: AddGamePlayed :exec
|
||||
UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
||||
@@ -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;
|
||||
@@ -1,11 +1,11 @@
|
||||
-- name: ClearSongs :exec
|
||||
DELETE FROM song;
|
||||
|
||||
-- name: ClearSongsByGameId :exec
|
||||
DELETE FROM song WHERE game_id = $1;
|
||||
-- name: ClearSongsBySoundtrackId :exec
|
||||
DELETE FROM song WHERE soundtrack_id = $1;
|
||||
|
||||
-- name: AddSong :exec
|
||||
INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
|
||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: CheckSong :one
|
||||
SELECT COUNT(*) FROM song WHERE path = $1;
|
||||
@@ -22,14 +22,14 @@ UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4;
|
||||
-- name: AddHashToSong :exec
|
||||
UPDATE song SET hash=$1 where path=$2;
|
||||
|
||||
-- name: FindSongsFromGame :many
|
||||
-- name: FindSongsFromSoundtrack :many
|
||||
SELECT *
|
||||
FROM song
|
||||
WHERE game_id = $1;
|
||||
WHERE soundtrack_id = $1;
|
||||
|
||||
-- name: AddSongPlayed :exec
|
||||
UPDATE song SET times_played = times_played + 1
|
||||
WHERE game_id = $1 AND song_name = $2;
|
||||
WHERE soundtrack_id = $1 AND song_name = $2;
|
||||
|
||||
-- name: FetchAllSongs :many
|
||||
SELECT * FROM song;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- name: InsertSongInList :exec
|
||||
INSERT INTO song_list (match_date, match_id, song_no, game_name, song_name)
|
||||
INSERT INTO song_list (match_date, match_id, song_no, soundtrack_name, song_name)
|
||||
VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: GetSongList :many
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- name: ResetSoundtrackIdSeq :one
|
||||
SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);
|
||||
|
||||
-- name: GetSoundtrackNameById :one
|
||||
SELECT soundtrack_name FROM soundtrack WHERE id = $1;
|
||||
|
||||
-- name: GetSoundtrackById :one
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL;
|
||||
|
||||
-- name: SetSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL;
|
||||
|
||||
-- name: ClearSoundtracks :exec
|
||||
DELETE FROM soundtrack;
|
||||
|
||||
-- name: UpdateSoundtrackName :exec
|
||||
UPDATE soundtrack SET soundtrack_name=sqlc.arg(name), path=sqlc.arg(path), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: UpdateSoundtrackHash :exec
|
||||
UPDATE soundtrack SET hash=sqlc.arg(hash), last_changed=now() WHERE id=sqlc.arg(id);
|
||||
|
||||
-- name: RemoveSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=NULL WHERE id=$1;
|
||||
|
||||
-- name: GetIdBySoundtrackName :one
|
||||
SELECT id FROM soundtrack WHERE soundtrack_name = $1;
|
||||
|
||||
-- name: InsertSoundtrack :one
|
||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
|
||||
|
||||
-- name: InsertSoundtrackWithExistingId :exec
|
||||
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
||||
|
||||
-- name: FindAllSoundtracks :many
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY soundtrack_name;
|
||||
|
||||
-- name: GetAllSoundtracksIncludingDeleted :many
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
ORDER BY soundtrack_name;
|
||||
|
||||
-- name: AddSoundtrackPlayed :exec
|
||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Most played soundtracks with their songs
|
||||
-- name: GetMostPlayedGamesWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_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 soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played DESC, g.soundtrack_name
|
||||
LIMIT $1;
|
||||
|
||||
-- Least played soundtracks with their songs
|
||||
-- name: GetLeastPlayedGamesWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_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 soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played ASC, g.soundtrack_name
|
||||
LIMIT $1;
|
||||
|
||||
-- Most played songs with their soundtrack info
|
||||
-- name: GetMostPlayedSongsWithGame :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
s.times_played,
|
||||
s.file_name
|
||||
FROM song s
|
||||
JOIN soundtrack g ON s.soundtrack_id = g.id
|
||||
WHERE g.deleted IS NULL
|
||||
ORDER BY s.times_played DESC, s.song_name
|
||||
LIMIT $1;
|
||||
|
||||
-- Least played songs with their soundtrack info
|
||||
-- name: GetLeastPlayedSongsWithGame :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
s.times_played,
|
||||
s.file_name
|
||||
FROM song s
|
||||
JOIN soundtrack g ON s.soundtrack_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 soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.added,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.times_played = 0
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
|
||||
ORDER BY g.soundtrack_name;
|
||||
|
||||
-- Last played soundtracks (most recently played)
|
||||
-- name: GetLastPlayedGames :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.last_played DESC
|
||||
LIMIT $1;
|
||||
|
||||
-- Oldest played soundtracks (least recently played, but has been played at least once)
|
||||
-- name: GetOldestPlayedGames :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||
GROUP BY g.id, g.soundtrack_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_soundtracks,
|
||||
SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks,
|
||||
SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_soundtracks,
|
||||
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
|
||||
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
|
||||
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
|
||||
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL;
|
||||
@@ -1,246 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: game.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const addGamePlayed = `-- name: AddGamePlayed :exec
|
||||
UPDATE game SET times_played = times_played + 1, last_played = now() WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AddGamePlayed(ctx context.Context, id int32) error {
|
||||
_, err := q.db.Exec(ctx, addGamePlayed, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const clearGames = `-- name: ClearGames :exec
|
||||
DELETE FROM game
|
||||
`
|
||||
|
||||
func (q *Queries) ClearGames(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, clearGames)
|
||||
return err
|
||||
}
|
||||
|
||||
const findAllGames = `-- name: FindAllGames :many
|
||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM game
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY game_name
|
||||
`
|
||||
|
||||
func (q *Queries) FindAllGames(ctx context.Context) ([]Game, error) {
|
||||
rows, err := q.db.Query(ctx, findAllGames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.GameName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAllGamesIncludingDeleted = `-- name: GetAllGamesIncludingDeleted :many
|
||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM game
|
||||
ORDER BY game_name
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllGamesIncludingDeleted(ctx context.Context) ([]Game, error) {
|
||||
rows, err := q.db.Query(ctx, getAllGamesIncludingDeleted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Game
|
||||
for rows.Next() {
|
||||
var i Game
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.GameName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getGameById = `-- name: GetGameById :one
|
||||
SELECT id, game_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM game
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetGameById(ctx context.Context, id int32) (Game, error) {
|
||||
row := q.db.QueryRow(ctx, getGameById, id)
|
||||
var i Game
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.GameName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getGameNameById = `-- name: GetGameNameById :one
|
||||
SELECT game_name FROM game WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetGameNameById(ctx context.Context, id int32) (string, error) {
|
||||
row := q.db.QueryRow(ctx, getGameNameById, id)
|
||||
var game_name string
|
||||
err := row.Scan(&game_name)
|
||||
return game_name, err
|
||||
}
|
||||
|
||||
const getIdByGameName = `-- name: GetIdByGameName :one
|
||||
SELECT id FROM game WHERE game_name = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetIdByGameName(ctx context.Context, gameName string) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, getIdByGameName, gameName)
|
||||
var id int32
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const insertGame = `-- name: InsertGame :one
|
||||
INSERT INTO game (game_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
|
||||
`
|
||||
|
||||
type InsertGameParams struct {
|
||||
GameName string `json:"game_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertGame(ctx context.Context, arg InsertGameParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, insertGame, arg.GameName, arg.Path, arg.Hash)
|
||||
var id int32
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const insertGameWithExistingId = `-- name: InsertGameWithExistingId :exec
|
||||
INSERT INTO game (id, game_name, path, hash, added) VALUES ($1, $2, $3, $4, now())
|
||||
`
|
||||
|
||||
type InsertGameWithExistingIdParams struct {
|
||||
ID int32 `json:"id"`
|
||||
GameName string `json:"game_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertGameWithExistingId(ctx context.Context, arg InsertGameWithExistingIdParams) error {
|
||||
_, err := q.db.Exec(ctx, insertGameWithExistingId,
|
||||
arg.ID,
|
||||
arg.GameName,
|
||||
arg.Path,
|
||||
arg.Hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const removeDeletionDate = `-- name: RemoveDeletionDate :exec
|
||||
UPDATE game SET deleted=NULL WHERE id=$1
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveDeletionDate(ctx context.Context, id int32) error {
|
||||
_, err := q.db.Exec(ctx, removeDeletionDate, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const resetGameIdSeq = `-- name: ResetGameIdSeq :one
|
||||
SELECT setval('game_id_seq', (SELECT MAX(id) FROM game)+1)
|
||||
`
|
||||
|
||||
func (q *Queries) ResetGameIdSeq(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, resetGameIdSeq)
|
||||
var setval int64
|
||||
err := row.Scan(&setval)
|
||||
return setval, err
|
||||
}
|
||||
|
||||
const setGameDeletionDate = `-- name: SetGameDeletionDate :exec
|
||||
UPDATE game SET deleted=now() WHERE deleted IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) SetGameDeletionDate(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, setGameDeletionDate)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateGameHash = `-- name: UpdateGameHash :exec
|
||||
UPDATE game SET hash=$1, last_changed=now() WHERE id=$2
|
||||
`
|
||||
|
||||
type UpdateGameHashParams struct {
|
||||
Hash string `json:"hash"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateGameHash(ctx context.Context, arg UpdateGameHashParams) error {
|
||||
_, err := q.db.Exec(ctx, updateGameHash, arg.Hash, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateGameName = `-- name: UpdateGameName :exec
|
||||
UPDATE game SET game_name=$1, path=$2, last_changed=now() WHERE id=$3
|
||||
`
|
||||
|
||||
type UpdateGameNameParams struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateGameName(ctx context.Context, arg UpdateGameNameParams) error {
|
||||
_, err := q.db.Exec(ctx, updateGameName, arg.Name, arg.Path, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -6,23 +6,21 @@ package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID int32 `json:"id"`
|
||||
GameName string `json:"game_name"`
|
||||
Added time.Time `json:"added"`
|
||||
Deleted *time.Time `json:"deleted"`
|
||||
LastChanged *time.Time `json:"last_changed"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
LastPlayed *time.Time `json:"last_played"`
|
||||
NumberOfSongs int32 `json:"number_of_songs"`
|
||||
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 {
|
||||
GameID int32 `json:"game_id"`
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
@@ -34,10 +32,23 @@ type SongList struct {
|
||||
MatchDate time.Time `json:"match_date"`
|
||||
MatchID int32 `json:"match_id"`
|
||||
SongNo int32 `json:"song_no"`
|
||||
GameName *string `json:"game_name"`
|
||||
SoundtrackName *string `json:"soundtrack_name"`
|
||||
SongName *string `json:"song_name"`
|
||||
}
|
||||
|
||||
type Soundtrack struct {
|
||||
ID int32 `json:"id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
Added time.Time `json:"added"`
|
||||
Deleted *time.Time `json:"deleted"`
|
||||
LastChanged *time.Time `json:"last_changed"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
LastPlayed *time.Time `json:"last_played"`
|
||||
NumberOfSongs int32 `json:"number_of_songs"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Vgmq struct {
|
||||
SongNo int32 `json:"song_no"`
|
||||
Path *string `json:"path"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,11 +24,11 @@ func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) er
|
||||
}
|
||||
|
||||
const addSong = `-- name: AddSong :exec
|
||||
INSERT INTO song(game_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
type AddSongParams struct {
|
||||
GameID int32 `json:"game_id"`
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
FileName *string `json:"file_name"`
|
||||
@@ -37,7 +37,7 @@ type AddSongParams struct {
|
||||
|
||||
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
||||
_, err := q.db.Exec(ctx, addSong,
|
||||
arg.GameID,
|
||||
arg.SoundtrackID,
|
||||
arg.SongName,
|
||||
arg.Path,
|
||||
arg.FileName,
|
||||
@@ -48,16 +48,16 @@ func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
||||
|
||||
const addSongPlayed = `-- name: AddSongPlayed :exec
|
||||
UPDATE song SET times_played = times_played + 1
|
||||
WHERE game_id = $1 AND song_name = $2
|
||||
WHERE soundtrack_id = $1 AND song_name = $2
|
||||
`
|
||||
|
||||
type AddSongPlayedParams struct {
|
||||
GameID int32 `json:"game_id"`
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SongName string `json:"song_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error {
|
||||
_, err := q.db.Exec(ctx, addSongPlayed, arg.GameID, arg.SongName)
|
||||
_, err := q.db.Exec(ctx, addSongPlayed, arg.SoundtrackID, arg.SongName)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -92,17 +92,17 @@ func (q *Queries) ClearSongs(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const clearSongsByGameId = `-- name: ClearSongsByGameId :exec
|
||||
DELETE FROM song WHERE game_id = $1
|
||||
const clearSongsBySoundtrackId = `-- name: ClearSongsBySoundtrackId :exec
|
||||
DELETE FROM song WHERE soundtrack_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) ClearSongsByGameId(ctx context.Context, gameID int32) error {
|
||||
_, err := q.db.Exec(ctx, clearSongsByGameId, gameID)
|
||||
func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error {
|
||||
_, err := q.db.Exec(ctx, clearSongsBySoundtrackId, soundtrackID)
|
||||
return err
|
||||
}
|
||||
|
||||
const fetchAllSongs = `-- name: FetchAllSongs :many
|
||||
SELECT game_id, song_name, path, times_played, hash, file_name FROM song
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name FROM song
|
||||
`
|
||||
|
||||
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||
@@ -115,7 +115,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||
for rows.Next() {
|
||||
var i Song
|
||||
if err := rows.Scan(
|
||||
&i.GameID,
|
||||
&i.SoundtrackID,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
@@ -132,14 +132,14 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const findSongsFromGame = `-- name: FindSongsFromGame :many
|
||||
SELECT game_id, song_name, path, times_played, hash, file_name
|
||||
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name
|
||||
FROM song
|
||||
WHERE game_id = $1
|
||||
WHERE soundtrack_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song, error) {
|
||||
rows, err := q.db.Query(ctx, findSongsFromGame, gameID)
|
||||
func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error) {
|
||||
rows, err := q.db.Query(ctx, findSongsFromSoundtrack, soundtrackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song,
|
||||
for rows.Next() {
|
||||
var i Song
|
||||
if err := rows.Scan(
|
||||
&i.GameID,
|
||||
&i.SoundtrackID,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
@@ -166,14 +166,14 @@ func (q *Queries) FindSongsFromGame(ctx context.Context, gameID int32) ([]Song,
|
||||
}
|
||||
|
||||
const getSongWithHash = `-- name: GetSongWithHash :one
|
||||
SELECT game_id, song_name, path, times_played, hash, file_name FROM song WHERE hash = $1
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name FROM song WHERE hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
|
||||
row := q.db.QueryRow(ctx, getSongWithHash, hash)
|
||||
var i Song
|
||||
err := row.Scan(
|
||||
&i.GameID,
|
||||
&i.SoundtrackID,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
const getSongList = `-- name: GetSongList :many
|
||||
SELECT match_date, match_id, song_no, game_name, song_name
|
||||
SELECT match_date, match_id, song_no, soundtrack_name, song_name
|
||||
FROM song_list
|
||||
WHERE match_date = $1
|
||||
ORDER BY song_no DESC
|
||||
@@ -30,7 +30,7 @@ func (q *Queries) GetSongList(ctx context.Context, matchDate time.Time) ([]SongL
|
||||
&i.MatchDate,
|
||||
&i.MatchID,
|
||||
&i.SongNo,
|
||||
&i.GameName,
|
||||
&i.SoundtrackName,
|
||||
&i.SongName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -44,7 +44,7 @@ func (q *Queries) GetSongList(ctx context.Context, matchDate time.Time) ([]SongL
|
||||
}
|
||||
|
||||
const insertSongInList = `-- name: InsertSongInList :exec
|
||||
INSERT INTO song_list (match_date, match_id, song_no, game_name, song_name)
|
||||
INSERT INTO song_list (match_date, match_id, song_no, soundtrack_name, song_name)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
@@ -52,7 +52,7 @@ type InsertSongInListParams struct {
|
||||
MatchDate time.Time `json:"match_date"`
|
||||
MatchID int32 `json:"match_id"`
|
||||
SongNo int32 `json:"song_no"`
|
||||
GameName *string `json:"game_name"`
|
||||
SoundtrackName *string `json:"soundtrack_name"`
|
||||
SongName *string `json:"song_name"`
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (q *Queries) InsertSongInList(ctx context.Context, arg InsertSongInListPara
|
||||
arg.MatchDate,
|
||||
arg.MatchID,
|
||||
arg.SongNo,
|
||||
arg.GameName,
|
||||
arg.SoundtrackName,
|
||||
arg.SongName,
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: soundtrack.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AddSoundtrackPlayed(ctx context.Context, id int32) error {
|
||||
_, err := q.db.Exec(ctx, addSoundtrackPlayed, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const clearSoundtracks = `-- name: ClearSoundtracks :exec
|
||||
DELETE FROM soundtrack
|
||||
`
|
||||
|
||||
func (q *Queries) ClearSoundtracks(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, clearSoundtracks)
|
||||
return err
|
||||
}
|
||||
|
||||
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY soundtrack_name
|
||||
`
|
||||
|
||||
func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) {
|
||||
rows, err := q.db.Query(ctx, findAllSoundtracks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Soundtrack
|
||||
for rows.Next() {
|
||||
var i Soundtrack
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SoundtrackName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM soundtrack
|
||||
ORDER BY soundtrack_name
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error) {
|
||||
rows, err := q.db.Query(ctx, getAllSoundtracksIncludingDeleted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Soundtrack
|
||||
for rows.Next() {
|
||||
var i Soundtrack
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SoundtrackName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getIdBySoundtrackName = `-- name: GetIdBySoundtrackName :one
|
||||
SELECT id FROM soundtrack WHERE soundtrack_name = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, getIdBySoundtrackName, soundtrackName)
|
||||
var id int32
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const getSoundtrackById = `-- name: GetSoundtrackById :one
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash
|
||||
FROM soundtrack
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, error) {
|
||||
row := q.db.QueryRow(ctx, getSoundtrackById, id)
|
||||
var i Soundtrack
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SoundtrackName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSoundtrackNameById = `-- name: GetSoundtrackNameById :one
|
||||
SELECT soundtrack_name FROM soundtrack WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string, error) {
|
||||
row := q.db.QueryRow(ctx, getSoundtrackNameById, id)
|
||||
var soundtrack_name string
|
||||
err := row.Scan(&soundtrack_name)
|
||||
return soundtrack_name, err
|
||||
}
|
||||
|
||||
const insertSoundtrack = `-- name: InsertSoundtrack :one
|
||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
|
||||
`
|
||||
|
||||
type InsertSoundtrackParams struct {
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash)
|
||||
var id int32
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
|
||||
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now())
|
||||
`
|
||||
|
||||
type InsertSoundtrackWithExistingIdParams struct {
|
||||
ID int32 `json:"id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error {
|
||||
_, err := q.db.Exec(ctx, insertSoundtrackWithExistingId,
|
||||
arg.ID,
|
||||
arg.SoundtrackName,
|
||||
arg.Path,
|
||||
arg.Hash,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const removeSoundtrackDeletionDate = `-- name: RemoveSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=NULL WHERE id=$1
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error {
|
||||
_, err := q.db.Exec(ctx, removeSoundtrackDeletionDate, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const resetSoundtrackIdSeq = `-- name: ResetSoundtrackIdSeq :one
|
||||
SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1)
|
||||
`
|
||||
|
||||
func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, resetSoundtrackIdSeq)
|
||||
var setval int64
|
||||
err := row.Scan(&setval)
|
||||
return setval, err
|
||||
}
|
||||
|
||||
const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
|
||||
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) SetSoundtrackDeletionDate(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, setSoundtrackDeletionDate)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSoundtrackHash = `-- name: UpdateSoundtrackHash :exec
|
||||
UPDATE soundtrack SET hash=$1, last_changed=now() WHERE id=$2
|
||||
`
|
||||
|
||||
type UpdateSoundtrackHashParams struct {
|
||||
Hash string `json:"hash"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSoundtrackHash(ctx context.Context, arg UpdateSoundtrackHashParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSoundtrackHash, arg.Hash, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSoundtrackName = `-- name: UpdateSoundtrackName :exec
|
||||
UPDATE soundtrack SET soundtrack_name=$1, path=$2, last_changed=now() WHERE id=$3
|
||||
`
|
||||
|
||||
type UpdateSoundtrackNameParams struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSoundtrackName(ctx context.Context, arg UpdateSoundtrackNameParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSoundtrackName, arg.Name, arg.Path, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -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 soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.last_played DESC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetLastPlayedGamesRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Last played soundtracks (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.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLeastPlayedGamesWithSongs = `-- name: GetLeastPlayedGamesWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_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 soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played ASC, g.soundtrack_name
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetLeastPlayedGamesWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Least played soundtracks 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.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLeastPlayedSongsWithGame = `-- name: GetLeastPlayedSongsWithGame :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
s.times_played,
|
||||
s.file_name
|
||||
FROM song s
|
||||
JOIN soundtrack g ON s.soundtrack_id = g.id
|
||||
WHERE g.deleted IS NULL
|
||||
ORDER BY s.times_played ASC, s.song_name
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetLeastPlayedSongsWithGameRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_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 soundtrack 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.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&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 soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_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 soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played DESC, g.soundtrack_name
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetMostPlayedGamesWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Most played soundtracks 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.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getMostPlayedSongsWithGame = `-- name: GetMostPlayedSongsWithGame :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
s.song_name,
|
||||
s.path,
|
||||
s.times_played,
|
||||
s.file_name
|
||||
FROM song s
|
||||
JOIN soundtrack g ON s.soundtrack_id = g.id
|
||||
WHERE g.deleted IS NULL
|
||||
ORDER BY s.times_played DESC, s.song_name
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetMostPlayedSongsWithGameRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_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 soundtrack 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.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&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 soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.added,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.times_played = 0
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.added
|
||||
ORDER BY g.soundtrack_name
|
||||
`
|
||||
|
||||
type GetNeverPlayedGamesRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_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.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&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 soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL AND g.last_played IS NOT NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.last_played ASC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetOldestPlayedGamesRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Oldest played soundtracks (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.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getStatisticsSummary = `-- name: GetStatisticsSummary :one
|
||||
SELECT
|
||||
COUNT(*) as total_soundtracks,
|
||||
SUM(CASE WHEN times_played > 0 THEN 1 ELSE 0 END) as played_soundtracks,
|
||||
SUM(CASE WHEN times_played = 0 THEN 1 ELSE 0 END) as never_played_soundtracks,
|
||||
COALESCE(SUM(times_played), 0)::bigint as total_soundtrack_plays,
|
||||
COALESCE(AVG(times_played), 0)::float as avg_soundtrack_plays,
|
||||
COALESCE(MAX(times_played), 0)::bigint as max_soundtrack_plays,
|
||||
COALESCE(MIN(times_played), 0)::bigint as min_soundtrack_plays
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
`
|
||||
|
||||
type GetStatisticsSummaryRow struct {
|
||||
TotalSoundtracks int64 `json:"total_soundtracks"`
|
||||
PlayedSoundtracks int64 `json:"played_soundtracks"`
|
||||
NeverPlayedSoundtracks int64 `json:"never_played_soundtracks"`
|
||||
TotalSoundtrackPlays int64 `json:"total_soundtrack_plays"`
|
||||
AvgSoundtrackPlays float64 `json:"avg_soundtrack_plays"`
|
||||
MaxSoundtrackPlays int64 `json:"max_soundtrack_plays"`
|
||||
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
|
||||
}
|
||||
|
||||
// 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.TotalSoundtracks,
|
||||
&i.PlayedSoundtracks,
|
||||
&i.NeverPlayedSoundtracks,
|
||||
&i.TotalSoundtrackPlays,
|
||||
&i.AvgSoundtrackPlays,
|
||||
&i.MaxSoundtrackPlays,
|
||||
&i.MinSoundtrackPlays,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -16,6 +17,8 @@ var (
|
||||
testDBUser string
|
||||
testDBPassword string
|
||||
testDBName string
|
||||
// TestDatabase is the database instance for tests
|
||||
TestDatabase *Database
|
||||
)
|
||||
|
||||
// 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)
|
||||
createTestDatabase(host, port, dbname, user, password)
|
||||
|
||||
// Now run migrations using the existing function
|
||||
Migrate_db(host, port, user, password, dbname)
|
||||
InitDB(host, port, user, password, dbname)
|
||||
// Create database instance and run migrations
|
||||
var err error
|
||||
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
|
||||
func TestTearDownDB(t *testing.T) {
|
||||
// CloseDb() // Disabled to prevent pool closure between sequential tests
|
||||
if TestDatabase != nil {
|
||||
TestDatabase.Close()
|
||||
TestDatabase = nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearDatabase clears all data from the test database
|
||||
// Useful for running tests with a clean slate
|
||||
func TestClearDatabase(t *testing.T) {
|
||||
if Dbpool == nil {
|
||||
if TestDatabase == nil || TestDatabase.Pool == nil {
|
||||
t.Skip("Database not initialized")
|
||||
}
|
||||
|
||||
@@ -103,15 +118,16 @@ func TestClearDatabase(t *testing.T) {
|
||||
"game",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, table := range tables {
|
||||
_, err := Dbpool.Exec(Ctx, "TRUNCATE TABLE "+table+" CASCADE")
|
||||
_, err := TestDatabase.Pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
|
||||
if err != nil {
|
||||
t.Logf("Failed to truncate table %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Logf("Failed to reset game_id_seq: %v", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package middleware provides Echo middleware for the MusicServer application.
|
||||
package middleware
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -229,8 +229,8 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
|
||||
}
|
||||
|
||||
// GetAllGames godoc
|
||||
// @Summary Get all games
|
||||
// GetAllSoundtracks godoc
|
||||
// @Summary Get all soundtracks
|
||||
// @Description Returns a list of all games in order
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
@@ -238,17 +238,17 @@ func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/order [get]
|
||||
func (m *MusicHandler) GetAllGames(ctx *echo.Context) error {
|
||||
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
||||
if backend.Syncing {
|
||||
logging.GetLogger().Info("Syncing is in progress")
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
gameList := backend.GetAllGames()
|
||||
return ctx.JSON(http.StatusOK, gameList)
|
||||
soundtrackList := backend.GetAllSoundtracks()
|
||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||
}
|
||||
|
||||
// GetAllGamesRandom godoc
|
||||
// @Summary Get all games random
|
||||
// GetAllSoundtracksRandom godoc
|
||||
// @Summary Get all soundtracks random
|
||||
// @Description Returns a list of all games in random order
|
||||
// @Tags music
|
||||
// @Accept json
|
||||
@@ -256,13 +256,13 @@ func (m *MusicHandler) GetAllGames(ctx *echo.Context) error {
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/random [get]
|
||||
func (m *MusicHandler) GetAllGamesRandom(ctx *echo.Context) error {
|
||||
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
||||
if backend.Syncing {
|
||||
logging.GetLogger().Info("Syncing is in progress")
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
gameList := backend.GetAllGamesRandom()
|
||||
return ctx.JSON(http.StatusOK, gameList)
|
||||
soundtrackList := backend.GetAllSoundtracksRandom()
|
||||
return ctx.JSON(http.StatusOK, soundtrackList)
|
||||
}
|
||||
|
||||
// PutPlayed godoc
|
||||
|
||||
+97
-36
@@ -2,15 +2,16 @@ package server
|
||||
|
||||
import (
|
||||
"music-server/cmd/web"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"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"
|
||||
"music-server/internal/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -36,9 +37,9 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
||||
})))
|
||||
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://*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
@@ -57,47 +58,107 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
// Swagger UI
|
||||
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||
|
||||
// ============================================
|
||||
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
|
||||
// ============================================
|
||||
deprecatedMiddleware := middleware.DeprecationMiddleware
|
||||
|
||||
index := NewIndexHandler()
|
||||
e.GET("/version", index.GetVersion)
|
||||
e.GET("/dbtest", index.GetDBTest)
|
||||
e.GET("/health", index.HealthCheck)
|
||||
e.GET("/character", index.GetCharacter)
|
||||
e.GET("/characters", index.GetCharacterList)
|
||||
e.GET("/version", deprecatedMiddleware(index.GetVersion))
|
||||
e.GET("/dbtest", deprecatedMiddleware(index.GetDBTest))
|
||||
e.GET("/health", deprecatedMiddleware(index.HealthCheck))
|
||||
e.GET("/character", deprecatedMiddleware(index.GetCharacter))
|
||||
e.GET("/characters", deprecatedMiddleware(index.GetCharacterList))
|
||||
|
||||
download := NewDownloadHandler()
|
||||
e.GET("/download", download.checkLatest)
|
||||
e.GET("/download/list", download.listAssetsOfLatest)
|
||||
e.GET("/download/windows", download.downloadLatestWindows)
|
||||
e.GET("/download/linux", download.downloadLatestLinux)
|
||||
e.GET("/download", deprecatedMiddleware(download.checkLatest))
|
||||
e.GET("/download/list", deprecatedMiddleware(download.listAssetsOfLatest))
|
||||
e.GET("/download/windows", deprecatedMiddleware(download.downloadLatestWindows))
|
||||
e.GET("/download/linux", deprecatedMiddleware(download.downloadLatestLinux))
|
||||
|
||||
sync := NewSyncHandler()
|
||||
syncGroup := e.Group("/sync")
|
||||
syncGroup.GET("", sync.SyncGamesNewOnlyChanges)
|
||||
syncGroup.GET("/progress", sync.SyncProgress)
|
||||
syncGroup.GET("/new", sync.SyncGamesNewOnlyChanges)
|
||||
syncGroup.GET("/full", sync.SyncGamesNewFull)
|
||||
syncGroup.GET("/new/full", sync.SyncGamesNewFull)
|
||||
syncGroup.GET("/quick", sync.SyncGamesNewOnlyChanges)
|
||||
syncGroup.GET("/reset", sync.ResetGames)
|
||||
syncGroup.GET("", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
||||
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
|
||||
syncGroup.GET("/new", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
||||
syncGroup.GET("/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
|
||||
syncGroup.GET("/new/full", deprecatedMiddleware(sync.SyncSoundtracksNewFull))
|
||||
syncGroup.GET("/quick", deprecatedMiddleware(sync.SyncSoundtracksNewOnlyChanges))
|
||||
syncGroup.GET("/reset", deprecatedMiddleware(sync.ResetDB))
|
||||
|
||||
music := NewMusicHandler()
|
||||
musicGroup := e.Group("/music")
|
||||
musicGroup.GET("", music.GetSong)
|
||||
musicGroup.GET("/soundTest", music.GetSoundCheckSong)
|
||||
musicGroup.GET("/reset", music.ResetMusic)
|
||||
musicGroup.GET("/rand", music.GetRandomSong)
|
||||
musicGroup.GET("/rand/low", music.GetRandomSongLowChance)
|
||||
musicGroup.GET("/rand/classic", music.GetRandomSongClassic)
|
||||
musicGroup.GET("/info", music.GetSongInfo)
|
||||
musicGroup.GET("/list", music.GetPlayedSongs)
|
||||
musicGroup.GET("/next", music.GetNextSong)
|
||||
musicGroup.GET("/previous", music.GetPreviousSong)
|
||||
musicGroup.GET("/all", music.GetAllGamesRandom)
|
||||
musicGroup.GET("/all/order", music.GetAllGames)
|
||||
musicGroup.GET("/all/random", music.GetAllGamesRandom)
|
||||
musicGroup.PUT("/played", music.PutPlayed)
|
||||
musicGroup.GET("/addQue", music.AddLatestToQue)
|
||||
musicGroup.GET("/addPlayed", music.AddLatestPlayed)
|
||||
musicGroup.GET("", deprecatedMiddleware(music.GetSong))
|
||||
musicGroup.GET("/soundTest", deprecatedMiddleware(music.GetSoundCheckSong))
|
||||
musicGroup.GET("/reset", deprecatedMiddleware(music.ResetMusic))
|
||||
musicGroup.GET("/rand", deprecatedMiddleware(music.GetRandomSong))
|
||||
musicGroup.GET("/rand/low", deprecatedMiddleware(music.GetRandomSongLowChance))
|
||||
musicGroup.GET("/rand/classic", deprecatedMiddleware(music.GetRandomSongClassic))
|
||||
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
|
||||
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
|
||||
musicGroup.GET("/next", deprecatedMiddleware(music.GetNextSong))
|
||||
musicGroup.GET("/previous", deprecatedMiddleware(music.GetPreviousSong))
|
||||
musicGroup.GET("/all", deprecatedMiddleware(music.GetAllSoundtracksRandom))
|
||||
musicGroup.GET("/all/order", deprecatedMiddleware(music.GetAllSoundtracks))
|
||||
musicGroup.GET("/all/random", deprecatedMiddleware(music.GetAllSoundtracksRandom))
|
||||
musicGroup.PUT("/played", deprecatedMiddleware(music.PutPlayed))
|
||||
musicGroup.GET("/addQue", deprecatedMiddleware(music.AddLatestToQue))
|
||||
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()
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
|
||||
+65
-21
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/logging"
|
||||
"net/http"
|
||||
@@ -15,6 +16,10 @@ import (
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
db *db.Database
|
||||
tokenHandler *TokenHandler
|
||||
statisticsHandler *StatisticsHandler
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -29,7 +34,9 @@ var (
|
||||
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
|
||||
if logLevel == "" {
|
||||
logLevel = "info"
|
||||
@@ -39,8 +46,47 @@ func NewServer() *http.Server {
|
||||
logger := logging.GetLogger()
|
||||
|
||||
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,
|
||||
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",
|
||||
@@ -55,23 +101,21 @@ func NewServer() *http.Server {
|
||||
zap.String("charactersPath", charactersPath),
|
||||
)
|
||||
|
||||
//conf.SetupDb()
|
||||
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
||||
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
||||
}
|
||||
|
||||
db.Migrate_db(host, dbPort, username, password, dbName)
|
||||
|
||||
db.InitDB(host, dbPort, username, password, dbName)
|
||||
|
||||
// Declare Server config
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", NewServer.port),
|
||||
Handler: NewServer.RegisterRoutes(),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
return server
|
||||
return appServer
|
||||
}
|
||||
|
||||
// HTTPServer returns the underlying http.Server for serving HTTP requests.
|
||||
func (s *Server) HTTPServer() *http.Server {
|
||||
return s.httpServer
|
||||
}
|
||||
|
||||
// DB returns the database instance for dependency injection.
|
||||
func (s *Server) DB() *db.Database {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
|
||||
// This function is kept for backward compatibility.
|
||||
func NewServer() *http.Server {
|
||||
return NewServerInstance().HTTPServer()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestStatisticsEndpoints tests the statistics API endpoints
|
||||
func TestStatisticsEndpoints(t *testing.T) {
|
||||
// Skip if test database not configured
|
||||
e := StartTestServer(t)
|
||||
if e == nil {
|
||||
t.Skip("Test database not configured")
|
||||
}
|
||||
|
||||
// Get token first
|
||||
token := getTestToken(t, e)
|
||||
if token == "" {
|
||||
t.Skip("Could not get test token")
|
||||
}
|
||||
|
||||
// Test /api/v1/statistics/summary
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var summary backend.StatisticsSummary
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, summary)
|
||||
}
|
||||
|
||||
// TestPartialMigrationThenSyncThenComplete tests migration workflow
|
||||
// Note: This test requires the database to be in a specific state
|
||||
// It tests: partial migration → data insert → sync → complete migration
|
||||
func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
|
||||
// This test is complex and requires careful setup
|
||||
// For now, we test the final state: all migrations + sync
|
||||
|
||||
e := StartTestServer(t)
|
||||
if e == nil {
|
||||
t.Skip("Test database not configured")
|
||||
}
|
||||
|
||||
// Get token
|
||||
token := getTestToken(t, e)
|
||||
if token == "" {
|
||||
t.Skip("Could not get test token")
|
||||
}
|
||||
|
||||
// Insert test data manually (5 soundtracks with songs)
|
||||
insertTestData(t)
|
||||
|
||||
// Run sync to ensure data is properly loaded
|
||||
req := httptest.NewRequest(http.MethodGet, "/sync/new", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
// Verify data via statistics endpoint
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var summary backend.StatisticsSummary
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We inserted 5 soundtracks, so total should be at least 5
|
||||
// (there might be existing data)
|
||||
require.GreaterOrEqual(t, summary.TotalGames, int64(5))
|
||||
}
|
||||
|
||||
// insertTestData inserts 5 test soundtracks with songs into the database
|
||||
func insertTestData(t *testing.T) {
|
||||
if db.TestDatabase == nil || db.TestDatabase.Pool == nil {
|
||||
t.Skip("Test database not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
queries := repository.New(db.TestDatabase.Pool)
|
||||
|
||||
// Insert 5 soundtracks
|
||||
soundtracks := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"Test Soundtrack 1", "/path/to/soundtrack1"},
|
||||
{"Test Soundtrack 2", "/path/to/soundtrack2"},
|
||||
{"Test Soundtrack 3", "/path/to/soundtrack3"},
|
||||
{"Test Soundtrack 4", "/path/to/soundtrack4"},
|
||||
{"Test Soundtrack 5", "/path/to/soundtrack5"},
|
||||
}
|
||||
|
||||
for _, st := range soundtracks {
|
||||
_, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{
|
||||
SoundtrackName: st.name,
|
||||
Path: st.path,
|
||||
Hash: "test-hash-" + st.name,
|
||||
})
|
||||
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
|
||||
}
|
||||
|
||||
// Get soundtrack IDs
|
||||
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
|
||||
|
||||
// Insert songs for each soundtrack
|
||||
songData := []struct {
|
||||
soundtrackID int32
|
||||
songs []string
|
||||
}{
|
||||
{soundtrackIDs[0].ID, []string{"Song A", "Song B"}},
|
||||
{soundtrackIDs[1].ID, []string{"Song C", "Song D"}},
|
||||
{soundtrackIDs[2].ID, []string{"Song E"}},
|
||||
{soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}},
|
||||
{soundtrackIDs[4].ID, []string{"Song I"}},
|
||||
}
|
||||
|
||||
for _, sd := range songData {
|
||||
for _, songName := range sd.songs {
|
||||
err := queries.AddSong(ctx, repository.AddSongParams{
|
||||
SoundtrackID: sd.soundtrackID,
|
||||
SongName: songName,
|
||||
Path: "/path/to/" + songName + ".mp3",
|
||||
})
|
||||
require.NoError(t, err, "Failed to insert song: %s", songName)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Inserted %d soundtracks with %d total songs", len(soundtracks), 8)
|
||||
}
|
||||
|
||||
// getTestToken gets a valid token for testing
|
||||
func getTestToken(t *testing.T, e *echo.Echo) string {
|
||||
reqBody := `{"client_type": "test"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", strings.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Logf("Failed to get token: %s", rec.Body.String())
|
||||
return ""
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
return resp.Token
|
||||
}
|
||||
@@ -34,59 +34,59 @@ func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// SyncGamesNewOnlyChanges godoc
|
||||
// @Summary Sync games with only changes
|
||||
// SyncSoundtracksNewOnlyChanges godoc
|
||||
// @Summary Sync soundtracks with only changes
|
||||
// @Description Starts syncing games with only new changes
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing games"
|
||||
// @Success 200 {string} string "Start syncing soundtracks"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync [get]
|
||||
func (s *SyncHandler) SyncGamesNewOnlyChanges(ctx *echo.Context) error {
|
||||
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
||||
if backend.Syncing {
|
||||
logging.GetLogger().Warn("Syncing is already in progress")
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info("Starting sync with only changes")
|
||||
go backend.SyncGamesNewOnlyChanges()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing games")
|
||||
go backend.SyncSoundtracksNewOnlyChanges()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
|
||||
}
|
||||
|
||||
// SyncGamesNewFull godoc
|
||||
// SyncSoundtracksNewFull godoc
|
||||
// @Summary Sync all games fully
|
||||
// @Description Starts a full sync of all games
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing games full"
|
||||
// @Success 200 {string} string "Start syncing soundtracks full"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/full [get]
|
||||
func (s *SyncHandler) SyncGamesNewFull(ctx *echo.Context) error {
|
||||
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
||||
if backend.Syncing {
|
||||
logging.GetLogger().Warn("Syncing is already in progress")
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info("Starting full sync")
|
||||
go backend.SyncGamesNewFull()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing games full")
|
||||
go backend.SyncSoundtracksNewFull()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
|
||||
}
|
||||
|
||||
// ResetGames godoc
|
||||
// @Summary Reset games database
|
||||
// ResetDB godoc
|
||||
// @Summary Reset soundtracks database
|
||||
// @Description Resets the games database by deleting all games and songs
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Games and songs are deleted from the database"
|
||||
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/reset [get]
|
||||
func (s *SyncHandler) ResetGames(ctx *echo.Context) error {
|
||||
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
||||
if backend.Syncing {
|
||||
logging.GetLogger().Warn("Cannot reset - syncing is in progress")
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info("Resetting games database")
|
||||
logging.GetLogger().Info("Resetting soundtracks database")
|
||||
backend.ResetDB()
|
||||
return ctx.JSON(http.StatusOK, "Games and songs are deleted from the database")
|
||||
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
||||
db.TestClearDatabase(t)
|
||||
|
||||
// Before sync - should have no games
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
beforeCount := len(gamesBefore)
|
||||
t.Logf("Games before sync: %d", beforeCount)
|
||||
@@ -92,7 +92,7 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
// After sync - should have games
|
||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
afterCount := len(gamesAfter)
|
||||
t.Logf("Games after sync: %d", afterCount)
|
||||
@@ -112,8 +112,8 @@ func TestSyncMakesDifference(t *testing.T) {
|
||||
db.TestClearDatabase(t)
|
||||
|
||||
// Before sync - should have no games
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
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
|
||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(gamesAfter) > 0, "Should have games after sync")
|
||||
}
|
||||
@@ -199,8 +199,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
// Get initial count
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
beforeCount := len(gamesBefore)
|
||||
|
||||
// Run incremental sync (should not change count if nothing changed)
|
||||
@@ -211,7 +211,7 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Count should be the same
|
||||
gamesAfter, _ := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
afterCount := len(gamesAfter)
|
||||
|
||||
// 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)
|
||||
|
||||
// First ensure we have data
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
beforeCount := len(gamesBefore)
|
||||
|
||||
if beforeCount == 0 {
|
||||
@@ -238,7 +238,7 @@ func TestResetGames(t *testing.T) {
|
||||
t.Error("Sync did not complete within timeout")
|
||||
return
|
||||
}
|
||||
gamesBefore, _ = repo.FindAllGames(db.Ctx)
|
||||
gamesBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
beforeCount = len(gamesBefore)
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ func TestResetGames(t *testing.T) {
|
||||
// Note: reset might take a moment to propagate
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
gamesAfter, _ := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
afterCount := len(gamesAfter)
|
||||
|
||||
t.Logf("Games after reset: %d", afterCount)
|
||||
@@ -281,8 +281,8 @@ func TestSyncGamesNewFull(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify database is populated
|
||||
repo := repository.New(db.Dbpool)
|
||||
games, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
games, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(games) > 0, "Database should be populated after full sync")
|
||||
t.Logf("Full sync populated %d games", len(games))
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
@@ -45,8 +48,20 @@ func StartTestServer(t *testing.T) *echo.Echo {
|
||||
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
|
||||
s := &Server{}
|
||||
s := &Server{
|
||||
db: db.TestDatabase,
|
||||
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
|
||||
}
|
||||
handler := s.RegisterRoutes()
|
||||
|
||||
// Wrap the http.Handler in an echo.Echo
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
||||
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
||||
repo := repository.New(db.Dbpool)
|
||||
games, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
games, err := repo.FindAllSoundtracks(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
|
||||
if len(games) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user