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:
1 parent
75b6f0c1cf
commit
090e794460
78 files changed
+2919
-6303
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 }
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 '';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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!")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user