package handlers import ( "encoding/json" "fmt" "io" "net/http" "net/url" "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, }) } 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) }