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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user