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

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

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

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

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

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

No files matched your search

@@ -2,29 +2,32 @@ package server
import (
"net/http"
"os"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
"music-server/internal/backend"
)
type CharacterHandler struct {
characters *backend.CharacterService
}
func NewCharacterHandler() *CharacterHandler {
return &CharacterHandler{}
func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler {
return &CharacterHandler{characters: characters}
}
// GetCharacterList godoc
// @Summary Get list of characters
// @Description Returns a list of all available characters
// @Tags characters
// @Accept json
// @Produce json
// @Success 200 {array} string
// @Router /characters [get]
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
characters := backend.GetCharacterList()
characters, err := c.characters.List()
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, characters)
}
@@ -32,18 +35,17 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
// @Summary Get character image
// @Description Returns the image for a specific character
// @Tags characters
// @Accept json
// @Produce image/png
// @Param name query string true "Character name"
// @Produce image/jpeg
// @Param name query string true "Character file name"
// @Success 200 {file} file
// @Failure 400 {string} string "invalid name"
// @Failure 404 {string} string "not found"
// @Router /character [get]
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
character := ctx.QueryParam("name")
characterPath := backend.GetCharacter(character)
file, err := os.Open(characterPath)
f, err := c.characters.Open(ctx.QueryParam("name"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
return songError(err)
}
defer file.Close()
return ctx.Stream(http.StatusOK, "image/png", file)
return serveFile(ctx, f, "")
}
-47
View File
@@ -1,47 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
// TestGetCharacterList verifies the characters endpoint returns list of characters
func TestGetCharacterList(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/characters")
assert.Equal(t, http.StatusOK, resp.Code)
var characters []string
err := json.Unmarshal(resp.Body.Bytes(), &characters)
assert.NoError(t, err)
assert.NotEmpty(t, characters)
// Should contain our test characters
assert.Contains(t, characters, "char1.jpg")
assert.Contains(t, characters, "char2.png")
}
// TestGetCharacter verifies the character endpoint returns a file
func TestGetCharacter(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/character?name=char1.jpg")
// For now, just check that we get a response (not necessarily 200)
// The actual file serving might have issues with absolute paths
if resp.Code != http.StatusOK {
t.Logf("Got status %d instead of 200", resp.Code)
// Don't fail the test for now - we can investigate later
}
}
// TestGetCharacterNotFound verifies handling of non-existent character
func TestGetCharacterNotFound(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/character?name=nonexistent.jpg")
// Should return 404 or similar error
assert.NotEqual(t, http.StatusOK, resp.Code)
}
-71
View File
@@ -1,71 +0,0 @@
package server
import (
"github.com/labstack/echo/v5"
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
)
type DownloadHandler struct {
}
func NewDownloadHandler() *DownloadHandler {
return &DownloadHandler{}
}
// CheckLatest godoc
// @Summary Check for latest version
// @Description Checks for the latest version of the application
// @Tags download
// @Accept json
// @Produce json
// @Success 200 {string} string
// @Router /download [get]
func (d *DownloadHandler) checkLatest(ctx *echo.Context) error {
logging.GetLogger().Info("Checking latest version")
latest := backend.CheckLatest()
return ctx.JSON(http.StatusOK, latest)
}
// ListAssetsOfLatest godoc
// @Summary List assets of latest version
// @Description Lists all assets available for the latest version
// @Tags download
// @Accept json
// @Produce json
// @Success 200 {array} string
// @Router /download/list [get]
func (d *DownloadHandler) listAssetsOfLatest(ctx *echo.Context) error {
logging.GetLogger().Info("Listing assets")
assets := backend.ListAssetsOfLatest()
return ctx.JSON(http.StatusOK, assets)
}
// DownloadLatestWindows godoc
// @Summary Download latest Windows version
// @Description Redirects to download the latest Windows version
// @Tags download
// @Produce octet-stream
// @Success 302 {string} string
// @Router /download/windows [get]
func (d *DownloadHandler) downloadLatestWindows(ctx *echo.Context) error {
logging.GetLogger().Info("Downloading latest windows")
asset := backend.DownloadLatestWindows()
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
return ctx.Redirect(http.StatusFound, asset)
}
// DownloadLatestLinux godoc
// @Summary Download latest Linux version
// @Description Redirects to download the latest Linux version
// @Tags download
// @Produce octet-stream
// @Success 302 {string} string
// @Router /download/linux [get]
func (d *DownloadHandler) downloadLatestLinux(ctx *echo.Context) error {
logging.GetLogger().Info("Downloading latest linux")
asset := backend.DownloadLatestLinux()
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
return ctx.Redirect(http.StatusFound, asset)
}
+81
View File
@@ -0,0 +1,81 @@
package server
import (
"net/http"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
)
type DownloadHandler struct {
releases *backend.ReleaseService
}
func NewDownloadHandler(releases *backend.ReleaseService) *DownloadHandler {
return &DownloadHandler{releases: releases}
}
// CheckLatest godoc
// @Summary Check for latest version
// @Description Returns the latest version of the client application
// @Tags download
// @Produce json
// @Success 200 {string} string
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download [get]
func (d *DownloadHandler) CheckLatest(ctx *echo.Context) error {
latest, err := d.releases.LatestVersion(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, latest)
}
// ListAssetsOfLatest godoc
// @Summary List assets of latest version
// @Description Lists all assets available for the latest version
// @Tags download
// @Produce json
// @Success 200 {array} string
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/list [get]
func (d *DownloadHandler) ListAssetsOfLatest(ctx *echo.Context) error {
assets, err := d.releases.AssetNames(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, assets)
}
// DownloadLatestWindows godoc
// @Summary Download latest Windows version
// @Description Redirects to download the latest Windows version
// @Tags download
// @Success 302 {string} string
// @Failure 404 {string} string "not found"
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/windows [get]
func (d *DownloadHandler) DownloadLatestWindows(ctx *echo.Context) error {
return d.redirectToAsset(ctx, ".exe")
}
// DownloadLatestLinux godoc
// @Summary Download latest Linux version
// @Description Redirects to download the latest Linux version
// @Tags download
// @Success 302 {string} string
// @Failure 404 {string} string "not found"
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/linux [get]
func (d *DownloadHandler) DownloadLatestLinux(ctx *echo.Context) error {
return d.redirectToAsset(ctx, ".x86_64")
}
func (d *DownloadHandler) redirectToAsset(ctx *echo.Context, suffix string) error {
url, err := d.releases.AssetURL(ctx.Request().Context(), suffix)
if err != nil {
return songError(err)
}
return ctx.Redirect(http.StatusFound, url)
}
+61
View File
@@ -0,0 +1,61 @@
package server
import (
"errors"
"net/http"
"os"
"path/filepath"
"time"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// serveFile sends a file with support for Range requests and caching headers.
// contentType may be empty to detect it from the file extension.
func serveFile(c *echo.Context, f *os.File, contentType string) error {
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
if info.IsDir() {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
}
if contentType != "" {
c.Response().Header().Set(echo.HeaderContentType, contentType)
}
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
return nil
}
// serveSong streams a song. The write deadline is lifted because songs can take long to send to slow clients.
func serveSong(c *echo.Context, path string, err error) error {
if err != nil {
return songError(err)
}
f, err := os.Open(path)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
}
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
return serveFile(c, f, "audio/mpeg")
}
// songError maps backend errors to HTTP errors.
func songError(err error) error {
switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return echo.NewHTTPError(http.StatusNotFound, err.Error())
case errors.Is(err, backend.ErrInvalidName):
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
case errors.Is(err, backend.ErrUpstream):
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
default:
logging.GetLogger().Error("Request failed", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
}
}
-29
View File
@@ -1,29 +0,0 @@
package server
import (
"net/http"
"github.com/labstack/echo/v5"
"music-server/internal/db"
)
type HealthHandler struct {
db *db.Database
}
func NewHealthHandler(database *db.Database) *HealthHandler {
return &HealthHandler{db: database}
}
// HealthCheck godoc
//
// @Summary Check server health
// @Description Returns the health status of the server
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {string} string "OK"
// @Router /health [get]
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, h.db.Health())
}
+33
View File
@@ -0,0 +1,33 @@
package server
import (
"context"
"net/http"
"github.com/labstack/echo/v5"
)
// HealthChecker reports the health of a dependency.
type HealthChecker interface {
Health(ctx context.Context) map[string]string
}
type HealthHandler struct {
db HealthChecker
}
func NewHealthHandler(db HealthChecker) *HealthHandler {
return &HealthHandler{db: db}
}
// HealthCheck godoc
//
// @Summary Check server health
// @Description Returns the health status of the server
// @Tags health
// @Produce json
// @Success 200 {object} map[string]string
// @Router /health [get]
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, h.db.Health(ctx.Request().Context()))
}
-24
View File
@@ -1,24 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
// TestHealthCheck verifies the health endpoint returns database status
func TestHealthCheck(t *testing.T) {
e := StartTestServer(t)
// No explicit teardown - handled by StartTestServer's sync.Once
resp := MakeTestRequest(t, e, "GET", "/health")
assert.Equal(t, http.StatusOK, resp.Code)
var healthData map[string]string
err := json.Unmarshal(resp.Body.Bytes(), &healthData)
assert.NoError(t, err)
assert.NotEmpty(t, healthData)
assert.Equal(t, "up", healthData["status"])
}
+17 -85
View File
@@ -2,98 +2,30 @@ package middleware
import (
"net/http"
"sync"
"time"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
)
// RateLimiter implements a simple IP-based rate limiter
type RateLimiter struct {
visits map[string]*Visit
mu sync.Mutex
limit int // requests per window
window time.Duration // time window
}
type Visit struct {
count int
firstSeen time.Time
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
visits: make(map[string]*Visit),
limit: limit,
window: window,
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
config := echoMiddleware.DefaultRateLimiterConfig
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
Rate: float64(limit) / window.Seconds(),
Burst: limit,
ExpiresIn: window,
})
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", identifier),
zap.String("path", c.Request().URL.Path))
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Rate limit exceeded. Please try again later.",
})
}
// Start cleanup goroutine
go rl.cleanup()
return rl
return echoMiddleware.RateLimiterWithConfig(config)
}
// cleanup periodically removes old entries
func (rl *RateLimiter) cleanup() {
for {
time.Sleep(rl.window)
rl.mu.Lock()
now := time.Now()
for ip, visit := range rl.visits {
if now.Sub(visit.firstSeen) > rl.window {
delete(rl.visits, ip)
}
}
rl.mu.Unlock()
}
}
// RateLimitMiddleware returns an Echo middleware for rate limiting
func RateLimitMiddleware(rl *RateLimiter) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
ip := c.RealIP()
rl.mu.Lock()
defer rl.mu.Unlock()
visit, exists := rl.visits[ip]
now := time.Now()
if !exists {
// First visit from this IP
rl.visits[ip] = &Visit{
count: 1,
firstSeen: now,
}
return next(c)
}
// Check if window has expired
if now.Sub(visit.firstSeen) > rl.window {
// Reset for new window
visit.count = 1
visit.firstSeen = now
return next(c)
}
// Check if limit exceeded
if visit.count >= rl.limit {
logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", ip),
zap.String("path", c.Request().URL.Path))
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Rate limit exceeded. Please try again later.",
})
}
// Increment count
visit.count++
return next(c)
}
}
}
+11 -11
View File
@@ -1,22 +1,22 @@
package middleware
import (
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
)
// SyncCheckMiddleware blocks requests when syncing is in progress.
// It returns HTTP 423 Locked with a standard message.
// This middleware should be applied to handlers that cannot execute during sync.
func SyncCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Syncing is in progress - request blocked")
return c.JSON(http.StatusLocked, "Syncing is in progress")
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if isSyncing() {
logging.GetLogger().Info("Syncing is in progress - request blocked")
return c.JSON(http.StatusLocked, "Syncing is in progress")
}
return next(c)
}
return next(c)
}
}
+21 -46
View File
@@ -1,87 +1,62 @@
package middleware
import (
"context"
"net/http"
"os"
"strings"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// TokenAuthMiddleware returns an Echo middleware that validates session tokens
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
func BearerToken(c *echo.Context) (string, bool) {
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
return token, ok && token != "" && !strings.Contains(token, " ")
}
// TokenAuth validates session tokens. When disabled is true all requests are let through.
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Session auth disable bypass - allow all requests through without token validation
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
if disableSessionAuth {
logging.GetLogger().Debug("Session auth disabled - bypassing token authentication",
zap.String("path", c.Request().URL.Path),
zap.String("ip", c.RealIP()))
if disabled {
return next(c)
}
// Extract token from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
if c.Request().Header.Get("Authorization") == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
}
// Bearer token format
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
token, ok := BearerToken(c)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
}
token := parts[1]
queries := repository.New(pool)
session, err := queries.GetSession(c.Request().Context(), token)
ctx := c.Request().Context()
session, err := q.GetSession(ctx, token)
if err != nil {
logging.GetLogger().Warn("Invalid token attempt",
zap.String("token", token),
zap.String("ip", c.RealIP()),
zap.String("error", err.Error()),
)
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
}
// Check if token is expired
if time.Now().After(session.ExpiresAt.Time) {
// Clean up expired session in background
go func() {
queries.DeleteSession(c.Request().Context(), token)
}()
// Clean up in the background with a context that outlives the request.
go func(ctx context.Context) {
if err := q.DeleteSession(ctx, token); err != nil {
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
}
}(context.WithoutCancel(ctx))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
}
// Add session to request context for potential use by handlers
c.Set("session", session)
return next(c)
}
}
}
// TokenIPCheckMiddleware checks if the request IP matches the session IP
func TokenIPCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
sessionVal := c.Get("session")
if sessionVal == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "No session in context"})
}
session := sessionVal.(repository.Session)
if session.IpAddress != c.RealIP() {
logging.GetLogger().Warn("Token IP mismatch",
zap.String("token_ip", session.IpAddress),
zap.String("request_ip", c.RealIP()),
)
}
return next(c)
}
}
@@ -1,47 +1,43 @@
package server
import (
"net/http"
"strconv"
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
"os"
"strconv"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
type MusicHandler struct {
music *backend.MusicService
}
func NewMusicHandler() *MusicHandler {
return &MusicHandler{}
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
return &MusicHandler{music: music}
}
// GetSong godoc
// @Summary Get a specific song
// @Description Returns a specific song by name
// @Description Returns the song at the given position in the queue
// @Tags music
// @Accept json
// @Produce audio/mpeg
// @Param song query string true "Song name"
// @Param song query int true "Song number in the queue"
// @Success 200 {file} file
// @Failure 400 {string} string "song can't be empty"
// @Success 206 {file} file
// @Failure 400 {string} string "song must be a number"
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music [get]
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
song := ctx.QueryParam("song")
if song == "" {
return ctx.String(http.StatusBadRequest, "song can't be empty")
}
songPath := backend.GetSong(song)
file, err := os.Open(songPath)
song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
return ctx.String(http.StatusBadRequest, "song must be a number")
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.Song(song)
return serveSong(ctx, path, err)
}
// GetSoundCheckSong godoc
@@ -54,25 +50,21 @@ func (m *MusicHandler) GetSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/soundTest [get]
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
songPath := backend.GetSoundCheckSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.SoundCheckSong()
return serveSong(ctx, path, err)
}
// ResetMusic godoc
// @Summary Reset music state
// @Description Resets the music state
// @Tags music
// @Accept json
// @Success 204
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/reset [get]
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
backend.Reset()
if err := m.music.Reset(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
@@ -86,18 +78,13 @@ func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand [get]
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
songPath := backend.GetRandomSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.RandomSong(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongLowChance godoc
// @Summary Get random song with low chance
// @Description Returns a random song with low chance selection
// @Description Returns a random song, favouring soundtracks that have been played less
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
@@ -105,18 +92,13 @@ func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/low [get]
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
songPath := backend.GetRandomSongLowChance()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongClassic godoc
// @Summary Get random classic song
// @Description Returns a random song from the classic selection
// @Description Returns a random song where every song has the same chance
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
@@ -124,39 +106,30 @@ func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/classic [get]
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
songPath := backend.GetRandomSongClassic()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.RandomSongClassic(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetSongInfo godoc
// @Summary Get current song info
// @Description Returns information about the current song
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Success 200 {object} backend.SongInfo
// @Router /music/info [get]
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
song := backend.GetSongInfo()
return ctx.JSON(http.StatusOK, song)
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
}
// GetPlayedSongs godoc
// @Summary Get played songs list
// @Description Returns a list of played songs
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Success 200 {array} backend.SongInfo
// @Router /music/list [get]
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
songList := backend.GetPlayedSongs()
return ctx.JSON(http.StatusOK, songList)
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
}
// GetNextSong godoc
@@ -169,13 +142,8 @@ func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/next [get]
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
songPath := backend.GetNextSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.NextSong()
return serveSong(ctx, path, err)
}
// GetPreviousSong godoc
@@ -188,51 +156,48 @@ func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/previous [get]
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
songPath := backend.GetPreviousSong()
file, err := os.Open(songPath)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
defer file.Close()
return ctx.Stream(http.StatusOK, "audio/mpeg", file)
path, err := m.music.PreviousSong()
return serveSong(ctx, path, err)
}
// GetAllSoundtracks godoc
// @Summary Get all soundtracks
// @Description Returns a list of all soundtracks in order
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/order [get]
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
soundtrackList := backend.GetAllSoundtracks()
return ctx.JSON(http.StatusOK, soundtrackList)
names, err := m.music.SoundtrackNames(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, names)
}
// GetAllSoundtracksRandom godoc
// @Summary Get all soundtracks random
// @Description Returns a list of all soundtracks in random order
// @Tags music
// @Accept json
// @Produce json
// @Success 200 {array} map[string]interface{}
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/random [get]
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
soundtrackList := backend.GetAllSoundtracksRandom()
return ctx.JSON(http.StatusOK, soundtrackList)
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, names)
}
// PutPlayed godoc
// @Summary Mark song as played
// @Description Marks a song as played by its ID
// @Description Marks the song at the given queue position as played
// @Tags music
// @Accept json
// @Produce json
// @Param song query int true "Song ID"
// @Success 204
// @Param song query int true "Song number in the queue"
// @Success 200
// @Failure 400 {string} string "Bad Request"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/played [put]
@@ -242,7 +207,9 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
return ctx.JSON(http.StatusBadRequest, err.Error())
}
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
backend.SetPlayed(song)
if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
@@ -250,24 +217,24 @@ func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
// @Summary Add latest to queue
// @Description Adds the latest song to the queue
// @Tags music
// @Accept json
// @Success 204
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addQue [get]
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
backend.AddLatestToQue()
m.music.AddLatestToQueue()
return ctx.NoContent(http.StatusOK)
}
// AddLatestPlayed godoc
// @Summary Add latest to played
// @Description Adds the latest song to the played list
// @Description Marks the current song as played
// @Tags music
// @Accept json
// @Success 204
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addPlayed [get]
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
backend.AddLatestPlayed()
if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
+120 -236
View File
@@ -1,22 +1,23 @@
package server
import (
"net/http"
"time"
_ "music-server/cmd/docs" // registers the OpenAPI spec
"music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"net/http"
"sort"
"strings"
"time"
"github.com/a-h/templ"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
echoSwagger "github.com/swaggo/echo-swagger/v2"
"github.com/swaggo/swag"
"go.uber.org/zap"
)
// @Title MusicServer API
// @title MusicServer API
// @version 1.0
// @description API for the MusicServer application
// @termsOfService http://sanplex.xyz/terms/
@@ -27,19 +28,45 @@ import (
// @license.name MIT
// @license.url http://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /
// handlers holds all HTTP handlers.
type handlers struct {
health *HealthHandler
version *VersionHandler
character *CharacterHandler
download *DownloadHandler
sync *SyncHandler
music *MusicHandler
statistics *StatisticsHandler
token *TokenHandler
}
func (s *Server) handlers() handlers {
return handlers{
health: NewHealthHandler(s.db),
version: NewVersionHandler(),
character: NewCharacterHandler(s.characters),
download: NewDownloadHandler(s.releases),
sync: NewSyncHandler(s.sync),
music: NewMusicHandler(s.music),
statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
}
}
func (s *Server) RegisterRoutes() http.Handler {
e := echo.New()
if len(s.cfg.TrustedProxies) > 0 {
var trust []echo.TrustOption
for _, ipNet := range s.cfg.TrustedProxies {
trust = append(trust, echo.TrustIPRange(ipNet))
}
e.IPExtractor = echo.ExtractIPFromXFFHeader(trust...)
}
// Serve OpenAPI spec at /openapi
e.GET("/openapi", echo.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, "cmd/docs/swagger.json")
})))
e.Use(logging.RequestLogger())
e.Use(echoMiddleware.Recover())
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: []string{"https://*", "http://*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
@@ -48,240 +75,97 @@ func (s *Server) RegisterRoutes() http.Handler {
MaxAge: 300,
}))
fileServer := http.FileServer(http.FS(web.Assets))
e.GET("/assets/*", echo.WrapHandler(fileServer))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
e.Static("/", "/frontend")
// Swagger UI
e.GET("/openapi", serveOpenAPI)
e.GET("/swagger/*", echoSwagger.WrapHandler)
// ============================================
// Legacy Endpoints (Deprecated - use /api/v1/ instead)
// ============================================
deprecatedMiddleware := middleware.DeprecationMiddleware
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
e.Static("/", s.cfg.FrontendPath)
health := NewHealthHandler(s.db)
e.GET("/health", deprecatedMiddleware(health.HealthCheck))
h := s.handlers()
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
version := NewVersionHandler()
e.GET("/version", deprecatedMiddleware(version.GetLatestVersion))
e.GET("/version/history", deprecatedMiddleware(version.GetVersionHistory))
// Legacy endpoints (deprecated - use /api/v1/ instead)
legacy := e.Group("", middleware.DeprecationMiddleware)
registerCommon(legacy, h, syncLock)
legacy.GET("/health", h.health.HealthCheck)
legacy.GET("/characters", h.character.GetCharacterList)
character := NewCharacterHandler()
e.GET("/character", deprecatedMiddleware(character.GetCharacter))
e.GET("/characters", deprecatedMiddleware(character.GetCharacterList))
download := NewDownloadHandler()
e.GET("/download", deprecatedMiddleware(download.checkLatest))
e.GET("/download/list", deprecatedMiddleware(download.listAssetsOfLatest))
e.GET("/download/windows", deprecatedMiddleware(download.downloadLatestWindows))
e.GET("/download/linux", deprecatedMiddleware(download.downloadLatestLinux))
sync := NewSyncHandler()
syncGroup := e.Group("/sync")
syncGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/progress", deprecatedMiddleware(sync.SyncProgress))
syncGroup.GET("/new", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/new/full", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewFull)))
syncGroup.GET("/quick", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.SyncSoundtracksNewOnlyChanges)))
syncGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(sync.ResetDB)))
music := NewMusicHandler()
musicGroup := e.Group("/music")
musicGroup.GET("", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSong)))
musicGroup.GET("/soundTest", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetSoundCheckSong)))
musicGroup.GET("/reset", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.ResetMusic)))
musicGroup.GET("/rand", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSong)))
musicGroup.GET("/rand/low", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongLowChance)))
musicGroup.GET("/rand/classic", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetRandomSongClassic)))
musicGroup.GET("/info", deprecatedMiddleware(music.GetSongInfo))
musicGroup.GET("/list", deprecatedMiddleware(music.GetPlayedSongs))
musicGroup.GET("/next", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetNextSong)))
musicGroup.GET("/previous", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetPreviousSong)))
musicGroup.GET("/all", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.GET("/all/order", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracks)))
musicGroup.GET("/all/random", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.GetAllSoundtracksRandom)))
musicGroup.PUT("/played", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.PutPlayed)))
musicGroup.GET("/addQue", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestToQue)))
musicGroup.GET("/addPlayed", deprecatedMiddleware(middleware.SyncCheckMiddleware(music.AddLatestPlayed)))
// ============================================
// API v1 Routes with Token Authentication
// ============================================
// Create /api/v1 group
// API v1
apiV1 := e.Group("/api/v1")
// Create rate limiter for token creation: 10 requests per minute per IP
tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
// Token endpoints (public - no token required)
tokenGroup := apiV1.Group("/token")
tokenGroup.POST("", func(c *echo.Context) error {
return s.tokenHandler.CreateTokenHandler(c)
}, tokenRateLimitMiddleware)
tokenGroup.DELETE("", func(c *echo.Context) error {
return s.tokenHandler.DeleteTokenHandler(c)
})
tokenGroup.POST("/cleanup", func(c *echo.Context) error {
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
})
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
// Protected endpoints - require valid token
// Create token auth middleware with pool access
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
registerCommon(protected, h, syncLock)
protected.GET("/character/list", h.character.GetCharacterList)
// Statistics endpoints (protected)
statisticsHandler := s.statisticsHandler
statsGroup := protectedV1.Group("/statistics")
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
return statisticsHandler.GetNeverPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
return statisticsHandler.GetLastPlayedSoundtracks(c)
})
statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
return statisticsHandler.GetOldestPlayedSoundtracks(c)
})
statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSongs(c)
})
statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSongs(c)
})
statsGroup.GET("/summary", func(c *echo.Context) error {
return statisticsHandler.GetStatisticsSummary(c)
})
stats := protected.Group("/statistics")
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
stats.GET("/summary", h.statistics.GetStatisticsSummary)
// Version endpoints (protected)
versionGroup := protectedV1.Group("/version")
versionGroup.GET("", func(c *echo.Context) error {
return version.GetLatestVersion(c)
})
versionGroup.GET("/history", func(c *echo.Context) error {
return version.GetVersionHistory(c)
})
// Character endpoints (protected)
characterGroup := protectedV1.Group("/character")
characterGroup.GET("", func(c *echo.Context) error {
return character.GetCharacter(c)
})
characterGroup.GET("/list", func(c *echo.Context) error {
return character.GetCharacterList(c)
})
// Download endpoints (protected)
downloadGroup := protectedV1.Group("/download")
downloadGroup.GET("", func(c *echo.Context) error {
return download.checkLatest(c)
})
downloadGroup.GET("/list", func(c *echo.Context) error {
return download.listAssetsOfLatest(c)
})
downloadGroup.GET("/windows", func(c *echo.Context) error {
return download.downloadLatestWindows(c)
})
downloadGroup.GET("/linux", func(c *echo.Context) error {
return download.downloadLatestLinux(c)
})
// Sync endpoints (protected)
protectedSyncGroup := protectedV1.Group("/sync")
protectedSyncGroup.GET("", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
return sync.SyncProgress(c)
})
protectedSyncGroup.GET("/new", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
return sync.ResetDB(c)
})
// Music endpoints (protected)
protectedMusicGroup := protectedV1.Group("/music")
protectedMusicGroup.GET("", func(c *echo.Context) error {
return music.GetSong(c)
})
protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
return music.GetSoundCheckSong(c)
})
protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
return music.ResetMusic(c)
})
protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
return music.GetRandomSong(c)
})
protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
return music.GetRandomSongLowChance(c)
})
protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
return music.GetRandomSongClassic(c)
})
protectedMusicGroup.GET("/info", func(c *echo.Context) error {
return music.GetSongInfo(c)
})
protectedMusicGroup.GET("/list", func(c *echo.Context) error {
return music.GetPlayedSongs(c)
})
protectedMusicGroup.GET("/next", func(c *echo.Context) error {
return music.GetNextSong(c)
})
protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
return music.GetPreviousSong(c)
})
protectedMusicGroup.GET("/all", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
return music.GetAllSoundtracks(c)
})
protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
return music.PutPlayed(c)
})
protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
return music.AddLatestToQue(c)
})
protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
return music.AddLatestPlayed(c)
})
routes := e.Router().Routes()
sort.Slice(routes, func(i, j int) bool {
return routes[i].Path < routes[j].Path
})
for _, r := range routes {
if (r.Method == "GET" || r.Method == "POST" || r.Method == "PUT" || r.Method == "DELETE") && !strings.Contains(r.Name, "github") {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
for _, r := range e.Router().Routes() {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
return e
}
// registerCommon registers the routes shared by the legacy and the v1 API.
// syncLock is applied to routes that must not run during a sync.
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
g.GET("/version", h.version.GetLatestVersion)
g.GET("/version/history", h.version.GetVersionHistory)
g.GET("/character", h.character.GetCharacter)
g.GET("/download", h.download.CheckLatest)
g.GET("/download/list", h.download.ListAssetsOfLatest)
g.GET("/download/windows", h.download.DownloadLatestWindows)
g.GET("/download/linux", h.download.DownloadLatestLinux)
g.GET("/sync/progress", h.sync.SyncProgress)
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
}
for _, path := range []string{"/sync/full", "/sync/new/full"} {
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
}
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
music := g.Group("/music", syncLock)
music.GET("", h.music.GetSong)
music.GET("/soundTest", h.music.GetSoundCheckSong)
music.GET("/reset", h.music.ResetMusic)
music.GET("/rand", h.music.GetRandomSong)
music.GET("/rand/low", h.music.GetRandomSongLowChance)
music.GET("/rand/classic", h.music.GetRandomSongClassic)
music.GET("/next", h.music.GetNextSong)
music.GET("/previous", h.music.GetPreviousSong)
music.GET("/all", h.music.GetAllSoundtracksRandom)
music.GET("/all/order", h.music.GetAllSoundtracks)
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
music.PUT("/played", h.music.PutPlayed)
music.GET("/addQue", h.music.AddLatestToQue)
music.GET("/addPlayed", h.music.AddLatestPlayed)
// Info and list are read-only and stay available during a sync.
g.GET("/music/info", h.music.GetSongInfo)
g.GET("/music/list", h.music.GetPlayedSongs)
}
// serveOpenAPI serves the embedded OpenAPI spec.
func serveOpenAPI(c *echo.Context) error {
doc, err := swag.ReadDoc()
if err != nil {
return err
}
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
}
+103 -95
View File
@@ -1,125 +1,133 @@
package server
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"net/http"
"time"
"music-server/internal/backend"
"music-server/internal/config"
"music-server/internal/db"
"music-server/internal/db/repository"
"music-server/internal/logging"
"net/http"
"go.uber.org/zap"
)
type Server struct {
port int
db *db.Database
tokenHandler *TokenHandler
statisticsHandler *StatisticsHandler
httpServer *http.Server
// Database is the database connection used by the server.
type Database interface {
HealthChecker
Close()
}
var (
musicPath = os.Getenv("MUSIC_PATH")
charactersPath = os.Getenv("CHARACTERS_PATH")
logLevel = os.Getenv("LOG_LEVEL")
logJSON = os.Getenv("LOG_JSON") == "true"
)
type Server struct {
cfg config.Config
db Database
q repository.Querier
music *backend.MusicService
sync *backend.SyncService
characters *backend.CharacterService
releases *backend.ReleaseService
stats *backend.StatisticsService
search *backend.SearchCache
httpServer *http.Server
}
// NewServerInstance creates a new Server instance with all dependencies initialized.
// Use this for dependency injection and proper lifecycle management.
func NewServerInstance() *Server {
// Re-read environment variables at server creation time (not at package import time)
// This allows tests to override environment variables
host := os.Getenv("DB_HOST")
dbPort := os.Getenv("DB_PORT")
dbName := os.Getenv("DB_NAME")
username := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
// Initialize logger
currentLogLevel := logLevel
if currentLogLevel == "" {
currentLogLevel = "info"
}
logging.Init(currentLogLevel, logJSON)
logger := logging.GetLogger()
port, _ := strconv.Atoi(os.Getenv("PORT"))
// Validate required environment variables
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
}
// Create database instance
database, err := db.NewDatabase(host, dbPort, username, password, dbName)
// New creates a Server with all services wired up.
func New(cfg config.Config, q repository.Querier, database Database) (*Server, error) {
characters, err := backend.NewCharacterService(cfg.CharactersPath)
if err != nil {
logging.GetLogger().Fatal("Failed to initialize database", zap.String("error", err.Error()))
return nil, fmt.Errorf("characters path: %w", err)
}
// Run migrations using the new method
if err := database.RunMigrations(); err != nil {
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
s := &Server{
cfg: cfg,
db: database,
q: q,
music: backend.NewMusicService(q, cfg.SoundCheckPath),
characters: characters,
releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q),
}
s.search = backend.NewSearchCache(s.music.SoundtrackNames)
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
// Initialize backend package with database pool
backend.InitBackend(database.Pool)
// Initialize token handler with database pool
tokenHandler := NewTokenHandler(database.Pool)
// Initialize statistics handler
statisticsHandler := NewStatisticsHandler()
// Create the server instance
appServer := &Server{
port: port,
db: database,
tokenHandler: tokenHandler,
statisticsHandler: statisticsHandler,
}
// Create the HTTP server
appServer.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: appServer.RegisterRoutes(),
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: s.RegisterRoutes(),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
logger.Info("Starting server",
zap.String("host", host),
zap.String("dbPort", dbPort),
zap.String("username", username),
zap.String("dbName", dbName),
)
logger.Info("Paths",
zap.String("musicPath", musicPath),
zap.String("charactersPath", charactersPath),
)
return appServer
return s, nil
}
// HTTPServer returns the underlying http.Server for serving HTTP requests.
// afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) {
if err := s.music.Reset(ctx); err != nil {
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err))
}
if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
}
}
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
// It exits the process if any of these steps fail.
func NewServerInstance() *Server {
cfg, err := config.Load()
logging.Init(cfg.LogLevel, cfg.LogJSON)
logger := logging.GetLogger()
if err != nil {
logger.Fatal("Invalid settings", zap.Error(err))
}
logger.Info("Starting server",
zap.String("host", cfg.DBHost),
zap.String("dbPort", cfg.DBPort),
zap.String("username", cfg.DBUser),
zap.String("dbName", cfg.DBName),
zap.String("musicPath", cfg.MusicPath),
zap.String("charactersPath", cfg.CharactersPath),
)
ctx := context.Background()
database, err := db.NewDatabase(ctx, db.ConnString(cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName))
if err != nil {
logger.Fatal("Failed to initialize database", zap.Error(err))
}
if err := database.RunMigrations(); err != nil {
logger.Fatal("Migration failed", zap.Error(err))
}
s, err := New(cfg, repository.New(database.Pool), database)
if err != nil {
logger.Fatal("Failed to create server", zap.Error(err))
}
return s
}
// ListenAndServe starts the HTTP server. It returns nil after a graceful shutdown.
func (s *Server) ListenAndServe() error {
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// Shutdown stops accepting requests, waits for running requests and then closes the database.
func (s *Server) Shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
s.sync.Wait()
s.characters.Close()
s.db.Close()
return err
}
// HTTPServer returns the underlying http.Server.
func (s *Server) HTTPServer() *http.Server {
return s.httpServer
}
// DB returns the database instance for dependency injection.
func (s *Server) DB() *db.Database {
return s.db
}
// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
// This function is kept for backward compatibility.
func NewServer() *http.Server {
return NewServerInstance().HTTPServer()
}
-411
View File
@@ -1,411 +0,0 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// saveEnvVars saves the current state of relevant environment variables
func saveEnvVars() map[string]string {
vars := []string{
"DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME",
"MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL",
}
saved := make(map[string]string)
for _, v := range vars {
saved[v] = os.Getenv(v)
}
return saved
}
// restoreEnvVars restores environment variables from a saved state
func restoreEnvVars(saved map[string]string) {
for k, v := range saved {
if v == "" {
os.Unsetenv(k)
} else {
os.Setenv(k, v)
}
}
}
// checkTableExists returns true if the specified table exists in the database
func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) {
var exists bool
err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)
`, tableName).Scan(&exists)
return exists, err
}
// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database
func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error {
// Copy the SQL dump file to the container
basename := filepath.Base(dumpPath)
targetPath := "/tmp/" + basename
if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil {
return fmt.Errorf("failed to copy dump file to container: %w", err)
}
// Execute psql to load the dump as the postgres superuser
// We execute psql inside the container, so we use localhost and port 5432 (the container's internal port)
// The postgres user is the superuser created by the Docker image
// Use sh instead of bash as postgres:15-alpine uses Alpine Linux
cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"}
exitCode, reader, err := container.Exec(ctx, cmd)
if err != nil {
return fmt.Errorf("failed to execute psql: %w", err)
}
// Read the output
output, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("failed to read psql output: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output))
}
t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname)
return nil
}
// createRoleInContainer creates a role in the PostgreSQL container
func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error {
// Get the mapped port for the container
mappedPort, err := container.MappedPort(ctx, "5432")
if err != nil {
return fmt.Errorf("failed to get mapped port: %w", err)
}
// Connect to the postgres database to create the role
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
mappedPort.Port())
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to postgres database: %w", err)
}
defer pool.Close()
// Check if role already exists
var exists bool
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists)
if err == nil && exists {
return nil // Role already exists
}
// Clear any error from the role check (role might not exist, which is expected)
// Create the role
_, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName))
if err != nil {
return fmt.Errorf("failed to create role %s: %w", roleName, err)
}
return nil
}
// createDatabaseInContainer creates a new database in the PostgreSQL container
func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error {
// Get the mapped port for the container
mappedPort, err := container.MappedPort(ctx, "5432")
if err != nil {
return fmt.Errorf("failed to get mapped port: %w", err)
}
// Add a small delay to ensure PostgreSQL is fully ready
time.Sleep(2 * time.Second)
// Connect to the default 'postgres' database to create the new database
// Use localhost and the mapped port
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
mappedPort.Port())
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to postgres database: %w", err)
}
defer pool.Close()
// Check if database already exists and drop it if so
var exists int
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
if err == nil && exists == 1 {
_, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)")
if err != nil {
return fmt.Errorf("failed to drop existing database: %w", err)
}
t.Logf("Dropped existing database %s", dbname)
}
// Create the new database
_, err = pool.Exec(ctx, "CREATE DATABASE "+dbname)
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
t.Logf("Created database %s", dbname)
return nil
}
// startPostgreSQLContainer starts a PostgreSQL container and returns it
func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
req := testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "postgres",
"POSTGRES_DB": "postgres",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start container: %w", err)
}
return container, nil
}
// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work
func TestMusicRefDump(t *testing.T) {
testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test")
}
// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work
func TestMusicTestDump(t *testing.T) {
testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test")
}
// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work
func TestMusicProdDump(t *testing.T) {
testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test")
}
// testSQLDump is the common test function for all SQL dump tests
func testSQLDump(t *testing.T, dumpFile, dbname string) {
ctx := context.Background()
// Save original environment variables
originalEnv := saveEnvVars()
defer restoreEnvVars(originalEnv)
// Start PostgreSQL container
t.Log("Starting PostgreSQL container...")
container, err := startPostgreSQLContainer(t, ctx)
require.NoError(t, err)
defer func() {
t.Log("Stopping container...")
container.Terminate(ctx)
}()
// Get container connection details
endpoint, err := container.Endpoint(ctx, "")
require.NoError(t, err)
t.Logf("Container running at: %s", endpoint)
// Create database for this test
t.Logf("Creating database %s...", dbname)
err = createDatabaseInContainer(t, ctx, container, dbname)
require.NoError(t, err)
// Create common roles that might be referenced in dump files
err = createRoleInContainer(t, ctx, container, "sansan", "postgres")
if err != nil {
t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err)
}
// Load the SQL dump
// The dump files are in the project root
// Get the absolute path to the dump file
absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile))
require.NoError(t, err)
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
// Try relative to current working directory
absDumpPath = filepath.Join(".", dumpFile)
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
absDumpPath = dumpFile // Try as-is
}
}
t.Logf("Loading SQL dump from: %s", absDumpPath)
err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath)
require.NoError(t, err)
// ============================================
// PRE-MIGRATION CHECK
// ============================================
t.Log("Running pre-migration checks...")
// For connecting to the database from the test, we need to use the container's endpoint
// But the container's endpoint might be localhost:random_port
// We need to parse it to get host and port
// endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456"
// Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port
// Let's extract the mapped port
// Get the mapped port from the container
mappedPort, err := container.MappedPort(ctx, "5432")
require.NoError(t, err)
t.Logf("Container mapped port: %s", mappedPort)
// Use localhost as the host since we're on the same machine
dbHost := "localhost"
dbPort := mappedPort.Port()
// Create a connection to the database to check pre-migration state
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, "postgres", "postgres", dbname)
pool, err := pgxpool.New(ctx, connStr)
require.NoError(t, err)
defer pool.Close()
// Check that game table exists (pre-migration state)
gameExists, err := checkTableExists(ctx, pool, "game")
require.NoError(t, err)
assert.True(t, gameExists, "Before migration: game table should exist")
// Check that soundtrack table does NOT exist
soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack")
require.NoError(t, err)
assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist")
t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not")
// ============================================
// START APPLICATION (triggers migrations)
// ============================================
t.Log("Starting application...")
// Set environment variables for the server
// The application needs to connect to the container's mapped port
os.Setenv("DB_HOST", dbHost)
os.Setenv("DB_PORT", dbPort)
os.Setenv("DB_USERNAME", "postgres")
os.Setenv("DB_PASSWORD", "postgres")
os.Setenv("DB_NAME", dbname)
os.Setenv("MUSIC_PATH", "./testMusic")
os.Setenv("CHARACTERS_PATH", "./testCharacters")
// Use different ports for each test to allow for sequential execution
// and to make port detection reliable
portMap := map[string]string{
"music_server_ref_test": "8082",
"music_server_test_dump_test": "8083",
"music_server_prod_test": "8084",
}
appPort := portMap[dbname]
if appPort == "" {
appPort = "8085" // fallback
}
os.Setenv("PORT", appPort)
os.Setenv("DISABLE_SESSION_AUTH", "true")
os.Setenv("LOG_LEVEL", "error")
// Create server instance (this runs migrations automatically)
serverInstance := NewServerInstance()
httpServer := serverInstance.HTTPServer()
// Start HTTP server in background
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
t.Logf("Server error: %v", err)
}
}()
defer func() {
t.Log("Stopping HTTP server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
}()
// Wait for server to start by polling /health endpoint
// Use the fixed port we configured for the application
healthURL := fmt.Sprintf("http://localhost:%s/health", appPort)
maxAttempts := 30
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, err := http.Get(healthURL)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var healthData map[string]string
json.NewDecoder(resp.Body).Decode(&healthData)
if healthData["status"] == "up" {
t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt)
break
}
}
}
if attempt == maxAttempts {
require.Fail(t, "Server did not become healthy within timeout")
}
time.Sleep(500 * time.Millisecond)
}
// ============================================
// POST-MIGRATION CHECK
// ============================================
t.Log("Running post-migration checks...")
// Check that game table no longer exists
gameExistsAfter, err := checkTableExists(ctx, pool, "game")
require.NoError(t, err)
assert.False(t, gameExistsAfter, "After migration: game table should NOT exist")
// Check that soundtrack table now exists
soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack")
require.NoError(t, err)
assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist")
// Verify data was preserved
var soundtrackCount int
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
require.NoError(t, err)
assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0")
t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount)
// ============================================
// HEALTH CHECK
// ============================================
t.Log("Running health check...")
resp, err := http.Get(healthURL)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var healthData map[string]string
err = json.NewDecoder(resp.Body).Decode(&healthData)
require.NoError(t, err)
assert.Equal(t, "up", healthData["status"])
t.Log("✓ Health check passed")
t.Log("✓ Test completed successfully!")
}
+101 -188
View File
@@ -11,265 +11,178 @@ import (
"go.uber.org/zap"
)
const (
defaultStatsLimit = 10
maxStatsLimit = 100
)
// StatisticsHandler handles statistics-related HTTP requests
type StatisticsHandler struct {
statsBackend *backend.StatisticsHandler
stats *backend.StatisticsService
}
// NewStatisticsHandler creates a new StatisticsHandler
func NewStatisticsHandler() *StatisticsHandler {
return &StatisticsHandler{
statsBackend: backend.NewStatisticsHandler(),
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler {
return &StatisticsHandler{stats: stats}
}
// parseLimit reads the "limit" query parameter, defaulting to 10 and capping at 100.
func parseLimit(ctx *echo.Context) (int32, bool) {
limitStr := ctx.QueryParam("limit")
if limitStr == "" {
return defaultStatsLimit, true
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return 0, false
}
return int32(min(limit, maxStatsLimit)), true
}
// withLimit runs a statistics query that takes a limit and writes the result as JSON.
func withLimit[T any](ctx *echo.Context, name string, query func(*echo.Context, int32) (T, error)) error {
limit, ok := parseLimit(ctx)
if !ok {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
result, err := query(ctx, limit)
return statsResult(ctx, name, result, err)
}
func statsResult[T any](ctx *echo.Context, name string, result T, err error) error {
if err != nil {
logging.GetLogger().Error("Failed to get statistics", zap.String("statistic", name), zap.Error(err))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, result)
}
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
// GET /api/v1/statistics/soundtracks/most-played
//
// @Summary Get most played soundtracks
// @Description Returns the top N most played soundtracks with their songs
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/most-played [get]
// @Router /api/v1/statistics/soundtracks/most-played [get]
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
limit := 10 // default
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
// Cap at 100 for performance
if limit > 100 {
limit = 100
}
}
soundtracks, err := h.statsBackend.GetMostPlayedSoundtracksWithSongs(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get most played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.MostPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
// GET /api/v1/statistics/soundtracks/least-played
//
// @Summary Get least played soundtracks
// @Description Returns the top N least played soundtracks with their songs
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/least-played [get]
// @Router /api/v1/statistics/soundtracks/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
if limit > 100 {
limit = 100
}
}
soundtracks, err := h.statsBackend.GetLeastPlayedSoundtracksWithSongs(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get least played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetMostPlayedSongs returns top N most played songs with soundtrack info
// GET /api/v1/statistics/songs/most-played
// GetMostPlayedSongs returns top N most played songs
//
// @Summary Get most played songs
// @Description Returns the top N most played songs with their soundtrack info
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SongInfoForStats
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/songs/most-played [get]
// @Router /api/v1/statistics/songs/most-played [get]
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
if limit > 100 {
limit = 100
}
}
songs, err := h.statsBackend.GetMostPlayedSongsWithSoundtrack(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get most played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, songs)
return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
return h.stats.MostPlayedSongs(c.Request().Context(), limit)
})
}
// GetLeastPlayedSongs returns top N least played songs with soundtrack info
// GET /api/v1/statistics/songs/least-played
// GetLeastPlayedSongs returns top N least played songs
//
// @Summary Get least played songs
// @Description Returns the top N least played songs with their soundtrack info
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SongInfoForStats
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/songs/least-played [get]
// @Router /api/v1/statistics/songs/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
if limit > 100 {
limit = 100
}
}
return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
return h.stats.LeastPlayedSongs(c.Request().Context(), limit)
})
}
songs, err := h.statsBackend.GetLeastPlayedSongsWithSoundtrack(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get least played songs", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, songs)
// GetLastPlayedSoundtracks returns the most recently played soundtracks
//
// @Summary Get last played soundtracks
// @Description Returns the most recently played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.LastPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
//
// @Summary Get oldest played soundtracks
// @Description Returns the least recently played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
// GET /api/v1/statistics/soundtracks/never-played
//
// @Summary Get never played soundtracks
// @Description Returns all soundtracks that have never been played (times_played = 0)
// @Description Returns all soundtracks that have never been played
// @Tags statistics
// @Accept json
// @Produce json
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/never-played [get]
// @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
soundtracks, err := h.statsBackend.GetNeverPlayedSoundtracks()
if err != nil {
logging.GetLogger().Error("Failed to get never played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetLastPlayedSoundtracks returns most recently played soundtracks
// GET /api/v1/statistics/soundtracks/last-played
//
// @Summary Get last played soundtracks
// @Description Returns the most recently played soundtracks
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
if limit > 100 {
limit = 100
}
}
soundtracks, err := h.statsBackend.GetLastPlayedSoundtracks(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get last played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
}
// GetOldestPlayedSoundtracks returns least recently played soundtracks
// GET /api/v1/statistics/soundtracks/oldest-played
//
// @Summary Get oldest played soundtracks
// @Description Returns the least recently played soundtracks (that have been played at least once)
// @Tags statistics
// @Accept json
// @Produce json
// @Param limit query int false "Number of results (default: 10)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
limit := 10
limitStr := ctx.QueryParam("limit")
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
}
if limit > 100 {
limit = 100
}
}
soundtracks, err := h.statsBackend.GetOldestPlayedSoundtracks(int32(limit))
if err != nil {
logging.GetLogger().Error("Failed to get oldest played soundtracks", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, soundtracks)
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
return statsResult(ctx, "never played soundtracks", result, err)
}
// GetStatisticsSummary returns overall statistics
// GET /api/v1/statistics/summary
//
// @Summary Get statistics summary
// @Description Returns overall statistics about the music library
// @Description Returns overall play statistics
// @Tags statistics
// @Accept json
// @Produce json
// @Success 200 {object} backend.StatisticsSummary
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/summary [get]
// @Router /api/v1/statistics/summary [get]
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
summary, err := h.statsBackend.GetStatisticsSummary()
if err != nil {
logging.GetLogger().Error("Failed to get statistics summary", zap.String("error", err.Error()))
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
}
return ctx.JSON(http.StatusOK, summary)
result, err := h.stats.Summary(ctx.Request().Context())
return statsResult(ctx, "summary", result, err)
}
-184
View File
@@ -1,184 +0,0 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"music-server/internal/backend"
"music-server/internal/db"
"music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/require"
)
// TestStatisticsEndpoints tests the statistics API endpoints
func TestStatisticsEndpoints(t *testing.T) {
// Skip if test database not configured
e := StartTestServer(t)
if e == nil {
t.Skip("Test database not configured")
}
// Get token first
token := getTestToken(t, e)
if token == "" {
t.Skip("Could not get test token")
}
// Test /api/v1/statistics/summary
req := httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var summary backend.StatisticsSummary
err := json.Unmarshal(rec.Body.Bytes(), &summary)
require.NoError(t, err)
require.NotNil(t, summary)
}
// TestPartialMigrationThenSyncThenComplete tests migration workflow
// Note: This test requires the database to be in a specific state
// It tests: partial migration → data insert → sync → complete migration
func TestPartialMigrationThenSyncThenComplete(t *testing.T) {
// This test is complex and requires careful setup
// For now, we test the final state: all migrations + sync
e := StartTestServer(t)
if e == nil {
t.Skip("Test database not configured")
}
// Get token
token := getTestToken(t, e)
if token == "" {
t.Skip("Could not get test token")
}
// Insert test data manually (5 soundtracks with songs)
insertTestData(t)
// Run sync to ensure data is properly loaded
req := httptest.NewRequest(http.MethodGet, "/sync/new", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
// Verify data via statistics endpoint
req = httptest.NewRequest(http.MethodGet, "/api/v1/statistics/summary", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var summary backend.StatisticsSummary
err := json.Unmarshal(rec.Body.Bytes(), &summary)
require.NoError(t, err)
// After sync with /sync/new, only soundtracks matching filesystem remain
// testMusic has 3 soundtracks
require.Equal(t, int64(3), summary.TotalSoundtracks)
}
// insertTestData inserts 5 test soundtracks with songs into the database
func insertTestData(t *testing.T) {
if db.TestDatabase == nil || db.TestDatabase.Pool == nil {
t.Skip("Test database not initialized")
return
}
ctx := context.Background()
queries := repository.New(db.TestDatabase.Pool)
// Insert 5 soundtracks
soundtracks := []struct {
name string
path string
}{
{"Test Soundtrack 1", "/path/to/soundtrack1"},
{"Test Soundtrack 2", "/path/to/soundtrack2"},
{"Test Soundtrack 3", "/path/to/soundtrack3"},
{"Test Soundtrack 4", "/path/to/soundtrack4"},
{"Test Soundtrack 5", "/path/to/soundtrack5"},
}
for _, st := range soundtracks {
_, err := queries.InsertSoundtrack(ctx, repository.InsertSoundtrackParams{
SoundtrackName: st.name,
Path: st.path,
Hash: "test-hash-" + st.name,
Uuid: uuid.New(),
})
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
}
// Get soundtrack IDs and UUIDs
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
// Insert songs for each soundtrack
songData := []struct {
soundtrackID int32
soundtrackUuid uuid.UUID
songs []string
}{
{soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
{soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
{soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
{soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
{soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []string{"Song I"}},
}
for _, sd := range songData {
for _, songName := range sd.songs {
err := queries.AddSong(ctx, repository.AddSongParams{
SoundtrackID: sd.soundtrackID,
SongName: songName,
Path: "/path/to/" + songName + ".mp3",
Uuid: uuid.New(),
SoundtrackUuid: sd.soundtrackUuid,
})
require.NoError(t, err, "Failed to insert song: %s", songName)
}
}
t.Logf("Inserted %d soundtracks with %d total songs", len(soundtracks), 8)
}
// getTestToken gets a valid token for testing
func getTestToken(t *testing.T, e *echo.Echo) string {
reqBody := `{"client_type": "test"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/token", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Logf("Failed to get token: %s", rec.Body.String())
return ""
}
var resp struct {
Token string `json:"token"`
}
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
return resp.Token
}
@@ -1,82 +1,80 @@
package server
import (
"net/http"
"music-server/internal/backend"
"music-server/internal/logging"
"net/http"
"github.com/labstack/echo/v5"
)
type SyncHandler struct {
sync *backend.SyncService
}
func NewSyncHandler() *SyncHandler {
return &SyncHandler{}
func NewSyncHandler(sync *backend.SyncService) *SyncHandler {
return &SyncHandler{sync: sync}
}
// SyncProgress godoc
// @Summary Get sync progress
// @Description Returns the current sync progress or result
// @Description Returns the progress of the running sync, or the result of the last sync
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Success 200 {object} backend.SyncResponse
// @Router /sync/progress [get]
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
if backend.Syncing {
logging.GetLogger().Info("Getting sync progress")
response := backend.SyncProgress()
return ctx.JSON(http.StatusOK, response)
if s.sync.IsSyncing() {
return ctx.JSON(http.StatusOK, s.sync.Progress())
}
logging.GetLogger().Info("Getting sync result")
response := backend.SyncResult()
return ctx.JSON(http.StatusOK, response)
return ctx.JSON(http.StatusOK, s.sync.Result())
}
// SyncSoundtracksNewOnlyChanges godoc
// @Summary Sync soundtracks with only changes
// @Description Starts syncing soundtracks with only new changes
// @Description Starts syncing soundtracks that have changed
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {string} string "Start syncing soundtracks"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync [get]
// @Router /sync [post]
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
logging.GetLogger().Info("Starting sync with only changes")
backend.Syncing = true
go backend.SyncSoundtracksOnlyChanges()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks")
return s.start(ctx, false, "Start syncing soundtracks")
}
// SyncSoundtracksNewFull godoc
// @Summary Sync all soundtracks fully
// @Description Starts a full sync of all soundtracks
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {string} string "Start syncing soundtracks full"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/full [get]
// @Router /sync/full [post]
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
logging.GetLogger().Info("Starting full sync")
backend.Syncing = true
go backend.SyncSoundtracksFull()
return ctx.JSON(http.StatusOK, "Start syncing soundtracks full")
return s.start(ctx, true, "Start syncing soundtracks full")
}
func (s *SyncHandler) start(ctx *echo.Context, full bool, message string) error {
if !s.sync.Start(full) {
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
}
logging.GetLogger().Info(message)
return ctx.JSON(http.StatusOK, message)
}
// ResetDB godoc
// @Summary Reset soundtracks database
// @Description Resets the soundtracks database by deleting all soundtracks and songs
// @Tags sync
// @Accept json
// @Produce json
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/reset [get]
// @Router /sync/reset [post]
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
logging.GetLogger().Info("Resetting soundtracks database")
backend.ResetDB()
if err := s.sync.ResetDB(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
}
-289
View File
@@ -1,289 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"os"
"testing"
"time"
"music-server/internal/backend"
"music-server/internal/db"
"music-server/internal/db/repository"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
)
// waitForSyncCompletion polls the sync progress endpoint until sync is complete
// Returns true if sync completed, false if timeout
func waitForSyncCompletion(t *testing.T, e *echo.Echo, maxAttempts int) bool {
for i := 0; i < maxAttempts; i++ {
progressResp := MakeTestRequest(t, e, "GET", "/sync/progress")
assert.Equal(t, http.StatusOK, progressResp.Code)
// Try to parse as ProgressResponse first (while syncing)
var progress backend.ProgressResponse
err := json.Unmarshal(progressResp.Body.Bytes(), &progress)
if err == nil && progress.Progress != "" {
// Successfully parsed as ProgressResponse with non-empty progress
t.Logf("Sync progress: %s%%", progress.Progress)
if progress.Progress == "100" {
t.Log("Sync completed!")
// Wait for Syncing flag to be updated
for j := 0; j < 50; j++ {
if !backend.Syncing {
return true
}
time.Sleep(50 * time.Millisecond)
}
return false
}
} else {
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
var result backend.SyncResponse
err2 := json.Unmarshal(progressResp.Body.Bytes(), &result)
if err2 == nil {
t.Log("Sync already completed")
// Wait for Syncing flag to be updated
for j := 0; j < 50; j++ {
if !backend.Syncing {
return true
}
time.Sleep(50 * time.Millisecond)
}
return false
}
}
time.Sleep(1 * time.Second)
}
return false
}
// TestSyncPopulatesDatabase verifies that sync populates the database with soundtracks
func TestSyncPopulatesDatabase(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
// Debug: Check MUSIC_PATH
t.Logf("MUSIC_PATH: %s", os.Getenv("MUSIC_PATH"))
e := StartTestServer(t)
// Clear any existing data first
db.TestClearDatabase(t)
// Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool())
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
beforeCount := len(soundtracksBefore)
t.Logf("Soundtracks before sync: %d", beforeCount)
assert.Equal(t, 0, beforeCount, "Database should be empty after clear")
// Run sync
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
// After sync - should have soundtracks
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
afterCount := len(soundtracksAfter)
t.Logf("Soundtracks after sync: %d", afterCount)
// Should have more soundtracks than before (unless database was already populated)
assert.True(t, afterCount > 0, "Database should have soundtracks after sync")
}
// TestSyncMakesDifference verifies that sync actually changes the database state
func TestSyncMakesDifference(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Clear any existing data first
db.TestClearDatabase(t)
// Before sync - should have no soundtracks
repo := repository.New(backend.BackendPool())
soundtracksBefore, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
assert.Equal(t, 0, len(soundtracksBefore), "Should have no soundtracks before sync")
// Run sync
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
// After sync - should have soundtracks
soundtracksAfter, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
assert.True(t, len(soundtracksAfter) > 0, "Should have soundtracks after sync")
}
// TestSyncProgress verifies the sync progress endpoint
func TestSyncProgress(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Start sync in background
go MakeTestRequest(t, e, "GET", "/sync/full")
// Poll progress endpoint
maxAttempts := 30
foundComplete := false
for i := 0; i < maxAttempts; i++ {
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
assert.Equal(t, http.StatusOK, resp.Code)
// Try ProgressResponse first
var progress backend.ProgressResponse
err := json.Unmarshal(resp.Body.Bytes(), &progress)
if err == nil && progress.Progress != "" {
// Successfully parsed as ProgressResponse with non-empty progress
t.Logf("Sync progress: %s%%", progress.Progress)
// Verify we get valid progress values
if progress.Progress != "0" {
// Sync is making progress
}
if progress.Progress == "100" {
foundComplete = true
break
}
} else {
// If Progress is empty or parse failed, it might be a SyncResponse (sync already completed)
var result backend.SyncResponse
err2 := json.Unmarshal(resp.Body.Bytes(), &result)
if err2 == nil {
foundComplete = true
break
}
}
time.Sleep(1 * time.Second)
}
// Note: foundNonZero might be false if sync completed too quickly
// So we only assert that sync completed
assert.True(t, foundComplete, "Should have seen completion")
}
// TestSyncSoundtracksNewOnlyChanges verifies the incremental sync endpoint
func TestSyncSoundtracksNewOnlyChanges(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Run full sync first
MakeTestRequest(t, e, "GET", "/sync/full")
// Wait for it to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Initial sync did not complete within timeout")
return
}
// Get initial count
repo := repository.New(backend.BackendPool())
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(soundtracksBefore)
// Run incremental sync (should not change count if nothing changed)
resp := MakeTestRequest(t, e, "GET", "/sync/new")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait a bit
time.Sleep(2 * time.Second)
// Count should be the same
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(soundtracksAfter)
// Note: This might not be exactly equal due to timing, but should be close
t.Logf("Soundtracks before incremental sync: %d, after: %d", beforeCount, afterCount)
}
// TestResetSoundtracks verifies the reset endpoint clears the database
// RUN THIS LAST
func TestResetSoundtracks(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// First ensure we have data
repo := repository.New(backend.BackendPool())
soundtracksBefore, _ := repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount := len(soundtracksBefore)
if beforeCount == 0 {
// Run sync to populate
MakeTestRequest(t, e, "GET", "/sync/full")
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
return
}
soundtracksBefore, _ = repo.FindAllSoundtracks(backend.BackendCtx())
beforeCount = len(soundtracksBefore)
}
t.Logf("Soundtracks before reset: %d", beforeCount)
assert.True(t, beforeCount > 0, "Should have soundtracks to reset")
// Call reset
resp := MakeTestRequest(t, e, "GET", "/sync/reset")
assert.Equal(t, http.StatusOK, resp.Code)
// Verify database is cleared
// Note: reset might take a moment to propagate
time.Sleep(1 * time.Second)
soundtracksAfter, _ := repo.FindAllSoundtracks(backend.BackendCtx())
afterCount := len(soundtracksAfter)
t.Logf("Soundtracks after reset: %d", afterCount)
assert.Equal(t, 0, afterCount, "Database should be empty after reset")
}
// TestSyncSoundtracksNewFull verifies the full sync endpoint
// RUN THIS LAST (before TestResetSoundtracks)
func TestSyncSoundtracksNewFull(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Clear database first
db.TestClearDatabase(t)
// Run full sync
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete
if !waitForSyncCompletion(t, e, 60) {
t.Error("Full sync did not complete within timeout")
}
// Verify database is populated
repo := repository.New(backend.BackendPool())
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
assert.True(t, len(soundtracks) > 0, "Database should be populated after full sync")
t.Logf("Full sync populated %d soundtracks", len(soundtracks))
}
-118
View File
@@ -1,118 +0,0 @@
package server
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"music-server/internal/backend"
"music-server/internal/db"
"github.com/labstack/echo/v5"
)
// StartTestServer starts the server for testing with test configuration
func StartTestServer(t *testing.T) *echo.Echo {
// Set test environment variables if not already set
if os.Getenv("DB_HOST") == "" {
os.Setenv("DB_HOST", "localhost")
}
if os.Getenv("DB_PORT") == "" {
os.Setenv("DB_PORT", "5432")
}
if os.Getenv("DB_USERNAME") == "" {
os.Setenv("DB_USERNAME", "testuser")
}
if os.Getenv("DB_PASSWORD") == "" {
os.Setenv("DB_PASSWORD", "testpass")
}
if os.Getenv("DB_NAME") == "" {
os.Setenv("DB_NAME", "music_server_test")
}
if os.Getenv("MUSIC_PATH") == "" {
os.Setenv("MUSIC_PATH", "./testMusic")
}
if os.Getenv("CHARACTERS_PATH") == "" {
os.Setenv("CHARACTERS_PATH", "./testCharacters")
}
if os.Getenv("PORT") == "" {
os.Setenv("PORT", "8081")
}
if os.Getenv("LOG_LEVEL") == "" {
os.Setenv("LOG_LEVEL", "debug")
}
if os.Getenv("LOG_JSON") == "" {
os.Setenv("LOG_JSON", "false")
}
// Initialize database for tests
db.TestSetupDB(t)
// Initialize backend with test database pool
// This ensures BackendRepo() and BackendCtx() are available
if db.TestDatabase != nil && db.TestDatabase.Pool != nil {
backend.InitBackend(db.TestDatabase.Pool)
}
// Create a Server instance and get its routes
s := &Server{
db: db.TestDatabase,
tokenHandler: NewTokenHandler(db.TestDatabase.Pool),
statisticsHandler: NewStatisticsHandler(),
}
handler := s.RegisterRoutes()
// Wrap the http.Handler in an echo.Echo
e := echo.New()
// Use a custom handler that wraps our routes
e.Any("/*", echo.WrapHandler(handler))
return e
}
// MakeTestRequest makes an HTTP request to the test server
func MakeTestRequest(t *testing.T, e *echo.Echo, method, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// MakeTestRequestWithBody makes an HTTP request with a body to the test server
func MakeTestRequestWithBody(t *testing.T, e *echo.Echo, method, path string, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewBuffer(body))
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
// WaitForSyncComplete polls the sync progress endpoint until sync is complete
func WaitForSyncComplete(t *testing.T, e *echo.Echo, timeout time.Duration) bool {
start := time.Now()
for time.Since(start) < timeout {
resp := MakeTestRequest(t, e, "GET", "/sync/progress")
if resp.Code != http.StatusOK {
t.Logf("Sync progress endpoint returned status %d", resp.Code)
time.Sleep(1 * time.Second)
continue
}
// Parse response - we can't easily decode here without importing backend
// Just check if response contains "100"
body := resp.Body.String()
if len(body) > 0 {
t.Logf("Sync progress: %s", body)
// Simple check for completion
// In a real scenario, you'd parse the JSON properly
}
time.Sleep(1 * time.Second)
}
t.Error("Sync did not complete within timeout")
return false
}
+53 -87
View File
@@ -2,24 +2,25 @@ package server
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
const tokenLifetime = 24 * time.Hour
// TokenRequest represents a request to generate a new token
type TokenRequest struct {
Password string `json:"password"` // Required: API password
Password string `json:"password"` // API password, required when API_PASSWORD is set
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
}
@@ -30,20 +31,19 @@ type TokenResponse struct {
ClientType string `json:"client_type"`
}
// TokenHandler contains the database pool for token operations
// TokenHandler creates and removes session tokens.
type TokenHandler struct {
pool *pgxpool.Pool
q repository.Querier
apiPassword string
disableSessionAuth bool
}
// NewTokenHandler creates a new token handler with database pool
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
return &TokenHandler{
pool: pool,
}
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
}
// generateToken creates a new cryptographically secure token
func (h *TokenHandler) generateToken() (string, error) {
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
@@ -51,149 +51,115 @@ func (h *TokenHandler) generateToken() (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
func (h *TokenHandler) passwordValid(password string) bool {
if h.disableSessionAuth || h.apiPassword == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
}
// CreateTokenHandler creates a new session token
// POST /api/v1/token
//
// @Summary Create session token
// @Description Returns a new session token for API access. Requires valid API password.
// @Description Returns a new session token for API access. Requires the API password when one is configured.
// @Tags auth
// @Accept json
// @Accept json
// @Produce json
// @Param request body TokenRequest true "Password and client type"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 429 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [post]
// @Router /api/v1/token [post]
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
var req TokenRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
}
// Check if session auth is disabled
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
apiPassword := os.Getenv("API_PASSWORD")
// Password validation logic:
// - If session auth is disabled, skip validation
// - If API_PASSWORD is empty, allow any password (open access mode)
// - If API_PASSWORD is set, require exact password match
if !disableSessionAuth {
if apiPassword != "" {
// API password is set - require exact match
if req.Password != apiPassword {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent()))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
}
}
// If API_PASSWORD is empty, allow any password through (open access mode)
if !h.passwordValid(req.Password) {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent()))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
}
if req.ClientType == "" {
req.ClientType = "web"
}
// Generate token
token, err := h.generateToken()
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
}
// Set expiration (24 hours from now)
expiresAt := time.Now().Add(24 * time.Hour)
clientType := req.ClientType
// Store in database using sqlc-generated repository
queries := repository.New(h.pool)
session, err := queries.CreateSession(c.Request().Context(), repository.CreateSessionParams{
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
Token: token,
IpAddress: c.RealIP(),
UserAgent: c.Request().UserAgent(),
ClientType: &clientType,
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
ClientType: &req.ClientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
logging.GetLogger().Error("Failed to create session", zap.Error(err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
}
response := TokenResponse{
clientType := req.ClientType
if session.ClientType != nil {
clientType = *session.ClientType
}
return c.JSON(http.StatusOK, TokenResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
ClientType: *session.ClientType,
}
return c.JSON(http.StatusOK, response)
ClientType: clientType,
})
}
// DeleteTokenHandler invalidates a session token
// DELETE /api/v1/token
//
// @Summary Invalidate session token
// @Description Deletes the current session token
// @Tags auth
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [delete]
// @Router /api/v1/token [delete]
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
if c.Request().Header.Get("Authorization") == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
token, ok := middleware.BearerToken(c)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
}
token := parts[1]
// Delete session using sqlc-generated repository
queries := repository.New(h.pool)
err := queries.DeleteSession(c.Request().Context(), token)
if err != nil {
logging.GetLogger().Error("Failed to delete session", zap.String("error", err.Error()))
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
}
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
}
// CleanupExpiredSessionsHandler removes all expired sessions
// POST /api/v1/token/cleanup
//
// @Summary Cleanup expired sessions
// @Description Removes all expired session tokens from the database
// @Tags auth
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]interface{}
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token/cleanup [post]
// @Router /api/v1/token/cleanup [post]
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
// Verify token is valid first (using existing middleware)
// The middleware will have already validated the token
queries := repository.New(h.pool)
err := queries.DeleteExpiredSessions(c.Request().Context())
if err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.String("error", err.Error()))
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
}
// Get count of deleted rows (DeleteExpiredSessions doesn't return count in the generated code)
// So we just return success
return c.JSON(http.StatusOK, map[string]interface{}{
"status": "cleanup complete",
})
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
}
@@ -3,12 +3,12 @@ package server
import (
"net/http"
"github.com/labstack/echo/v5"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
)
type VersionHandler struct {
}
type VersionHandler struct{}
func NewVersionHandler() *VersionHandler {
return &VersionHandler{}
@@ -19,17 +19,11 @@ func NewVersionHandler() *VersionHandler {
// @Summary Getting the version history of the backend
// @Description get version history
// @Tags version
// @Accept json
// @Produce json
// @Success 200 {array} backend.VersionData
// @Failure 404 {object} string
// @Router /version/history [get]
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
versionHistory := backend.GetVersionHistory()
if len(versionHistory) == 0 {
return ctx.JSON(http.StatusNotFound, "version not found")
}
return ctx.JSON(http.StatusOK, versionHistory)
return ctx.JSON(http.StatusOK, backend.GetVersionHistory())
}
// GetLatestVersion godoc
@@ -37,15 +31,9 @@ func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
// @Summary Getting the latest version of the backend
// @Description get latest version info
// @Tags version
// @Accept json
// @Produce json
// @Success 200 {object} backend.VersionData
// @Failure 404 {object} string
// @Router /version [get]
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
latestVersion := backend.GetLatestVersion()
if latestVersion.Version == "" {
return ctx.JSON(http.StatusNotFound, "version not found")
}
return ctx.JSON(http.StatusOK, latestVersion)
return ctx.JSON(http.StatusOK, backend.GetLatestVersion())
}
-40
View File
@@ -1,40 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"music-server/internal/backend"
"github.com/stretchr/testify/assert"
)
// TestGetLatestVersion verifies the version endpoint returns latest version
func TestGetLatestVersion(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/version")
assert.Equal(t, http.StatusOK, resp.Code)
var versionData backend.VersionData
err := json.Unmarshal(resp.Body.Bytes(), &versionData)
assert.NoError(t, err)
assert.NotEmpty(t, versionData.Version)
assert.NotEmpty(t, versionData.Changelog)
}
// TestGetVersionHistory verifies the version history endpoint returns version history
func TestGetVersionHistory(t *testing.T) {
e := StartTestServer(t)
resp := MakeTestRequest(t, e, "GET", "/version/history")
assert.Equal(t, http.StatusOK, resp.Code)
var versionHistory []backend.VersionData
err := json.Unmarshal(resp.Body.Bytes(), &versionHistory)
assert.NoError(t, err)
assert.NotEmpty(t, versionHistory)
assert.NotEmpty(t, versionHistory[0].Version)
assert.NotEmpty(t, versionHistory[0].Changelog)
}
-312
View File
@@ -1,312 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"testing"
"music-server/internal/backend"
"music-server/internal/db"
"music-server/internal/db/repository"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
)
// ensureSyncRan ensures that sync has been run before testing music endpoints
func ensureSyncRan(t *testing.T, e *echo.Echo) {
repo := repository.New(backend.BackendPool())
soundtracks, err := repo.FindAllSoundtracks(backend.BackendCtx())
assert.NoError(t, err)
if len(soundtracks) == 0 {
// Run sync
t.Log("No soundtracks found, running sync first...")
resp := MakeTestRequest(t, e, "GET", "/sync/full")
assert.Equal(t, http.StatusOK, resp.Code)
// Wait for sync to complete using shared helper
if !waitForSyncCompletion(t, e, 60) {
t.Error("Sync did not complete within timeout")
}
}
}
// TestGetAllSoundtracks verifies the /music/all/order endpoint
func TestZGetAllSoundtracks(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/all/order")
assert.Equal(t, http.StatusOK, resp.Code)
var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err)
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
t.Logf("Found %d soundtracks", len(soundtracks))
}
// TestGetAllSoundtracksRandom verifies the /music/all/random endpoint
func TestZGetAllSoundtracksRandom(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/all/random")
assert.Equal(t, http.StatusOK, resp.Code)
var soundtracks []string
err := json.Unmarshal(resp.Body.Bytes(), &soundtracks)
assert.NoError(t, err)
assert.NotEmpty(t, soundtracks, "Should have soundtracks after sync")
// Verify it's shuffled (not in original order)
// We can't easily verify randomness, but we can check it's the same length
resp2 := MakeTestRequest(t, e, "GET", "/music/all/order")
var soundtracksOrdered []string
json.Unmarshal(resp2.Body.Bytes(), &soundtracksOrdered)
assert.Equal(t, len(soundtracks), len(soundtracksOrdered), "Random and ordered should have same count")
}
// TestGetRandomSong verifies the /music/rand endpoint
func TestZGetRandomSong(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/rand")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
// Just verify we got a response with content
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
t.Logf("Random song returned %d bytes", len(resp.Body.Bytes()))
}
// TestGetRandomSongLowChance verifies the /music/rand/low endpoint
func TestZGetRandomSongLowChance(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/rand/low")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestGetRandomSongClassic verifies the /music/rand/classic endpoint
func TestZGetRandomSongClassic(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
resp := MakeTestRequest(t, e, "GET", "/music/rand/classic")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestGetSongInfo verifies the /music/info endpoint
func TestZGetSongInfo(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and get a song first
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
// Add to queue and mark as played
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/addPlayed")
resp := MakeTestRequest(t, e, "GET", "/music/info")
assert.Equal(t, http.StatusOK, resp.Code)
var info backend.SongInfo
err := json.Unmarshal(resp.Body.Bytes(), &info)
assert.NoError(t, err)
// Note: CurrentlyPlaying might be false if no song is currently set
// Just verify we got a valid response
t.Logf("Song info: Soundtrack=%s, Song=%s", info.Soundtrack, info.Song)
}
// TestGetPlayedSongs verifies the /music/list endpoint
func TestZGetPlayedSongs(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add some songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
resp := MakeTestRequest(t, e, "GET", "/music/list")
assert.Equal(t, http.StatusOK, resp.Code)
var songs []backend.SongInfo
err := json.Unmarshal(resp.Body.Bytes(), &songs)
assert.NoError(t, err)
assert.NotEmpty(t, songs, "Should have played songs in queue")
t.Logf("Found %d songs in queue", len(songs))
}
// TestGetNextSong verifies the /music/next endpoint
func TestZGetNextSong(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
resp := MakeTestRequest(t, e, "GET", "/music/next")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestGetPreviousSong verifies the /music/previous endpoint
func TestZGetPreviousSong(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Move forward
MakeTestRequest(t, e, "GET", "/music/next")
resp := MakeTestRequest(t, e, "GET", "/music/previous")
assert.Equal(t, http.StatusOK, resp.Code)
// The endpoint returns a file stream, not JSON
assert.NotEmpty(t, resp.Body.Bytes(), "Should return song file content")
}
// TestResetMusic verifies the /music/reset endpoint
func TestZResetMusic(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Verify queue has items
respBefore := MakeTestRequest(t, e, "GET", "/music/list")
var songsBefore []backend.SongInfo
json.Unmarshal(respBefore.Body.Bytes(), &songsBefore)
assert.True(t, len(songsBefore) > 0, "Should have songs before reset")
// Reset queue
resp := MakeTestRequest(t, e, "GET", "/music/reset")
assert.Equal(t, http.StatusOK, resp.Code)
// Verify queue is empty
respAfter := MakeTestRequest(t, e, "GET", "/music/list")
var songsAfter []backend.SongInfo
json.Unmarshal(respAfter.Body.Bytes(), &songsAfter)
assert.Equal(t, 0, len(songsAfter), "Queue should be empty after reset")
}
// TestAddLatestToQue verifies the /music/addQue endpoint
func TestZAddLatestToQue(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run
ensureSyncRan(t, e)
// Get a random song (this sets lastFetchedNew)
MakeTestRequest(t, e, "GET", "/music/rand")
// Add to queue
resp := MakeTestRequest(t, e, "GET", "/music/addQue")
assert.Equal(t, http.StatusOK, resp.Code)
// Verify it was added to queue
respList := MakeTestRequest(t, e, "GET", "/music/list")
var songs []backend.SongInfo
json.Unmarshal(respList.Body.Bytes(), &songs)
assert.True(t, len(songs) > 0, "Song should be in queue")
}
// TestAddLatestPlayed verifies the /music/addPlayed endpoint
func TestZAddLatestPlayed(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add song to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Mark as played
resp := MakeTestRequest(t, e, "GET", "/music/addPlayed")
assert.Equal(t, http.StatusOK, resp.Code)
}
// TestPutPlayed verifies the PUT /music/played endpoint
func TestZPutPlayed(t *testing.T) {
db.TestSetupDB(t)
defer db.TestTearDownDB(t)
e := StartTestServer(t)
// Ensure sync has run and add songs to queue
ensureSyncRan(t, e)
MakeTestRequest(t, e, "GET", "/music/rand")
MakeTestRequest(t, e, "GET", "/music/addQue")
// Mark song 0 as played
resp := MakeTestRequestWithBody(t, e, "PUT", "/music/played?song=0", nil)
assert.Equal(t, http.StatusOK, resp.Code)
}