mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-13 01:47:52 +02:00
516 lines
15 KiB
Go
516 lines
15 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SongLinkClient handles song.link API interactions
|
|
type SongLinkClient struct {
|
|
client *http.Client
|
|
}
|
|
|
|
// TrackAvailability represents track availability on different platforms
|
|
type TrackAvailability struct {
|
|
SpotifyID string `json:"spotify_id"`
|
|
Tidal bool `json:"tidal"`
|
|
Amazon bool `json:"amazon"`
|
|
Qobuz bool `json:"qobuz"`
|
|
Deezer bool `json:"deezer"`
|
|
TidalURL string `json:"tidal_url,omitempty"`
|
|
AmazonURL string `json:"amazon_url,omitempty"`
|
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
|
DeezerURL string `json:"deezer_url,omitempty"`
|
|
DeezerID string `json:"deezer_id,omitempty"`
|
|
}
|
|
|
|
var (
|
|
globalSongLinkClient *SongLinkClient
|
|
songLinkClientOnce sync.Once
|
|
)
|
|
|
|
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
|
func NewSongLinkClient() *SongLinkClient {
|
|
songLinkClientOnce.Do(func() {
|
|
globalSongLinkClient = &SongLinkClient{
|
|
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
|
}
|
|
})
|
|
return globalSongLinkClient
|
|
}
|
|
|
|
// CheckTrackAvailability checks track availability on streaming platforms
|
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
|
if spotifyTrackID == "" {
|
|
return nil, fmt.Errorf("spotify track ID is empty")
|
|
}
|
|
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
|
|
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := DefaultRetryConfig()
|
|
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 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("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{
|
|
SpotifyID: spotifyTrackID,
|
|
}
|
|
|
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
availability.Tidal = true
|
|
availability.TidalURL = tidalLink.URL
|
|
}
|
|
|
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
availability.Amazon = true
|
|
availability.AmazonURL = amazonLink.URL
|
|
}
|
|
|
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.Deezer = true
|
|
availability.DeezerURL = deezerLink.URL
|
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
}
|
|
|
|
if isrc != "" {
|
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
// GetStreamingURLs gets streaming URLs for a Spotify track
|
|
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 checkQobuzAvailability(isrc string) bool {
|
|
client := NewHTTPClientWithTimeout(10 * time.Second)
|
|
appID := "798273057"
|
|
|
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
|
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
|
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(client, req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return false
|
|
}
|
|
|
|
var searchResp struct {
|
|
Tracks struct {
|
|
Total int `json:"total"`
|
|
} `json:"tracks"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
return false
|
|
}
|
|
|
|
return searchResp.Tracks.Total > 0
|
|
}
|
|
|
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
|
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 ""
|
|
}
|
|
|
|
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
|
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
|
|
}
|
|
|
|
// AlbumAvailability represents album availability on different platforms
|
|
type AlbumAvailability struct {
|
|
SpotifyID string `json:"spotify_id"`
|
|
Deezer bool `json:"deezer"`
|
|
DeezerURL string `json:"deezer_url,omitempty"`
|
|
DeezerID string `json:"deezer_id,omitempty"`
|
|
}
|
|
|
|
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
|
// Use global rate limiter
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
// Build API URL for album
|
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
|
|
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := DefaultRetryConfig()
|
|
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
|
|
}
|
|
|
|
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
|
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
|
|
}
|
|
|
|
// ========================================
|
|
// Deezer ID Support - Query SongLink using Deezer as source
|
|
// ========================================
|
|
|
|
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
|
// 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")
|
|
}
|
|
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
|
|
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := DefaultRetryConfig()
|
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Handle specific error codes
|
|
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
|
|
}
|
|
|
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
availability.Amazon = true
|
|
availability.AmazonURL = amazonLink.URL
|
|
}
|
|
|
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.DeezerURL = deezerLink.URL
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
|
// 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)
|
|
}
|
|
|
|
// Use global rate limiter
|
|
songLinkRateLimiter.WaitForSlot()
|
|
|
|
// Build API URL using platform, type, and id parameters (as per API docs)
|
|
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
|
url.QueryEscape(platform),
|
|
url.QueryEscape(entityType),
|
|
url.QueryEscape(entityID))
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
retryConfig := DefaultRetryConfig()
|
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Handle specific error codes
|
|
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
|
|
}
|
|
|
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
availability.Amazon = true
|
|
availability.AmazonURL = amazonLink.URL
|
|
}
|
|
|
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
availability.Deezer = true
|
|
availability.DeezerURL = deezerLink.URL
|
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
}
|
|
|
|
return availability, nil
|
|
}
|
|
|
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
|
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 ""
|
|
}
|
|
|
|
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
|
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
|
|
}
|
|
|
|
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
|
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
|
|
}
|
|
|
|
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
|
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
|
|
}
|