musiclink/internal/services/idonthavespotify.go

175 lines
4.3 KiB
Go

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
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
}