- 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>
595 lines
15 KiB
Go
595 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
var (
|
|
httpClient = &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
// No body-read timeout: MP3 files are large and stream takes time.
|
|
// DisableCompression=true prevents Go from auto-decompressing gzip while
|
|
// forwarding the upstream's Content-Length (compressed size), which would
|
|
// cause ERR_CONTENT_LENGTH_MISMATCH in the browser.
|
|
mp3StreamClient = &http.Client{
|
|
Timeout: 5 * time.Minute,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
Transport: &http.Transport{
|
|
DisableCompression: true,
|
|
},
|
|
}
|
|
|
|
dlRegex = regexp.MustCompile(`href="(/get/music/[^"]+\.mp3)"`)
|
|
metaRegex = regexp.MustCompile(`data-musmeta='([^']+)'`)
|
|
durRegex = regexp.MustCompile(`track__time[^>]*>([^<\s][^<]*)`)
|
|
titleRegex = regexp.MustCompile(`track__title[^>]*>([^<]+)<`)
|
|
artistRegex = regexp.MustCompile(`track__desc[^>]*>([^<]+)<`)
|
|
)
|
|
|
|
// Search result cache
|
|
type searchCacheEntry struct {
|
|
results []SearchResult
|
|
expiry time.Time
|
|
}
|
|
|
|
var (
|
|
searchCacheMu sync.RWMutex
|
|
searchCacheMap = map[string]*searchCacheEntry{}
|
|
)
|
|
|
|
func cacheKey(q string) string {
|
|
return strings.ToLower(strings.TrimSpace(q))
|
|
}
|
|
|
|
func getSearchCache(q string) ([]SearchResult, bool) {
|
|
key := cacheKey(q)
|
|
searchCacheMu.RLock()
|
|
e, ok := searchCacheMap[key]
|
|
searchCacheMu.RUnlock()
|
|
if !ok || time.Now().After(e.expiry) {
|
|
return nil, false
|
|
}
|
|
return e.results, true
|
|
}
|
|
|
|
func setSearchCache(q string, results []SearchResult) {
|
|
key := cacheKey(q)
|
|
searchCacheMu.Lock()
|
|
searchCacheMap[key] = &searchCacheEntry{results: results, expiry: time.Now().Add(30 * time.Minute)}
|
|
if len(searchCacheMap) > 1000 {
|
|
now := time.Now()
|
|
for k, v := range searchCacheMap {
|
|
if now.After(v.expiry) {
|
|
delete(searchCacheMap, k)
|
|
}
|
|
}
|
|
}
|
|
searchCacheMu.Unlock()
|
|
}
|
|
|
|
type musMeta struct {
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
Img string `json:"img"`
|
|
}
|
|
|
|
type SearchResult struct {
|
|
MP3 string `json:"mp3"`
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
Duration string `json:"duration"`
|
|
Img string `json:"img"`
|
|
}
|
|
|
|
func hitmotopHeaders(req *http.Request) {
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
req.Header.Set("Accept", "*/*")
|
|
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9")
|
|
req.Header.Set("Referer", "https://rus.hitmotop.com/")
|
|
req.Header.Set("Origin", "https://rus.hitmotop.com")
|
|
}
|
|
|
|
func TopChartsHandler(c *gin.Context) {
|
|
targetURL := "https://rus.hitmotop.com/"
|
|
req, err := http.NewRequest("GET", targetURL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
|
|
return
|
|
}
|
|
hitmotopHeaders(req)
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "fetch failed"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
|
|
return
|
|
}
|
|
|
|
results := parseSearchResults(string(body))
|
|
if len(results) > 20 {
|
|
results = results[:20]
|
|
}
|
|
c.Header("Cache-Control", "public, max-age=600")
|
|
c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
func SearchHandler(c *gin.Context) {
|
|
q := c.Query("q")
|
|
if q == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing query"})
|
|
return
|
|
}
|
|
|
|
if cached, ok := getSearchCache(q); ok {
|
|
c.JSON(http.StatusOK, cached)
|
|
return
|
|
}
|
|
|
|
targetURL := fmt.Sprintf("https://rus.hitmotop.com/search?q=%s", url.QueryEscape(q))
|
|
req, err := http.NewRequest("GET", targetURL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
|
|
return
|
|
}
|
|
hitmotopHeaders(req)
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "search request failed"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
|
|
return
|
|
}
|
|
|
|
results := parseSearchResults(string(body))
|
|
setSearchCache(q, results)
|
|
c.JSON(http.StatusOK, results)
|
|
}
|
|
|
|
func parseSearchResults(html string) []SearchResult {
|
|
dlMatches := dlRegex.FindAllStringSubmatch(html, -1)
|
|
metaMatches := metaRegex.FindAllStringSubmatch(html, -1)
|
|
|
|
results := make([]SearchResult, 0, len(dlMatches))
|
|
for i, dm := range dlMatches {
|
|
if len(dm) < 2 {
|
|
continue
|
|
}
|
|
href := dm[1]
|
|
originalMp3 := "https://rus.hitmotop.com" + href
|
|
mp3 := "/api/proxy/mp3?url=" + url.QueryEscape(originalMp3)
|
|
|
|
var meta musMeta
|
|
if i < len(metaMatches) && len(metaMatches[i]) >= 2 {
|
|
_ = json.Unmarshal([]byte(metaMatches[i][1]), &meta)
|
|
}
|
|
|
|
hrefPos := strings.Index(html, href)
|
|
chunk := ""
|
|
if hrefPos >= 0 {
|
|
start := hrefPos - 800
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := hrefPos + 200
|
|
if end > len(html) {
|
|
end = len(html)
|
|
}
|
|
chunk = html[start:end]
|
|
}
|
|
|
|
dur := ""
|
|
if m := durRegex.FindStringSubmatch(chunk); len(m) >= 2 {
|
|
dur = strings.TrimSpace(m[1])
|
|
}
|
|
title := meta.Title
|
|
if title == "" {
|
|
if m := titleRegex.FindStringSubmatch(chunk); len(m) >= 2 {
|
|
title = strings.TrimSpace(m[1])
|
|
}
|
|
}
|
|
artist := meta.Artist
|
|
if artist == "" {
|
|
if m := artistRegex.FindStringSubmatch(chunk); len(m) >= 2 {
|
|
artist = strings.TrimSpace(m[1])
|
|
}
|
|
}
|
|
|
|
results = append(results, SearchResult{
|
|
MP3: mp3,
|
|
Title: title,
|
|
Artist: artist,
|
|
Duration: dur,
|
|
Img: meta.Img,
|
|
})
|
|
}
|
|
return results
|
|
}
|
|
|
|
func ImgProxyHandler(c *gin.Context) {
|
|
imgURL := c.Query("url")
|
|
if !strings.HasPrefix(imgURL, "http") {
|
|
c.Status(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", imgURL, nil)
|
|
if err != nil {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
|
req.Header.Set("Referer", "https://rus.hitmotop.com/")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
ct := resp.Header.Get("Content-Type")
|
|
if ct == "" {
|
|
ct = "image/jpeg"
|
|
}
|
|
c.Header("Cache-Control", "public, max-age=86400")
|
|
c.Header("Content-Type", ct)
|
|
c.Status(resp.StatusCode)
|
|
io.Copy(c.Writer, resp.Body)
|
|
}
|
|
|
|
func MP3ProxyHandler(c *gin.Context) {
|
|
mp3URL := c.Query("url")
|
|
if !strings.HasPrefix(mp3URL, "https://rus.hitmotop.com/") &&
|
|
!strings.HasPrefix(mp3URL, "https://hitmotop.com/") {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "only hitmotop URLs allowed"})
|
|
return
|
|
}
|
|
rangeHeader := c.GetHeader("Range")
|
|
fetchMP3(c, mp3URL, rangeHeader, 0)
|
|
}
|
|
|
|
type yandexArtist struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type yandexTrackInfo struct {
|
|
Title string `json:"title"`
|
|
Artists []yandexArtist `json:"artists"`
|
|
}
|
|
|
|
func YandexPlaylistHandler(c *gin.Context) {
|
|
rawURL := c.Query("url")
|
|
if rawURL == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
|
|
return
|
|
}
|
|
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || !strings.Contains(u.Host, "yandex") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid yandex music url"})
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
var playlistUUID string
|
|
if len(parts) >= 2 && parts[0] == "playlists" {
|
|
playlistUUID = parts[1]
|
|
}
|
|
if playlistUUID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported url, use share link from Яндекс.Музыка"})
|
|
return
|
|
}
|
|
|
|
apiURL := "https://api.music.yandex.net/playlist/" + url.PathEscape(playlistUUID)
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
|
|
return
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
req.Header.Set("X-Yandex-Music-Client", "WindowsPhone/3.20")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "yandex unreachable"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("yandex returned %d", resp.StatusCode)})
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
|
|
return
|
|
}
|
|
|
|
var apiResp struct {
|
|
Result struct {
|
|
Title string `json:"title"`
|
|
Tracks []struct {
|
|
Track *yandexTrackInfo `json:"track,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Artists []yandexArtist `json:"artists,omitempty"`
|
|
} `json:"tracks"`
|
|
} `json:"result"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "parse failed"})
|
|
return
|
|
}
|
|
|
|
tracks := make([]string, 0, len(apiResp.Result.Tracks))
|
|
for _, t := range apiResp.Result.Tracks {
|
|
var info *yandexTrackInfo
|
|
if t.Track != nil {
|
|
info = t.Track
|
|
} else if t.Title != "" {
|
|
info = &yandexTrackInfo{Title: t.Title, Artists: t.Artists}
|
|
}
|
|
if info == nil || info.Title == "" {
|
|
continue
|
|
}
|
|
trackStr := info.Title
|
|
if len(info.Artists) > 0 && info.Artists[0].Name != "" {
|
|
trackStr = info.Artists[0].Name + " — " + info.Title
|
|
}
|
|
tracks = append(tracks, trackStr)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"name": apiResp.Result.Title,
|
|
"tracks": tracks,
|
|
})
|
|
}
|
|
|
|
// ── 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)
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", targetURL, nil)
|
|
if err != nil {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
hitmotopHeaders(req)
|
|
req.Header.Set("Accept", "audio/mpeg, audio/*, */*")
|
|
if rangeHeader != "" {
|
|
req.Header.Set("Range", rangeHeader)
|
|
}
|
|
|
|
resp, err := mp3StreamClient.Do(req)
|
|
if err != nil {
|
|
if !c.Writer.Written() {
|
|
c.Status(http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 307 || resp.StatusCode == 308 {
|
|
location := resp.Header.Get("Location")
|
|
if location == "" {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(location, "http") {
|
|
parsed, _ := url.Parse(targetURL)
|
|
location = "https://" + parsed.Host + location
|
|
}
|
|
fetchMP3(c, location, rangeHeader, redirectCount+1)
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "audio/mpeg")
|
|
for _, h := range []string{"Content-Length", "Content-Range", "Accept-Ranges"} {
|
|
if v := resp.Header.Get(h); v != "" {
|
|
c.Header(h, v)
|
|
}
|
|
}
|
|
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
|
|
c.Header("Cache-Control", "public, max-age=300")
|
|
}
|
|
c.Status(resp.StatusCode)
|
|
io.Copy(c.Writer, resp.Body)
|
|
}
|