mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 09:01:33 +02:00
979 lines
28 KiB
Go
979 lines
28 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
type SongLinkClient struct {
|
|
client *http.Client
|
|
}
|
|
|
|
type songLinkPlatformLink struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type TrackAvailability struct {
|
|
SpotifyID string `json:"spotify_id"`
|
|
Tidal bool `json:"tidal"`
|
|
Amazon bool `json:"amazon"`
|
|
Qobuz bool `json:"qobuz"`
|
|
Deezer bool `json:"deezer"`
|
|
YouTube bool `json:"youtube"`
|
|
TidalURL string `json:"tidal_url,omitempty"`
|
|
AmazonURL string `json:"amazon_url,omitempty"`
|
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
|
DeezerURL string `json:"deezer_url,omitempty"`
|
|
YouTubeURL string `json:"youtube_url,omitempty"`
|
|
DeezerID string `json:"deezer_id,omitempty"`
|
|
QobuzID string `json:"qobuz_id,omitempty"`
|
|
TidalID string `json:"tidal_id,omitempty"`
|
|
YouTubeID string `json:"youtube_id,omitempty"`
|
|
}
|
|
|
|
var (
|
|
globalSongLinkClient *SongLinkClient
|
|
songLinkClientOnce sync.Once
|
|
songLinkRegion = "US"
|
|
songLinkRegionMu sync.RWMutex
|
|
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
|
return GetDeezerClient().SearchByISRC(ctx, isrc)
|
|
}
|
|
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
|
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
|
}
|
|
songLinkRetryConfig = DefaultRetryConfig
|
|
)
|
|
|
|
func NewSongLinkClient() *SongLinkClient {
|
|
songLinkClientOnce.Do(func() {
|
|
globalSongLinkClient = &SongLinkClient{
|
|
client: NewMetadataHTTPClient(SongLinkTimeout),
|
|
}
|
|
})
|
|
return globalSongLinkClient
|
|
}
|
|
|
|
func normalizeSongLinkRegion(region string) string {
|
|
normalized := strings.ToUpper(strings.TrimSpace(region))
|
|
if len(normalized) != 2 {
|
|
return "US"
|
|
}
|
|
for _, ch := range normalized {
|
|
if ch < 'A' || ch > 'Z' {
|
|
return "US"
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func SetSongLinkRegion(region string) {
|
|
normalized := normalizeSongLinkRegion(region)
|
|
songLinkRegionMu.Lock()
|
|
songLinkRegion = normalized
|
|
songLinkRegionMu.Unlock()
|
|
}
|
|
|
|
func GetSongLinkRegion() string {
|
|
songLinkRegionMu.RLock()
|
|
region := songLinkRegion
|
|
songLinkRegionMu.RUnlock()
|
|
return region
|
|
}
|
|
|
|
func songLinkBaseURL() string {
|
|
opts := GetNetworkCompatibilityOptions()
|
|
if opts.AllowHTTP {
|
|
return "http://api.song.link/v1-alpha.1/links"
|
|
}
|
|
return "https://api.song.link/v1-alpha.1/links"
|
|
}
|
|
|
|
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
|
if userCountry == "" {
|
|
userCountry = GetSongLinkRegion()
|
|
}
|
|
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
|
if userCountry != "" {
|
|
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
|
}
|
|
return apiURL
|
|
}
|
|
|
|
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
|
if userCountry == "" {
|
|
userCountry = GetSongLinkRegion()
|
|
}
|
|
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
|
songLinkBaseURL(),
|
|
url.QueryEscape(platform),
|
|
url.QueryEscape(entityType),
|
|
url.QueryEscape(entityID))
|
|
if userCountry != "" {
|
|
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
|
}
|
|
return apiURL
|
|
}
|
|
|
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
|
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
|
isrc = strings.ToUpper(strings.TrimSpace(isrc))
|
|
|
|
switch {
|
|
case spotifyTrackID != "":
|
|
return s.checkTrackAvailabilityFromSpotify(spotifyTrackID)
|
|
case isrc != "":
|
|
return s.checkTrackAvailabilityFromISRC(isrc)
|
|
default:
|
|
return nil, fmt.Errorf("spotify track ID and ISRC are empty")
|
|
}
|
|
}
|
|
|
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
|
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
|
if pageErr == nil {
|
|
return availability, nil
|
|
}
|
|
|
|
if !songLinkRateLimiter.TryAcquire() {
|
|
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
|
}
|
|
|
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
|
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := songLinkRetryConfig()
|
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 400 {
|
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
}
|
|
if resp.StatusCode == 429 {
|
|
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
|
}
|
|
|
|
body, err := ReadResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var songLinkResp struct {
|
|
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
|
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
|
}
|
|
|
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
|
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
|
req, err := http.NewRequest("GET", pageURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 404 {
|
|
return nil, fmt.Errorf("track not found on song.link page")
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := ReadResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
|
}
|
|
|
|
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var pageData struct {
|
|
Props struct {
|
|
PageProps struct {
|
|
PageData struct {
|
|
Sections []struct {
|
|
Links []struct {
|
|
Platform string `json:"platform"`
|
|
URL string `json:"url"`
|
|
Show bool `json:"show"`
|
|
} `json:"links"`
|
|
} `json:"sections"`
|
|
} `json:"pageData"`
|
|
} `json:"pageProps"`
|
|
} `json:"props"`
|
|
}
|
|
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
|
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
|
}
|
|
|
|
linksByPlatform := make(map[string]songLinkPlatformLink)
|
|
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
|
for _, link := range section.Links {
|
|
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
|
continue
|
|
}
|
|
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
|
}
|
|
}
|
|
|
|
if len(linksByPlatform) == 0 {
|
|
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
|
}
|
|
|
|
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
|
}
|
|
|
|
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
|
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
|
const endMarker = `</script>`
|
|
|
|
start := bytes.Index(body, []byte(startMarker))
|
|
if start < 0 {
|
|
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
|
}
|
|
start += len(startMarker)
|
|
|
|
end := bytes.Index(body[start:], []byte(endMarker))
|
|
if end < 0 {
|
|
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
|
}
|
|
|
|
return body[start : start+end], nil
|
|
}
|
|
|
|
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
|
defer cancel()
|
|
|
|
track, err := songLinkSearchByISRC(ctx, isrc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err)
|
|
}
|
|
|
|
deezerTrackID := songLinkExtractDeezerTrackID(track)
|
|
if deezerTrackID == "" {
|
|
return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc)
|
|
}
|
|
|
|
availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err)
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
func songLinkExtractDeezerTrackID(track *TrackMetadata) string {
|
|
if track == nil {
|
|
return ""
|
|
}
|
|
|
|
if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok {
|
|
deezerID = strings.TrimSpace(deezerID)
|
|
if deezerID != "" {
|
|
return deezerID
|
|
}
|
|
}
|
|
|
|
if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" {
|
|
return deezerID
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
urls := make(map[string]string)
|
|
if availability.TidalURL != "" {
|
|
urls["tidal"] = availability.TidalURL
|
|
}
|
|
if availability.AmazonURL != "" {
|
|
urls["amazon"] = availability.AmazonURL
|
|
}
|
|
|
|
return urls, nil
|
|
}
|
|
|
|
func extractDeezerIDFromURL(deezerURL string) string {
|
|
parts := strings.Split(deezerURL, "/")
|
|
if len(parts) > 0 {
|
|
lastPart := parts[len(parts)-1]
|
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
|
lastPart = lastPart[:idx]
|
|
}
|
|
return lastPart
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractQobuzIDFromURL extracts Qobuz track ID from URL.
|
|
// URL formats:
|
|
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
|
// - https://open.qobuz.com/track/12345678
|
|
// - https://www.qobuz.com/track/12345678
|
|
// - https://play.qobuz.com/track/12345678
|
|
func extractQobuzIDFromURL(qobuzURL string) string {
|
|
if qobuzURL == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.Contains(qobuzURL, "/track/") {
|
|
parts := strings.Split(qobuzURL, "/track/")
|
|
if len(parts) > 1 {
|
|
idPart := parts[1]
|
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
idPart = strings.TrimSpace(idPart)
|
|
if idPart != "" && isNumeric(idPart) {
|
|
return idPart
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to extract from album URL with track highlight (e.g. ?trackId=12345678)
|
|
if strings.Contains(qobuzURL, "trackId=") {
|
|
parts := strings.Split(qobuzURL, "trackId=")
|
|
if len(parts) > 1 {
|
|
idPart := parts[1]
|
|
if idx := strings.Index(idPart, "&"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
idPart = strings.TrimSpace(idPart)
|
|
if idPart != "" && isNumeric(idPart) {
|
|
return idPart
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last resort: get last numeric segment from URL
|
|
parts := strings.Split(qobuzURL, "/")
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
part := parts[i]
|
|
if idx := strings.Index(part, "?"); idx > 0 {
|
|
part = part[:idx]
|
|
}
|
|
part = strings.TrimSpace(part)
|
|
if part != "" && isNumeric(part) {
|
|
return part
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractTidalIDFromURL(tidalURL string) string {
|
|
if tidalURL == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.Contains(tidalURL, "/track/") {
|
|
parts := strings.Split(tidalURL, "/track/")
|
|
if len(parts) > 1 {
|
|
idPart := parts[1]
|
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
idPart = strings.TrimSpace(idPart)
|
|
if idPart != "" && isNumeric(idPart) {
|
|
return idPart
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractYouTubeIDFromURL(youtubeURL string) string {
|
|
if youtubeURL == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.Contains(youtubeURL, "youtu.be/") {
|
|
parts := strings.Split(youtubeURL, "youtu.be/")
|
|
if len(parts) >= 2 {
|
|
idPart := parts[1]
|
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
if idx := strings.Index(idPart, "&"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
return strings.TrimSpace(idPart)
|
|
}
|
|
}
|
|
|
|
parsed, err := url.Parse(youtubeURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
if v := parsed.Query().Get("v"); v != "" {
|
|
return v
|
|
}
|
|
|
|
if strings.Contains(parsed.Path, "/embed/") {
|
|
parts := strings.Split(parsed.Path, "/embed/")
|
|
if len(parts) >= 2 {
|
|
return strings.Split(parts[1], "/")[0]
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// isNumeric is defined in library_scan.go
|
|
|
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !availability.Deezer || availability.DeezerID == "" {
|
|
return "", fmt.Errorf("track not found on Deezer")
|
|
}
|
|
|
|
return availability.DeezerID, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
|
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !availability.YouTube || availability.YouTubeURL == "" {
|
|
return "", fmt.Errorf("track not found on YouTube")
|
|
}
|
|
|
|
return availability.YouTubeURL, nil
|
|
}
|
|
|
|
type AlbumAvailability struct {
|
|
SpotifyID string `json:"spotify_id"`
|
|
Deezer bool `json:"deezer"`
|
|
DeezerURL string `json:"deezer_url,omitempty"`
|
|
DeezerID string `json:"deezer_id,omitempty"`
|
|
}
|
|
|
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
|
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := songLinkRetryConfig()
|
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := ReadResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var songLinkResp struct {
|
|
LinksByPlatform map[string]struct {
|
|
URL string `json:"url"`
|
|
} `json:"linksByPlatform"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
availability := &AlbumAvailability{
|
|
SpotifyID: spotifyAlbumID,
|
|
}
|
|
|
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.Deezer = true
|
|
availability.DeezerURL = deezerLink.URL
|
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
|
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !availability.Deezer || availability.DeezerID == "" {
|
|
return "", fmt.Errorf("album not found on Deezer")
|
|
}
|
|
|
|
return availability.DeezerID, nil
|
|
}
|
|
|
|
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
|
if deezerTrackID == "" {
|
|
return nil, fmt.Errorf("deezer track ID is empty")
|
|
}
|
|
|
|
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
|
if err != nil {
|
|
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
|
idhsClient := NewIDHSClient()
|
|
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
|
}
|
|
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
|
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := songLinkRetryConfig()
|
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 400 {
|
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
}
|
|
if resp.StatusCode == 429 {
|
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := ReadResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var songLinkResp struct {
|
|
LinksByPlatform map[string]struct {
|
|
URL string `json:"url"`
|
|
} `json:"linksByPlatform"`
|
|
EntitiesByUniqueId map[string]struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
ArtistName string `json:"artistName"`
|
|
} `json:"entitiesByUniqueId"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
availability := &TrackAvailability{
|
|
Deezer: true,
|
|
DeezerID: deezerTrackID,
|
|
}
|
|
|
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
}
|
|
|
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
availability.Tidal = true
|
|
availability.TidalURL = tidalLink.URL
|
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
}
|
|
|
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
availability.Amazon = true
|
|
availability.AmazonURL = amazonLink.URL
|
|
}
|
|
|
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
availability.Qobuz = true
|
|
availability.QobuzURL = qobuzLink.URL
|
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
}
|
|
|
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.DeezerURL = deezerLink.URL
|
|
}
|
|
|
|
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = ytMusicLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
}
|
|
if !availability.YouTube {
|
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = youtubeLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
}
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
|
// entityType: "song" or "album"
|
|
// entityID: the ID on that platform
|
|
func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entityID string) (*TrackAvailability, error) {
|
|
if entityID == "" {
|
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
|
}
|
|
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := songLinkRetryConfig()
|
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 400 {
|
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
|
}
|
|
if resp.StatusCode == 404 {
|
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
}
|
|
if resp.StatusCode == 429 {
|
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := ReadResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var songLinkResp struct {
|
|
LinksByPlatform map[string]struct {
|
|
URL string `json:"url"`
|
|
} `json:"linksByPlatform"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
availability := &TrackAvailability{}
|
|
|
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
}
|
|
|
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
availability.Tidal = true
|
|
availability.TidalURL = tidalLink.URL
|
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
}
|
|
|
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
availability.Amazon = true
|
|
availability.AmazonURL = amazonLink.URL
|
|
}
|
|
|
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
availability.Qobuz = true
|
|
availability.QobuzURL = qobuzLink.URL
|
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
}
|
|
|
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.Deezer = true
|
|
availability.DeezerURL = deezerLink.URL
|
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
}
|
|
|
|
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = ytMusicLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
}
|
|
if !availability.YouTube {
|
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = youtubeLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
}
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
|
availability := &TrackAvailability{
|
|
SpotifyID: spotifyTrackID,
|
|
}
|
|
|
|
if availability.SpotifyID == "" {
|
|
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
}
|
|
}
|
|
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
|
availability.Tidal = true
|
|
availability.TidalURL = tidalLink.URL
|
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
}
|
|
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
availability.Amazon = true
|
|
availability.AmazonURL = amazonLink.URL
|
|
}
|
|
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
|
availability.Qobuz = true
|
|
availability.QobuzURL = qobuzLink.URL
|
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
}
|
|
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.Deezer = true
|
|
availability.DeezerURL = deezerLink.URL
|
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
}
|
|
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = ytMusicLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
}
|
|
if !availability.YouTube {
|
|
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = youtubeLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
}
|
|
}
|
|
|
|
return availability
|
|
}
|
|
|
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
|
parts := strings.Split(spotifyURL, "/track/")
|
|
if len(parts) > 1 {
|
|
idPart := parts[1]
|
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
|
idPart = idPart[:idx]
|
|
}
|
|
return idPart
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if availability.SpotifyID == "" {
|
|
return "", fmt.Errorf("track not found on Spotify")
|
|
}
|
|
|
|
return availability.SpotifyID, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !availability.Tidal || availability.TidalURL == "" {
|
|
return "", fmt.Errorf("track not found on Tidal")
|
|
}
|
|
|
|
return availability.TidalURL, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !availability.Amazon || availability.AmazonURL == "" {
|
|
return "", fmt.Errorf("track not found on Amazon Music")
|
|
}
|
|
|
|
return availability.AmazonURL, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
|
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !availability.YouTube || availability.YouTubeURL == "" {
|
|
return "", fmt.Errorf("track not found on YouTube")
|
|
}
|
|
|
|
return availability.YouTubeURL, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
apiURL := buildSongLinkURLFromTarget(inputURL, "")
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := songLinkRetryConfig()
|
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
|
return nil, fmt.Errorf("track not found on SongLink")
|
|
}
|
|
if resp.StatusCode == 429 {
|
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := ReadResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var songLinkResp struct {
|
|
LinksByPlatform map[string]struct {
|
|
URL string `json:"url"`
|
|
EntityID string `json:"entityUniqueId"`
|
|
} `json:"linksByPlatform"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
availability := &TrackAvailability{}
|
|
|
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
}
|
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
availability.Tidal = true
|
|
availability.TidalURL = tidalLink.URL
|
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
}
|
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
availability.Amazon = true
|
|
availability.AmazonURL = amazonLink.URL
|
|
}
|
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
availability.Qobuz = true
|
|
availability.QobuzURL = qobuzLink.URL
|
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
}
|
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.Deezer = true
|
|
availability.DeezerURL = deezerLink.URL
|
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
}
|
|
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = ytMusicLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
}
|
|
if !availability.YouTube {
|
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
availability.YouTube = true
|
|
availability.YouTubeURL = youtubeLink.URL
|
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
}
|
|
}
|
|
|
|
return availability, nil
|
|
}
|