package services import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) const defaultAPIURL = "https://idonthavespotify.sjdonado.com/api/search?v=1" // Client calls the idonthavespotify API to resolve music links. type Client struct { httpClient *http.Client apiURL string } // NewClient creates a new idonthavespotify API client. func NewClient(apiURL string) *Client { if apiURL == "" { apiURL = defaultAPIURL } return &Client{ httpClient: &http.Client{Timeout: 15 * time.Second}, apiURL: apiURL, } } // apiRequest is the request body for the idonthavespotify API. type apiRequest struct { Link string `json:"link"` Adapters []string `json:"adapters,omitempty"` } // APIResponse is the response from the idonthavespotify API. type APIResponse struct { ID string `json:"id"` Type string `json:"type"` // "song", "album", "playlist", "artist", "podcast" Title string `json:"title"` Description string `json:"description"` Image string `json:"image"` Audio string `json:"audio"` Source string `json:"source"` UniversalLink string `json:"universalLink"` Links []APILink `json:"links"` } // APILink represents a single platform link in the API response. type APILink struct { Type string `json:"type"` // "spotify", "appleMusic", "youtube", "tidal", etc. URL string `json:"url"` IsVerified bool `json:"isVerified"` NotAvailable bool `json:"notAvailable"` } // Resolve takes a music link and returns links to the same content on other platforms. func (c *Client) Resolve(ctx context.Context, link string) (*APIResponse, error) { reqBody := apiRequest{Link: link} jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshaling request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("making request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body := readErrorBody(resp) if body != "" { return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body) } return nil, fmt.Errorf("API returned status %d", resp.StatusCode) } var apiResp APIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return &apiResp, nil } // ToResolvedLinks converts an API response to our internal ResolvedLinks format. func (r *APIResponse) ToResolvedLinks() *ResolvedLinks { result := &ResolvedLinks{ Type: apiTypeToLinkType(r.Type), Links: make(map[ServiceType]string), } // Set track info if available if r.Type == "song" { result.Track = &Track{ Title: r.Title, ArtworkURL: r.Image, PreviewURL: r.Audio, } // Parse artist from description if possible // Description format: "Artist · Album · Song · Year" } // Map links to our service types for _, link := range r.Links { if link.NotAvailable { continue } if svcType, ok := apiTypeToServiceType(link.Type); ok { result.Links[svcType] = link.URL } } return result } func apiTypeToLinkType(t string) LinkType { switch t { case "song": return LinkTypeTrack case "album": return LinkTypeAlbum case "artist": return LinkTypeArtist case "playlist": return LinkTypePlaylist default: return LinkTypeTrack } } func apiTypeToServiceType(t string) (ServiceType, bool) { switch t { case "spotify": return ServiceSpotify, true case "appleMusic": return ServiceAppleMusic, true case "youtube", "youtubeMusic": return ServiceYouTube, true case "deezer": return ServiceDeezer, true case "soundCloud": return ServiceSoundCloud, true case "tidal": return ServiceTidal, true case "bandcamp": return ServiceBandcamp, true case "qobuz": return ServiceQobuz, true default: return "", false } } func readErrorBody(resp *http.Response) string { const maxErrorBytes = 2048 data, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBytes)) if err != nil { return "" } msg := strings.TrimSpace(string(data)) if msg == "" { return "" } return msg }