mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-02-17 17:31:33 -05:00
## 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
525 lines
16 KiB
Go
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()
|
|
}
|