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

@@ -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)