musiclink/internal/services/idonthavespotify.go
Meta-Repo Bot 8366b92dd8 Initial commit: MusicLink bot for cross-platform music link conversion
A matterbridge bot that detects music links (Spotify, YouTube, Apple Music,
etc.) in chat messages and responds with equivalent links on other platforms.

Features:
- Connects to matterbridge via WebSocket API
- Detects links from 7 music services (Spotify, YouTube, Apple, Deezer, etc.)
- Uses idonthavespotify API for conversion (no API credentials required)
- Automatic reconnection with exponential backoff
- Platform setup guide for NixOS deployment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:56:17 +00:00

153 lines
3.8 KiB
Go

package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"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() *Client {
return &Client{
httpClient: &http.Client{Timeout: 15 * time.Second},
apiURL: defaultAPIURL,
}
}
// 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 {
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
}
}