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