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