506 lines
13 KiB
Go
506 lines
13 KiB
Go
package backend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
|
|
|
var (
|
|
isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`)
|
|
amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`)
|
|
amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`)
|
|
)
|
|
|
|
type SongLinkClient struct {
|
|
client *http.Client
|
|
}
|
|
|
|
type SongLinkURLs struct {
|
|
TidalURL string `json:"tidal_url"`
|
|
AmazonURL string `json:"amazon_url"`
|
|
ISRC string `json:"isrc"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type songLinkAPIResponse struct {
|
|
LinksByPlatform map[string]struct {
|
|
URL string `json:"url"`
|
|
} `json:"linksByPlatform"`
|
|
}
|
|
|
|
type qobuzAvailabilityTrack struct {
|
|
ID int64 `json:"id"`
|
|
Album struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
RelativeURL string `json:"relative_url"`
|
|
} `json:"album"`
|
|
}
|
|
|
|
func NewSongLinkClient() *SongLinkClient {
|
|
return &SongLinkClient{
|
|
client: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) {
|
|
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, region)
|
|
if err != nil && (links == nil || (links.TidalURL == "" && links.AmazonURL == "")) {
|
|
return nil, err
|
|
}
|
|
|
|
urls := &SongLinkURLs{}
|
|
if links != nil {
|
|
urls.TidalURL = links.TidalURL
|
|
urls.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
|
|
urls.ISRC = links.ISRC
|
|
}
|
|
|
|
if urls.TidalURL == "" && urls.AmazonURL == "" {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("no streaming URLs found")
|
|
}
|
|
|
|
return urls, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) {
|
|
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
|
|
|
|
availability := &TrackAvailability{
|
|
SpotifyID: spotifyTrackID,
|
|
}
|
|
|
|
if links != nil {
|
|
availability.TidalURL = links.TidalURL
|
|
availability.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
|
|
availability.DeezerURL = normalizeDeezerTrackURL(links.DeezerURL)
|
|
availability.Tidal = availability.TidalURL != ""
|
|
availability.Amazon = availability.AmazonURL != ""
|
|
availability.Deezer = availability.DeezerURL != ""
|
|
}
|
|
|
|
isrc := ""
|
|
if links != nil {
|
|
isrc = strings.TrimSpace(links.ISRC)
|
|
}
|
|
|
|
if isrc == "" && availability.DeezerURL != "" {
|
|
if resolvedISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
|
|
isrc = resolvedISRC
|
|
}
|
|
}
|
|
|
|
if isrc == "" {
|
|
if fallbackISRC, fallbackErr := s.lookupSpotifyISRC(spotifyTrackID); fallbackErr == nil {
|
|
isrc = fallbackISRC
|
|
} else if err == nil {
|
|
err = fallbackErr
|
|
}
|
|
}
|
|
|
|
if isrc != "" {
|
|
availability.Qobuz, availability.QobuzURL = checkQobuzAvailability(isrc)
|
|
}
|
|
|
|
if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz {
|
|
return availability, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return availability, err
|
|
}
|
|
|
|
return availability, fmt.Errorf("no platforms found")
|
|
}
|
|
|
|
func qobuzNormalizeRelativeURL(rawURL string) string {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
|
|
return rawURL
|
|
}
|
|
if strings.HasPrefix(rawURL, "/") {
|
|
return "https://www.qobuz.com" + rawURL
|
|
}
|
|
return "https://www.qobuz.com/" + rawURL
|
|
}
|
|
|
|
func qobuzSlugifySegment(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
|
|
var builder strings.Builder
|
|
lastDash := false
|
|
for _, r := range value {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
default:
|
|
if !lastDash {
|
|
builder.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return strings.Trim(builder.String(), "-")
|
|
}
|
|
|
|
func qobuzAlbumSlugURL(albumTitle string, albumID string) string {
|
|
albumID = strings.TrimSpace(albumID)
|
|
if albumID == "" {
|
|
return ""
|
|
}
|
|
|
|
slug := qobuzSlugifySegment(albumTitle)
|
|
if slug == "" {
|
|
return fmt.Sprintf("https://www.qobuz.com/album/%s", albumID)
|
|
}
|
|
|
|
return fmt.Sprintf("https://www.qobuz.com/album/%s/%s", slug, albumID)
|
|
}
|
|
|
|
func checkQobuzAvailability(isrc string) (bool, string) {
|
|
var searchResp struct {
|
|
Tracks struct {
|
|
Total int `json:"total"`
|
|
Items []qobuzAvailabilityTrack `json:"items"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
|
"query": {strings.TrimSpace(isrc)},
|
|
"limit": {"1"},
|
|
}, &searchResp); err != nil {
|
|
return false, ""
|
|
}
|
|
|
|
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
|
return false, ""
|
|
}
|
|
|
|
item := searchResp.Tracks.Items[0]
|
|
qobuzURL := strings.TrimSpace(item.Album.URL)
|
|
if qobuzURL == "" {
|
|
qobuzURL = qobuzNormalizeRelativeURL(item.Album.RelativeURL)
|
|
}
|
|
if qobuzURL == "" {
|
|
qobuzURL = qobuzAlbumSlugURL(item.Album.Title, item.Album.ID)
|
|
}
|
|
if qobuzURL == "" && item.ID > 0 {
|
|
qobuzURL = fmt.Sprintf("https://www.qobuz.com/us-en/track/%d", item.ID)
|
|
}
|
|
|
|
return true, qobuzURL
|
|
}
|
|
|
|
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
|
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
|
|
if links != nil && links.DeezerURL != "" {
|
|
deezerURL := normalizeDeezerTrackURL(links.DeezerURL)
|
|
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
|
return deezerURL, nil
|
|
}
|
|
|
|
isrc := ""
|
|
if links != nil {
|
|
isrc = strings.TrimSpace(links.ISRC)
|
|
}
|
|
if isrc == "" {
|
|
fallbackISRC, lookupErr := s.lookupSpotifyISRC(spotifyTrackID)
|
|
if lookupErr == nil {
|
|
isrc = fallbackISRC
|
|
} else if err == nil {
|
|
err = lookupErr
|
|
}
|
|
}
|
|
|
|
if isrc != "" {
|
|
deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(isrc)
|
|
if deezerErr == nil {
|
|
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
|
return deezerURL, nil
|
|
}
|
|
if err == nil {
|
|
err = deezerErr
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "", fmt.Errorf("deezer link not found")
|
|
}
|
|
|
|
func getDeezerISRC(deezerURL string) (string, error) {
|
|
trackID, err := extractDeezerTrackID(deezerURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(apiURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to call Deezer API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var deezerTrack struct {
|
|
ID int64 `json:"id"`
|
|
ISRC string `json:"isrc"`
|
|
Title string `json:"title"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
|
|
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
|
|
}
|
|
|
|
if deezerTrack.ISRC == "" {
|
|
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
|
|
}
|
|
|
|
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
|
|
return strings.ToUpper(strings.TrimSpace(deezerTrack.ISRC)), nil
|
|
}
|
|
|
|
func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) {
|
|
links, err := s.resolveSpotifyTrackLinks(spotifyID, "")
|
|
if links != nil && links.ISRC != "" {
|
|
return links.ISRC, nil
|
|
}
|
|
|
|
if links != nil && links.DeezerURL != "" {
|
|
if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil {
|
|
return isrc, nil
|
|
}
|
|
}
|
|
|
|
isrc, lookupErr := s.lookupSpotifyISRC(spotifyID)
|
|
if lookupErr == nil && isrc != "" {
|
|
return isrc, nil
|
|
}
|
|
|
|
if err != nil && lookupErr != nil {
|
|
return "", fmt.Errorf("%v | %v", err, lookupErr)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if lookupErr != nil {
|
|
return "", lookupErr
|
|
}
|
|
|
|
return "", fmt.Errorf("ISRC not found")
|
|
}
|
|
|
|
func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) {
|
|
return s.lookupSpotifyISRC(spotifyID)
|
|
}
|
|
|
|
func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) {
|
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL))
|
|
if region != "" {
|
|
apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region))
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", songLinkUserAgent)
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to call song.link: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read song.link response: %w", err)
|
|
}
|
|
if len(body) == 0 {
|
|
return nil, fmt.Errorf("song.link returned empty response")
|
|
}
|
|
|
|
var parsed songLinkAPIResponse
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
bodyStr := string(body)
|
|
if len(bodyStr) > 200 {
|
|
bodyStr = bodyStr[:200] + "..."
|
|
}
|
|
return nil, fmt.Errorf("failed to decode song.link response: %w (response: %s)", err, bodyStr)
|
|
}
|
|
|
|
return &parsed, nil
|
|
}
|
|
|
|
func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) {
|
|
apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc)))
|
|
|
|
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", songLinkUserAgent)
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to call Deezer ISRC API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("Deezer ISRC API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var payload struct {
|
|
ID int64 `json:"id"`
|
|
ISRC string `json:"isrc"`
|
|
Link string `json:"link"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return "", fmt.Errorf("failed to decode Deezer ISRC response: %w", err)
|
|
}
|
|
|
|
if payload.Link != "" {
|
|
return normalizeDeezerTrackURL(payload.Link), nil
|
|
}
|
|
if payload.ID > 0 {
|
|
return normalizeDeezerTrackURL(fmt.Sprintf("https://www.deezer.com/track/%d", payload.ID)), nil
|
|
}
|
|
|
|
return "", fmt.Errorf("deezer track link not found for ISRC %s", isrc)
|
|
}
|
|
|
|
func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) {
|
|
if resp == nil {
|
|
return
|
|
}
|
|
|
|
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
|
|
links.TidalURL = strings.TrimSpace(link.URL)
|
|
fmt.Println("✓ Tidal URL found")
|
|
}
|
|
|
|
if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" {
|
|
links.AmazonURL = normalizeAmazonMusicURL(link.URL)
|
|
fmt.Println("✓ Amazon URL found")
|
|
}
|
|
|
|
if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
|
|
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
|
|
fmt.Println("✓ Deezer URL found")
|
|
}
|
|
}
|
|
|
|
func normalizeAmazonMusicURL(rawURL string) string {
|
|
amazonURL := strings.TrimSpace(rawURL)
|
|
if amazonURL == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.Contains(amazonURL, "trackAsin=") {
|
|
parts := strings.Split(amazonURL, "trackAsin=")
|
|
if len(parts) > 1 {
|
|
trackAsin := strings.Split(parts[1], "&")[0]
|
|
if trackAsin != "" {
|
|
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
|
|
}
|
|
}
|
|
}
|
|
|
|
if match := amazonAlbumTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
|
|
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
|
|
}
|
|
|
|
if match := amazonTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
|
|
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func normalizeDeezerTrackURL(rawURL string) string {
|
|
trackID, err := extractDeezerTrackID(rawURL)
|
|
if err != nil {
|
|
return strings.TrimSpace(rawURL)
|
|
}
|
|
return fmt.Sprintf("https://www.deezer.com/track/%s", trackID)
|
|
}
|
|
|
|
func extractDeezerTrackID(rawURL string) (string, error) {
|
|
cleanURL := strings.TrimSpace(rawURL)
|
|
if cleanURL == "" {
|
|
return "", fmt.Errorf("empty Deezer URL")
|
|
}
|
|
|
|
parts := strings.Split(cleanURL, "/track/")
|
|
if len(parts) < 2 {
|
|
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
|
|
}
|
|
|
|
trackID := strings.Split(parts[1], "?")[0]
|
|
trackID = strings.Trim(trackID, "/ ")
|
|
if trackID == "" {
|
|
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
|
|
}
|
|
|
|
return trackID, nil
|
|
}
|
|
|
|
func hasAnySongLinkData(links *resolvedTrackLinks) bool {
|
|
if links == nil {
|
|
return false
|
|
}
|
|
return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != ""
|
|
}
|
|
|
|
func firstISRCMatch(body string) string {
|
|
match := isrcPattern.FindStringSubmatch(strings.ToUpper(body))
|
|
if len(match) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(match[1])
|
|
}
|