Files
Fabric/internal/tools/spotify/spotify.go
Kayvan Sylvan 719590abb6 feat: add Spotify metadata retrieval via --spotify flag
## CHANGES
- Add Spotify plugin with OAuth token handling and metadata
- Wire --spotify flag into CLI processing and output
- Register Spotify in plugin setup, env, and registry
- Update shell completions to include --spotify option
- Add i18n strings for Spotify configuration errors
- Add unit and integration tests for Spotify API
- Set gopls integration build tags for workspace
2026-01-20 15:57:59 -08:00

525 lines
16 KiB
Go

// Package spotify provides Spotify Web API integration for podcast metadata retrieval.
//
// Requirements:
// - Spotify Developer Account: Required to obtain Client ID and Client Secret
// - Client Credentials: Stored in .env file via fabric --setup
//
// The implementation uses OAuth2 Client Credentials flow for authentication.
// Note: The Spotify Web API does NOT provide access to podcast transcripts.
// For transcript functionality, users should use fabric's --transcribe-file feature
// with audio obtained from other sources.
package spotify
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/danielmiessler/fabric/internal/plugins"
)
const (
// Spotify API endpoints
tokenURL = "https://accounts.spotify.com/api/token"
apiBaseURL = "https://api.spotify.com/v1"
)
// URL pattern regexes for parsing Spotify URLs
var (
showPatternRegex = regexp.MustCompile(`spotify\.com/show/([a-zA-Z0-9]+)`)
episodePatternRegex = regexp.MustCompile(`spotify\.com/episode/([a-zA-Z0-9]+)`)
)
// NewSpotify creates a new Spotify client instance.
func NewSpotify() *Spotify {
label := "Spotify"
ret := &Spotify{}
ret.PluginBase = &plugins.PluginBase{
Name: i18n.T("spotify_label"),
SetupDescription: i18n.T("spotify_setup_description") + " " + i18n.T("optional_marker"),
EnvNamePrefix: plugins.BuildEnvVariablePrefix(label),
}
ret.ClientId = ret.AddSetupQuestion("Client ID", false)
ret.ClientSecret = ret.AddSetupQuestion("Client Secret", false)
return ret
}
// Spotify represents a Spotify API client.
type Spotify struct {
*plugins.PluginBase
ClientId *plugins.SetupQuestion
ClientSecret *plugins.SetupQuestion
// OAuth2 token management
accessToken string
tokenExpiry time.Time
tokenMutex sync.RWMutex
httpClient *http.Client
}
// initClient ensures the HTTP client and access token are initialized.
func (s *Spotify) initClient() error {
if s.httpClient == nil {
s.httpClient = &http.Client{Timeout: 30 * time.Second}
}
// Check if we need to refresh the token
s.tokenMutex.RLock()
needsRefresh := s.accessToken == "" || time.Now().After(s.tokenExpiry)
s.tokenMutex.RUnlock()
if needsRefresh {
return s.refreshAccessToken()
}
return nil
}
// refreshAccessToken obtains a new access token using Client Credentials flow.
func (s *Spotify) refreshAccessToken() error {
if s.ClientId.Value == "" || s.ClientSecret.Value == "" {
return fmt.Errorf("%s", i18n.T("spotify_not_configured"))
}
// Prepare the token request
data := url.Values{}
data.Set("grant_type", "client_credentials")
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create token request: %w", err)
}
// Set Basic Auth header with Client ID and Secret
auth := base64.StdEncoding.EncodeToString([]byte(s.ClientId.Value + ":" + s.ClientSecret.Value))
req.Header.Set("Authorization", "Basic "+auth)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to request access token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to get access token: status %d, body: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return fmt.Errorf("failed to decode token response: %w", err)
}
s.tokenMutex.Lock()
s.accessToken = tokenResp.AccessToken
// Set expiry slightly before actual expiry to avoid edge cases
s.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second)
s.tokenMutex.Unlock()
return nil
}
// doRequest performs an authenticated request to the Spotify API.
func (s *Spotify) doRequest(method, endpoint string) ([]byte, error) {
if err := s.initClient(); err != nil {
return nil, err
}
reqURL := apiBaseURL + endpoint
req, err := http.NewRequest(method, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
s.tokenMutex.RLock()
req.Header.Set("Authorization", "Bearer "+s.accessToken)
s.tokenMutex.RUnlock()
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API request failed: status %d, body: %s", resp.StatusCode, string(body))
}
return body, nil
}
// GetShowOrEpisodeId extracts show or episode ID from a Spotify URL.
func (s *Spotify) GetShowOrEpisodeId(urlStr string) (showId string, episodeId string, err error) {
// Extract show ID
showMatch := showPatternRegex.FindStringSubmatch(urlStr)
if len(showMatch) > 1 {
showId = showMatch[1]
}
// Extract episode ID
episodeMatch := episodePatternRegex.FindStringSubmatch(urlStr)
if len(episodeMatch) > 1 {
episodeId = episodeMatch[1]
}
if showId == "" && episodeId == "" {
err = fmt.Errorf(i18n.T("spotify_invalid_url"), urlStr)
}
return
}
// ShowMetadata represents metadata for a Spotify show (podcast).
type ShowMetadata struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Publisher string `json:"publisher"`
TotalEpisodes int `json:"total_episodes"`
Languages []string `json:"languages"`
MediaType string `json:"media_type"`
ExternalURL string `json:"external_url"`
ImageURL string `json:"image_url,omitempty"`
}
// EpisodeMetadata represents metadata for a Spotify episode.
type EpisodeMetadata struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ReleaseDate string `json:"release_date"`
DurationMs int `json:"duration_ms"`
DurationMinutes int `json:"duration_minutes"`
Language string `json:"language"`
Explicit bool `json:"explicit"`
ExternalURL string `json:"external_url"`
AudioPreviewURL string `json:"audio_preview_url,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ShowId string `json:"show_id"`
ShowName string `json:"show_name"`
}
// SearchResult represents a search result item.
type SearchResult struct {
Shows []ShowMetadata `json:"shows"`
}
// GetShowMetadata retrieves metadata for a Spotify show (podcast).
func (s *Spotify) GetShowMetadata(showId string) (*ShowMetadata, error) {
body, err := s.doRequest("GET", "/shows/"+showId)
if err != nil {
return nil, fmt.Errorf(i18n.T("spotify_error_getting_metadata"), err)
}
var resp struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Publisher string `json:"publisher"`
TotalEpisodes int `json:"total_episodes"`
Languages []string `json:"languages"`
MediaType string `json:"media_type"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Images []struct {
URL string `json:"url"`
} `json:"images"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("failed to parse show metadata: %w", err)
}
if resp.Id == "" {
return nil, fmt.Errorf(i18n.T("spotify_no_show_found"), showId)
}
metadata := &ShowMetadata{
Id: resp.Id,
Name: resp.Name,
Description: resp.Description,
Publisher: resp.Publisher,
TotalEpisodes: resp.TotalEpisodes,
Languages: resp.Languages,
MediaType: resp.MediaType,
ExternalURL: resp.ExternalUrls.Spotify,
}
if len(resp.Images) > 0 {
metadata.ImageURL = resp.Images[0].URL
}
return metadata, nil
}
// GetEpisodeMetadata retrieves metadata for a Spotify episode.
func (s *Spotify) GetEpisodeMetadata(episodeId string) (*EpisodeMetadata, error) {
body, err := s.doRequest("GET", "/episodes/"+episodeId)
if err != nil {
return nil, fmt.Errorf(i18n.T("spotify_error_getting_metadata"), err)
}
var resp struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ReleaseDate string `json:"release_date"`
DurationMs int `json:"duration_ms"`
Language string `json:"language"`
Explicit bool `json:"explicit"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
AudioPreviewUrl string `json:"audio_preview_url"`
Images []struct {
URL string `json:"url"`
} `json:"images"`
Show struct {
Id string `json:"id"`
Name string `json:"name"`
} `json:"show"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("failed to parse episode metadata: %w", err)
}
if resp.Id == "" {
return nil, fmt.Errorf(i18n.T("spotify_no_episode_found"), episodeId)
}
metadata := &EpisodeMetadata{
Id: resp.Id,
Name: resp.Name,
Description: resp.Description,
ReleaseDate: resp.ReleaseDate,
DurationMs: resp.DurationMs,
DurationMinutes: resp.DurationMs / 60000,
Language: resp.Language,
Explicit: resp.Explicit,
ExternalURL: resp.ExternalUrls.Spotify,
AudioPreviewURL: resp.AudioPreviewUrl,
ShowId: resp.Show.Id,
ShowName: resp.Show.Name,
}
if len(resp.Images) > 0 {
metadata.ImageURL = resp.Images[0].URL
}
return metadata, nil
}
// SearchShows searches for podcasts/shows matching the query.
func (s *Spotify) SearchShows(query string, limit int) (*SearchResult, error) {
if limit <= 0 || limit > 50 {
limit = 20 // Default limit
}
endpoint := fmt.Sprintf("/search?q=%s&type=show&limit=%d", url.QueryEscape(query), limit)
body, err := s.doRequest("GET", endpoint)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
var resp struct {
Shows struct {
Items []struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Publisher string `json:"publisher"`
TotalEpisodes int `json:"total_episodes"`
Languages []string `json:"languages"`
MediaType string `json:"media_type"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
Images []struct {
URL string `json:"url"`
} `json:"images"`
} `json:"items"`
} `json:"shows"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("failed to parse search results: %w", err)
}
result := &SearchResult{
Shows: make([]ShowMetadata, 0, len(resp.Shows.Items)),
}
for _, item := range resp.Shows.Items {
show := ShowMetadata{
Id: item.Id,
Name: item.Name,
Description: item.Description,
Publisher: item.Publisher,
TotalEpisodes: item.TotalEpisodes,
Languages: item.Languages,
MediaType: item.MediaType,
ExternalURL: item.ExternalUrls.Spotify,
}
if len(item.Images) > 0 {
show.ImageURL = item.Images[0].URL
}
result.Shows = append(result.Shows, show)
}
return result, nil
}
// GetShowEpisodes retrieves episodes for a given show.
func (s *Spotify) GetShowEpisodes(showId string, limit int) ([]EpisodeMetadata, error) {
if limit <= 0 || limit > 50 {
limit = 20 // Default limit
}
endpoint := fmt.Sprintf("/shows/%s/episodes?limit=%d", showId, limit)
body, err := s.doRequest("GET", endpoint)
if err != nil {
return nil, fmt.Errorf("failed to get show episodes: %w", err)
}
var resp struct {
Items []struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ReleaseDate string `json:"release_date"`
DurationMs int `json:"duration_ms"`
Language string `json:"language"`
Explicit bool `json:"explicit"`
ExternalUrls struct {
Spotify string `json:"spotify"`
} `json:"external_urls"`
AudioPreviewUrl string `json:"audio_preview_url"`
Images []struct {
URL string `json:"url"`
} `json:"images"`
} `json:"items"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("failed to parse episodes: %w", err)
}
episodes := make([]EpisodeMetadata, 0, len(resp.Items))
for _, item := range resp.Items {
ep := EpisodeMetadata{
Id: item.Id,
Name: item.Name,
Description: item.Description,
ReleaseDate: item.ReleaseDate,
DurationMs: item.DurationMs,
DurationMinutes: item.DurationMs / 60000,
Language: item.Language,
Explicit: item.Explicit,
ExternalURL: item.ExternalUrls.Spotify,
AudioPreviewURL: item.AudioPreviewUrl,
ShowId: showId,
}
if len(item.Images) > 0 {
ep.ImageURL = item.Images[0].URL
}
episodes = append(episodes, ep)
}
return episodes, nil
}
// GrabMetadataForURL retrieves metadata for a Spotify URL (show or episode).
func (s *Spotify) GrabMetadataForURL(urlStr string) (any, error) {
showId, episodeId, err := s.GetShowOrEpisodeId(urlStr)
if err != nil {
return nil, err
}
if episodeId != "" {
return s.GetEpisodeMetadata(episodeId)
}
if showId != "" {
return s.GetShowMetadata(showId)
}
return nil, fmt.Errorf(i18n.T("spotify_invalid_url"), urlStr)
}
// FormatMetadataAsText formats metadata as human-readable text suitable for LLM processing.
func (s *Spotify) FormatMetadataAsText(metadata any) string {
var sb strings.Builder
switch m := metadata.(type) {
case *ShowMetadata:
sb.WriteString("# Spotify Podcast/Show\n\n")
sb.WriteString(fmt.Sprintf("**Title**: %s\n", m.Name))
sb.WriteString(fmt.Sprintf("**Publisher**: %s\n", m.Publisher))
sb.WriteString(fmt.Sprintf("**Total Episodes**: %d\n", m.TotalEpisodes))
if len(m.Languages) > 0 {
sb.WriteString(fmt.Sprintf("**Languages**: %s\n", strings.Join(m.Languages, ", ")))
}
sb.WriteString(fmt.Sprintf("**Media Type**: %s\n", m.MediaType))
sb.WriteString(fmt.Sprintf("**URL**: %s\n\n", m.ExternalURL))
sb.WriteString("## Description\n\n")
sb.WriteString(m.Description)
sb.WriteString("\n")
case *EpisodeMetadata:
sb.WriteString("# Spotify Episode\n\n")
sb.WriteString(fmt.Sprintf("**Title**: %s\n", m.Name))
sb.WriteString(fmt.Sprintf("**Show**: %s\n", m.ShowName))
sb.WriteString(fmt.Sprintf("**Release Date**: %s\n", m.ReleaseDate))
sb.WriteString(fmt.Sprintf("**Duration**: %d minutes\n", m.DurationMinutes))
sb.WriteString(fmt.Sprintf("**Language**: %s\n", m.Language))
sb.WriteString(fmt.Sprintf("**Explicit**: %v\n", m.Explicit))
sb.WriteString(fmt.Sprintf("**URL**: %s\n", m.ExternalURL))
if m.AudioPreviewURL != "" {
sb.WriteString(fmt.Sprintf("**Audio Preview**: %s\n", m.AudioPreviewURL))
}
sb.WriteString("\n## Description\n\n")
sb.WriteString(m.Description)
sb.WriteString("\n")
case *SearchResult:
sb.WriteString("# Spotify Search Results\n\n")
for i, show := range m.Shows {
sb.WriteString(fmt.Sprintf("## %d. %s\n", i+1, show.Name))
sb.WriteString(fmt.Sprintf("- **Publisher**: %s\n", show.Publisher))
sb.WriteString(fmt.Sprintf("- **Episodes**: %d\n", show.TotalEpisodes))
sb.WriteString(fmt.Sprintf("- **URL**: %s\n", show.ExternalURL))
// Truncate description for search results
desc := show.Description
if len(desc) > 200 {
desc = desc[:200] + "..."
}
sb.WriteString(fmt.Sprintf("- **Description**: %s\n\n", desc))
}
}
return sb.String()
}