feat: nginx reverse proxy, Spotify import, overlay system, UI overhaul

- Add nginx as single entry point: /api/* → backend, /* → web
- NEXT_PUBLIC_API_URL="" so all API calls are relative (go through nginx)
- Add Spotify playlist import (Client Credentials OAuth, up to 500 tracks)
- Add Yandex/Spotify tabbed import UI on /playlists
- Add stream overlay system (SSE + polling fallback, 9 styles)
- Reorganize pages into (main) route group
- Add QueuePanel, VersionsPanel, Toaster components
- Add overlay settings tab in /settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 00:45:53 +03:00
parent 87ba7a0ecf
commit 428548a620
55 changed files with 5934 additions and 2052 deletions

View File

@@ -7,25 +7,29 @@ import (
)
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPass string
DBName string
JWTSecret string
Port string
DBHost string
DBPort string
DBUser string
DBPass string
DBName string
JWTSecret string
AllowedOrigins string
CookieSecure bool
}
func Load() *Config {
_ = godotenv.Load()
return &Config{
Port: getEnv("PORT", "8080"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "partymix"),
DBPass: getEnv("DB_PASSWORD", "partymix"),
DBName: getEnv("DB_NAME", "partymix"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
Port: getEnv("PORT", "8080"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "5432"),
DBUser: getEnv("DB_USER", "partymix"),
DBPass: getEnv("DB_PASSWORD", "partymix"),
DBName: getEnv("DB_NAME", "partymix"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3001,http://localhost:3000"),
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
}
}

View File

@@ -12,6 +12,9 @@ import (
"gorm.io/gorm"
)
const cookieName = "pm_token"
const cookieMaxAge = 60 * 60 * 24 * 30 // 30 days
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
@@ -58,7 +61,7 @@ type loginReq struct {
Password string `json:"password" binding:"required"`
}
func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
func Login(db *gorm.DB, jwtSecret string, cookieSecure bool) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -83,10 +86,15 @@ func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user,
})
c.SetCookie(cookieName, token, cookieMaxAge, "/", "", cookieSecure, true)
c.JSON(http.StatusOK, gin.H{"user": user})
}
}
func Logout(cookieSecure bool) gin.HandlerFunc {
return func(c *gin.Context) {
c.SetCookie(cookieName, "", -1, "/", "", cookieSecure, true)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}

View File

@@ -43,8 +43,13 @@ func parseToken(tokenStr, secret string) (*jwtClaims, error) {
func AuthRequired(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
tokenStr := strings.TrimPrefix(header, "Bearer ")
// Cookie-first, Bearer header as fallback for backwards compat
tokenStr, err := c.Cookie(cookieName)
if err != nil || tokenStr == "" {
header := c.GetHeader("Authorization")
tokenStr = strings.TrimPrefix(header, "Bearer ")
}
if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return

View File

@@ -0,0 +1,175 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type OverlayState struct {
Title string `json:"title"`
Artist string `json:"artist"`
Cover string `json:"cover"`
IsPlaying bool `json:"is_playing"`
Progress float64 `json:"progress"`
Duration float64 `json:"duration"`
Enabled bool `json:"enabled"`
Design string `json:"design"`
Style string `json:"style"`
AccentColor string `json:"accent_color"`
Position string `json:"position"`
Font string `json:"font"`
TextColor string `json:"text_color"`
ShowCover bool `json:"show_cover"`
ShowEq bool `json:"show_eq"`
Palette string `json:"palette"`
CustomBg string `json:"custom_bg"`
CustomText string `json:"custom_text"`
CustomText2 string `json:"custom_text2"`
CustomChroma string `json:"custom_chroma"`
CustomTitleBg string `json:"custom_title_bg"`
CustomBodyBg string `json:"custom_body_bg"`
Margin float64 `json:"margin"`
Scale float64 `json:"scale"`
Opacity float64 `json:"opacity"`
UpdatedAt int64 `json:"updated_at"`
}
type overlayEntry struct {
mu sync.RWMutex
state OverlayState
}
var overlayMap sync.Map // userID -> *overlayEntry
// ── SSE hub ───────────────────────────────────────────────────────────────────
type sseHub struct {
mu sync.RWMutex
clients map[chan OverlayState]struct{}
}
var sseHubs sync.Map // userID -> *sseHub
func getOrCreateOverlayEntry(userID string) *overlayEntry {
v, _ := overlayMap.LoadOrStore(userID, &overlayEntry{
state: OverlayState{Design: "minimal", Enabled: true, Scale: 1, Opacity: 1, Margin: 24},
})
return v.(*overlayEntry)
}
func getOrCreateHub(userID string) *sseHub {
v, _ := sseHubs.LoadOrStore(userID, &sseHub{
clients: make(map[chan OverlayState]struct{}),
})
return v.(*sseHub)
}
func broadcastOverlay(userID string, state OverlayState) {
hub, ok := sseHubs.Load(userID)
if !ok {
return
}
h := hub.(*sseHub)
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.clients {
select {
case ch <- state:
default:
}
}
}
// PUT /api/overlay/state (requires auth)
func PushOverlayState(c *gin.Context) {
userID := currentUserID(c)
var state OverlayState
if err := c.ShouldBindJSON(&state); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
state.UpdatedAt = time.Now().UnixMilli()
entry := getOrCreateOverlayEntry(userID)
entry.mu.Lock()
entry.state = state
entry.mu.Unlock()
broadcastOverlay(userID, state)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/overlay/:token/state (public, fallback polling)
func GetOverlayState(c *gin.Context) {
token := c.Param("token")
v, ok := overlayMap.Load(token)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
entry := v.(*overlayEntry)
entry.mu.RLock()
state := entry.state
entry.mu.RUnlock()
c.JSON(http.StatusOK, state)
}
// GET /api/overlay/:token/stream (SSE, public)
func StreamOverlayState(c *gin.Context) {
token := c.Param("token")
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
// Send current state immediately
if v, ok := overlayMap.Load(token); ok {
entry := v.(*overlayEntry)
entry.mu.RLock()
state := entry.state
entry.mu.RUnlock()
data, _ := json.Marshal(state)
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
} else {
fmt.Fprintf(c.Writer, "event: notfound\ndata: {}\n\n")
}
c.Writer.Flush()
ch := make(chan OverlayState, 8)
hub := getOrCreateHub(token)
hub.mu.Lock()
hub.clients[ch] = struct{}{}
hub.mu.Unlock()
defer func() {
hub.mu.Lock()
delete(hub.clients, ch)
hub.mu.Unlock()
close(ch)
}()
ctx := c.Request.Context()
ticker := time.NewTicker(25 * time.Second)
defer ticker.Stop()
for {
select {
case state, ok := <-ch:
if !ok {
return
}
data, _ := json.Marshal(state)
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
c.Writer.Flush()
case <-ticker.C:
fmt.Fprintf(c.Writer, ": keepalive\n\n")
c.Writer.Flush()
case <-ctx.Done():
return
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
@@ -378,6 +379,167 @@ func YandexPlaylistHandler(c *gin.Context) {
})
}
// ── Spotify ───────────────────────────────────────────────────────────────────
var (
spotifyTokenMu sync.Mutex
spotifyTokenVal string
spotifyTokenExp time.Time
)
func getSpotifyToken() (string, error) {
spotifyTokenMu.Lock()
defer spotifyTokenMu.Unlock()
if spotifyTokenVal != "" && time.Now().Before(spotifyTokenExp) {
return spotifyTokenVal, nil
}
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
return "", fmt.Errorf("spotify credentials not configured")
}
body := strings.NewReader("grant_type=client_credentials")
req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", body)
if err != nil {
return "", err
}
req.SetBasicAuth(clientID, clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var tok struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", err
}
spotifyTokenVal = tok.AccessToken
spotifyTokenExp = time.Now().Add(time.Duration(tok.ExpiresIn-30) * time.Second)
return spotifyTokenVal, nil
}
func SpotifyPlaylistHandler(c *gin.Context) {
rawURL := c.Query("url")
if rawURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
return
}
var playlistID string
if strings.HasPrefix(rawURL, "spotify:playlist:") {
playlistID = strings.TrimPrefix(rawURL, "spotify:playlist:")
} else {
u, err := url.Parse(rawURL)
if err != nil || !strings.Contains(u.Host, "spotify.com") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spotify url"})
return
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i, p := range parts {
if p == "playlist" && i+1 < len(parts) {
playlistID = parts[i+1]
break
}
}
}
if playlistID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "could not extract playlist id"})
return
}
// strip query params from id if present
if idx := strings.IndexAny(playlistID, "?#"); idx >= 0 {
playlistID = playlistID[:idx]
}
token, err := getSpotifyToken()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
doReq := func(apiURL string) ([]byte, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("spotify returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// Fetch playlist name
nameBody, err := doReq("https://api.spotify.com/v1/playlists/" + url.PathEscape(playlistID) + "?fields=name")
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
var plMeta struct {
Name string `json:"name"`
}
_ = json.Unmarshal(nameBody, &plMeta)
// Fetch tracks (paginated, up to 500)
type spArtist struct{ Name string `json:"name"` }
type spTrack struct {
Name string `json:"name"`
Artists []spArtist `json:"artists"`
}
type spItem struct {
Track *spTrack `json:"track"`
}
type spPage struct {
Items []spItem `json:"items"`
Next *string `json:"next"`
}
tracks := make([]string, 0, 100)
nextURL := fmt.Sprintf(
"https://api.spotify.com/v1/playlists/%s/tracks?fields=items(track(name,artists(name))),next&limit=100",
url.PathEscape(playlistID),
)
for nextURL != "" && len(tracks) < 500 {
pageBody, err := doReq(nextURL)
if err != nil {
break
}
var page spPage
if err := json.Unmarshal(pageBody, &page); err != nil {
break
}
for _, item := range page.Items {
if item.Track == nil || item.Track.Name == "" {
continue
}
t := item.Track.Name
if len(item.Track.Artists) > 0 && item.Track.Artists[0].Name != "" {
t = item.Track.Artists[0].Name + " — " + item.Track.Name
}
tracks = append(tracks, t)
}
if page.Next != nil {
nextURL = *page.Next
} else {
nextURL = ""
}
}
c.JSON(http.StatusOK, gin.H{
"name": plMeta.Name,
"tracks": tracks,
})
}
func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) {
if redirectCount > 5 {
c.Status(http.StatusInternalServerError)

View File

@@ -52,11 +52,11 @@ type User struct {
type Playlist struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"not null;index" json:"user_id"`
UserID string `gorm:"not null;index;index:idx_playlist_user_created,composite:1" json:"user_id"`
Name string `gorm:"not null" json:"name"`
IsPublic bool `gorm:"default:false" json:"is_public"`
IsPublic bool `gorm:"default:false;index:idx_playlist_public_created,composite:1" json:"is_public"`
Tags string `gorm:"default:''" json:"-"`
CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `gorm:"index:idx_playlist_public_created,composite:2;index:idx_playlist_user_created,composite:2" json:"created_at"`
Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"`
}

View File

@@ -2,22 +2,29 @@ package router
import (
"net/http"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/handlers"
"gorm.io/gorm"
)
func New(db *gorm.DB, jwtSecret string) *gin.Engine {
func New(db *gorm.DB, cfg *config.Config) *gin.Engine {
r := gin.Default()
origins := strings.Split(cfg.AllowedOrigins, ",")
for i, o := range origins {
origins[i] = strings.TrimSpace(o)
}
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowOrigins: origins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
AllowCredentials: false,
AllowCredentials: true,
}))
r.GET("/health", func(c *gin.Context) {
@@ -31,25 +38,27 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine {
proxy.GET("/img", handlers.ImgProxyHandler)
proxy.GET("/mp3", handlers.MP3ProxyHandler)
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
proxy.GET("/spotify-playlist", handlers.SpotifyPlaylistHandler)
}
auth := r.Group("/api/auth")
{
auth.POST("/register", handlers.Register(db))
auth.POST("/login", handlers.Login(db, jwtSecret))
auth.GET("/me", handlers.AuthRequired(jwtSecret), handlers.Me(db))
auth.POST("/login", handlers.Login(db, cfg.JWTSecret, cfg.CookieSecure))
auth.POST("/logout", handlers.Logout(cfg.CookieSecure))
auth.GET("/me", handlers.AuthRequired(cfg.JWTSecret), handlers.Me(db))
}
r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db))
versions := r.Group("/api/versions", handlers.AuthRequired(jwtSecret))
versions := r.Group("/api/versions", handlers.AuthRequired(cfg.JWTSecret))
{
versions.GET("", handlers.GetVersions(db))
versions.POST("", handlers.SaveVersion(db))
versions.DELETE("", handlers.DeleteVersion(db))
}
playlists := r.Group("/api/playlists", handlers.AuthRequired(jwtSecret))
playlists := r.Group("/api/playlists", handlers.AuthRequired(cfg.JWTSecret))
{
playlists.GET("", handlers.GetPlaylists(db))
playlists.POST("", handlers.CreatePlaylist(db))
@@ -59,6 +68,13 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine {
playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db))
}
overlay := r.Group("/api/overlay")
{
overlay.PUT("/state", handlers.AuthRequired(cfg.JWTSecret), handlers.PushOverlayState)
overlay.GET("/:token/state", handlers.GetOverlayState)
overlay.GET("/:token/stream", handlers.StreamOverlayState)
}
remote := r.Group("/api/remote")
{
remote.POST("", handlers.CreateRemoteRoom)

View File

@@ -11,7 +11,7 @@ import (
func main() {
cfg := config.Load()
db := database.Connect(cfg)
r := router.New(db, cfg.JWTSecret)
r := router.New(db, cfg)
log.Printf("Party Mix backend starting on :%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed: %v", err)