Restructure backend into services and fix security, crash and sync issues

Security
- Serve character images through os.Root so names like ../ can't escape
  the characters folder
- Sync start and /sync/reset are POST only (legacy and /api/v1)
- /api/v1/token/cleanup requires a token, password compared in constant
  time, tokens no longer logged
- Echo's rate limiter replaces the custom one; TRUSTED_PROXIES enables
  X-Forwarded-For for reverse proxies

Crashes and races
- Sync state lives in SyncService with atomics and a mutex instead of
  unsynchronised globals (fixes concurrent map writes during full sync)
- Only one sync can run; /api/v1 sync and music routes are locked during
  sync like the legacy ones
- No log.Fatal outside startup: errors become 4xx/5xx responses
- Search uses an in-order character match instead of unescaped regexes
- Empty queue / no songs return 404 instead of panicking or looping

Behaviour
- Sync result is computed once and stays the same between polls
- Swagger and /openapi serve the embedded spec
- Shutdown stops HTTP before closing the database
- Expired tokens are deleted with a context that outlives the request
- Files are served with Range support and correct content types; audio
  has no write deadline

Performance
- Migration 000006 adds soundtrack.fingerprint (names, sizes, mtimes) so
  unchanged folders are skipped without reading any files
- Songs are reconciled per soundtrack with one query plus needed writes;
  renames keep play counts
- Moved MUSIC_PATH updates soundtrack and song paths
- .id cleanup only runs for changed soundtracks or a full sync
- Classic random uses one SQL query; Gitea release lookups are cached
  with a timeout

Structure
- internal/config reads all environment variables once
- sqlc emits a Querier interface; backend globals become services
  injected into handlers
- One route registration shared by legacy and /api/v1
- Removed dead code (dbHelper.go, unused middleware, duplicate Gitea
  functions, embedded Swagger UI copy) and lib/pq
- templ and swag pinned as go tools
This commit is contained in:
Sansan committed 2026-10-06 15:20:14 +02:00
1 parent 75b6f0c1cf
commit 090e794460
78 files changed
+2919 -6303

No files matched your search

-40
View File
@@ -1,40 +0,0 @@
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
}
+47 -27
View File
@@ -1,43 +1,63 @@
package backend
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"music-server/internal/logging"
)
func GetCharacterList() []string {
charactersPath := os.Getenv("CHARACTERS_PATH")
logging.GetLogger().Debug("Getting character list", zap.String("path", charactersPath))
// Clean the path - remove trailing slashes and then add one for consistency
charactersPath = strings.TrimSuffix(charactersPath, "/")
charactersPath += "/"
files, err := os.ReadDir(charactersPath)
if err != nil {
logging.GetLogger().Fatal("Failed to read characters directory", zap.String("path", charactersPath), zap.String("error", err.Error()))
}
// CharacterService serves character images from a single directory.
type CharacterService struct {
root *os.Root
}
// NewCharacterService opens the characters directory. Files outside it can never be served.
func NewCharacterService(path string) (*CharacterService, error) {
root, err := os.OpenRoot(path)
if err != nil {
return nil, err
}
return &CharacterService{root: root}, nil
}
// List returns the file names of all character images.
func (c *CharacterService) List() ([]string, error) {
entries, err := fs.ReadDir(c.root.FS(), ".")
if err != nil {
return nil, err
}
var characters []string
for _, file := range files {
if isImage(file) {
characters = append(characters, file.Name())
for _, entry := range entries {
if !entry.IsDir() && isImage(entry.Name()) {
characters = append(characters, entry.Name())
}
}
return characters
return characters, nil
}
func GetCharacter(character string) string {
charactersPath := os.Getenv("CHARACTERS_PATH")
// Clean the path - remove trailing slashes and then add one for consistency
charactersPath = strings.TrimSuffix(charactersPath, "/")
charactersPath += "/"
logging.GetLogger().Debug("Getting character", zap.String("character", character), zap.String("path", charactersPath+character))
return charactersPath + character
// Open opens the character image with the given file name.
func (c *CharacterService) Open(name string) (*os.File, error) {
if name == "" || name != filepath.Base(name) || !isImage(name) {
return nil, ErrInvalidName
}
f, err := c.root.Open(name)
if errors.Is(err, fs.ErrNotExist) {
return nil, ErrNotFound
}
return f, err
}
func isImage(entry os.DirEntry) bool {
return !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".jpeg") ||
strings.HasSuffix(entry.Name(), ".png"))
// Close releases the characters directory.
func (c *CharacterService) Close() error {
return c.root.Close()
}
func isImage(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".jpg", ".jpeg", ".png":
return true
}
return false
}
-90
View File
@@ -1,90 +0,0 @@
package backend
import (
"io/fs"
"os"
"testing"
)
func TestIsImage(t *testing.T) {
tests := []struct {
name string
entry fs.DirEntry
expected bool
}{
{
name: "jpg file",
entry: &mockDirEntry{name: "test.jpg", isDir: false},
expected: true,
},
{
name: "jpeg file",
entry: &mockDirEntry{name: "test.jpeg", isDir: false},
expected: true,
},
{
name: "png file",
entry: &mockDirEntry{name: "test.png", isDir: false},
expected: true,
},
{
name: "directory",
entry: &mockDirEntry{name: "test", isDir: true},
expected: false,
},
{
name: "txt file",
entry: &mockDirEntry{name: "test.txt", isDir: false},
expected: false,
},
{
name: "mp3 file",
entry: &mockDirEntry{name: "test.mp3", isDir: false},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isImage(tt.entry)
if result != tt.expected {
t.Errorf("isImage() = %v, want %v", result, tt.expected)
}
})
}
}
type mockDirEntry struct {
name string
isDir bool
}
func (m *mockDirEntry) Name() string { return m.name }
func (m *mockDirEntry) IsDir() bool { return m.isDir }
func (m *mockDirEntry) Type() fs.FileMode { return 0 }
func (m *mockDirEntry) Info() (fs.FileInfo, error) { return nil, nil }
func (m *mockDirEntry) Sys() interface{} { return nil }
func TestGetCharacter(t *testing.T) {
os.Setenv("CHARACTERS_PATH", "/test/path")
defer os.Unsetenv("CHARACTERS_PATH")
result := GetCharacter("test.jpg")
expected := "/test/path/test.jpg"
if result != expected {
t.Errorf("GetCharacter() = %v, want %v", result, expected)
}
}
func TestGetCharacterWithTrailingSlash(t *testing.T) {
os.Setenv("CHARACTERS_PATH", "/test/path/")
defer os.Unsetenv("CHARACTERS_PATH")
result := GetCharacter("test.jpg")
expected := "/test/path/test.jpg"
if result != expected {
t.Errorf("GetCharacter() = %v, want %v", result, expected)
}
}
-103
View File
@@ -1,103 +0,0 @@
package backend
import (
"encoding/json"
"net/http"
"strings"
"music-server/internal/logging"
"go.uber.org/zap"
)
type giteaResponse struct {
Id int `json:"id"`
Name string `json:"name"`
Assets []assetResponse `json:"assets"`
}
type assetResponse struct {
Id int `json:"id"`
Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"`
}
func CheckLatest() string {
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
if err != nil {
logging.GetLogger().Fatal("Failed to check latest version", zap.String("error", err.Error()))
}
defer resp.Body.Close()
//Create a variable of the same type as our model
var cResp giteaResponse
//Decode the data
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
}
logging.GetLogger().Debug("Checked latest version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
return cResp.Name
}
func ListAssetsOfLatest() []string {
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
if err != nil {
logging.GetLogger().Fatal("Failed to list assets", zap.String("error", err.Error()))
}
defer resp.Body.Close()
//Create a variable of the same type as our model
var cResp giteaResponse
//Decode the data
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
}
logging.GetLogger().Debug("Listing assets", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
var assets []string
for _, asset := range cResp.Assets {
logging.GetLogger().Debug("Found asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
assets = append(assets, asset.Name)
}
return assets
}
func DownloadLatestWindows() string {
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
if err != nil {
logging.GetLogger().Fatal("Failed to download latest Windows version", zap.String("error", err.Error()))
}
defer resp.Body.Close()
//Create a variable of the same type as our model
var cResp giteaResponse
//Decode the data
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
}
logging.GetLogger().Debug("Downloading Windows version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
for _, asset := range cResp.Assets {
logging.GetLogger().Debug("Checking asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
if strings.HasSuffix(asset.Name, ".exe") {
return asset.DownloadUrl
}
}
return ""
}
func DownloadLatestLinux() string {
resp, err := http.Get("https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest")
if err != nil {
logging.GetLogger().Fatal("Failed to download latest Linux version", zap.String("error", err.Error()))
}
defer resp.Body.Close()
//Create a variable of the same type as our model
var cResp giteaResponse
//Decode the data
if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil {
logging.GetLogger().Fatal("Failed to decode response", zap.String("error", err.Error()))
}
logging.GetLogger().Debug("Downloading Linux version", zap.Int("id", cResp.Id), zap.String("name", cResp.Name))
for _, asset := range cResp.Assets {
logging.GetLogger().Debug("Checking asset", zap.Int("id", cResp.Id), zap.String("name", cResp.Name), zap.String("asset", asset.Name))
if strings.HasSuffix(asset.Name, ".x86_64") {
return asset.DownloadUrl
}
}
return ""
}
-61
View File
@@ -1,61 +0,0 @@
package backend
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCheckLatest(t *testing.T) {
mockResponse := giteaResponse{
Id: 1,
Name: "v1.0.0",
Assets: []assetResponse{
{Id: 1, Name: "app.exe", DownloadUrl: "http://example.com/app.exe"},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mockResponse)
}))
defer server.Close()
originalURL := "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest"
_ = originalURL
// Note: This test would need mocking of http.Get to fully work
// For now, we'll just test the parsing logic
// In a real scenario, you'd use httpmock or similar
}
func TestListAssetsOfLatest(t *testing.T) {
mockResponse := giteaResponse{
Id: 1,
Name: "v1.0.0",
Assets: []assetResponse{
{Id: 1, Name: "app.exe", DownloadUrl: "http://example.com/app.exe"},
{Id: 2, Name: "app.x86_64", DownloadUrl: "http://example.com/app.x86_64"},
{Id: 3, Name: "app.dmg", DownloadUrl: "http://example.com/app.dmg"},
},
}
// Test the parsing of the response
var cResp giteaResponse
data, _ := json.Marshal(mockResponse)
json.Unmarshal(data, &cResp)
var assets []string
for _, asset := range cResp.Assets {
assets = append(assets, asset.Name)
}
if len(assets) != 3 {
t.Errorf("Expected 3 assets, got %d", len(assets))
}
if assets[0] != "app.exe" {
t.Errorf("Expected first asset to be app.exe, got %s", assets[0])
}
}
+16
View File
@@ -0,0 +1,16 @@
package backend
import "errors"
var (
// ErrQueueEmpty is returned when the play queue has no songs.
ErrQueueEmpty = errors.New("song queue is empty")
// ErrNoSongs is returned when no playable song could be found.
ErrNoSongs = errors.New("no playable songs found")
// ErrNotFound is returned when a requested resource does not exist.
ErrNotFound = errors.New("not found")
// ErrInvalidName is returned when a requested file name is not allowed.
ErrInvalidName = errors.New("invalid name")
// ErrUpstream is returned when an external service could not be reached.
ErrUpstream = errors.New("upstream service unavailable")
)
+285 -247
View File
@@ -1,16 +1,25 @@
package backend
import (
"math/rand"
"context"
"errors"
"math/rand/v2"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"music-server/internal/db/repository"
"music-server/internal/logging"
"os"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
// maxPickAttempts bounds how many broken songs are skipped before giving up.
const maxPickAttempts = 100
type SongInfo struct {
Soundtrack string `json:"Soundtrack"`
SoundtrackPlayed int32 `json:"SoundtrackPlayed"`
@@ -20,303 +29,332 @@ type SongInfo struct {
SongNo int `json:"SongNo"`
}
var currentSong = -1
// MusicService picks songs and keeps track of the shared play queue.
type MusicService struct {
q repository.Querier
soundCheckPath string
var soundtracksNew []repository.Soundtrack
cacheMu sync.Mutex
soundtracks []repository.Soundtrack
var songQueNew []repository.Song
var lastFetchedNew repository.Song
func initRepo() {
// 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")
}
// Queue state is shared by all clients and intentionally not locked.
queue []repository.Song
currentSong int
lastFetched repository.Song
}
func getAllSoundtracks() []repository.Soundtrack {
if len(soundtracksNew) == 0 {
initRepo()
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
}
return soundtracksNew
// NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs.
func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService {
return &MusicService{q: q, soundCheckPath: soundCheckPath, currentSong: -1}
}
func GetSoundCheckSong() string {
files, err := os.ReadDir("songs")
if err != nil {
logging.GetLogger().Fatal("Failed to read songs directory", zap.String("error", err.Error()))
}
fileInfo := files[rand.Intn(len(files))]
return "songs/" + fileInfo.Name()
}
func Reset() {
songQueNew = nil
currentSong = -1
initRepo()
soundtracksNew, _ = BackendRepo().FindAllSoundtracks(BackendCtx())
}
func AddLatestToQue() {
if lastFetchedNew.Path != "" {
currentSong = len(songQueNew)
songQueNew = append(songQueNew, lastFetchedNew)
lastFetchedNew = repository.Song{}
}
}
func AddLatestPlayed() {
if len(songQueNew) == 0 {
return
}
currentSongData := songQueNew[currentSong]
initRepo()
BackendRepo().AddSoundtrackPlayed(BackendCtx(), currentSongData.SoundtrackID)
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: currentSongData.SoundtrackID, SongName: currentSongData.SongName})
}
func SetPlayed(songNumber int) {
if len(songQueNew) == 0 || songNumber >= len(songQueNew) {
return
}
songData := songQueNew[songNumber]
initRepo()
BackendRepo().AddSoundtrackPlayed(BackendCtx(), songData.SoundtrackID)
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{SoundtrackID: songData.SoundtrackID, SongName: songData.SongName})
}
func GetRandomSong() string {
getAllSoundtracks()
if len(soundtracksNew) == 0 {
return ""
}
song := getSongFromList(soundtracksNew)
lastFetchedNew = song
return song.Path
}
func GetRandomSongLowChance() string {
getAllSoundtracks()
var listOfSoundtracks []repository.Soundtrack
var averagePlayed = getAveragePlayed()
for _, data := range soundtracksNew {
timesToAdd := averagePlayed - data.TimesPlayed
if timesToAdd <= 0 {
listOfSoundtracks = append(listOfSoundtracks, data)
} else {
for i := int32(0); i < timesToAdd; i++ {
listOfSoundtracks = append(listOfSoundtracks, data)
}
}
}
song := getSongFromList(listOfSoundtracks)
lastFetchedNew = song
return song.Path
}
func GetRandomSongClassic() string {
getAllSoundtracks()
var listOfAllSongs []repository.Song
for _, soundtrack := range soundtracksNew {
songList, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
listOfAllSongs = append(listOfAllSongs, songList...)
}
songFound := false
var song repository.Song
for !songFound {
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
soundtrackData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID)
func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) {
m.cacheMu.Lock()
defer m.cacheMu.Unlock()
if len(m.soundtracks) == 0 {
soundtracks, err := m.q.FindAllSoundtracks(ctx)
if err != nil {
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName),
zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName))
return nil, err
}
m.soundtracks = soundtracks
}
return m.soundtracks, nil
}
// Reset clears the play queue and reloads the soundtrack list.
func (m *MusicService) Reset(ctx context.Context) error {
m.queue = nil
m.currentSong = -1
m.cacheMu.Lock()
m.soundtracks = nil
m.cacheMu.Unlock()
_, err := m.allSoundtracks(ctx)
return err
}
// SoundCheckSong returns the path of a random sound check song.
func (m *MusicService) SoundCheckSong() (string, error) {
files, err := os.ReadDir(m.soundCheckPath)
if err != nil {
return "", err
}
files = slices.DeleteFunc(files, func(f os.DirEntry) bool { return f.IsDir() })
if len(files) == 0 {
return "", ErrNoSongs
}
return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil
}
// AddLatestToQueue appends the last fetched random song to the queue.
func (m *MusicService) AddLatestToQueue() {
if m.lastFetched.Path != "" {
m.currentSong = len(m.queue)
m.queue = append(m.queue, m.lastFetched)
m.lastFetched = repository.Song{}
}
}
// AddLatestPlayed increments the play counters of the current song.
func (m *MusicService) AddLatestPlayed(ctx context.Context) error {
if !m.validIndex(m.currentSong) {
return nil
}
return m.markPlayed(ctx, m.queue[m.currentSong])
}
// SetPlayed increments the play counters of the song at the given queue position.
func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error {
if !m.validIndex(songNumber) {
return nil
}
return m.markPlayed(ctx, m.queue[songNumber])
}
func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error {
if err := m.q.AddSoundtrackPlayed(ctx, song.SoundtrackID); err != nil {
return err
}
return m.q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: song.SoundtrackID, SongName: song.SongName})
}
func (m *MusicService) validIndex(i int) bool {
return i >= 0 && i < len(m.queue)
}
// RandomSong picks a random song from a random soundtrack.
func (m *MusicService) RandomSong(ctx context.Context) (string, error) {
soundtracks, err := m.allSoundtracks(ctx)
if err != nil {
return "", err
}
candidates := make([]weightedSoundtrack, len(soundtracks))
for i, s := range soundtracks {
candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1}
}
return m.pickAndRemember(ctx, candidates)
}
// RandomSongLowChance picks a random song, favouring soundtracks played less than average.
func (m *MusicService) RandomSongLowChance(ctx context.Context) (string, error) {
soundtracks, err := m.allSoundtracks(ctx)
if err != nil {
return "", err
}
return m.pickAndRemember(ctx, lowChanceWeights(soundtracks))
}
func (m *MusicService) pickAndRemember(ctx context.Context, candidates []weightedSoundtrack) (string, error) {
song, err := m.pickSong(ctx, candidates)
if err != nil {
return "", err
}
m.lastFetched = song
return song.Path, nil
}
// RandomSongClassic picks a random song where every song has the same chance.
func (m *MusicService) RandomSongClassic(ctx context.Context) (string, error) {
for range maxPickAttempts {
row, err := m.q.GetRandomActiveSong(ctx)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNoSongs
}
if err != nil {
return "", err
}
if !songIsPlayable(row.Song, row.SoundtrackPath) {
if err := m.removeBrokenSong(ctx, row.Song); err != nil {
return "", err
}
continue
}
//Check if file exists and open
openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && soundtrackData.Path+*song.FileName != song.Path) {
//File not found
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName),
zap.String("soundtrack", soundtrackData.SoundtrackName),
zap.String("filename", *song.FileName))
} else {
songFound = true
}
err = openFile.Close()
if err != nil {
logging.GetLogger().Error("Failed to close file", zap.String("error", err.Error()))
}
m.lastFetched = row.Song
return row.Song.Path, nil
}
lastFetchedNew = song
return song.Path
return "", ErrNoSongs
}
func GetSongInfo() SongInfo {
if songQueNew == nil {
// SongInfo returns information about the current song in the queue.
func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
if !m.validIndex(m.currentSong) {
return SongInfo{}
}
var currentSongData = songQueNew[currentSong]
currentSoundtrackData := getCurrentSoundtrack(currentSongData)
song := m.queue[m.currentSong]
soundtrack := m.soundtrackFor(ctx, song)
return SongInfo{
Soundtrack: currentSoundtrackData.SoundtrackName,
SoundtrackPlayed: currentSoundtrackData.TimesPlayed,
Song: currentSongData.SongName,
SongPlayed: currentSongData.TimesPlayed,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: true,
SongNo: currentSong,
SongNo: m.currentSong,
}
}
func GetPlayedSongs() []SongInfo {
// PlayedSongs returns all songs in the queue.
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
var songList []SongInfo
for i, song := range songQueNew {
soundtrackData := getCurrentSoundtrack(song)
for i, song := range m.queue {
soundtrack := m.soundtrackFor(ctx, song)
songList = append(songList, SongInfo{
Soundtrack: soundtrackData.SoundtrackName,
SoundtrackPlayed: soundtrackData.TimesPlayed,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
Song: song.SongName,
SongPlayed: song.TimesPlayed,
CurrentlyPlaying: i == currentSong,
CurrentlyPlaying: i == m.currentSong,
SongNo: i,
})
}
return songList
}
func GetSong(song string) string {
currentSong, _ = strconv.Atoi(song)
if currentSong >= len(songQueNew) {
currentSong = len(songQueNew) - 1
} else if currentSong < 0 {
currentSong = 0
// Song jumps to the given queue position (clamped to the queue) and returns its path.
func (m *MusicService) Song(songNumber int) (string, error) {
if len(m.queue) == 0 {
return "", ErrQueueEmpty
}
songData := songQueNew[currentSong]
return songData.Path
m.currentSong = max(0, min(songNumber, len(m.queue)-1))
return m.queue[m.currentSong].Path, nil
}
func GetAllSoundtracks() []string {
getAllSoundtracks()
var jsonArray []string
for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
// NextSong moves to the next song in the queue, staying on the last one at the end.
func (m *MusicService) NextSong() (string, error) {
if len(m.queue) == 0 {
return "", ErrQueueEmpty
}
return jsonArray
return m.Song(m.currentSong + 1)
}
func GetAllSoundtracksRandom() []string {
getAllSoundtracks()
var jsonArray []string
for _, soundtrack := range soundtracksNew {
jsonArray = append(jsonArray, soundtrack.SoundtrackName)
// PreviousSong moves to the previous song in the queue, staying on the first one at the start.
func (m *MusicService) PreviousSong() (string, error) {
if len(m.queue) == 0 {
return "", ErrQueueEmpty
}
rand.Shuffle(len(jsonArray), func(i, j int) { jsonArray[i], jsonArray[j] = jsonArray[j], jsonArray[i] })
return jsonArray
return m.Song(m.currentSong - 1)
}
func GetNextSong() string {
if songQueNew == nil {
return ""
// SoundtrackNames returns all soundtrack names in alphabetical order.
func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
soundtracks, err := m.allSoundtracks(ctx)
if err != nil {
return nil, err
}
if currentSong == len(songQueNew)-1 || currentSong == -1 {
songData := songQueNew[currentSong]
return songData.Path
} else {
currentSong = currentSong + 1
songData := songQueNew[currentSong]
return songData.Path
names := make([]string, len(soundtracks))
for i, s := range soundtracks {
names[i] = s.SoundtrackName
}
return names, nil
}
func GetPreviousSong() string {
if songQueNew == nil {
return ""
}
if currentSong == -1 || currentSong == 0 {
songData := songQueNew[0]
return songData.Path
} else {
currentSong = currentSong - 1
songData := songQueNew[currentSong]
return songData.Path
}
// SoundtrackNamesRandom returns all soundtrack names in random order.
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
names, err := m.SoundtrackNames(ctx)
rand.Shuffle(len(names), func(i, j int) { names[i], names[j] = names[j], names[i] })
return names, err
}
func getSongFromList(soundtracks []repository.Soundtrack) repository.Song {
songFound := false
var song repository.Song
for !songFound {
soundtrack := getRandomSoundtrack(soundtracks)
songs, _ := BackendRepo().FindSongsFromSoundtrack(BackendCtx(), soundtrack.ID)
type weightedSoundtrack struct {
soundtrack repository.Soundtrack
weight int
}
// lowChanceWeights gives each soundtrack a weight of how far it is below the average play count (at least 1).
func lowChanceWeights(soundtracks []repository.Soundtrack) []weightedSoundtrack {
average := averagePlayed(soundtracks)
weighted := make([]weightedSoundtrack, len(soundtracks))
for i, s := range soundtracks {
weighted[i] = weightedSoundtrack{soundtrack: s, weight: max(1, int(average-s.TimesPlayed))}
}
return weighted
}
func averagePlayed(soundtracks []repository.Soundtrack) int32 {
if len(soundtracks) == 0 {
return 0
}
var sum int64
for _, s := range soundtracks {
sum += int64(s.TimesPlayed)
}
return int32(sum / int64(len(soundtracks)))
}
func pickWeighted(candidates []weightedSoundtrack) int {
total := 0
for _, c := range candidates {
total += c.weight
}
n := rand.IntN(total)
for i, c := range candidates {
if n < c.weight {
return i
}
n -= c.weight
}
return len(candidates) - 1
}
// pickSong picks a random playable song. Soundtracks without songs are dropped from the candidates
// and broken songs are removed from the database.
func (m *MusicService) pickSong(ctx context.Context, candidates []weightedSoundtrack) (repository.Song, error) {
candidates = slices.Clone(candidates)
for range maxPickAttempts {
if len(candidates) == 0 {
return repository.Song{}, ErrNoSongs
}
i := pickWeighted(candidates)
soundtrack := candidates[i].soundtrack
songs, err := m.q.FindSongsFromSoundtrack(ctx, soundtrack.ID)
if err != nil {
return repository.Song{}, err
}
if len(songs) == 0 {
candidates = slices.Delete(candidates, i, i+1)
continue
}
song = songs[rand.Intn(len(songs))]
song := songs[rand.IntN(len(songs))]
logging.GetLogger().Debug("Selected song", zap.String("song", song.SongName), zap.String("path", song.Path))
//Check if file exists and open
openFile, err := os.Open(song.Path)
if err != nil || (song.FileName != nil && soundtrack.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
//File not found
BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
logging.GetLogger().Warn("Song not found, removed from database",
zap.String("song", song.SongName),
zap.String("soundtrack", soundtrack.SoundtrackName),
zap.Any("filename", song.FileName))
} else {
songFound = true
}
err = openFile.Close()
if err != nil {
logging.GetLogger().Error("Failed to close file", zap.String("path", song.Path), zap.String("error", err.Error()))
if !songIsPlayable(song, soundtrack.Path) {
if err := m.removeBrokenSong(ctx, song); err != nil {
return repository.Song{}, err
}
continue
}
return song, nil
}
return song
return repository.Song{}, ErrNoSongs
}
func getCurrentSoundtrack(currentSongData repository.Song) repository.Soundtrack {
for _, soundtrack := range soundtracksNew {
if soundtrack.ID == currentSongData.SoundtrackID {
func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Song) error {
logging.GetLogger().Warn("Song not found, removing from database",
zap.String("song", song.SongName),
zap.String("path", song.Path))
return m.q.RemoveBrokenSong(ctx, repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path})
}
// songIsPlayable reports whether the song file exists and its stored path is consistent.
func songIsPlayable(song repository.Song, soundtrackPath string) bool {
if song.FileName != nil {
if soundtrackPath+*song.FileName != song.Path || strings.HasSuffix(strings.ToLower(*song.FileName), ".wav") {
return false
}
}
info, err := os.Stat(song.Path)
return err == nil && !info.IsDir()
}
func (m *MusicService) soundtrackFor(ctx context.Context, song repository.Song) repository.Soundtrack {
soundtracks, err := m.allSoundtracks(ctx)
if err != nil {
logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err))
return repository.Soundtrack{}
}
for _, soundtrack := range soundtracks {
if soundtrack.ID == song.SoundtrackID {
return soundtrack
}
}
return repository.Soundtrack{}
}
func getAveragePlayed() int32 {
getAllSoundtracks()
var sum int32
for _, data := range soundtracksNew {
sum += data.TimesPlayed
}
return sum / int32(len(soundtracksNew))
}
func getRandomSoundtrack(listOfSoundtracks []repository.Soundtrack) repository.Soundtrack {
return listOfSoundtracks[rand.Intn(len(listOfSoundtracks))]
}
-201
View File
@@ -1,201 +0,0 @@
package backend
import (
"math/rand"
"testing"
"music-server/internal/db/repository"
)
// Test the average calculation logic directly without database access
func TestCalculateAverage(t *testing.T) {
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
var sum int32
for _, data := range soundtracks {
sum += data.TimesPlayed
}
result := sum / int32(len(soundtracks))
expected := int32(20)
if result != expected {
t.Errorf("Average calculation = %v, want %v", result, expected)
}
}
func TestCalculateAverageEmpty(t *testing.T) {
soundtracks := []repository.Soundtrack{}
if len(soundtracks) == 0 {
result := int32(0)
expected := int32(0)
if result != expected {
t.Errorf("Average calculation with empty list = %v, want %v", result, expected)
}
return
}
var sum int32
for _, data := range soundtracks {
sum += data.TimesPlayed
}
result := sum / int32(len(soundtracks))
expected := int32(0)
if result != expected {
t.Errorf("Average calculation with empty list = %v, want %v", result, expected)
}
}
func TestCalculateAverageSingle(t *testing.T) {
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 42},
}
var sum int32
for _, data := range soundtracks {
sum += data.TimesPlayed
}
result := sum / int32(len(soundtracks))
expected := int32(42)
if result != expected {
t.Errorf("Average calculation with single soundtrack = %v, want %v", result, expected)
}
}
func TestGetRandomSoundtrack(t *testing.T) {
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
// Set seed for reproducible tests
rand.Seed(42)
result := soundtracks[rand.Intn(len(soundtracks))]
if result.SoundtrackName == "" {
t.Error("random soundtrack selection returned empty soundtrack")
}
found := false
for _, s := range soundtracks {
if s.SoundtrackName == result.SoundtrackName {
found = true
break
}
}
if !found {
t.Errorf("random soundtrack selection returned soundtrack not in list: %v", result.SoundtrackName)
}
}
func TestFindSoundtrackByID(t *testing.T) {
soundtracks := []repository.Soundtrack{
{ID: 1, SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{ID: 3, SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
tests := []struct {
name string
soundtracks []repository.Soundtrack
soundtrackID int32
expected repository.Soundtrack
}{
{
name: "existing soundtrack",
soundtracks: soundtracks,
soundtrackID: 2,
expected: repository.Soundtrack{ID: 2, SoundtrackName: "Soundtrack2", TimesPlayed: 20},
},
{
name: "non-existing soundtrack",
soundtracks: soundtracks,
soundtrackID: 99,
expected: repository.Soundtrack{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result repository.Soundtrack
for _, s := range tt.soundtracks {
if s.ID == tt.soundtrackID {
result = s
break
}
}
if result.ID != tt.expected.ID || result.SoundtrackName != tt.expected.SoundtrackName {
t.Errorf("findSoundtrackByID() = %v, want %v", result, tt.expected)
}
})
}
}
func TestExtractSoundtrackNames(t *testing.T) {
soundtracks := []repository.Soundtrack{
{SoundtrackName: "Soundtrack1", TimesPlayed: 10},
{SoundtrackName: "Soundtrack2", TimesPlayed: 20},
{SoundtrackName: "Soundtrack3", TimesPlayed: 30},
}
var result []string
for _, s := range soundtracks {
result = append(result, s.SoundtrackName)
}
expected := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
if len(result) != len(expected) {
t.Errorf("extractSoundtrackNames() length = %d, want %d", len(result), len(expected))
return
}
for i, v := range result {
if v != expected[i] {
t.Errorf("extractSoundtrackNames()[%d] = %v, want %v", i, v, expected[i])
}
}
}
func TestShuffleSoundtrackNames(t *testing.T) {
soundtracks := []string{"Soundtrack1", "Soundtrack2", "Soundtrack3"}
// Test that shuffle doesn't lose any elements
// We can't test the order since it's random, but we can test length and contents
original := make([]string, len(soundtracks))
copy(original, soundtracks)
// Simple shuffle implementation for testing
for i := range soundtracks {
j := i // In real code this would be random
soundtracks[i], soundtracks[j] = soundtracks[j], soundtracks[i]
}
if len(soundtracks) != len(original) {
t.Errorf("shuffleSoundtrackNames() changed length from %d to %d", len(original), len(soundtracks))
return
}
// Check all original elements are still present
for _, orig := range original {
found := false
for _, s := range soundtracks {
if s == orig {
found = true
break
}
}
if !found {
t.Errorf("shuffleSoundtrackNames() lost element: %v", orig)
}
}
}
+114
View File
@@ -0,0 +1,114 @@
package backend
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"music-server/internal/logging"
"go.uber.org/zap"
)
const releaseCacheTTL = 5 * time.Minute
type giteaRelease struct {
Id int `json:"id"`
Name string `json:"name"`
Assets []giteaAsset `json:"assets"`
}
type giteaAsset struct {
Id int `json:"id"`
Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"`
}
// ReleaseService looks up the latest client release on Gitea.
type ReleaseService struct {
url string
client *http.Client
now func() time.Time
mu sync.Mutex
cached giteaRelease
fetchedAt time.Time
}
// NewReleaseService creates a ReleaseService that reads the latest release from url.
func NewReleaseService(url string) *ReleaseService {
return &ReleaseService{url: url, client: &http.Client{Timeout: 10 * time.Second}, now: time.Now}
}
func (r *ReleaseService) latest(ctx context.Context) (giteaRelease, error) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.fetchedAt.IsZero() && r.now().Sub(r.fetchedAt) < releaseCacheTTL {
return r.cached, nil
}
release, err := r.fetch(ctx)
if err != nil {
logging.GetLogger().Error("Failed to fetch latest release", zap.String("url", r.url), zap.Error(err))
return giteaRelease{}, fmt.Errorf("%w: %v", ErrUpstream, err)
}
r.cached, r.fetchedAt = release, r.now()
return release, nil
}
func (r *ReleaseService) fetch(ctx context.Context) (giteaRelease, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.url, nil)
if err != nil {
return giteaRelease{}, err
}
resp, err := r.client.Do(req)
if err != nil {
return giteaRelease{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return giteaRelease{}, fmt.Errorf("unexpected status %s", resp.Status)
}
var release giteaRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return giteaRelease{}, err
}
return release, nil
}
// LatestVersion returns the name of the latest release.
func (r *ReleaseService) LatestVersion(ctx context.Context) (string, error) {
release, err := r.latest(ctx)
return release.Name, err
}
// AssetNames returns the names of all assets of the latest release.
func (r *ReleaseService) AssetNames(ctx context.Context) ([]string, error) {
release, err := r.latest(ctx)
if err != nil {
return nil, err
}
names := make([]string, len(release.Assets))
for i, asset := range release.Assets {
names[i] = asset.Name
}
return names, nil
}
// AssetURL returns the download URL of the first asset of the latest release with the given suffix.
func (r *ReleaseService) AssetURL(ctx context.Context, suffix string) (string, error) {
release, err := r.latest(ctx)
if err != nil {
return "", err
}
for _, asset := range release.Assets {
if strings.HasSuffix(asset.Name, suffix) {
return asset.DownloadUrl, nil
}
}
return "", ErrNotFound
}
+52 -69
View File
@@ -1,7 +1,7 @@
package backend
import (
"regexp"
"context"
"strings"
"sync"
)
@@ -13,88 +13,71 @@ type SoundtrackData struct {
Cleaned string
}
var (
searchCache []SoundtrackData
searchCacheMutex sync.Mutex
searchRegexCache = make(map[string]*regexp.Regexp)
searchRegexCacheMutex sync.Mutex
)
// SearchCache holds precomputed search data for all soundtrack names.
type SearchCache struct {
load func(context.Context) ([]string, error)
// GetSearchCache returns the precomputed search cache, initializing it if needed
func GetSearchCache() []SoundtrackData {
searchCacheMutex.Lock()
defer searchCacheMutex.Unlock()
if len(searchCache) == 0 {
loadSearchCache()
}
return searchCache
mu sync.Mutex
cache []SoundtrackData
}
// ReloadSearchCache clears and reloads the search cache.
// This should be called after a sync operation to ensure search results are up to date.
func ReloadSearchCache() {
searchCacheMutex.Lock()
defer searchCacheMutex.Unlock()
searchCache = nil
loadSearchCache()
// NewSearchCache creates a SearchCache that gets the soundtrack names from load.
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache {
return &SearchCache{load: load}
}
// loadSearchCache loads the soundtrack data into the cache
func loadSearchCache() {
soundtracks := GetAllSoundtracks()
searchCache = make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks {
cleaned := cleanSearchTerm(soundtrack)
lowercase := strings.ToLower(soundtrack)
searchCache[i] = SoundtrackData{
Original: soundtrack,
Lowercase: lowercase,
Cleaned: cleaned,
// Get returns the precomputed search data, loading it if needed.
func (s *SearchCache) Get(ctx context.Context) ([]SoundtrackData, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.cache) == 0 {
if err := s.reload(ctx); err != nil {
return nil, err
}
}
return s.cache, nil
}
// CleanSearchTermForMatch cleans a search term for better matching
func CleanSearchTermForMatch(term string) string {
term = strings.ReplaceAll(term, " ", "")
term = strings.ReplaceAll(term, "é", "e")
term = strings.ReplaceAll(term, "+", "plus")
term = strings.ReplaceAll(term, "&", "and")
term = strings.ReplaceAll(term, "'n", "and")
return strings.ToLower(term)
// Reload reloads the search data. Call it after a sync.
func (s *SearchCache) Reload(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.reload(ctx)
}
// cleanSearchTerm cleans a search term for better matching (internal)
func cleanSearchTerm(term string) string {
return CleanSearchTermForMatch(term)
}
// MatchRegexCached checks if a search term matches using cached compiled regex
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
if searchTerm == "" {
return true
func (s *SearchCache) reload(ctx context.Context) error {
soundtracks, err := s.load(ctx)
if err != nil {
return err
}
// Check cache first
searchRegexCacheMutex.Lock()
re, exists := searchRegexCache[searchTerm]
if !exists {
re = compileSearchRegex(searchTerm)
searchRegexCache[searchTerm] = re
cache := make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks {
cache[i] = SoundtrackData{
Original: soundtrack,
Lowercase: strings.ToLower(soundtrack),
Cleaned: CleanSearchTerm(soundtrack),
}
}
searchRegexCacheMutex.Unlock()
return re.MatchString(soundtrackName)
s.cache = cache
return nil
}
// compileSearchRegex compiles a search term into a regex pattern
func compileSearchRegex(searchTerm string) *regexp.Regexp {
regText := ".*"
for _, letter := range searchTerm {
regText += string(letter) + ".*"
var searchReplacer = strings.NewReplacer(" ", "", "é", "e", "+", "plus", "&", "and", "'n", "and")
// CleanSearchTerm normalises a search term or soundtrack name for matching.
func CleanSearchTerm(term string) string {
return searchReplacer.Replace(strings.ToLower(term))
}
// MatchesSubsequence reports whether all characters of term appear in text in the same order.
func MatchesSubsequence(term, text string) bool {
rest := text
for _, r := range term {
i := strings.IndexRune(rest, r)
if i < 0 {
return false
}
rest = rest[i+len(string(r)):]
}
re, _ := regexp.Compile(regText)
return re
return true
}
+68 -188
View File
@@ -1,12 +1,11 @@
package backend
import (
"context"
"encoding/json"
"time"
"music-server/internal/logging"
"go.uber.org/zap"
"music-server/internal/db/repository"
)
// SoundtrackWithSongs represents a soundtrack with its songs for statistics
@@ -39,239 +38,120 @@ type StatisticsSummary struct {
MinSoundtrackPlays int64 `json:"min_soundtrack_plays"`
}
// StatisticsHandler manages statistics operations
type StatisticsHandler struct {
// Uses the global backend repo initialized via InitBackend
// StatisticsService reads play statistics.
type StatisticsService struct {
q repository.Querier
}
// NewStatisticsHandler creates a new StatisticsHandler
func NewStatisticsHandler() *StatisticsHandler {
return &StatisticsHandler{}
// NewStatisticsService creates a StatisticsService.
func NewStatisticsService(q repository.Querier) *StatisticsService {
return &StatisticsService{q: q}
}
// GetMostPlayedSoundtracksWithSongs returns the top N most played soundtracks with their songs
func (h *StatisticsHandler) GetMostPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
// soundtrackRow covers the generated rows that share the soundtrack-with-songs shape.
type soundtrackRow interface {
repository.GetMostPlayedSoundtracksWithSongsRow |
repository.GetLeastPlayedSoundtracksWithSongsRow |
repository.GetLastPlayedSoundtracksRow |
repository.GetOldestPlayedSoundtracksRow
}
// Get raw results
rows, err := queries.GetMostPlayedSoundtracksWithSongs(ctx, limit)
func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, error) {
if err != nil {
return nil, err
}
// Convert to SoundtrackWithSongs
var result []SoundtrackWithSongs
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)
}
}
for _, r := range rows {
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
Songs: parseSongs(row.Songs),
})
}
return result, nil
}
// GetLeastPlayedSoundtracksWithSongs returns the top N least played soundtracks with their songs
func (h *StatisticsHandler) GetLeastPlayedSoundtracksWithSongs(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetLeastPlayedSoundtracksWithSongs(ctx, limit)
if err != nil {
return nil, err
}
var result []SoundtrackWithSongs
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, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
})
}
return result, nil
type songRow interface {
repository.GetMostPlayedSongsWithSoundtrackRow | repository.GetLeastPlayedSongsWithSoundtrackRow
}
// GetMostPlayedSongsWithSoundtrack returns the top N most played songs with their soundtrack info
func (h *StatisticsHandler) GetMostPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetMostPlayedSongsWithSoundtrack(ctx, limit)
func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
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,
})
for _, r := range rows {
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
}
return result, nil
}
// GetLeastPlayedSongsWithSoundtrack returns the top N least played songs with their soundtrack info
func (h *StatisticsHandler) GetLeastPlayedSongsWithSoundtrack(limit int32) ([]SongInfoForStats, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetLeastPlayedSongsWithSoundtrack(ctx, limit)
if err != nil {
return nil, err
func parseSongs(data []byte) []SongInfoForStats {
songs := []SongInfoForStats{}
if data != nil {
if err := json.Unmarshal(data, &songs); err != nil {
return []SongInfoForStats{}
}
}
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
return songs
}
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
func (h *StatisticsHandler) GetNeverPlayedSoundtracks() ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
// MostPlayedSoundtracks returns the top N most played soundtracks with their songs
func (s *StatisticsService) MostPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetMostPlayedSoundtracksWithSongs(ctx, limit))
}
rows, err := queries.GetNeverPlayedSoundtracks(ctx)
// LeastPlayedSoundtracks returns the top N least played soundtracks with their songs
func (s *StatisticsService) LeastPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetLeastPlayedSoundtracksWithSongs(ctx, limit))
}
// LastPlayedSoundtracks returns the most recently played soundtracks
func (s *StatisticsService) LastPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetLastPlayedSoundtracks(ctx, limit))
}
// OldestPlayedSoundtracks returns the least recently played soundtracks
func (s *StatisticsService) OldestPlayedSoundtracks(ctx context.Context, limit int32) ([]SoundtrackWithSongs, error) {
return toSoundtracks(s.q.GetOldestPlayedSoundtracks(ctx, limit))
}
// NeverPlayedSoundtracks returns soundtracks that have never been played
func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]SoundtrackWithSongs, error) {
rows, err := s.q.GetNeverPlayedSoundtracks(ctx)
if err != nil {
return nil, err
}
var result []SoundtrackWithSongs
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, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: nil,
Songs: songs,
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
Songs: parseSongs(row.Songs),
})
}
return result, nil
}
// GetLastPlayedSoundtracks returns the most recently played soundtracks
func (h *StatisticsHandler) GetLastPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetLastPlayedSoundtracks(ctx, limit)
if err != nil {
return nil, err
}
var result []SoundtrackWithSongs
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, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
})
}
return result, nil
// MostPlayedSongs returns the top N most played songs with their soundtrack info
func (s *StatisticsService) MostPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
return toSongs(s.q.GetMostPlayedSongsWithSoundtrack(ctx, limit))
}
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(limit int32) ([]SoundtrackWithSongs, error) {
queries := BackendRepo()
ctx := BackendCtx()
rows, err := queries.GetOldestPlayedSoundtracks(ctx, limit)
if err != nil {
return nil, err
}
var result []SoundtrackWithSongs
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, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
SoundtrackName: row.SoundtrackName,
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
Songs: songs,
})
}
return result, nil
// LeastPlayedSongs returns the top N least played songs with their soundtrack info
func (s *StatisticsService) LeastPlayedSongs(ctx context.Context, limit int32) ([]SongInfoForStats, error) {
return toSongs(s.q.GetLeastPlayedSongsWithSoundtrack(ctx, limit))
}
// GetStatisticsSummary returns overall statistics
func (h *StatisticsHandler) GetStatisticsSummary() (*StatisticsSummary, error) {
queries := BackendRepo()
ctx := BackendCtx()
row, err := queries.GetStatisticsSummary(ctx)
// Summary returns overall statistics
func (s *StatisticsService) Summary(ctx context.Context) (StatisticsSummary, error) {
row, err := s.q.GetStatisticsSummary(ctx)
if err != nil {
return nil, err
}
return &StatisticsSummary{
TotalSoundtracks: int64(row.TotalSoundtracks),
PlayedSoundtracks: int64(row.PlayedSoundtracks),
NeverPlayedSoundtracks: int64(row.NeverPlayedSoundtracks),
TotalSoundtrackPlays: int64(row.TotalSoundtrackPlays),
AvgSoundtrackPlays: float64(row.AvgSoundtrackPlays),
MaxSoundtrackPlays: int64(row.MaxSoundtrackPlays),
MinSoundtrackPlays: 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()))
return StatisticsSummary{}, err
}
return StatisticsSummary(row), nil
}
+590 -518
View File
File diff suppressed because it is too large. Load diff
-204
View File
@@ -1,204 +0,0 @@
package backend
import (
"io/fs"
"os"
"testing"
"time"
)
func TestContains(t *testing.T) {
tests := []struct {
name string
slice []string
search string
expected bool
}{
{
name: "element exists",
slice: []string{"a", "b", "c"},
search: "b",
expected: true,
},
{
name: "element does not exist",
slice: []string{"a", "b", "c"},
search: "d",
expected: false,
},
{
name: "empty slice",
slice: []string{},
search: "a",
expected: false,
},
{
name: "element at start",
slice: []string{"a", "b", "c"},
search: "a",
expected: true,
},
{
name: "element at end",
slice: []string{"a", "b", "c"},
search: "c",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := contains(tt.slice, tt.search)
if result != tt.expected {
t.Errorf("contains() = %v, want %v", result, tt.expected)
}
})
}
}
func TestIsSong(t *testing.T) {
mockFileInfo := &mockFileInfoForSong{name: "test.mp3", isDir: false, size: 100}
result := isSong(mockFileInfo)
if !result {
t.Error("isSong() should return true for .mp3 file")
}
mockFileInfo2 := &mockFileInfoForSong{name: "test.txt", isDir: false, size: 100}
result = isSong(mockFileInfo2)
if result {
t.Error("isSong() should return false for .txt file")
}
mockFileInfo3 := &mockFileInfoForSong{name: "test", isDir: true, size: 100}
result = isSong(mockFileInfo3)
if result {
t.Error("isSong() should return false for directory")
}
}
func TestIsCoverImage(t *testing.T) {
tests := []struct {
name string
fileInfo fs.FileInfo
expected bool
}{
{
name: "cover.jpg",
fileInfo: &mockFileInfoForCover{name: "cover.jpg", isDir: false, size: 100},
expected: true,
},
{
name: "cover.png",
fileInfo: &mockFileInfoForCover{name: "cover.png", isDir: false, size: 100},
expected: true,
},
{
name: "my_cover.jpg",
fileInfo: &mockFileInfoForCover{name: "my_cover.jpg", isDir: false, size: 100},
expected: true,
},
{
name: "image.jpg",
fileInfo: &mockFileInfoForCover{name: "image.jpg", isDir: false, size: 100},
expected: false,
},
{
name: "cover.txt",
fileInfo: &mockFileInfoForCover{name: "cover.txt", isDir: false, size: 100},
expected: false,
},
{
name: "directory",
fileInfo: &mockFileInfoForCover{name: "cover", isDir: true, size: 100},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isCoverImage(tt.fileInfo)
if result != tt.expected {
t.Errorf("isCoverImage() = %v, want %v", result, tt.expected)
}
})
}
}
func TestGetIdFromFileNew(t *testing.T) {
tests := []struct {
name string
fileInfo os.FileInfo
expected int32
}{
{
name: "valid id file",
fileInfo: &mockFileInfoForId{name: ".123.id", isDir: false, size: 100},
expected: 123,
},
{
name: "invalid id file (directory)",
fileInfo: &mockFileInfoForId{name: ".123.id", isDir: true, size: 100},
expected: -1,
},
{
name: "invalid id file (no .id extension)",
fileInfo: &mockFileInfoForId{name: "123.txt", isDir: false, size: 100},
expected: -1,
},
{
name: "invalid id file (not a number)",
fileInfo: &mockFileInfoForId{name: ".abc.id", isDir: false, size: 100},
expected: 0, // strconv.Atoi returns 0 for invalid numbers (error is ignored)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getIdFromFile(tt.fileInfo)
if result != tt.expected {
t.Errorf("getIdFromFile() = %v, want %v", result, tt.expected)
}
})
}
}
// Mock types for testing
type mockFileInfoForSong struct {
name string
isDir bool
size int64
}
func (m *mockFileInfoForSong) Name() string { return m.name }
func (m *mockFileInfoForSong) Size() int64 { return m.size }
func (m *mockFileInfoForSong) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForSong) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForSong) IsDir() bool { return m.isDir }
func (m *mockFileInfoForSong) Sys() interface{} { return nil }
type mockFileInfoForCover struct {
name string
isDir bool
size int64
}
func (m *mockFileInfoForCover) Name() string { return m.name }
func (m *mockFileInfoForCover) Size() int64 { return m.size }
func (m *mockFileInfoForCover) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForCover) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForCover) IsDir() bool { return m.isDir }
func (m *mockFileInfoForCover) Sys() interface{} { return nil }
type mockFileInfoForId struct {
name string
isDir bool
size int64
}
func (m *mockFileInfoForId) Name() string { return m.name }
func (m *mockFileInfoForId) Size() int64 { return m.size }
func (m *mockFileInfoForId) Mode() os.FileMode { return 0 }
func (m *mockFileInfoForId) ModTime() time.Time { return time.Time{} }
func (m *mockFileInfoForId) IsDir() bool { return m.isDir }
func (m *mockFileInfoForId) Sys() interface{} { return nil }
+118
View File
@@ -0,0 +1,118 @@
// Package config loads the application configuration from environment variables.
package config
import (
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
)
// DefaultReleasesURL is the Gitea endpoint used to look up the latest client release.
const DefaultReleasesURL = "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest"
// Config holds all settings read from the environment.
type Config struct {
Port int
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
MusicPath string
CharactersPath string
SoundCheckPath string
FrontendPath string
LogLevel string
LogJSON bool
// APIPassword is required to create tokens. Empty means any password is accepted.
APIPassword string
DisableSessionAuth bool
// TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs.
TrustedProxies []*net.IPNet
ReleasesURL string
}
// Load reads the configuration from the environment and validates it.
func Load() (Config, error) {
return load(os.Getenv)
}
func load(getenv func(string) string) (Config, error) {
cfg := Config{
DBHost: getenv("DB_HOST"),
DBPort: getenv("DB_PORT"),
DBUser: getenv("DB_USERNAME"),
DBPassword: getenv("DB_PASSWORD"),
DBName: getenv("DB_NAME"),
MusicPath: getenv("MUSIC_PATH"),
CharactersPath: getenv("CHARACTERS_PATH"),
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
LogJSON: getenv("LOG_JSON") == "true",
APIPassword: getenv("API_PASSWORD"),
DisableSessionAuth: getenv("DISABLE_SESSION_AUTH") == "true",
ReleasesURL: withDefault(getenv("RELEASES_URL"), DefaultReleasesURL),
}
var errs []error
port, err := strconv.Atoi(withDefault(getenv("PORT"), "8080"))
if err != nil {
errs = append(errs, fmt.Errorf("PORT: %w", err))
}
cfg.Port = port
for name, value := range map[string]string{
"DB_HOST": cfg.DBHost,
"DB_PORT": cfg.DBPort,
"DB_USERNAME": cfg.DBUser,
"DB_PASSWORD": cfg.DBPassword,
"DB_NAME": cfg.DBName,
"MUSIC_PATH": cfg.MusicPath,
"CHARACTERS_PATH": cfg.CharactersPath,
} {
if value == "" {
errs = append(errs, fmt.Errorf("%s is required", name))
}
}
cfg.TrustedProxies, err = parseCIDRs(getenv("TRUSTED_PROXIES"))
if err != nil {
errs = append(errs, fmt.Errorf("TRUSTED_PROXIES: %w", err))
}
return cfg, errors.Join(errs...)
}
func parseCIDRs(value string) ([]*net.IPNet, error) {
var nets []*net.IPNet
for part := range strings.SplitSeq(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
_, ipNet, err := net.ParseCIDR(part)
if err != nil {
return nil, err
}
nets = append(nets, ipNet)
}
return nets, nil
}
func withDefault(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
+69 -84
View File
@@ -2,54 +2,57 @@ package db
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"net"
"net/url"
"time"
"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"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/lib/pq"
"github.com/jackc/pgx/v5/stdlib"
"go.uber.org/zap"
)
// Database holds the database connection pool and context
//go:embed "migrations/*.sql"
var MigrationsFs embed.FS
// Database holds the database connection pool
type Database struct {
Pool *pgxpool.Pool
Ctx context.Context
}
// ConnString builds a PostgreSQL connection URL.
func ConnString(host, port, user, password, dbname string) string {
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(user, password),
Host: net.JoinHostPort(host, port),
Path: dbname,
RawQuery: "sslmode=disable",
}
return u.String()
}
// 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)
func NewDatabase(ctx context.Context, connString string) (*Database, error) {
pool, err := pgxpool.New(ctx, connString)
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 {
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("database query failed: %w", err)
return nil, fmt.Errorf("database ping failed: %w", err)
}
logging.GetLogger().Info("Database connected", zap.String("status", success))
return &Database{Pool: pool, Ctx: ctx}, nil
logging.GetLogger().Info("Database connected")
return &Database{Pool: pool}, nil
}
// Close closes the database connection pool
@@ -61,82 +64,64 @@ func (db *Database) Close() {
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func (db *Database) Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
func (db *Database) Health(ctx context.Context) map[string]string {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := db.Pool.Ping(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = err.Error()
return stats
if err := db.Pool.Ping(ctx); err != nil {
return map[string]string{"status": "down", "error": err.Error()}
}
stats["status"] = "up"
return stats
return map[string]string{"status": "up"}
}
// 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)
m, closeFn, err := db.NewMigrator()
if err != nil {
return fmt.Errorf("failed to open database for migration: %w", err)
return err
}
defer sqlDb.Close()
defer closeFn()
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
version, _, err := m.Version()
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
if err := m.Up(); err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migration failed: %w", err)
}
logging.GetLogger().Info("Database already up to date")
return nil
}
versionAfter, _, _ := m.Version()
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
return nil
}
// NewMigrator returns a migrator for the embedded migrations using the pool's connection settings.
// The returned function closes the underlying connection.
func (db *Database) NewMigrator() (*migrate.Migrate, func(), error) {
sqlDB := stdlib.OpenDB(*db.Pool.Config().ConnConfig)
driver, err := pgxmigrate.WithInstance(sqlDB, &pgxmigrate.Config{})
if err != nil {
return fmt.Errorf("failed to create migration driver: %w", err)
sqlDB.Close()
return nil, nil, 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)
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to read migration files: %w", err)
}
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
m, err := migrate.NewWithInstance("iofs", files, "pgx5", driver)
if err != nil {
return fmt.Errorf("failed to create migrator: %w", err)
sqlDB.Close()
return nil, nil, 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
return m, func() { m.Close() }, nil
}
-207
View File
@@ -1,207 +0,0 @@
package db
import (
"context"
"database/sql"
"embed"
"fmt"
"strconv"
"time"
"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/file"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/lib/pq"
"go.uber.org/zap"
)
// TODO: DEPRECATED - Remove these global variables once all code is migrated to use Database struct
// Use database.go's Database struct instead. These globals remain for backward compatibility
// with legacy code paths. New code should use the Database struct from database.go.
var Dbpool *pgxpool.Pool
var Ctx = context.Background()
//go:embed "migrations/*.sql"
var MigrationsFs embed.FS
func InitDB(host string, port string, user string, password string, dbname string) {
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))
var err error
Dbpool, err = pgxpool.New(Ctx, psqlInfo)
if err != nil {
logging.GetLogger().Fatal("Unable to connect to database", zap.String("error", err.Error()))
}
var success string
err = Dbpool.QueryRow(Ctx, "select 'Successfully connected!'").Scan(&success)
if err != nil {
logging.GetLogger().Fatal("Database query failed", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Database connected", zap.String("status", success))
}
func CloseDb() {
logging.GetLogger().Info("Closing database connection")
Dbpool.Close()
}
func ResetSoundtrackIdSeq() {
_, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
if err != nil {
logging.GetLogger().Error("Failed to reset soundtrack ID sequence", zap.String("error", err.Error()))
}
}
func createDb(host string, port string, user string, password string, dbname string) {
// Connect to the default postgres database to create new database
// In PostgreSQL, we need to connect to an existing database (postgres) to create a new one
conninfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", host, port, user, password)
db, err := sql.Open("postgres", conninfo)
defer db.Close()
if err != nil {
logging.GetLogger().Fatal("Failed to connect for database creation", zap.String("error", err.Error()))
}
_, err = db.Exec("create database " + dbname)
if err != nil {
//handle the error
logging.GetLogger().Fatal("Failed to create database", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Database created", zap.String("dbname", dbname))
}
func Migrate_db(host string, port string, user string, password string, dbname string) {
migrationInfo := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
user, password, host, port, dbname)
logging.GetLogger().Debug("Migration info", zap.String("url", migrationInfo))
db, err := sql.Open("postgres", migrationInfo)
if err != nil {
logging.GetLogger().Error("Failed to open database for migration", zap.String("error", err.Error()))
}
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
logging.GetLogger().Error("Failed to create migration driver", zap.String("error", err.Error()))
}
files, err := iofs.New(MigrationsFs, "migrations")
if err != nil {
logging.GetLogger().Fatal("Failed to create migration files", zap.String("error", err.Error()))
}
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
if err != nil {
logging.GetLogger().Fatal("Failed to create migrator", zap.String("error", err.Error()))
}
/*m, err := migrate.NewWithDatabaseInstance(
"file://./db/migrations/",
"postgres", driver)
if err != nil {
logging.GetLogger().Error("Migration setup error", zap.String("error", err.Error()))
}*/
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))
//err = m.Force(1)
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
//if err != nil {
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
//}
// 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 completed")
db.Close()
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
//err := s.db.PingContext(ctx)
err := Dbpool.Ping(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
logging.GetLogger().Fatal("Database health check failed", zap.String("error", err.Error()))
return stats
}
// Database is up, add more statistics
stats["status"] = "up"
stats["message"] = "It's healthy"
// Get database stats (like open connections, in use, idle, etc.)
//dbStats := s.db.Stats()
dbStats := Dbpool.Stat()
//stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["open_connections"] = strconv.Itoa(int(dbStats.NewConnsCount()))
//stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["in_use"] = strconv.Itoa(int(dbStats.AcquiredConns()))
//stats["idle"] = strconv.Itoa(dbStats.Idle)
stats["idle"] = strconv.Itoa(int(dbStats.IdleConns()))
//stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
stats["wait_count"] = strconv.FormatInt(dbStats.AcquireCount(), 10)
//stats["wait_duration"] = dbStats.WaitDuration.String()
stats["wait_duration"] = dbStats.AcquireDuration().String()
//stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleDestroyCount(), 10)
//stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeDestroyCount(), 10)
// Evaluate stats to provide a health message
if int(dbStats.NewConnsCount()) > 40 { // Assuming 50 is the max for this example
stats["message"] = "The database is experiencing heavy load."
}
if dbStats.AcquireCount() > 1000 {
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
}
if dbStats.MaxIdleDestroyCount() > int64(dbStats.NewConnsCount())/2 {
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
}
if dbStats.MaxLifetimeDestroyCount() > int64(dbStats.NewConnsCount())/2 {
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
}
return stats
}
-251
View File
@@ -1,251 +0,0 @@
package db
import (
"database/sql"
"fmt"
"os"
"testing"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "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 := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
// Use a unique database name for this test
dbname := "music_server_migration_test"
if host == "" || port == "" || user == "" || password == "" {
t.Skip("Test database environment variables not set (DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD)")
}
// 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, hash)
VALUES ($1, $2, $3, $4)`,
s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name))
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, 9, songCount, "Expected 9 songs")
t.Log("✓ Manually inserted 5 games with 9 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, 9, songCount, "Expected 9 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 using Go migrate library
func applyMigrations(t *testing.T, host, port, user, password, dbname string, steps int) {
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
user, password, host, port, dbname)
db, err := sql.Open("postgres", migrationURL)
require.NoError(t, err)
defer db.Close()
driver, err := postgres.WithInstance(db, &postgres.Config{})
require.NoError(t, err)
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"postgres", driver)
require.NoError(t, err)
// Get current version
version, _, err := m.Version()
if err != nil && err != migrate.ErrNilVersion {
require.NoError(t, err)
}
if err == migrate.ErrNilVersion {
version = 0
}
t.Logf("Current migration version: %d", version)
// Apply exactly 'steps' migrations
if steps > 0 {
err = m.Steps(steps)
if err != nil && err != migrate.ErrNoChange {
require.NoError(t, err)
}
} else if steps < 0 {
err = m.Steps(steps)
require.NoError(t, err)
}
// Get new version
newVersion, _, err := m.Version()
if err != nil && err != migrate.ErrNilVersion {
require.NoError(t, err)
}
if err == migrate.ErrNilVersion {
newVersion = 0
}
t.Logf("Migration version after applying %d steps: %d", steps, newVersion)
}
@@ -0,0 +1 @@
ALTER TABLE soundtrack DROP COLUMN fingerprint;
@@ -0,0 +1,3 @@
-- Cheap fingerprint (file names, sizes and modification times) used to skip
-- content hashing of soundtracks that have not changed since the last sync.
ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
+13 -1
View File
@@ -41,7 +41,7 @@ SELECT * FROM song WHERE uuid = $1;
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
-- name: RemoveBrokenSongs :exec
DELETE FROM song WHERE uuid = ANY($1);
DELETE FROM song WHERE uuid = ANY(sqlc.arg(uuids)::uuid[]);
-- name: GetSongsBySoundtrackUUID :many
SELECT *
@@ -50,3 +50,15 @@ WHERE soundtrack_uuid = $1;
-- name: GetSongUUIDsBySoundtrackId :many
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
-- name: UpdateSongByUUID :exec
UPDATE song SET song_name=sqlc.arg(song_name), file_name=sqlc.arg(file_name), path=sqlc.arg(path), hash=sqlc.arg(hash)
WHERE uuid=sqlc.arg(uuid);
-- name: GetRandomActiveSong :one
SELECT sqlc.embed(song), soundtrack.path AS soundtrack_path
FROM song
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
WHERE soundtrack.deleted IS NULL
ORDER BY random()
LIMIT 1;
+6
View File
@@ -13,6 +13,12 @@ AND deleted IS NULL;
-- name: SetSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL;
-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY(sqlc.arg(ids)::int[]));
-- name: SetSoundtrackFingerprint :exec
UPDATE soundtrack SET fingerprint=sqlc.arg(fingerprint) WHERE id=sqlc.arg(id);
-- name: ClearSoundtracks :exec
DELETE FROM soundtrack;
+1
View File
@@ -51,6 +51,7 @@ type Soundtrack struct {
NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"`
Fingerprint string `json:"fingerprint"`
}
type Vgmq struct {
+76
View File
@@ -0,0 +1,76 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package repository
import (
"context"
"time"
"github.com/google/uuid"
)
type Querier interface {
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error
AddSong(ctx context.Context, arg AddSongParams) error
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
AddSoundtrackPlayed(ctx context.Context, id int32) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) error
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteExpiredSessions(ctx context.Context) error
DeleteSession(ctx context.Context, token string) error
FetchAllSongs(ctx context.Context) ([]Song, error)
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error)
GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
// Last played soundtracks (most recently played)
GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error)
// Least played songs with their soundtrack info
GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithSoundtrackRow, error)
// Least played soundtracks with their songs
GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error)
// Most played songs with their soundtrack info
GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithSoundtrackRow, error)
// Most played soundtracks with their songs
GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error)
// Soundtracks that have never been played (times_played = 0)
GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error)
// Oldest played soundtracks (least recently played, but has been played at least once)
GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error)
GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error)
GetSession(ctx context.Context, token string) (Session, error)
GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error)
GetSongList(ctx context.Context, matchDate time.Time) ([]SongList, error)
GetSongUUIDsBySoundtrackId(ctx context.Context, soundtrackID int32) ([]GetSongUUIDsBySoundtrackIdRow, error)
GetSongWithHash(ctx context.Context, hash string) (Song, error)
GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error)
GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, error)
GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (Soundtrack, error)
GetSoundtrackNameById(ctx context.Context, id int32) (string, error)
GetSoundtrackUUIDById(ctx context.Context, id int32) (uuid.UUID, error)
// Get statistics summary
GetStatisticsSummary(ctx context.Context) (GetStatisticsSummaryRow, error)
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
ListSessions(ctx context.Context) ([]Session, error)
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
SetSoundtrackDeletionDate(ctx context.Context) error
SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error
UpdateSong(ctx context.Context, arg UpdateSongParams) error
UpdateSongByUUID(ctx context.Context, arg UpdateSongByUUIDParams) error
UpdateSoundtrackHash(ctx context.Context, arg UpdateSoundtrackHashParams) error
UpdateSoundtrackName(ctx context.Context, arg UpdateSoundtrackNameParams) error
}
var _ Querier = (*Queries)(nil)
+58 -3
View File
@@ -181,6 +181,37 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
return items, nil
}
const getRandomActiveSong = `-- name: GetRandomActiveSong :one
SELECT song.soundtrack_id, song.song_name, song.path, song.times_played, song.hash, song.file_name, song.uuid, song.soundtrack_uuid, soundtrack.path AS soundtrack_path
FROM song
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
WHERE soundtrack.deleted IS NULL
ORDER BY random()
LIMIT 1
`
type GetRandomActiveSongRow struct {
Song Song `json:"song"`
SoundtrackPath string `json:"soundtrack_path"`
}
func (q *Queries) GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error) {
row := q.db.QueryRow(ctx, getRandomActiveSong)
var i GetRandomActiveSongRow
err := row.Scan(
&i.Song.SoundtrackID,
&i.Song.SongName,
&i.Song.Path,
&i.Song.TimesPlayed,
&i.Song.Hash,
&i.Song.FileName,
&i.Song.Uuid,
&i.Song.SoundtrackUuid,
&i.SoundtrackPath,
)
return i, err
}
const getSongByUUID = `-- name: GetSongByUUID :one
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1
`
@@ -300,11 +331,11 @@ func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongPara
}
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
DELETE FROM song WHERE uuid = ANY($1)
DELETE FROM song WHERE uuid = ANY($1::uuid[])
`
func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
func (q *Queries) RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error {
_, err := q.db.Exec(ctx, removeBrokenSongs, uuids)
return err
}
@@ -328,3 +359,27 @@ func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) error {
)
return err
}
const updateSongByUUID = `-- name: UpdateSongByUUID :exec
UPDATE song SET song_name=$1, file_name=$2, path=$3, hash=$4
WHERE uuid=$5
`
type UpdateSongByUUIDParams struct {
SongName string `json:"song_name"`
FileName *string `json:"file_name"`
Path string `json:"path"`
Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"`
}
func (q *Queries) UpdateSongByUUID(ctx context.Context, arg UpdateSongByUUIDParams) error {
_, err := q.db.Exec(ctx, updateSongByUUID,
arg.SongName,
arg.FileName,
arg.Path,
arg.Hash,
arg.Uuid,
)
return err
}
+31 -4
View File
@@ -30,7 +30,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
}
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
WHERE deleted IS NULL
ORDER BY soundtrack_name
@@ -57,6 +57,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
); err != nil {
return nil, err
}
@@ -69,7 +70,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
}
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
ORDER BY soundtrack_name
`
@@ -95,6 +96,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
); err != nil {
return nil, err
}
@@ -118,7 +120,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
}
const getSoundtrackById = `-- name: GetSoundtrackById :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
WHERE id = $1
AND deleted IS NULL
@@ -139,12 +141,13 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
)
return i, err
}
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
WHERE uuid = $1
AND deleted IS NULL
@@ -165,6 +168,7 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
)
return i, err
}
@@ -240,6 +244,15 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
return err
}
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
`
func (q *Queries) MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error {
_, err := q.db.Exec(ctx, markSoundtracksDeletedExcept, ids)
return err
}
const removeSoundtrackDeletionDate = `-- name: RemoveSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=NULL WHERE id=$1
`
@@ -269,6 +282,20 @@ func (q *Queries) SetSoundtrackDeletionDate(ctx context.Context) error {
return err
}
const setSoundtrackFingerprint = `-- name: SetSoundtrackFingerprint :exec
UPDATE soundtrack SET fingerprint=$1 WHERE id=$2
`
type SetSoundtrackFingerprintParams struct {
Fingerprint string `json:"fingerprint"`
ID int32 `json:"id"`
}
func (q *Queries) SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error {
_, err := q.db.Exec(ctx, setSoundtrackFingerprint, arg.Fingerprint, arg.ID)
return err
}
const updateSoundtrackHash = `-- name: UpdateSoundtrackHash :exec
UPDATE soundtrack SET hash=$1, last_changed=now() WHERE id=$2
`
-150
View File
@@ -1,150 +0,0 @@
package db
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"sync"
"testing"
)
var (
testDBSetupOnce sync.Once
testDBHost string
testDBPort string
testDBUser string
testDBPassword string
testDBName string
// TestDatabase is the database instance for tests
TestDatabase *Database
)
// TestSetupDB initializes the test database using existing functions
// It creates the database if it doesn't exist and runs migrations
// Uses sync.Once to ensure it only runs once across all tests
func TestSetupDB(t *testing.T) {
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
dbname := os.Getenv("DB_NAME")
if host == "" || port == "" || user == "" || password == "" || dbname == "" {
t.Skip("Test database environment variables not set")
}
// Store for TestTearDownDB
testDBHost = host
testDBPort = port
testDBUser = user
testDBPassword = password
testDBName = dbname
// Only run setup once
testDBSetupOnce.Do(func() {
// Create the database first (testuser is a superuser in the container)
createTestDatabase(host, port, dbname, user, password)
// 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)
}
// Clean up any existing schema to ensure clean state
ctx := context.Background()
_, err = TestDatabase.Pool.Exec(ctx, "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;")
if err != nil {
t.Logf("Warning: Could not clean schema: %v", err)
// Continue anyway, migrations might still work
}
// Run migrations
if err := TestDatabase.RunMigrations(); err != nil {
// Clean up on failure to prevent nil pointer issues in other tests
TestDatabase.Close()
TestDatabase = nil
t.Fatalf("Failed to run migrations: %v", err)
}
})
}
// createTestDatabase creates the test database
// In the test container, POSTGRES_USER is created as a superuser
func createTestDatabase(host, port, dbname, user, password string) {
// Connect to the postgres database to create new database
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 {
log.Println("Warning: Could not connect to create test database:", err)
return
}
defer db.Close()
// Check if database exists
var dbExists int
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&dbExists)
if err != nil && err != sql.ErrNoRows {
log.Println("Warning: Could not check if database exists:", err)
return
}
if dbExists == 0 {
// Create database
_, err = db.Exec("CREATE DATABASE " + dbname)
if err != nil {
log.Println("Warning: Could not create database:", err)
return
}
log.Println("Created test database:", dbname)
}
}
// TestTearDownDB closes the test database connection
// Note: We don't actually close the pool between tests to avoid
// "closed pool" errors when tests run sequentially
func TestTearDownDB(t *testing.T) {
// CloseDb() // Disabled to prevent pool closure between sequential tests
// Note: We also don't nil TestDatabase to allow reuse across 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 TestDatabase == nil || TestDatabase.Pool == nil {
t.Skip("Database not initialized")
}
// Clear all tables in reverse order to respect foreign keys
// Note: This assumes the tables exist and have the expected structure
// After migration 000005, game table was renamed to soundtrack
tables := []string{
"song_list",
"song",
"soundtrack",
"vgmq",
"sessions",
}
ctx := context.Background()
for _, table := range tables {
_, err := TestDatabase.Pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
if err != nil {
t.Logf("Failed to truncate table %s: %v", table, err)
}
}
// Reset sequences (renamed from game_id_seq to soundtrack_id_seq in migration 000005)
var seqErr error
_, seqErr = TestDatabase.Pool.Exec(ctx, "SELECT setval('soundtrack_id_seq', 1, false)")
if seqErr != nil {
t.Logf("Failed to reset soundtrack_id_seq: %v", seqErr)
}
}
@@ -2,29 +2,32 @@ package server
import (
"net/http"
"os"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
"music-server/internal/backend"
)
type CharacterHandler struct {
characters *backend.CharacterService
}
func NewCharacterHandler() *CharacterHandler {
return &CharacterHandler{}
func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler {
return &CharacterHandler{characters: characters}
}
// GetCharacterList godoc
// @Summary Get list of characters
// @Description Returns a list of all available characters
// @Tags characters
// @Accept json
// @Produce json
// @Success 200 {array} string
// @Router /characters [get]
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
characters := backend.GetCharacterList()
characters, err := c.characters.List()
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, characters)
}
@@ -32,18 +35,17 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
// @Summary Get character image
// @Description Returns the image for a specific character
// @Tags characters
// @Accept json
// @Produce image/png
// @Param name query string true "Character name"
// @Produce image/jpeg
// @Param name query string true "Character file name"
// @Success 200 {file} file
// @Failure 400 {string} string "invalid name"
// @Failure 404 {string} string "not found"
// @Router /character [get]
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
character := ctx.QueryParam("name")
characterPath := backend.GetCharacter(character)
file, err := os.Open(characterPath)
f, err := c.characters.Open(ctx.QueryParam("name"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
return songError(err)
}
defer file.Close()
return ctx.Stream(http.StatusOK, "image/png", file)
return serveFile(ctx, f, "")
}
-47
View File
@@ -1,47 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
// TestGetCharacterList verifies the characters endpoint returns list of characters
func TestGetCharacterList(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/characters")
assert.Equal(t, http.StatusOK, resp.Code)
var characters []string
err := json.Unmarshal(resp.Body.Bytes(), &characters)
assert.NoError(t, err)
assert.NotEmpty(t, characters)
// Should contain our test characters
assert.Contains(t, characters, "char1.jpg")
assert.Contains(t, characters, "char2.png")
}
// TestGetCharacter verifies the character endpoint returns a file
func TestGetCharacter(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/character?name=char1.jpg")
// For now, just check that we get a response (not necessarily 200)
// The actual file serving might have issues with absolute paths
if resp.Code != http.StatusOK {
t.Logf("Got status %d instead of 200", resp.Code)
// Don't fail the test for now - we can investigate later
}
}
// TestGetCharacterNotFound verifies handling of non-existent character
func TestGetCharacterNotFound(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/character?name=nonexistent.jpg")
// Should return 404 or similar error
assert.NotEqual(t, http.StatusOK, resp.Code)
}
-71
View File
@@ -1,71 +0,0 @@
package server
import (
"github.com/labstack/echo/v5"
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
)
type DownloadHandler struct {
}
func NewDownloadHandler() *DownloadHandler {
return &DownloadHandler{}
}
// CheckLatest godoc
// @Summary Check for latest version
// @Description Checks for the latest version of the application
// @Tags download
// @Accept json
// @Produce json
// @Success 200 {string} string
// @Router /download [get]
func (d *DownloadHandler) checkLatest(ctx *echo.Context) error {
logging.GetLogger().Info("Checking latest version")
latest := backend.CheckLatest()
return ctx.JSON(http.StatusOK, latest)
}
// ListAssetsOfLatest godoc
// @Summary List assets of latest version
// @Description Lists all assets available for the latest version
// @Tags download
// @Accept json
// @Produce json
// @Success 200 {array} string
// @Router /download/list [get]
func (d *DownloadHandler) listAssetsOfLatest(ctx *echo.Context) error {
logging.GetLogger().Info("Listing assets")
assets := backend.ListAssetsOfLatest()
return ctx.JSON(http.StatusOK, assets)
}
// DownloadLatestWindows godoc
// @Summary Download latest Windows version
// @Description Redirects to download the latest Windows version
// @Tags download
// @Produce octet-stream
// @Success 302 {string} string
// @Router /download/windows [get]
func (d *DownloadHandler) downloadLatestWindows(ctx *echo.Context) error {
logging.GetLogger().Info("Downloading latest windows")
asset := backend.DownloadLatestWindows()
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
return ctx.Redirect(http.StatusFound, asset)
}
// DownloadLatestLinux godoc
// @Summary Download latest Linux version
// @Description Redirects to download the latest Linux version
// @Tags download
// @Produce octet-stream
// @Success 302 {string} string
// @Router /download/linux [get]
func (d *DownloadHandler) downloadLatestLinux(ctx *echo.Context) error {
logging.GetLogger().Info("Downloading latest linux")
asset := backend.DownloadLatestLinux()
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
return ctx.Redirect(http.StatusFound, asset)
}
+81
View File
@@ -0,0 +1,81 @@
package server
import (
"net/http"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
)
type DownloadHandler struct {
releases *backend.ReleaseService
}
func NewDownloadHandler(releases *backend.ReleaseService) *DownloadHandler {
return &DownloadHandler{releases: releases}
}
// CheckLatest godoc
// @Summary Check for latest version
// @Description Returns the latest version of the client application
// @Tags download
// @Produce json
// @Success 200 {string} string
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download [get]
func (d *DownloadHandler) CheckLatest(ctx *echo.Context) error {
latest, err := d.releases.LatestVersion(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, latest)
}
// ListAssetsOfLatest godoc
// @Summary List assets of latest version
// @Description Lists all assets available for the latest version
// @Tags download
// @Produce json
// @Success 200 {array} string
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/list [get]
func (d *DownloadHandler) ListAssetsOfLatest(ctx *echo.Context) error {
assets, err := d.releases.AssetNames(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, assets)
}
// DownloadLatestWindows godoc
// @Summary Download latest Windows version
// @Description Redirects to download the latest Windows version
// @Tags download
// @Success 302 {string} string
// @Failure 404 {string} string "not found"
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/windows [get]
func (d *DownloadHandler) DownloadLatestWindows(ctx *echo.Context) error {
return d.redirectToAsset(ctx, ".exe")
}
// DownloadLatestLinux godoc
// @Summary Download latest Linux version
// @Description Redirects to download the latest Linux version
// @Tags download
// @Success 302 {string} string
// @Failure 404 {string} string "not found"
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/linux [get]
func (d *DownloadHandler) DownloadLatestLinux(ctx *echo.Context) error {
return d.redirectToAsset(ctx, ".x86_64")
}
func (d *DownloadHandler) redirectToAsset(ctx *echo.Context, suffix string) error {
url, err := d.releases.AssetURL(ctx.Request().Context(), suffix)
if err != nil {
return songError(err)
}
return ctx.Redirect(http.StatusFound, url)
}
+61
View File
@@ -0,0 +1,61 @@
package server
import (
"errors"
"net/http"
"os"
"path/filepath"
"time"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// serveFile sends a file with support for Range requests and caching headers.
// contentType may be empty to detect it from the file extension.
func serveFile(c *echo.Context, f *os.File, contentType string) error {
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
if info.IsDir() {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
}
if contentType != "" {
c.Response().Header().Set(echo.HeaderContentType, contentType)
}
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
return nil
}
// serveSong streams a song. The write deadline is lifted because songs can take long to send to slow clients.
func serveSong(c *echo.Context, path string, err error) error {
if err != nil {
return songError(err)
}
f, err := os.Open(path)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
}
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
return serveFile(c, f, "audio/mpeg")
}
// songError maps backend errors to HTTP errors.
func songError(err error) error {
switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return echo.NewHTTPError(http.StatusNotFound, err.Error())
case errors.Is(err, backend.ErrInvalidName):
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
case errors.Is(err, backend.ErrUpstream):
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
default:
logging.GetLogger().Error("Request failed", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
}
}
-29
View File
@@ -1,29 +0,0 @@
package server
import (
"net/http"
"github.com/labstack/echo/v5"
"music-server/internal/db"
)
type HealthHandler struct {
db *db.Database
}
func NewHealthHandler(database *db.Database) *HealthHandler {
return &HealthHandler{db: database}
}
// HealthCheck godoc
//
// @Summary Check server health
// @Description Returns the health status of the server
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {string} string "OK"
// @Router /health [get]
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, h.db.Health())
}
+33
View File
@@ -0,0 +1,33 @@
package server
import (
"context"
"net/http"
"github.com/labstack/echo/v5"
)
// HealthChecker reports the health of a dependency.
type HealthChecker interface {
Health(ctx context.Context) map[string]string
}
type HealthHandler struct {
db HealthChecker
}
func NewHealthHandler(db HealthChecker) *HealthHandler {
return &HealthHandler{db: db}
}
// HealthCheck godoc
//
// @Summary Check server health
// @Description Returns the health status of the server
// @Tags health
// @Produce json
// @Success 200 {object} map[string]string
// @Router /health [get]
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, h.db.Health(ctx.Request().Context()))
}
-24
View File
@@ -1,24 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
// TestHealthCheck verifies the health endpoint returns database status
func TestHealthCheck(t *testing.T) {
e := StartTestServer(t)
// No explicit teardown - handled by StartTestServer's sync.Once
resp := MakeTestRequest(t, e, "GET", "/health")
assert.Equal(t, http.StatusOK, resp.Code)
var healthData map[string]string
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
assert.NoError(t, err)
assert.NotEmpty(t, healthData)
assert.Equal(t, "up", healthData["status"])
}
+17 -85
View File
@@ -2,98 +2,30 @@ package middleware
import (
"net/http"
"sync"
"time"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
)
// RateLimiter implements a simple IP-based rate limiter
type RateLimiter struct {
visits map[string]*Visit
mu sync.Mutex
limit int // requests per window
window time.Duration // time window
}
type Visit struct {
count int
firstSeen time.Time
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
visits: make(map[string]*Visit),
limit: limit,
window: window,
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
config := echoMiddleware.DefaultRateLimiterConfig
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
Rate: float64(limit) / window.Seconds(),
Burst: limit,
ExpiresIn: window,
})
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", identifier),
zap.String("path", c.Request().URL.Path))
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Rate limit exceeded. Please try again later.",
})
}
// Start cleanup goroutine
go rl.cleanup()
return rl
return echoMiddleware.RateLimiterWithConfig(config)
}
// cleanup periodically removes old entries
func (rl *RateLimiter) cleanup() {
for {
time.Sleep(rl.window)
rl.mu.Lock()
now := time.Now()
for ip, visit := range rl.visits {
if now.Sub(visit.firstSeen) > rl.window {
delete(rl.visits, ip)
}
}
rl.mu.Unlock()
}
}
// RateLimitMiddleware returns an Echo middleware for rate limiting
func RateLimitMiddleware(rl *RateLimiter) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
ip := c.RealIP()
rl.mu.Lock()
defer rl.mu.Unlock()
visit, exists := rl.visits[ip]
now := time.Now()
if !exists {
// First visit from this IP
rl.visits[ip] = &Visit{
count: 1,
firstSeen: now,
}
return next(c)
}
// Check if window has expired
if now.Sub(visit.firstSeen) > rl.window {
// Reset for new window
visit.count = 1
visit.firstSeen = now
return next(c)
}
// Check if limit exceeded
if visit.count >= rl.limit {
logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", ip),
zap.String("path", c.Request().URL.Path))
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Rate limit exceeded. Please try again later.",
})
}
// Increment count
visit.count++
return next(c)
}
}
}
+11 -11
View File
@@ -1,22 +1,22 @@
package middleware
import (
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
)
// SyncCheckMiddleware blocks requests when syncing is in progress.
// It returns HTTP 423 Locked with a standard message.
// This middleware should be applied to handlers that cannot execute during sync.
func SyncCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress - request blocked")
return c.JSON(http.StatusLocked, "Syncing is in progress")
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if isSyncing() {
logging.GetLogger().Info("Syncing is in progress - request blocked")
return c.JSON(http.StatusLocked, "Syncing is in progress")
}
return next(c)
}
return next(c)
}
}
+21 -46
View File
@@ -1,87 +1,62 @@
package middleware
import (
"context"
"net/http"
"os"
"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 {
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
func BearerToken(c *echo.Context) (string, bool) {
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
return token, ok && token != "" && !strings.Contains(token, " ")
}
// TokenAuth validates session tokens. When disabled is true all requests are let through.
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Session auth disable bypass - allow all requests through without token validation
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
if disableSessionAuth {
logging.GetLogger().Debug("Session auth disabled - bypassing token authentication",
zap.String("path", c.Request().URL.Path),
zap.String("ip", c.RealIP()))
if disabled {
return next(c)
}
// Extract token from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
if c.Request().Header.Get("Authorization") == "" {
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" {
token, ok := BearerToken(c)
if !ok {
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)
ctx := c.Request().Context()
session, err := q.GetSession(ctx, 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)
}()
// Clean up in the background with a context that outlives the request.
go func(ctx context.Context) {
if err := q.DeleteSession(ctx, token); err != nil {
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
}
}(context.WithoutCancel(ctx))
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)
}
}
@@ -1,47 +1,43 @@
package server
import (
"net/http"
"strconv"
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
"os"
"strconv"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
type MusicHandler struct {
music *backend.MusicService
}
func NewMusicHandler() *MusicHandler {
return &MusicHandler{}
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
return &MusicHandler{music: music}
}
// GetSong godoc
// @Summary Get a specific song
// @Description Returns a specific song by name
// @Description Returns the song at the given position in the queue
// @Tags music
// @Accept json
// @Produce audio/mpeg
// @Param song query string true "Song name"
// @Param song query int true "Song number in the queue"
// @Success 200 {file} file
// @Failure 400 {string} string "song can't be empty"
// @Success 206 {file} file
// @Failure 400 {string} string "song must be a number"
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music [get]
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
song := ctx.QueryParam("song")
if song == "" {
return ctx.String(http.StatusBadRequest, "song can't be empty")
}
songPath := backend.GetSong(song)
file, err := os.Open(songPath)
song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
return ctx.String(http.StatusBadRequest, "song must be a number")
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.Song(song)
return serveSong(ctx, path, err)
}
// GetSoundCheckSong godoc
@@ -54,25 +50,21 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/soundTest [get]
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
songPath := backend.GetSoundCheckSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.SoundCheckSong()
return serveSong(ctx, path, err)
}
// ResetMusic godoc
// @Summary Reset music state
// @Description Resets the music state
// @Tags music
// @Accept json
// @Success 204
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/reset [get]
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
backend.Reset()
if err := m.music.Reset(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
@@ -86,18 +78,13 @@ func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand [get]
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
songPath := backend.GetRandomSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.RandomSong(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongLowChance godoc
// @Summary Get random song with low chance
// @Description Returns a random song with low chance selection
// @Description Returns a random song, favouring soundtracks that have been played less
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
@@ -105,18 +92,13 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/low [get]
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
songPath := backend.GetRandomSongLowChance()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongClassic godoc
// @Summary Get random classic song
// @Description Returns a random song from the classic selection
// @Description Returns a random song where every song has the same chance
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
@@ -124,39 +106,30 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/classic [get]
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
songPath := backend.GetRandomSongClassic()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.RandomSongClassic(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetSongInfo godoc
// @Summary Get current song info
// @Description Returns information about the current song
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Success 200 {object} backend.SongInfo
// @Router /music/info [get]
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
song := backend.GetSongInfo()
return ctx.JSON(http.StatusOK, song)
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
}
// GetPlayedSongs godoc
// @Summary Get played songs list
// @Description Returns a list of played songs
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Success 200 {array} backend.SongInfo
// @Router /music/list [get]
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
songList := backend.GetPlayedSongs()
return ctx.JSON(http.StatusOK, songList)
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
}
// GetNextSong godoc
@@ -169,13 +142,8 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/next [get]
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
songPath := backend.GetNextSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.NextSong()
return serveSong(ctx, path, err)
}
// GetPreviousSong godoc
@@ -188,51 +156,48 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/previous [get]
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
songPath := backend.GetPreviousSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.PreviousSong()
return serveSong(ctx, path, err)
}
// GetAllSoundtracks godoc
// @Summary Get all soundtracks
// @Description Returns a list of all soundtracks in order
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/order [get]
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
soundtrackList := backend.GetAllSoundtracks()
return ctx.JSON(http.StatusOK, soundtrackList)
names, err := m.music.SoundtrackNames(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, names)
}
// GetAllSoundtracksRandom godoc
// @Summary Get all soundtracks random
// @Description Returns a list of all soundtracks in random order
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/random [get]
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
soundtrackList := backend.GetAllSoundtracksRandom()
return ctx.JSON(http.StatusOK, soundtrackList)
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, names)
}
// PutPlayed godoc
// @Summary Mark song as played
// @Description Marks a song as played by its ID
// @Description Marks the song at the given queue position as played
// @Tags music
// @Accept json
// @Produce json
// @Param song query int true "Song ID"
// @Success 204
// @Param song query int true "Song number in the queue"
// @Success 200
// @Failure 400 {string} string "Bad Request"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/played [put]
@@ -242,7 +207,9 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
return ctx.JSON(http.StatusBadRequest, err.Error())
}
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
backend.SetPlayed(song)
if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
@@ -250,24 +217,24 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
// @Summary Add latest to queue
// @Description Adds the latest song to the queue
// @Tags music
// @Accept json
// @Success 204
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addQue [get]
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
backend.AddLatestToQue()
m.music.AddLatestToQueue()
return ctx.NoContent(http.StatusOK)
}
// AddLatestPlayed godoc
// @Summary Add latest to played
// @Description Adds the latest song to the played list
// @Description Marks the current song as played
// @Tags music
// @Accept json
// @Success 204
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addPlayed [get]
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
backend.AddLatestPlayed()
if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
+120 -236
View File
@@ -1,22 +1,23 @@
package server
import (
"net/http"
"time"
_ "music-server/cmd/docs" // registers the OpenAPI spec
"music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"net/http"
"sort"
"strings"
"time"
"github.com/a-h/templ"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
echoSwagger "github.com/swaggo/echo-swagger/v2"
"github.com/swaggo/swag"
"go.uber.org/zap"
)
// @Title MusicServer API
// @title MusicServer API
// @version 1.0
// @description API for the MusicServer application
// @termsOfService http://sanplex.xyz/terms/
@@ -27,19 +28,45 @@ import (
// @license.name MIT
// @license.url http://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /
// handlers holds all HTTP handlers.
type handlers struct {
health *HealthHandler
version *VersionHandler
character *CharacterHandler
download *DownloadHandler
sync *SyncHandler
music *MusicHandler
statistics *StatisticsHandler
token *TokenHandler
}
func (s *Server) handlers() handlers {
return handlers{
health: NewHealthHandler(s.db),
version: NewVersionHandler(),
character: NewCharacterHandler(s.characters),
download: NewDownloadHandler(s.releases),
sync: NewSyncHandler(s.sync),
music: NewMusicHandler(s.music),
statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
}
}
func (s *Server) RegisterRoutes() http.Handler {
e := echo.New()
if len(s.cfg.TrustedProxies) > 0 {
var trust []echo.TrustOption
for _, ipNet := range s.cfg.TrustedProxies {
trust = append(trust, echo.TrustIPRange(ipNet))
}
e.IPExtractor = echo.ExtractIPFromXFFHeader(trust...)
}
// Serve OpenAPI spec at /openapi
e.GET("/openapi", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, "cmd/docs/swagger.json")
})))
e.Use(logging.RequestLogger())
e.Use(echoMiddleware.Recover())
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"https://*", "http://*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
@@ -48,240 +75,97 @@ func (s *Server) RegisterRoutes() http.Handler {
MaxAge: 300,
}))
fileServer := http.FileServer(http.FS(web.Assets))
e.GET("/assets/*", echo.WrapHandler(fileServer))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
e.Static("/", "/frontend")
// Swagger UI
e.GET("/openapi", serveOpenAPI)
e.GET("/swagger/*", echoSwagger.WrapHandler)
// ============================================
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
// ============================================
deprecatedMiddleware := middleware.DeprecationMiddleware
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
e.Static("/", s.cfg.FrontendPath)
health := NewHealthHandler(s.db)
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
h := s.handlers()
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
version := NewVersionHandler()
e.GET("/version", deprecatedMiddleware(version.GetLatestVersion))
e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory))
// Legacy endpoints (deprecated - use /api/v1/ instead)
legacy := e.Group("", middleware.DeprecationMiddleware)
registerCommon(legacy, h, syncLock)
legacy.GET("/health", h.health.HealthCheck)
legacy.GET("/characters", h.character.GetCharacterList)
character := NewCharacterHandler()
e.GET("/character", deprecatedMiddleware(character.GetCharacter))
e.GET("/characters", deprecatedMiddleware(character.GetCharacterList))
download := NewDownloadHandler()
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("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
music := NewMusicHandler()
musicGroup := e.Group("/music")
musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
// ============================================
// API v1 Routes with Token Authentication
// ============================================
// Create /api/v1 group
// API v1
apiV1 := e.Group("/api/v1")
// Create rate limiter for token creation: 10 requests per minute per IP
tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
// Token endpoints (public - no token required)
tokenGroup := apiV1.Group("/token")
tokenGroup.POST("", func(c *echo.Context) error {
return s.tokenHandler.CreateTokenHandler(c)
}, tokenRateLimitMiddleware)
tokenGroup.DELETE("", func(c *echo.Context) error {
return s.tokenHandler.DeleteTokenHandler(c)
})
tokenGroup.POST("/cleanup", func(c *echo.Context) error {
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
})
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
// Protected endpoints - require valid token
// Create token auth middleware with pool access
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
registerCommon(protected, h, syncLock)
protected.GET("/character/list", h.character.GetCharacterList)
// Statistics endpoints (protected)
statisticsHandler := s.statisticsHandler
statsGroup := protectedV1.Group("/statistics")
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
return statisticsHandler.GetNeverPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
return statisticsHandler.GetLastPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
return statisticsHandler.GetOldestPlayedSoundtracks(c)
})
statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSongs(c)
})
statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSongs(c)
})
statsGroup.GET("/summary", func(c *echo.Context) error {
return statisticsHandler.GetStatisticsSummary(c)
})
stats := protected.Group("/statistics")
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
stats.GET("/summary", h.statistics.GetStatisticsSummary)
// Version endpoints (protected)
versionGroup := protectedV1.Group("/version")
versionGroup.GET("", func(c *echo.Context) error {
return version.GetLatestVersion(c)
})
versionGroup.GET("/history", func(c *echo.Context) error {
return version.GetVersionHistory(c)
})
// Character endpoints (protected)
characterGroup := protectedV1.Group("/character")
characterGroup.GET("", func(c *echo.Context) error {
return character.GetCharacter(c)
})
characterGroup.GET("/list", func(c *echo.Context) error {
return character.GetCharacterList(c)
})
// Download endpoints (protected)
downloadGroup := protectedV1.Group("/download")
downloadGroup.GET("", func(c *echo.Context) error {
return download.checkLatest(c)
})
downloadGroup.GET("/list", func(c *echo.Context) error {
return download.listAssetsOfLatest(c)
})
downloadGroup.GET("/windows", func(c *echo.Context) error {
return download.downloadLatestWindows(c)
})
downloadGroup.GET("/linux", func(c *echo.Context) error {
return download.downloadLatestLinux(c)
})
// Sync endpoints (protected)
protectedSyncGroup := protectedV1.Group("/sync")
protectedSyncGroup.GET("", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
return sync.SyncProgress(c)
})
protectedSyncGroup.GET("/new", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
return sync.ResetDB(c)
})
// Music endpoints (protected)
protectedMusicGroup := protectedV1.Group("/music")
protectedMusicGroup.GET("", func(c *echo.Context) error {
return music.GetSong(c)
})
protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
return music.GetSoundCheckSong(c)
})
protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
return music.ResetMusic(c)
})
protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
return music.GetRandomSong(c)
})
protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
return music.GetRandomSongLowChance(c)
})
protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
return music.GetRandomSongClassic(c)
})
protectedMusicGroup.GET("/info", func(c *echo.Context) error {
return music.GetSongInfo(c)
})
protectedMusicGroup.GET("/list", func(c *echo.Context) error {
return music.GetPlayedSongs(c)
})
protectedMusicGroup.GET("/next", func(c *echo.Context) error {
return music.GetNextSong(c)
})
protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
return music.GetPreviousSong(c)
})
protectedMusicGroup.GET("/all", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
return music.GetAllSoundtracks(c)
})
protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
return music.PutPlayed(c)
})
protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
return music.AddLatestToQue(c)
})
protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
return music.AddLatestPlayed(c)
})
routes := e.Router().Routes()
sort.Slice(routes, func(i, j int) bool {
return routes[i].Path < routes[j].Path
})
for _, r := range routes {
if (r.Method == "GET" || r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE") && !strings.Contains(r.Name, "github") {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
for _, r := range e.Router().Routes() {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
return e
}
// registerCommon registers the routes shared by the legacy and the v1 API.
// syncLock is applied to routes that must not run during a sync.
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
g.GET("/version", h.version.GetLatestVersion)
g.GET("/version/history", h.version.GetVersionHistory)
g.GET("/character", h.character.GetCharacter)
g.GET("/download", h.download.CheckLatest)
g.GET("/download/list", h.download.ListAssetsOfLatest)
g.GET("/download/windows", h.download.DownloadLatestWindows)
g.GET("/download/linux", h.download.DownloadLatestLinux)
g.GET("/sync/progress", h.sync.SyncProgress)
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
}
for _, path := range []string{"/sync/full", "/sync/new/full"} {
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
}
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
music := g.Group("/music", syncLock)
music.GET("", h.music.GetSong)
music.GET("/soundTest", h.music.GetSoundCheckSong)
music.GET("/reset", h.music.ResetMusic)
music.GET("/rand", h.music.GetRandomSong)
music.GET("/rand/low", h.music.GetRandomSongLowChance)
music.GET("/rand/classic", h.music.GetRandomSongClassic)
music.GET("/next", h.music.GetNextSong)
music.GET("/previous", h.music.GetPreviousSong)
music.GET("/all", h.music.GetAllSoundtracksRandom)
music.GET("/all/order", h.music.GetAllSoundtracks)
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
music.PUT("/played", h.music.PutPlayed)
music.GET("/addQue", h.music.AddLatestToQue)
music.GET("/addPlayed", h.music.AddLatestPlayed)
// Info and list are read-only and stay available during a sync.
g.GET("/music/info", h.music.GetSongInfo)
g.GET("/music/list", h.music.GetPlayedSongs)
}
// serveOpenAPI serves the embedded OpenAPI spec.
func serveOpenAPI(c *echo.Context) error {
doc, err := swag.ReadDoc()
if err != nil {
return err
}
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
}
+103 -95
View File
@@ -1,125 +1,133 @@
package server
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"net/http"
"time"
"music-server/internal/backend"
"music-server/internal/config"
"music-server/internal/db"
"music-server/internal/db/repository"
"music-server/internal/logging"
"net/http"
"go.uber.org/zap"
)
type Server struct {
port int
db *db.Database
tokenHandler *TokenHandler
statisticsHandler *StatisticsHandler
httpServer *http.Server
// Database is the database connection used by the server.
type Database interface {
HealthChecker
Close()
}
var (
musicPath = os.Getenv("MUSIC_PATH")
charactersPath = os.Getenv("CHARACTERS_PATH")
logLevel = os.Getenv("LOG_LEVEL")
logJSON = os.Getenv("LOG_JSON") == "true"
)
type Server struct {
cfg config.Config
db Database
q repository.Querier
music *backend.MusicService
sync *backend.SyncService
characters *backend.CharacterService
releases *backend.ReleaseService
stats *backend.StatisticsService
search *backend.SearchCache
httpServer *http.Server
}
// NewServerInstance creates a new Server instance with all dependencies initialized.
// Use this for dependency injection and proper lifecycle management.
func NewServerInstance() *Server {
// Re-read environment variables at server creation time (not at package import time)
// This allows tests to override environment variables
host := os.Getenv("DB_HOST")
dbPort := os.Getenv("DB_PORT")
dbName := os.Getenv("DB_NAME")
username := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
// Initialize logger
currentLogLevel := logLevel
if currentLogLevel == "" {
currentLogLevel = "info"
}
logging.Init(currentLogLevel, logJSON)
logger := logging.GetLogger()
port, _ := strconv.Atoi(os.Getenv("PORT"))
// 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)
// New creates a Server with all services wired up.
func New(cfg config.Config, q repository.Querier, database Database) (*Server, error) {
characters, err := backend.NewCharacterService(cfg.CharactersPath)
if err != nil {
logging.GetLogger().Fatal("Failed to initialize database", zap.String("error", err.Error()))
return nil, fmt.Errorf("characters path: %w", err)
}
// Run migrations using the new method
if err := database.RunMigrations(); err != nil {
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
s := &Server{
cfg: cfg,
db: database,
q: q,
music: backend.NewMusicService(q, cfg.SoundCheckPath),
characters: characters,
releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q),
}
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
// 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(),
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: s.RegisterRoutes(),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
logger.Info("Starting server",
zap.String("host", host),
zap.String("dbPort", dbPort),
zap.String("username", username),
zap.String("dbName", dbName),
)
logger.Info("Paths",
zap.String("musicPath", musicPath),
zap.String("charactersPath", charactersPath),
)
return appServer
return s, nil
}
// HTTPServer returns the underlying http.Server for serving HTTP requests.
// afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) {
if err := s.music.Reset(ctx); err != nil {
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err))
}
if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
}
}
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
// It exits the process if any of these steps fail.
func NewServerInstance() *Server {
cfg, err := config.Load()
logging.Init(cfg.LogLevel, cfg.LogJSON)
logger := logging.GetLogger()
if err != nil {
logger.Fatal("Invalid settings", zap.Error(err))
}
logger.Info("Starting server",
zap.String("host", cfg.DBHost),
zap.String("dbPort", cfg.DBPort),
zap.String("username", cfg.DBUser),
zap.String("dbName", cfg.DBName),
zap.String("musicPath", cfg.MusicPath),
zap.String("charactersPath", cfg.CharactersPath),
)
ctx := context.Background()
database, err := db.NewDatabase(ctx, db.ConnString(cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName))
if err != nil {
logger.Fatal("Failed to initialize database", zap.Error(err))
}
if err := database.RunMigrations(); err != nil {
logger.Fatal("Migration failed", zap.Error(err))
}
s, err := New(cfg, repository.New(database.Pool), database)
if err != nil {
logger.Fatal("Failed to create server", zap.Error(err))
}
return s
}
// ListenAndServe starts the HTTP server. It returns nil after a graceful shutdown.
func (s *Server) ListenAndServe() error {
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// Shutdown stops accepting requests, waits for running requests and then closes the database.
func (s *Server) Shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
s.sync.Wait()
s.characters.Close()
s.db.Close()
return err
}
// HTTPServer returns the underlying http.Server.
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()
}
-411
View File
@@ -1,411 +0,0 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// saveEnvVars saves the current state of relevant environment variables
func saveEnvVars() map[string]string {
vars := []string{
"DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME",
"MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL",
}
saved := make(map[string]string)
for _, v := range vars {
saved[v] = os.Getenv(v)
}
return saved
}
// restoreEnvVars restores environment variables from a saved state
func restoreEnvVars(saved map[string]string) {
for k, v := range saved {
if v == "" {
os.Unsetenv(k)
} else {
os.Setenv(k, v)
}
}
}
// checkTableExists returns true if the specified table exists in the database
func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) {
var exists bool
err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)
`, tableName).Scan(&exists)
return exists, err
}
// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database
func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error {
// Copy the SQL dump file to the container
basename := filepath.Base(dumpPath)
targetPath := "/tmp/" + basename
if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil {
return fmt.Errorf("failed to copy dump file to container: %w", err)
}
// Execute psql to load the dump as the postgres superuser
// We execute psql inside the container, so we use localhost and port 5432 (the container's internal port)
// The postgres user is the superuser created by the Docker image
// Use sh instead of bash as postgres:15-alpine uses Alpine Linux
cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"}
exitCode, reader, err := container.Exec(ctx, cmd)
if err != nil {
return fmt.Errorf("failed to execute psql: %w", err)
}
// Read the output
output, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("failed to read psql output: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output))
}
t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname)
return nil
}
// createRoleInContainer creates a role in the PostgreSQL container
func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error {
// Get the mapped port for the container
mappedPort, err := container.MappedPort(ctx, "5432")
if err != nil {
return fmt.Errorf("failed to get mapped port: %w", err)
}
// Connect to the postgres database to create the role
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
mappedPort.Port())
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to postgres database: %w", err)
}
defer pool.Close()
// Check if role already exists
var exists bool
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists)
if err == nil && exists {
return nil // Role already exists
}
// Clear any error from the role check (role might not exist, which is expected)
// Create the role
_, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName))
if err != nil {
return fmt.Errorf("failed to create role %s: %w", roleName, err)
}
return nil
}
// createDatabaseInContainer creates a new database in the PostgreSQL container
func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error {
// Get the mapped port for the container
mappedPort, err := container.MappedPort(ctx, "5432")
if err != nil {
return fmt.Errorf("failed to get mapped port: %w", err)
}
// Add a small delay to ensure PostgreSQL is fully ready
time.Sleep(2 * time.Second)
// Connect to the default 'postgres' database to create the new database
// Use localhost and the mapped port
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
mappedPort.Port())
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to postgres database: %w", err)
}
defer pool.Close()
// Check if database already exists and drop it if so
var exists int
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
if err == nil && exists == 1 {
_, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)")
if err != nil {
return fmt.Errorf("failed to drop existing database: %w", err)
}
t.Logf("Dropped existing database %s", dbname)
}
// Create the new database
_, err = pool.Exec(ctx, "CREATE DATABASE "+dbname)
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
t.Logf("Created database %s", dbname)
return nil
}
// startPostgreSQLContainer starts a PostgreSQL container and returns it
func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
req := testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "postgres",
"POSTGRES_DB": "postgres",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start container: %w", err)
}
return container, nil
}
// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work
func TestMusicRefDump(t *testing.T) {
testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test")
}
// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work
func TestMusicTestDump(t *testing.T) {
testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test")
}
// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work
func TestMusicProdDump(t *testing.T) {
testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test")
}
// testSQLDump is the common test function for all SQL dump tests
func testSQLDump(t *testing.T, dumpFile, dbname string) {
ctx := context.Background()
// Save original environment variables
originalEnv := saveEnvVars()
defer restoreEnvVars(originalEnv)
// Start PostgreSQL container
t.Log("Starting PostgreSQL container...")
container, err := startPostgreSQLContainer(t, ctx)
require.NoError(t, err)
defer func() {
t.Log("Stopping container...")
container.Terminate(ctx)
}()
// Get container connection details
endpoint, err := container.Endpoint(ctx, "")
require.NoError(t, err)
t.Logf("Container running at: %s", endpoint)
// Create database for this test
t.Logf("Creating database %s...", dbname)
err = createDatabaseInContainer(t, ctx, container, dbname)
require.NoError(t, err)
// Create common roles that might be referenced in dump files
err = createRoleInContainer(t, ctx, container, "sansan", "postgres")
if err != nil {
t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err)
}
// Load the SQL dump
// The dump files are in the project root
// Get the absolute path to the dump file
absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile))
require.NoError(t, err)
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
// Try relative to current working directory
absDumpPath = filepath.Join(".", dumpFile)
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
absDumpPath = dumpFile // Try as-is
}
}
t.Logf("Loading SQL dump from: %s", absDumpPath)
err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath)
require.NoError(t, err)
// ============================================
// PRE-MIGRATION CHECK
// ============================================
t.Log("Running pre-migration checks...")
// For connecting to the database from the test, we need to use the container's endpoint
// But the container's endpoint might be localhost:random_port
// We need to parse it to get host and port
// endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456"
// Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port
// Let's extract the mapped port
// Get the mapped port from the container
mappedPort, err := container.MappedPort(ctx, "5432")
require.NoError(t, err)
t.Logf("Container mapped port: %s", mappedPort)
// Use localhost as the host since we're on the same machine
dbHost := "localhost"
dbPort := mappedPort.Port()
// Create a connection to the database to check pre-migration state
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, "postgres", "postgres", dbname)
pool, err := pgxpool.New(ctx, connStr)
require.NoError(t, err)
defer pool.Close()
// Check that game table exists (pre-migration state)
gameExists, err := checkTableExists(ctx, pool, "game")
require.NoError(t, err)
assert.True(t, gameExists, "Before migration: game table should exist")
// Check that soundtrack table does NOT exist
soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack")
require.NoError(t, err)
assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist")
t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not")
// ============================================
// START APPLICATION (triggers migrations)
// ============================================
t.Log("Starting application...")
// Set environment variables for the server
// The application needs to connect to the container's mapped port
os.Setenv("DB_HOST", dbHost)
os.Setenv("DB_PORT", dbPort)
os.Setenv("DB_USERNAME", "postgres")
os.Setenv("DB_PASSWORD", "postgres")
os.Setenv("DB_NAME", dbname)
os.Setenv("MUSIC_PATH", "./testMusic")
os.Setenv("CHARACTERS_PATH", "./testCharacters")
// Use different ports for each test to allow for sequential execution
// and to make port detection reliable
portMap := map[string]string{
"music_server_ref_test": "8082",
"music_server_test_dump_test": "8083",
"music_server_prod_test": "8084",
}
appPort := portMap[dbname]
if appPort == "" {
appPort = "8085" // fallback
}
os.Setenv("PORT", appPort)
os.Setenv("DISABLE_SESSION_AUTH", "true")
os.Setenv("LOG_LEVEL", "error")
// Create server instance (this runs migrations automatically)
serverInstance := NewServerInstance()
httpServer := serverInstance.HTTPServer()
// Start HTTP server in background
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
t.Logf("Server error: %v", err)
}
}()
defer func() {
t.Log("Stopping HTTP server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
}()
// Wait for server to start by polling /health endpoint
// Use the fixed port we configured for the application
healthURL := fmt.Sprintf("http://localhost:%s/health", appPort)
maxAttempts := 30
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, err := http.Get(healthURL)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var healthData map[string]string
json.NewDecoder(resp.Body).Decode(&healthData)
if healthData["status"] == "up" {
t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt)
break
}
}
}
if attempt == maxAttempts {
require.Fail(t, "Server did not become healthy within timeout")
}
time.Sleep(500 * time.Millisecond)
}
// ============================================
// POST-MIGRATION CHECK
// ============================================
t.Log("Running post-migration checks...")
// Check that game table no longer exists
gameExistsAfter, err := checkTableExists(ctx, pool, "game")
require.NoError(t, err)
assert.False(t, gameExistsAfter, "After migration: game table should NOT exist")
// Check that soundtrack table now exists
soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack")
require.NoError(t, err)
assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist")
// Verify data was preserved
var soundtrackCount int
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
require.NoError(t, err)
assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0")
t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount)
// ============================================
// HEALTH CHECK
// ============================================
t.Log("Running health check...")
resp, err := http.Get(healthURL)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var healthData map[string]string
err = json.NewDecoder(resp.Body).Decode(&healthData)
require.NoError(t, err)
assert.Equal(t, "up", healthData["status"])
t.Log("✓ Health check passed")
t.Log("✓ Test completed successfully!")
}
+101 -188
View File
@@ -11,265 +11,178 @@ import (
"go.uber.org/zap"
)
const (
defaultStatsLimit = 10
maxStatsLimit = 100
)
// StatisticsHandler handles statistics-related HTTP requests
type StatisticsHandler struct {
statsBackend *backend.StatisticsHandler
stats *backend.StatisticsService
}
// NewStatisticsHandler creates a new StatisticsHandler
func NewStatisticsHandler() *StatisticsHandler {
return &StatisticsHandler{
statsBackend: backend.NewStatisticsHandler(),
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler {
return &StatisticsHandler{stats: stats}
}
// parseLimit reads the "limit" query parameter, defaulting to 10 and capping at 100.
func parseLimit(ctx *echo.Context) (int32, bool) {
limitStr := ctx.QueryParam("limit")
if limitStr == "" {
return defaultStatsLimit, true
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return 0, false
}
return int32(min(limit, maxStatsLimit)), true
}
// withLimit runs a statistics query that takes a limit and writes the result as JSON.
func withLimit[T any](ctx *echo.Context, name string, query func(*echo.Context, int32) (T, error)) error {
limit, ok := parseLimit(ctx)
if !ok {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
result, err := query(ctx, limit)
return statsResult(ctx, name, result, err)
}
func statsResult[T any](ctx *echo.Context, name string, result T, err error) error {
if err != nil {
logging.GetLogger().Error("Failed to get statistics", zap.String("statistic", name), zap.Error(err))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, result)
}
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
// GET /api/v1/statistics/soundtracks/most-played
//
// @Summary Get most played soundtracks
// @Description Returns the top N most played soundtracks with their songs
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/most-played [get]
// @Router /api/v1/statistics/soundtracks/most-played [get]
func (h *StatisticsHandler) GetMostPlayedSoundtracks(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
}
}
soundtracks, err := h.statsBackend.GetMostPlayedSoundtracksWithSongs(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get most played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.MostPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
// GET /api/v1/statistics/soundtracks/least-played
//
// @Summary Get least played soundtracks
// @Description Returns the top N least played soundtracks with their songs
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/least-played [get]
// @Router /api/v1/statistics/soundtracks/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(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
}
}
soundtracks, err := h.statsBackend.GetLeastPlayedSoundtracksWithSongs(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get least played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetMostPlayedSongs returns top N most played songs with soundtrack info
// GET /api/v1/statistics/songs/most-played
// GetMostPlayedSongs returns top N most played songs
//
// @Summary Get most played songs
// @Description Returns the top N most played songs with their soundtrack info
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @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]
// @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.GetMostPlayedSongsWithSoundtrack(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)
return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
return h.stats.MostPlayedSongs(c.Request().Context(), limit)
})
}
// GetLeastPlayedSongs returns top N least played songs with soundtrack info
// GET /api/v1/statistics/songs/least-played
// GetLeastPlayedSongs returns top N least played songs
//
// @Summary Get least played songs
// @Description Returns the top N least played songs with their soundtrack info
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @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]
// @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
}
}
return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
return h.stats.LeastPlayedSongs(c.Request().Context(), limit)
})
}
songs, err := h.statsBackend.GetLeastPlayedSongsWithSoundtrack(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)
// GetLastPlayedSoundtracks returns the most recently played soundtracks
//
// @Summary Get last played soundtracks
// @Description Returns the most recently played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.LastPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
//
// @Summary Get oldest played soundtracks
// @Description Returns the least recently played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
// GET /api/v1/statistics/soundtracks/never-played
//
// @Summary Get never played soundtracks
// @Description Returns all soundtracks that have never been played (times_played = 0)
// @Description Returns all soundtracks that have never been played
// @Tags statistics
// @Accept json
// @Produce json
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/never-played [get]
// @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
if err != nil {
logging.GetLogger().Error("Failed to get never played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetLastPlayedSoundtracks returns most recently played soundtracks
// GET /api/v1/statistics/soundtracks/last-played
//
// @Summary Get last played soundtracks
// @Description Returns the most recently played soundtracks
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedSoundtracks(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
}
}
soundtracks, err := h.statsBackend.GetLastPlayedSoundtracks(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get last played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetOldestPlayedSoundtracks returns least recently played soundtracks
// GET /api/v1/statistics/soundtracks/oldest-played
//
// @Summary Get oldest played soundtracks
// @Description Returns the least recently played soundtracks (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.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(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
}
}
soundtracks, err := h.statsBackend.GetOldestPlayedSoundtracks(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get oldest played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
return statsResult(ctx, "never played soundtracks", result, err)
}
// GetStatisticsSummary returns overall statistics
// GET /api/v1/statistics/summary
//
// @Summary Get statistics summary
// @Description Returns overall statistics about the music library
// @Description Returns overall play statistics
// @Tags statistics
// @Accept json
// @Produce json
// @Success 200 {object} backend.StatisticsSummary
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/summary [get]
// @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)
result, err := h.stats.Summary(ctx.Request().Context())
return statsResult(ctx, "summary", result, err)
}
-184
View File
@@ -1,184 +0,0 @@
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/google/uuid"
"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)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
// 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)
// After sync with /sync/new, only soundtracks matching filesystem remain
// testMusic has 3 soundtracks
require.Equal(t, int64(3), summary.TotalSoundtracks)
}
// 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,
Uuid: uuid.New(),
})
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
}
// Get soundtrack IDs and UUIDs
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
// Insert songs for each soundtrack
songData := []struct {
soundtrackID int32
soundtrackUuid uuid.UUID
songs []string
}{
{soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
{soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
{soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
{soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
{soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []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",
Uuid: uuid.New(),
SoundtrackUuid: sd.soundtrackUuid,
})
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
}
@@ -1,82 +1,80 @@
package server
import (
"net/http"
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
"github.com/labstack/echo/v5"
)
type SyncHandler struct {
sync *backend.SyncService
}
func NewSyncHandler() *SyncHandler {
return &SyncHandler{}
func NewSyncHandler(sync *backend.SyncService) *SyncHandler {
return &SyncHandler{sync: sync}
}
// SyncProgress godoc
// @Summary Get sync progress
// @Description Returns the current sync progress or result
// @Description Returns the progress of the running sync, or the result of the last sync
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Success 200 {object} backend.SyncResponse
// @Router /sync/progress [get]
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Getting sync progress")
response := backend.SyncProgress()
return ctx.JSON(http.StatusOK, response)
if s.sync.IsSyncing() {
return ctx.JSON(http.StatusOK, s.sync.Progress())
}
logging.GetLogger().Info("Getting sync result")
response := backend.SyncResult()
return ctx.JSON(http.StatusOK, response)
return ctx.JSON(http.StatusOK, s.sync.Result())
}
// SyncSoundtracksNewOnlyChanges godoc
// @Summary Sync soundtracks with only changes
// @Description Starts syncing soundtracks with only new changes
// @Description Starts syncing soundtracks that have changed
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {string} string "Start syncing soundtracks"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync [get]
// @Router /sync [post]
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
logging.GetLogger().Info("Starting sync with only changes")
backend.Syncing = true
go backend.SyncSoundtracksOnlyChanges()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
return s.start(ctx, false, "Start syncing soundtracks")
}
// SyncSoundtracksNewFull godoc
// @Summary Sync all soundtracks fully
// @Description Starts a full sync of all soundtracks
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {string} string "Start syncing soundtracks full"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/full [get]
// @Router /sync/full [post]
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
logging.GetLogger().Info("Starting full sync")
backend.Syncing = true
go backend.SyncSoundtracksFull()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
return s.start(ctx, true, "Start syncing soundtracks full")
}
func (s *SyncHandler) start(ctx *echo.Context, full bool, message string) error {
if !s.sync.Start(full) {
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info(message)
return ctx.JSON(http.StatusOK, message)
}
// ResetDB godoc
// @Summary Reset soundtracks database
// @Description Resets the soundtracks database by deleting all soundtracks and songs
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/reset [get]
// @Router /sync/reset [post]
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
logging.GetLogger().Info("Resetting soundtracks database")
backend.ResetDB()
if err := s.sync.ResetDB(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
}
-289
View File
@@ -1,289 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"os"
"testing"
"time"
"music-server/internal/backend"
"music-server/internal/db"
"music-server/internal/db/repository"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
)
// waitForSyncCompletion polls the sync progress endpoint until sync is complete
// Returns true if sync completed, false if timeout
func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
for i := 0; i < maxAttempts; i++ {
progressResp := MakeTestRequest(t, e, "GET", "/sync/progress")
assert.Equal(t, http.StatusOK, progressResp.Code)
// Try to parse as ProgressResponse first (while syncing)
var progress backend.ProgressResponse
err := json.Unmarshal(progressResp.Body.Bytes(), &progress)
if err == nil && progress.Progress != "" {
// Successfully parsed as ProgressResponse with non-empty progress
t.Logf("Sync progress: %s%%", progress.Progress)
if progress.Progress == "100" {
t.Log("Sync completed!")
// Wait for Syncing flag to be updated
for j := 0; j < 50; j++ {
if !backend.Syncing {
return true
}
time.Sleep(50 * time.Millisecond)
}
return false
}
} else {
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
var result backend.SyncResponse
err2 := json.Unmarshal(progressResp.Body.Bytes(), &result)
if err2 == nil {
t.Log("Sync already completed")
// Wait for Syncing flag to be updated
for j := 0; j < 50; j++ {
if !backend.Syncing {
return true
}
time.Sleep(50 * time.Millisecond)
}
return false
}
}
time.Sleep(1 * time.Second)
}
return false
}
// TestSyncPopulatesDatabase verifies that sync populates the database with soundtracks
func TestSyncPopulatesDatabase(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
// Debug: Check MUSIC_PATH
t.Logf("MUSIC_PATH: %s", os.Getenv("MUSIC_PATH"))
e := StartTestServer(t)
// Clear any existing data first
db.TestClearDatabase(t)
// Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool())
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
beforeCount := len(soundtracksBefore)
t.Logf("Soundtracks before sync: %d", beforeCount)
assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
// Run sync
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
// After sync - should have soundtracks
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
afterCount := len(soundtracksAfter)
t.Logf("Soundtracks after sync: %d", afterCount)
// Should have more soundtracks than before (unless database was already populated)
assert.True(t, afterCount > 0, "Database should have soundtracks after sync")
}
// TestSyncMakesDifference verifies that sync actually changes the database state
func TestSyncMakesDifference(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Clear any existing data first
db.TestClearDatabase(t)
// Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool())
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
assert.Equal(t, 0, len(soundtracksBefore), "Should have no soundtracks before sync")
// Run sync
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
// After sync - should have soundtracks
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
assert.True(t, len(soundtracksAfter) > 0, "Should have soundtracks after sync")
}
// TestSyncProgress verifies the sync progress endpoint
func TestSyncProgress(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Start sync in background
go MakeTestRequest(t, e, "GET", "/sync/full")
// Poll progress endpoint
maxAttempts := 30
foundComplete := false
for i := 0; i < maxAttempts; i++ {
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
assert.Equal(t, http.StatusOK, resp.Code)
// Try ProgressResponse first
var progress backend.ProgressResponse
err := json.Unmarshal(resp.Body.Bytes(), &progress)
if err == nil && progress.Progress != "" {
// Successfully parsed as ProgressResponse with non-empty progress
t.Logf("Sync progress: %s%%", progress.Progress)
// Verify we get valid progress values
if progress.Progress != "0" {
// Sync is making progress
}
if progress.Progress == "100" {
foundComplete = true
break
}
} else {
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
var result backend.SyncResponse
err2 := json.Unmarshal(resp.Body.Bytes(), &result)
if err2 == nil {
foundComplete = true
break
}
}
time.Sleep(1 * time.Second)
}
// Note: foundNonZero might be false if sync completed too quickly
// So we only assert that sync completed
assert.True(t, foundComplete, "Should have seen completion")
}
// TestSyncSoundtracksNewOnlyChanges verifies the incremental sync endpoint
func TestSyncSoundtracksNewOnlyChanges(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Run full sync first
MakeTestRequest(t, e, "GET", "/sync/full")
// Wait for it to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Initial sync did not complete within timeout")
return
}
// Get initial count
repo := repository.New(backend.BackendPool())
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(soundtracksBefore)
// Run incremental sync (should not change count if nothing changed)
resp := MakeTestRequest(t, e, "GET", "/sync/new")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait a bit
time.Sleep(2 * time.Second)
// Count should be the same
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(soundtracksAfter)
// Note: This might not be exactly equal due to timing, but should be close
t.Logf("Soundtracks before incremental sync: %d, after: %d", beforeCount, afterCount)
}
// TestResetSoundtracks verifies the reset endpoint clears the database
// RUN THIS LAST
func TestResetSoundtracks(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// First ensure we have data
repo := repository.New(backend.BackendPool())
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(soundtracksBefore)
if beforeCount == 0 {
// Run sync to populate
MakeTestRequest(t, e, "GET", "/sync/full")
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
return
}
soundtracksBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount = len(soundtracksBefore)
}
t.Logf("Soundtracks before reset: %d", beforeCount)
assert.True(t, beforeCount > 0, "Should have soundtracks to reset")
// Call reset
resp := MakeTestRequest(t, e, "GET", "/sync/reset")
assert.Equal(t, http.StatusOK, resp.Code)
// Verify database is cleared
// Note: reset might take a moment to propagate
time.Sleep(1 * time.Second)
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(soundtracksAfter)
t.Logf("Soundtracks after reset: %d", afterCount)
assert.Equal(t, 0, afterCount, "Database should be empty after reset")
}
// TestSyncSoundtracksNewFull verifies the full sync endpoint
// RUN THIS LAST (before TestResetSoundtracks)
func TestSyncSoundtracksNewFull(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Clear database first
db.TestClearDatabase(t)
// Run full sync
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Full sync did not complete within timeout")
}
// Verify database is populated
repo := repository.New(backend.BackendPool())
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
assert.True(t, len(soundtracks) > 0, "Database should be populated after full sync")
t.Logf("Full sync populated %d soundtracks", len(soundtracks))
}
-118
View File
@@ -1,118 +0,0 @@
package server
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"music-server/internal/backend"
"music-server/internal/db"
"github.com/labstack/echo/v5"
)
// StartTestServer starts the server for testing with test configuration
func StartTestServer(t *testing.T) *echo.Echo {
// Set test environment variables if not already set
if os.Getenv("DB_HOST") == "" {
os.Setenv("DB_HOST", "localhost")
}
if os.Getenv("DB_PORT") == "" {
os.Setenv("DB_PORT", "5432")
}
if os.Getenv("DB_USERNAME") == "" {
os.Setenv("DB_USERNAME", "testuser")
}
if os.Getenv("DB_PASSWORD") == "" {
os.Setenv("DB_PASSWORD", "testpass")
}
if os.Getenv("DB_NAME") == "" {
os.Setenv("DB_NAME", "music_server_test")
}
if os.Getenv("MUSIC_PATH") == "" {
os.Setenv("MUSIC_PATH", "./testMusic")
}
if os.Getenv("CHARACTERS_PATH") == "" {
os.Setenv("CHARACTERS_PATH", "./testCharacters")
}
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "8081")
}
if os.Getenv("LOG_LEVEL") == "" {
os.Setenv("LOG_LEVEL", "debug")
}
if os.Getenv("LOG_JSON") == "" {
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{
db: db.TestDatabase,
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
statisticsHandler: NewStatisticsHandler(),
}
handler := s.RegisterRoutes()
// Wrap the http.Handler in an echo.Echo
e := echo.New()
// Use a custom handler that wraps our routes
e.Any("/*", echo.WrapHandler(handler))
return e
}
// MakeTestRequest makes an HTTP request to the test server
func MakeTestRequest(t *testing.T, e *echo.Echo, method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// MakeTestRequestWithBody makes an HTTP request with a body to the test server
func MakeTestRequestWithBody(t *testing.T, e *echo.Echo, method, path string, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewBuffer(body))
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// WaitForSyncComplete polls the sync progress endpoint until sync is complete
func WaitForSyncComplete(t *testing.T, e *echo.Echo, timeout time.Duration) bool {
start := time.Now()
for time.Since(start) < timeout {
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
if resp.Code != http.StatusOK {
t.Logf("Sync progress endpoint returned status %d", resp.Code)
time.Sleep(1 * time.Second)
continue
}
// Parse response - we can't easily decode here without importing backend
// Just check if response contains "100"
body := resp.Body.String()
if len(body) > 0 {
t.Logf("Sync progress: %s", body)
// Simple check for completion
// In a real scenario, you'd parse the JSON properly
}
time.Sleep(1 * time.Second)
}
t.Error("Sync did not complete within timeout")
return false
}
+53 -87
View File
@@ -2,24 +2,25 @@ package server
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
const tokenLifetime = 24 * time.Hour
// TokenRequest represents a request to generate a new token
type TokenRequest struct {
Password string `json:"password"` // Required: API password
Password string `json:"password"` // API password, required when API_PASSWORD is set
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
}
@@ -30,20 +31,19 @@ type TokenResponse struct {
ClientType string `json:"client_type"`
}
// TokenHandler contains the database pool for token operations
// TokenHandler creates and removes session tokens.
type TokenHandler struct {
pool *pgxpool.Pool
q repository.Querier
apiPassword string
disableSessionAuth bool
}
// NewTokenHandler creates a new token handler with database pool
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
return &TokenHandler{
pool: pool,
}
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
}
// generateToken creates a new cryptographically secure token
func (h *TokenHandler) generateToken() (string, error) {
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
@@ -51,149 +51,115 @@ func (h *TokenHandler) generateToken() (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
func (h *TokenHandler) passwordValid(password string) bool {
if h.disableSessionAuth || h.apiPassword == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
}
// CreateTokenHandler creates a new session token
// POST /api/v1/token
//
// @Summary Create session token
// @Description Returns a new session token for API access. Requires valid API password.
// @Description Returns a new session token for API access. Requires the API password when one is configured.
// @Tags auth
// @Accept json
// @Accept json
// @Produce json
// @Param request body TokenRequest true "Password and client type"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 429 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [post]
// @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"})
}
// Check if session auth is disabled
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
apiPassword := os.Getenv("API_PASSWORD")
// Password validation logic:
// - If session auth is disabled, skip validation
// - If API_PASSWORD is empty, allow any password (open access mode)
// - If API_PASSWORD is set, require exact password match
if !disableSessionAuth {
if apiPassword != "" {
// API password is set - require exact match
if req.Password != apiPassword {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent()))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
}
}
// If API_PASSWORD is empty, allow any password through (open access mode)
if !h.passwordValid(req.Password) {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent()))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
}
if req.ClientType == "" {
req.ClientType = "web"
}
// Generate token
token, err := h.generateToken()
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
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{
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
Token: token,
IpAddress: c.RealIP(),
UserAgent: c.Request().UserAgent(),
ClientType: &clientType,
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
ClientType: &req.ClientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
logging.GetLogger().Error("Failed to create session", zap.Error(err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
}
response := TokenResponse{
clientType := req.ClientType
if session.ClientType != nil {
clientType = *session.ClientType
}
return c.JSON(http.StatusOK, TokenResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
ClientType: *session.ClientType,
}
return c.JSON(http.StatusOK, response)
ClientType: clientType,
})
}
// 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]
// @Router /api/v1/token [delete]
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
if c.Request().Header.Get("Authorization") == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
token, ok := middleware.BearerToken(c)
if !ok {
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()))
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
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{}
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token/cleanup [post]
// @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()))
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
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",
})
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
}
@@ -3,12 +3,12 @@ package server
import (
"net/http"
"github.com/labstack/echo/v5"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
)
type VersionHandler struct {
}
type VersionHandler struct{}
func NewVersionHandler() *VersionHandler {
return &VersionHandler{}
@@ -19,17 +19,11 @@ func NewVersionHandler() *VersionHandler {
// @Summary Getting the version history of the backend
// @Description get version history
// @Tags version
// @Accept json
// @Produce json
// @Success 200 {array} backend.VersionData
// @Failure 404 {object} string
// @Router /version/history [get]
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
versionHistory := backend.GetVersionHistory()
if len(versionHistory) == 0 {
return ctx.JSON(http.StatusNotFound, "version not found")
}
return ctx.JSON(http.StatusOK, versionHistory)
return ctx.JSON(http.StatusOK, backend.GetVersionHistory())
}
// GetLatestVersion godoc
@@ -37,15 +31,9 @@ func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
// @Summary Getting the latest version of the backend
// @Description get latest version info
// @Tags version
// @Accept json
// @Produce json
// @Success 200 {object} backend.VersionData
// @Failure 404 {object} string
// @Router /version [get]
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
latestVersion := backend.GetLatestVersion()
if latestVersion.Version == "" {
return ctx.JSON(http.StatusNotFound, "version not found")
}
return ctx.JSON(http.StatusOK, latestVersion)
return ctx.JSON(http.StatusOK, backend.GetLatestVersion())
}
-40
View File
@@ -1,40 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"music-server/internal/backend"
"github.com/stretchr/testify/assert"
)
// TestGetLatestVersion verifies the version endpoint returns latest version
func TestGetLatestVersion(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/version")
assert.Equal(t, http.StatusOK, resp.Code)
var versionData backend.VersionData
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
assert.NoError(t, err)
assert.NotEmpty(t, versionData.Version)
assert.NotEmpty(t, versionData.Changelog)
}
// TestGetVersionHistory verifies the version history endpoint returns version history
func TestGetVersionHistory(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/version/history")
assert.Equal(t, http.StatusOK, resp.Code)
var versionHistory []backend.VersionData
err := json.Unmarshal(resp.Body.Bytes(), &versionHistory)
assert.NoError(t, err)
assert.NotEmpty(t, versionHistory)
assert.NotEmpty(t, versionHistory[0].Version)
assert.NotEmpty(t, versionHistory[0].Changelog)
}
-312
View File
@@ -1,312 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"music-server/internal/backend"
"music-server/internal/db"
"music-server/internal/db/repository"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
)
// ensureSyncRan ensures that sync has been run before testing music endpoints
func ensureSyncRan(t *testing.T, e *echo.Echo) {
repo := repository.New(backend.BackendPool())
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
if len(soundtracks) == 0 {
// Run sync
t.Log("No soundtracks found, running sync first...")
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete using shared helper
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
}
}
// TestGetAllSoundtracks verifies the /music/all/order endpoint
func TestZGetAllSoundtracks(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/all/order")
assert.Equal(t, http.StatusOK, resp.Code)
var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err)
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
t.Logf("Found %d soundtracks", len(soundtracks))
}
// TestGetAllSoundtracksRandom verifies the /music/all/random endpoint
func TestZGetAllSoundtracksRandom(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/all/random")
assert.Equal(t, http.StatusOK, resp.Code)
var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err)
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
// Verify it's shuffled (not in original order)
// We can't easily verify randomness, but we can check it's the same length
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
var soundtracksOrdered []string
json.Unmarshal(resp2.Body.Bytes(), &soundtracksOrdered)
assert.Equal(t, len(soundtracks), len(soundtracksOrdered), "Random and ordered should have same count")
}
// TestGetRandomSong verifies the /music/rand endpoint
func TestZGetRandomSong(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/rand")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
// Just verify we got a response with content
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
t.Logf("Random song returned %d bytes", len(resp.Body.Bytes()))
}
// TestGetRandomSongLowChance verifies the /music/rand/low endpoint
func TestZGetRandomSongLowChance(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/rand/low")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestGetRandomSongClassic verifies the /music/rand/classic endpoint
func TestZGetRandomSongClassic(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/rand/classic")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestGetSongInfo verifies the /music/info endpoint
func TestZGetSongInfo(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and get a song first
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
// Add to queue and mark as played
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/addPlayed")
resp := MakeTestRequest(t, e, "GET", "/music/info")
assert.Equal(t, http.StatusOK, resp.Code)
var info backend.SongInfo
err := json.Unmarshal(resp.Body.Bytes(), &info)
assert.NoError(t, err)
// Note: CurrentlyPlaying might be false if no song is currently set
// Just verify we got a valid response
t.Logf("Song info: Soundtrack=%s, Song=%s", info.Soundtrack, info.Song)
}
// TestGetPlayedSongs verifies the /music/list endpoint
func TestZGetPlayedSongs(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add some songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
resp := MakeTestRequest(t, e, "GET", "/music/list")
assert.Equal(t, http.StatusOK, resp.Code)
var songs []backend.SongInfo
err := json.Unmarshal(resp.Body.Bytes(), &songs)
assert.NoError(t, err)
assert.NotEmpty(t, songs, "Should have played songs in queue")
t.Logf("Found %d songs in queue", len(songs))
}
// TestGetNextSong verifies the /music/next endpoint
func TestZGetNextSong(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
resp := MakeTestRequest(t, e, "GET", "/music/next")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestGetPreviousSong verifies the /music/previous endpoint
func TestZGetPreviousSong(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Move forward
MakeTestRequest(t, e, "GET", "/music/next")
resp := MakeTestRequest(t, e, "GET", "/music/previous")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestResetMusic verifies the /music/reset endpoint
func TestZResetMusic(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Verify queue has items
respBefore := MakeTestRequest(t, e, "GET", "/music/list")
var songsBefore []backend.SongInfo
json.Unmarshal(respBefore.Body.Bytes(), &songsBefore)
assert.True(t, len(songsBefore) > 0, "Should have songs before reset")
// Reset queue
resp := MakeTestRequest(t, e, "GET", "/music/reset")
assert.Equal(t, http.StatusOK, resp.Code)
// Verify queue is empty
respAfter := MakeTestRequest(t, e, "GET", "/music/list")
var songsAfter []backend.SongInfo
json.Unmarshal(respAfter.Body.Bytes(), &songsAfter)
assert.Equal(t, 0, len(songsAfter), "Queue should be empty after reset")
}
// TestAddLatestToQue verifies the /music/addQue endpoint
func TestZAddLatestToQue(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
// Get a random song (this sets lastFetchedNew)
MakeTestRequest(t, e, "GET", "/music/rand")
// Add to queue
resp := MakeTestRequest(t, e, "GET", "/music/addQue")
assert.Equal(t, http.StatusOK, resp.Code)
// Verify it was added to queue
respList := MakeTestRequest(t, e, "GET", "/music/list")
var songs []backend.SongInfo
json.Unmarshal(respList.Body.Bytes(), &songs)
assert.True(t, len(songs) > 0, "Song should be in queue")
}
// TestAddLatestPlayed verifies the /music/addPlayed endpoint
func TestZAddLatestPlayed(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add song to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Mark as played
resp := MakeTestRequest(t, e, "GET", "/music/addPlayed")
assert.Equal(t, http.StatusOK, resp.Code)
}
// TestPutPlayed verifies the PUT /music/played endpoint
func TestZPutPlayed(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Mark song 0 as played
resp := MakeTestRequestWithBody(t, e, "PUT", "/music/played?song=0", nil)
assert.Equal(t, http.StatusOK, resp.Code)
}