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:
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
175
apps/backend/internal/handlers/overlay.go
Normal file
175
apps/backend/internal/handlers/overlay.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user