Compare commits

...

4 Commits

Author SHA1 Message Date
github-actions[bot]
c528a72b5b chore(release): Update version to v1.4.386 2026-01-21 00:17:17 +00:00
Kayvan Sylvan
89df6ac75e Merge pull request #1945 from ksylvan/implement-spotify-api
feat: Add Spotify API integration for podcast metadata retrieval
2026-01-20 16:13:04 -08:00
Kayvan Sylvan
963acdefbb chore: incoming 1945 changelog entry 2026-01-20 16:01:09 -08:00
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
23 changed files with 1200 additions and 15 deletions

View File

@@ -252,5 +252,8 @@
},
"[json]": {
"editor.formatOnSave": false
},
"gopls": {
"build.buildFlags": ["-tags=integration"]
}
}

View File

@@ -1,5 +1,15 @@
# Changelog
## v1.4.386 (2026-01-21)
### PR [#1945](https://github.com/danielmiessler/Fabric/pull/1945) by [ksylvan](https://github.com/ksylvan): feat: Add Spotify API integration for podcast metadata retrieval
- Add Spotify metadata retrieval via --spotify flag
- 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
## v1.4.385 (2026-01-20)
### PR [#1947](https://github.com/danielmiessler/Fabric/pull/1947) by [cleong14](https://github.com/cleong14): feat(patterns): add extract_bd_ideas pattern

View File

@@ -1,3 +1,3 @@
package main
var version = "v1.4.385"
var version = "v1.4.386"

Binary file not shown.

View File

@@ -148,6 +148,7 @@ _fabric() {
'(--debug)--debug[Set debug level (0=off, 1=basic, 2=detailed, 3=trace)]:debug level:(0 1 2 3)' \
'(--notification)--notification[Send desktop notification when command completes]' \
'(--notification-command)--notification-command[Custom command to run for notifications]:notification command:' \
'(--spotify)--spotify[Spotify podcast or episode URL to grab metadata]:spotify url:' \
'(-h --help)'{-h,--help}'[Show this help message]' \
'*:arguments:'
}

View File

@@ -109,6 +109,9 @@ _fabric() {
# No specific completion suggestions, user types the value
return 0
;;
--spotify)
return 0
;;
esac
# If the current word starts with '-', suggest options

View File

@@ -121,9 +121,9 @@ function __fabric_register_completions
complete -c $cmd -l metadata -d "Output video metadata"
complete -c $cmd -l yt-dlp-args -d "Additional arguments to pass to yt-dlp (e.g. '--cookies-from-browser brave')"
complete -c $cmd -l readability -d "Convert HTML input into a clean, readable view"
complete -c $cmd -l input-has-vars -d "Apply variables to user input"
complete -c $cmd -l no-variable-replacement -d "Disable pattern variable replacement"
complete -c $cmd -l dry-run -d "Show what would be sent to the model without actually sending it"
complete -c $cmd -l input-has-vars -d "Apply variables to user input"
complete -c $cmd -l no-variable-replacement -d "Disable pattern variable replacement"
complete -c $cmd -l dry-run -d "Show what would be sent to the model without actually sending it"
complete -c $cmd -l search -d "Enable web search tool for supported models (Anthropic, OpenAI, Gemini)"
complete -c $cmd -l serve -d "Serve the Fabric Rest API"
complete -c $cmd -l serveOllama -d "Serve the Fabric Rest API with ollama endpoints"
@@ -138,6 +138,7 @@ function __fabric_register_completions
complete -c $cmd -l split-media-file -d "Split audio/video files larger than 25MB using ffmpeg"
complete -c $cmd -l notification -d "Send desktop notification when command completes"
complete -c $cmd -s h -l help -d "Show this help message"
complete -c $cmd -l spotify -d 'Spotify podcast or episode URL to grab metadata'
end
__fabric_register_completions fabric

View File

@@ -59,6 +59,7 @@ type Flags struct {
YouTubeComments bool `long:"comments" description:"Grab comments from YouTube video and send to chat"`
YouTubeMetadata bool `long:"metadata" description:"Output video metadata"`
YtDlpArgs string `long:"yt-dlp-args" yaml:"ytDlpArgs" description:"Additional arguments to pass to yt-dlp (e.g. '--cookies-from-browser brave')"`
Spotify string `long:"spotify" description:"Spotify podcast or episode URL to grab metadata from and send to chat"`
Language string `short:"g" long:"language" description:"Specify the Language Code for the chat, e.g. -g=en -g=zh" default:""`
ScrapeURL string `short:"u" long:"scrape_url" description:"Scrape website URL to markdown using Jina AI"`
ScrapeQuestion string `short:"q" long:"scrape_question" description:"Search question using Jina AI"`

View File

@@ -87,5 +87,26 @@ func handleToolProcessing(currentFlags *Flags, registry *core.PluginRegistry) (m
}
}
// Handle Spotify podcast/episode metadata
if currentFlags.Spotify != "" {
if !registry.Spotify.IsConfigured() {
err = fmt.Errorf("%s", i18n.T("spotify_not_configured"))
return
}
var metadata any
if metadata, err = registry.Spotify.GrabMetadataForURL(currentFlags.Spotify); err != nil {
return
}
formattedMetadata := registry.Spotify.FormatMetadataAsText(metadata)
messageTools = AppendMessage(messageTools, formattedMetadata)
if !currentFlags.IsChatRequest() {
err = currentFlags.WriteOutput(messageTools)
return
}
}
return
}

View File

@@ -38,6 +38,7 @@ import (
"github.com/danielmiessler/fabric/internal/tools/custom_patterns"
"github.com/danielmiessler/fabric/internal/tools/jina"
"github.com/danielmiessler/fabric/internal/tools/lang"
"github.com/danielmiessler/fabric/internal/tools/spotify"
"github.com/danielmiessler/fabric/internal/tools/youtube"
"github.com/danielmiessler/fabric/internal/util"
)
@@ -83,6 +84,7 @@ func NewPluginRegistry(db *fsdb.Db) (ret *PluginRegistry, err error) {
YouTube: youtube.NewYouTube(),
Language: lang.NewLanguage(),
Jina: jina.NewClient(),
Spotify: spotify.NewSpotify(),
Strategies: strategy.NewStrategiesManager(),
}
@@ -156,6 +158,7 @@ type PluginRegistry struct {
YouTube *youtube.YouTube
Language *lang.Language
Jina *jina.Client
Spotify *spotify.Spotify
TemplateExtensions *template.ExtensionManager
Strategies *strategy.StrategiesManager
}
@@ -175,6 +178,7 @@ func (o *PluginRegistry) SaveEnvFile() (err error) {
o.YouTube.SetupFillEnvFileContent(&envFileContent)
o.Jina.SetupFillEnvFileContent(&envFileContent)
o.Spotify.SetupFillEnvFileContent(&envFileContent)
o.Language.SetupFillEnvFileContent(&envFileContent)
err = o.Db.SaveEnv(envFileContent.String())
@@ -348,7 +352,7 @@ func (o *PluginRegistry) runInteractiveSetup() (err error) {
groupsPlugins.AddGroupItems(i18n.T("setup_required_tools"), o.Defaults, o.PatternsLoader, o.Strategies)
// Add optional tools
groupsPlugins.AddGroupItems(i18n.T("setup_optional_configuration_header"), o.CustomPatterns, o.Jina, o.Language, o.YouTube)
groupsPlugins.AddGroupItems(i18n.T("setup_optional_configuration_header"), o.CustomPatterns, o.Jina, o.Language, o.Spotify, o.YouTube)
for {
groupsPlugins.Print(false)
@@ -489,9 +493,10 @@ func (o *PluginRegistry) Configure() (err error) {
o.PatternsLoader.Patterns.CustomPatternsDir = customPatternsDir
}
//YouTube and Jina are not mandatory, so ignore not configured error
//YouTube, Jina, Spotify are not mandatory, so ignore not configured error
_ = o.YouTube.Configure()
_ = o.Jina.Configure()
_ = o.Spotify.Configure()
_ = o.Language.Configure()
return
}

View File

@@ -3,8 +3,16 @@
"vendor_not_configured": "Anbieter %s ist nicht konfiguriert",
"vendor_no_transcription_support": "Anbieter %s unterstützt keine Audio-Transkription",
"transcription_model_required": "Transkriptionsmodell ist erforderlich (verwende --transcribe-model)",
"youtube_not_configured": "YouTube ist nicht konfiguriert, bitte führe das Setup-Verfahren aus",
"youtube_api_key_required": "YouTube API-Schlüssel für Kommentare und Metadaten erforderlich. Führe 'fabric --setup' aus, um zu konfigurieren",
"youtube_not_configured": "YouTube ist nicht konfiguriert, bitte führen Sie die Einrichtung durch",
"spotify_not_configured": "Spotify ist nicht konfiguriert, bitte führen Sie die Einrichtung durch",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - um Podcast-/Show-Metadaten von Spotify abzurufen",
"spotify_invalid_url": "Ungültige Spotify-URL, kann Show- oder Episoden-ID nicht abrufen: '%s'",
"spotify_error_getting_metadata": "Fehler beim Abrufen der Spotify-Metadaten: %v",
"spotify_no_show_found": "Keine Show mit ID gefunden: %s",
"spotify_no_episode_found": "Keine Episode mit ID gefunden: %s",
"spotify_url_help": "Spotify-Podcast- oder Episoden-URL zum Abrufen von Metadaten und Senden an den Chat",
"youtube_api_key_required": "YouTube API-Schlüssel erforderlich für Kommentare und Metadaten. Führen Sie 'fabric --setup' zur Konfiguration aus",
"youtube_ytdlp_not_found": "yt-dlp wurde nicht in PATH gefunden. Bitte installiere yt-dlp, um die YouTube-Transkript-Funktionalität zu nutzen",
"youtube_invalid_url": "ungültige YouTube-URL, kann keine Video- oder Playlist-ID abrufen: '%s'",
"youtube_url_is_playlist_not_video": "URL ist eine Playlist, kein Video",

View File

@@ -4,6 +4,14 @@
"vendor_no_transcription_support": "vendor %s does not support audio transcription",
"transcription_model_required": "transcription model is required (use --transcribe-model)",
"youtube_not_configured": "YouTube is not configured, please run the setup procedure",
"spotify_not_configured": "Spotify is not configured, please run the setup procedure",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - to grab podcast/show metadata from Spotify",
"spotify_invalid_url": "invalid Spotify URL, can't get show or episode ID: '%s'",
"spotify_error_getting_metadata": "error getting Spotify metadata: %v",
"spotify_no_show_found": "no show found with ID: %s",
"spotify_no_episode_found": "no episode found with ID: %s",
"spotify_url_help": "Spotify podcast or episode URL to grab metadata from and send to chat",
"youtube_api_key_required": "YouTube API key required for comments and metadata. Run 'fabric --setup' to configure",
"youtube_ytdlp_not_found": "yt-dlp not found in PATH. Please install yt-dlp to use YouTube transcript functionality",
"youtube_invalid_url": "invalid YouTube URL, can't get video or playlist ID: '%s'",

View File

@@ -3,10 +3,18 @@
"vendor_not_configured": "el proveedor %s no está configurado",
"vendor_no_transcription_support": "el proveedor %s no admite transcripción de audio",
"transcription_model_required": "se requiere un modelo de transcripción (usa --transcribe-model)",
"youtube_not_configured": "YouTube no está configurado, por favor ejecuta el procedimiento de configuración",
"youtube_api_key_required": "Se requiere clave de API de YouTube para comentarios y metadatos. Ejecuta 'fabric --setup' para configurar",
"youtube_not_configured": "YouTube no está configurado, por favor ejecute el procedimiento de configuración",
"spotify_not_configured": "Spotify no está configurado, por favor ejecute el procedimiento de configuración",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - para obtener metadatos de podcasts/programas de Spotify",
"spotify_invalid_url": "URL de Spotify no válida, no se puede obtener el ID del programa o episodio: '%s'",
"spotify_error_getting_metadata": "error al obtener metadatos de Spotify: %v",
"spotify_no_show_found": "no se encontró ningún programa con ID: %s",
"spotify_no_episode_found": "no se encontró ningún episodio con ID: %s",
"spotify_url_help": "URL de podcast o episodio de Spotify para obtener metadatos y enviar al chat",
"youtube_api_key_required": "Se requiere clave API de YouTube para comentarios y metadatos. Ejecute 'fabric --setup' para configurar",
"youtube_ytdlp_not_found": "yt-dlp no encontrado en PATH. Por favor instala yt-dlp para usar la funcionalidad de transcripción de YouTube",
"youtube_invalid_url": "URL de YouTube inválida, no se puede obtener ID de video o lista de reproducción: '%s'",
"youtube_invalid_url": "URL de YouTube no válida, no se puede obtener ID de video o lista de reproducción: '%s'",
"youtube_url_is_playlist_not_video": "La URL es una lista de reproducción, no un video",
"youtube_no_video_id_found": "no se encontró ID de video en la URL",
"youtube_rate_limit_exceeded": "Límite de tasa de YouTube excedido. Intenta de nuevo más tarde o usa diferentes argumentos de yt-dlp como '--sleep-requests 1' para ralentizar las solicitudes.",

View File

@@ -4,6 +4,14 @@
"vendor_no_transcription_support": "le fournisseur %s ne prend pas en charge la transcription audio",
"transcription_model_required": "un modèle de transcription est requis (utilisez --transcribe-model)",
"youtube_not_configured": "YouTube n'est pas configuré, veuillez exécuter la procédure de configuration",
"spotify_not_configured": "Spotify n'est pas configuré, veuillez exécuter la procédure de configuration",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - pour récupérer les métadonnées de podcasts/émissions depuis Spotify",
"spotify_invalid_url": "URL Spotify invalide, impossible d'obtenir l'ID de l'émission ou de l'épisode : '%s'",
"spotify_error_getting_metadata": "erreur lors de la récupération des métadonnées Spotify : %v",
"spotify_no_show_found": "aucune émission trouvée avec l'ID : %s",
"spotify_no_episode_found": "aucun épisode trouvé avec l'ID : %s",
"spotify_url_help": "URL de podcast ou d'épisode Spotify pour récupérer les métadonnées et envoyer au chat",
"youtube_api_key_required": "Clé API YouTube requise pour les commentaires et métadonnées. Exécutez 'fabric --setup' pour configurer",
"youtube_ytdlp_not_found": "yt-dlp introuvable dans PATH. Veuillez installer yt-dlp pour utiliser la fonctionnalité de transcription YouTube",
"youtube_invalid_url": "URL YouTube invalide, impossible d'obtenir l'ID de vidéo ou de liste de lecture : '%s'",

View File

@@ -3,8 +3,16 @@
"vendor_not_configured": "il fornitore %s non è configurato",
"vendor_no_transcription_support": "il fornitore %s non supporta la trascrizione audio",
"transcription_model_required": "è richiesto un modello di trascrizione (usa --transcribe-model)",
"youtube_not_configured": "YouTube non è configurato, per favore esegui la procedura di configurazione",
"youtube_api_key_required": "Chiave API YouTube richiesta per commenti e metadati. Esegui 'fabric --setup' per configurare",
"youtube_not_configured": "YouTube non è configurato, eseguire la procedura di configurazione",
"spotify_not_configured": "Spotify non è configurato, eseguire la procedura di configurazione",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - per ottenere metadati di podcast/show da Spotify",
"spotify_invalid_url": "URL Spotify non valido, impossibile ottenere l'ID dello show o dell'episodio: '%s'",
"spotify_error_getting_metadata": "errore durante il recupero dei metadati Spotify: %v",
"spotify_no_show_found": "nessuno show trovato con ID: %s",
"spotify_no_episode_found": "nessun episodio trovato con ID: %s",
"spotify_url_help": "URL di podcast o episodio Spotify per ottenere metadati e inviare alla chat",
"youtube_api_key_required": "Chiave API YouTube richiesta per commenti e metadati. Eseguire 'fabric --setup' per configurare",
"youtube_ytdlp_not_found": "yt-dlp non trovato in PATH. Per favore installa yt-dlp per usare la funzionalità di trascrizione YouTube",
"youtube_invalid_url": "URL YouTube non valido, impossibile ottenere l'ID del video o della playlist: '%s'",
"youtube_url_is_playlist_not_video": "L'URL è una playlist, non un video",

View File

@@ -4,6 +4,14 @@
"vendor_no_transcription_support": "ベンダー %s は音声転写をサポートしていません",
"transcription_model_required": "転写モデルが必要です(--transcribe-model を使用)",
"youtube_not_configured": "YouTubeが設定されていません。セットアップ手順を実行してください",
"spotify_not_configured": "Spotifyが設定されていません。セットアップ手順を実行してください",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - Spotifyからポッドキャスト/番組のメタデータを取得",
"spotify_invalid_url": "無効なSpotify URL、番組またはエピソードIDを取得できません'%s'",
"spotify_error_getting_metadata": "Spotifyメタデータの取得エラー%v",
"spotify_no_show_found": "ID %s の番組が見つかりません",
"spotify_no_episode_found": "ID %s のエピソードが見つかりません",
"spotify_url_help": "メタデータを取得してチャットに送信するSpotifyポッドキャストまたはエピソードURL",
"youtube_api_key_required": "コメントとメタデータにはYouTube APIキーが必要です。設定するには 'fabric --setup' を実行してください",
"youtube_ytdlp_not_found": "PATHにyt-dlpが見つかりません。YouTubeトランスクリプト機能を使用するにはyt-dlpをインストールしてください",
"youtube_invalid_url": "無効なYouTube URL、動画またはプレイリストIDを取得できません: '%s'",

View File

@@ -4,6 +4,14 @@
"vendor_no_transcription_support": "o fornecedor %s não suporta transcrição de áudio",
"transcription_model_required": "modelo de transcrição é necessário (use --transcribe-model)",
"youtube_not_configured": "YouTube não está configurado, por favor execute o procedimento de configuração",
"spotify_not_configured": "Spotify não está configurado, por favor execute o procedimento de configuração",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - para obter metadados de podcasts/programas do Spotify",
"spotify_invalid_url": "URL do Spotify inválida, não é possível obter o ID do programa ou episódio: '%s'",
"spotify_error_getting_metadata": "erro ao obter metadados do Spotify: %v",
"spotify_no_show_found": "nenhum programa encontrado com o ID: %s",
"spotify_no_episode_found": "nenhum episódio encontrado com o ID: %s",
"spotify_url_help": "URL de podcast ou episódio do Spotify para obter metadados e enviar ao chat",
"youtube_api_key_required": "Chave de API do YouTube necessária para comentários e metadados. Execute 'fabric --setup' para configurar",
"youtube_ytdlp_not_found": "yt-dlp não encontrado no PATH. Por favor instale o yt-dlp para usar a funcionalidade de transcrição do YouTube",
"youtube_invalid_url": "URL do YouTube inválida, não é possível obter o ID do vídeo ou da playlist: '%s'",

View File

@@ -4,6 +4,14 @@
"vendor_no_transcription_support": "o fornecedor %s não suporta transcrição de áudio",
"transcription_model_required": "modelo de transcrição é necessário (use --transcribe-model)",
"youtube_not_configured": "YouTube não está configurado, por favor execute o procedimento de configuração",
"spotify_not_configured": "Spotify não está configurado, por favor execute o procedimento de configuração",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - para obter metadados de podcasts/programas do Spotify",
"spotify_invalid_url": "URL do Spotify inválido, não é possível obter o ID do programa ou episódio: '%s'",
"spotify_error_getting_metadata": "erro ao obter metadados do Spotify: %v",
"spotify_no_show_found": "nenhum programa encontrado com o ID: %s",
"spotify_no_episode_found": "nenhum episódio encontrado com o ID: %s",
"spotify_url_help": "URL de podcast ou episódio do Spotify para obter metadados e enviar ao chat",
"youtube_api_key_required": "Chave de API do YouTube necessária para comentários e metadados. Execute 'fabric --setup' para configurar",
"youtube_ytdlp_not_found": "yt-dlp não encontrado no PATH. Por favor instale o yt-dlp para usar a funcionalidade de transcrição do YouTube",
"youtube_invalid_url": "URL do YouTube inválido, não é possível obter o ID do vídeo ou da lista de reprodução: '%s'",

View File

@@ -4,7 +4,15 @@
"vendor_no_transcription_support": "供应商 %s 不支持音频转录",
"transcription_model_required": "需要转录模型(使用 --transcribe-model",
"youtube_not_configured": "YouTube 未配置,请运行设置程序",
"youtube_api_key_required": "评论和元数据需要 YouTube API 密钥。运行 'fabric --setup' 进行配置",
"spotify_not_configured": "Spotify 未配置,请运行设置程序",
"spotify_label": "Spotify",
"spotify_setup_description": "Spotify - 从 Spotify 获取播客/节目元数据",
"spotify_invalid_url": "无效的 Spotify URL无法获取节目或剧集 ID'%s'",
"spotify_error_getting_metadata": "获取 Spotify 元数据时出错:%v",
"spotify_no_show_found": "未找到 ID 为 %s 的节目",
"spotify_no_episode_found": "未找到 ID 为 %s 的剧集",
"spotify_url_help": "Spotify 播客或剧集 URL用于获取元数据并发送到聊天",
"youtube_api_key_required": "YouTube API 密钥用于评论和元数据。运行 'fabric --setup' 进行配置",
"youtube_ytdlp_not_found": "在 PATH 中未找到 yt-dlp。请安装 yt-dlp 以使用 YouTube 转录功能",
"youtube_invalid_url": "无效的 YouTube URL无法获取视频或播放列表 ID'%s'",
"youtube_url_is_playlist_not_video": "URL 是播放列表,而不是视频",

View File

@@ -0,0 +1,524 @@
// 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()
}

View File

@@ -0,0 +1,238 @@
//go:build integration
// Integration tests for Spotify API.
// These tests require valid Spotify API credentials to run.
// Run with: go test -tags=integration ./internal/tools/spotify/...
//
// Required environment variables:
// - SPOTIFY_CLIENT_ID: Your Spotify Developer Client ID
// - SPOTIFY_CLIENT_SECRET: Your Spotify Developer Client Secret
package spotify
import (
"os"
"testing"
)
// Known public Spotify shows/episodes for testing.
// NOTE: These IDs are for The Joe Rogan Experience, one of the most popular
// podcasts on Spotify. If these become unavailable, update with another
// well-known, long-running podcast.
const (
// The Joe Rogan Experience - one of the most popular podcasts on Spotify
// cspell:disable-next-line
testShowID = "4rOoJ6Egrf8K2IrywzwOMk"
// A valid episode URL (episode of JRE)
// NOTE: If this specific episode is removed, the test will fail.
// Replace with any valid episode ID from the show.
testEpisodeID = "512ojhOuo1ktJprKbVcKyQ"
)
func setupIntegrationClient(t *testing.T) *Spotify {
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
t.Skip("Skipping integration test: SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set")
}
s := NewSpotify()
s.ClientId.Value = clientID
s.ClientSecret.Value = clientSecret
return s
}
func TestIntegration_GetShowMetadata(t *testing.T) {
s := setupIntegrationClient(t)
metadata, err := s.GetShowMetadata(testShowID)
if err != nil {
t.Fatalf("GetShowMetadata failed: %v", err)
}
if metadata == nil {
t.Fatal("GetShowMetadata returned nil metadata")
}
if metadata.Id != testShowID {
t.Errorf("Expected show ID %s, got %s", testShowID, metadata.Id)
}
if metadata.Name == "" {
t.Error("Show name should not be empty")
}
if metadata.Publisher == "" {
t.Error("Show publisher should not be empty")
}
t.Logf("Show: %s by %s (%d episodes)", metadata.Name, metadata.Publisher, metadata.TotalEpisodes)
}
func TestIntegration_GetEpisodeMetadata(t *testing.T) {
s := setupIntegrationClient(t)
metadata, err := s.GetEpisodeMetadata(testEpisodeID)
if err != nil {
t.Fatalf("GetEpisodeMetadata failed: %v", err)
}
if metadata == nil {
t.Fatal("GetEpisodeMetadata returned nil metadata")
}
if metadata.Id != testEpisodeID {
t.Errorf("Expected episode ID %s, got %s", testEpisodeID, metadata.Id)
}
if metadata.Name == "" {
t.Error("Episode name should not be empty")
}
if metadata.DurationMinutes <= 0 {
t.Error("Episode duration should be positive")
}
t.Logf("Episode: %s (%d minutes)", metadata.Name, metadata.DurationMinutes)
}
func TestIntegration_SearchShows(t *testing.T) {
s := setupIntegrationClient(t)
result, err := s.SearchShows("technology podcast", 5)
if err != nil {
t.Fatalf("SearchShows failed: %v", err)
}
if result == nil {
t.Fatal("SearchShows returned nil result")
}
if len(result.Shows) == 0 {
t.Error("SearchShows should return at least one result for 'technology podcast'")
}
for i, show := range result.Shows {
t.Logf("Result %d: %s by %s", i+1, show.Name, show.Publisher)
}
}
func TestIntegration_GetShowEpisodes(t *testing.T) {
s := setupIntegrationClient(t)
episodes, err := s.GetShowEpisodes(testShowID, 5)
if err != nil {
t.Fatalf("GetShowEpisodes failed: %v", err)
}
if len(episodes) == 0 {
t.Error("GetShowEpisodes should return at least one episode")
}
for i, ep := range episodes {
t.Logf("Episode %d: %s (%d min)", i+1, ep.Name, ep.DurationMinutes)
}
}
func TestIntegration_GrabMetadataForURL_Show(t *testing.T) {
s := setupIntegrationClient(t)
url := "https://open.spotify.com/show/" + testShowID
metadata, err := s.GrabMetadataForURL(url)
if err != nil {
t.Fatalf("GrabMetadataForURL failed: %v", err)
}
show, ok := metadata.(*ShowMetadata)
if !ok {
t.Fatalf("Expected ShowMetadata, got %T", metadata)
}
if show.Id != testShowID {
t.Errorf("Expected show ID %s, got %s", testShowID, show.Id)
}
}
func TestIntegration_GrabMetadataForURL_Episode(t *testing.T) {
s := setupIntegrationClient(t)
url := "https://open.spotify.com/episode/" + testEpisodeID
metadata, err := s.GrabMetadataForURL(url)
if err != nil {
t.Fatalf("GrabMetadataForURL failed: %v", err)
}
episode, ok := metadata.(*EpisodeMetadata)
if !ok {
t.Fatalf("Expected EpisodeMetadata, got %T", metadata)
}
if episode.Id != testEpisodeID {
t.Errorf("Expected episode ID %s, got %s", testEpisodeID, episode.Id)
}
}
func TestIntegration_FormatMetadataAsText(t *testing.T) {
s := setupIntegrationClient(t)
metadata, err := s.GrabMetadataForURL("https://open.spotify.com/show/" + testShowID)
if err != nil {
t.Fatalf("GrabMetadataForURL failed: %v", err)
}
text := s.FormatMetadataAsText(metadata)
if text == "" {
t.Error("FormatMetadataAsText returned empty string")
}
// Just log the output for manual inspection
t.Logf("Formatted metadata:\n%s", text)
}
func TestIntegration_GetShowMetadata_InvalidID(t *testing.T) {
s := setupIntegrationClient(t)
_, err := s.GetShowMetadata("invalid_show_id_12345")
if err == nil {
t.Error("GetShowMetadata with invalid ID should return an error")
}
t.Logf("Expected error for invalid show ID: %v", err)
}
func TestIntegration_GetEpisodeMetadata_InvalidID(t *testing.T) {
s := setupIntegrationClient(t)
_, err := s.GetEpisodeMetadata("invalid_episode_id_12345")
if err == nil {
t.Error("GetEpisodeMetadata with invalid ID should return an error")
}
t.Logf("Expected error for invalid episode ID: %v", err)
}
func TestIntegration_SearchShows_NoResults(t *testing.T) {
s := setupIntegrationClient(t)
// Search for something extremely unlikely to exist
// cspell:disable-next-line
result, err := s.SearchShows("xyzzy_nonexistent_podcast_12345_zyxwv", 5)
if err != nil {
t.Fatalf("SearchShows failed: %v", err)
}
// Should return empty results, not an error
if result == nil {
t.Fatal("SearchShows returned nil result")
}
// Log warning if we somehow got results for this nonsense query
if len(result.Shows) > 0 {
t.Logf("WARNING: Unexpectedly found %d results for nonsense query (test may need updating)", len(result.Shows))
} else {
t.Log("Search correctly returned 0 results for nonsense query")
}
}

View File

@@ -0,0 +1,306 @@
package spotify
import (
"strings"
"testing"
)
func TestGetShowOrEpisodeId(t *testing.T) {
s := NewSpotify()
tests := []struct {
name string
url string
wantShowId string
wantEpisodeId string
wantError bool
errorMsg string
}{
{
name: "valid show URL",
url: "https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk",
// cspell:disable-next-line
wantShowId: "4rOoJ6Egrf8K2IrywzwOMk",
wantEpisodeId: "",
wantError: false,
},
{
name: "valid episode URL",
url: "https://open.spotify.com/episode/512ojhOuo1ktJprKbVcKyQ",
wantShowId: "",
wantEpisodeId: "512ojhOuo1ktJprKbVcKyQ",
wantError: false,
},
{
name: "show URL with query params",
url: "https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk?si=abc123",
// cspell:disable-next-line
wantShowId: "4rOoJ6Egrf8K2IrywzwOMk",
wantEpisodeId: "",
wantError: false,
},
{
name: "episode URL with query params",
url: "https://open.spotify.com/episode/512ojhOuo1ktJprKbVcKyQ?si=def456",
wantShowId: "",
wantEpisodeId: "512ojhOuo1ktJprKbVcKyQ",
wantError: false,
},
{
name: "invalid URL - no show or episode",
url: "https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC",
wantShowId: "",
wantEpisodeId: "",
wantError: true,
errorMsg: "invalid Spotify URL",
},
{
name: "invalid URL - not spotify",
url: "https://example.com/show/123",
wantShowId: "",
wantEpisodeId: "",
wantError: true,
errorMsg: "invalid Spotify URL",
},
{
name: "empty URL",
url: "",
wantShowId: "",
wantEpisodeId: "",
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
showId, episodeId, err := s.GetShowOrEpisodeId(tt.url)
if tt.wantError {
if err == nil {
t.Errorf("GetShowOrEpisodeId(%q) expected error but got none", tt.url)
return
}
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("GetShowOrEpisodeId(%q) error = %v, want error containing %q", tt.url, err, tt.errorMsg)
}
return
}
if err != nil {
t.Errorf("GetShowOrEpisodeId(%q) unexpected error = %v", tt.url, err)
return
}
if showId != tt.wantShowId {
t.Errorf("GetShowOrEpisodeId(%q) showId = %q, want %q", tt.url, showId, tt.wantShowId)
}
if episodeId != tt.wantEpisodeId {
t.Errorf("GetShowOrEpisodeId(%q) episodeId = %q, want %q", tt.url, episodeId, tt.wantEpisodeId)
}
})
}
}
func TestFormatMetadataAsText_ShowMetadata(t *testing.T) {
s := NewSpotify()
show := &ShowMetadata{
Id: "test123",
Name: "Test Podcast",
Description: "A test podcast description",
Publisher: "Test Publisher",
TotalEpisodes: 100,
Languages: []string{"en", "es"},
MediaType: "audio",
ExternalURL: "https://open.spotify.com/show/test123",
}
result := s.FormatMetadataAsText(show)
// Verify key elements are present
if !strings.Contains(result, "# Spotify Podcast/Show") {
t.Error("FormatMetadataAsText missing header for show")
}
if !strings.Contains(result, "**Title**: Test Podcast") {
t.Error("FormatMetadataAsText missing title")
}
if !strings.Contains(result, "**Publisher**: Test Publisher") {
t.Error("FormatMetadataAsText missing publisher")
}
if !strings.Contains(result, "**Total Episodes**: 100") {
t.Error("FormatMetadataAsText missing total episodes")
}
if !strings.Contains(result, "en, es") {
t.Error("FormatMetadataAsText missing languages")
}
if !strings.Contains(result, "A test podcast description") {
t.Error("FormatMetadataAsText missing description")
}
}
func TestFormatMetadataAsText_EpisodeMetadata(t *testing.T) {
s := NewSpotify()
episode := &EpisodeMetadata{
Id: "ep123",
Name: "Test Episode",
Description: "A test episode description",
ReleaseDate: "2024-01-15",
DurationMs: 3600000,
DurationMinutes: 60,
Language: "en",
Explicit: false,
ExternalURL: "https://open.spotify.com/episode/ep123",
ShowId: "show123",
ShowName: "Test Show",
}
result := s.FormatMetadataAsText(episode)
// Verify key elements are present
if !strings.Contains(result, "# Spotify Episode") {
t.Error("FormatMetadataAsText missing header for episode")
}
if !strings.Contains(result, "**Title**: Test Episode") {
t.Error("FormatMetadataAsText missing title")
}
if !strings.Contains(result, "**Show**: Test Show") {
t.Error("FormatMetadataAsText missing show name")
}
if !strings.Contains(result, "**Release Date**: 2024-01-15") {
t.Error("FormatMetadataAsText missing release date")
}
if !strings.Contains(result, "**Duration**: 60 minutes") {
t.Error("FormatMetadataAsText missing duration")
}
if !strings.Contains(result, "A test episode description") {
t.Error("FormatMetadataAsText missing description")
}
}
func TestFormatMetadataAsText_SearchResult(t *testing.T) {
s := NewSpotify()
searchResult := &SearchResult{
Shows: []ShowMetadata{
{
Id: "show1",
Name: "First Show",
Description: "First show description",
Publisher: "Publisher One",
TotalEpisodes: 50,
ExternalURL: "https://open.spotify.com/show/show1",
},
{
Id: "show2",
Name: "Second Show",
Description: "Second show description",
Publisher: "Publisher Two",
TotalEpisodes: 25,
ExternalURL: "https://open.spotify.com/show/show2",
},
},
}
result := s.FormatMetadataAsText(searchResult)
// Verify key elements are present
if !strings.Contains(result, "# Spotify Search Results") {
t.Error("FormatMetadataAsText missing header for search results")
}
if !strings.Contains(result, "## 1. First Show") {
t.Error("FormatMetadataAsText missing first show")
}
if !strings.Contains(result, "## 2. Second Show") {
t.Error("FormatMetadataAsText missing second show")
}
if !strings.Contains(result, "**Publisher**: Publisher One") {
t.Error("FormatMetadataAsText missing publisher for first show")
}
if !strings.Contains(result, "**Episodes**: 50") {
t.Error("FormatMetadataAsText missing episode count")
}
}
func TestFormatMetadataAsText_NilAndUnknownTypes(t *testing.T) {
s := NewSpotify()
// Test with nil
result := s.FormatMetadataAsText(nil)
if result != "" {
t.Errorf("FormatMetadataAsText(nil) should return empty string, got %q", result)
}
// Test with unknown type
result = s.FormatMetadataAsText("unexpected string type")
if result != "" {
t.Errorf("FormatMetadataAsText(string) should return empty string, got %q", result)
}
// Test with another unknown type
result = s.FormatMetadataAsText(12345)
if result != "" {
t.Errorf("FormatMetadataAsText(int) should return empty string, got %q", result)
}
}
func TestNewSpotify(t *testing.T) {
s := NewSpotify()
if s == nil {
t.Fatal("NewSpotify() returned nil")
}
if s.PluginBase == nil {
t.Error("NewSpotify() PluginBase is nil")
}
if s.ClientId == nil {
t.Error("NewSpotify() ClientId is nil")
}
if s.ClientSecret == nil {
t.Error("NewSpotify() ClientSecret is nil")
}
}
func TestSpotify_IsConfigured(t *testing.T) {
s := NewSpotify()
// Since ClientId and ClientSecret are optional (not required),
// IsConfigured() returns true even when empty
// This is by design - Spotify is an optional plugin
if !s.IsConfigured() {
t.Error("NewSpotify() should be configured (optional settings are valid when empty)")
}
// Set credentials - should still be configured
s.ClientId.Value = "test_client_id"
s.ClientSecret.Value = "test_client_secret"
if !s.IsConfigured() {
t.Error("Spotify should be configured after setting credentials")
}
}
func TestSpotify_HasCredentials(t *testing.T) {
s := NewSpotify()
// Without credentials, attempting to use the API should fail
// This tests the actual validation in refreshAccessToken
if s.ClientId.Value != "" || s.ClientSecret.Value != "" {
t.Error("NewSpotify() should have empty credentials initially")
}
// Set credentials
s.ClientId.Value = "test_client_id"
s.ClientSecret.Value = "test_client_secret"
if s.ClientId.Value != "test_client_id" {
t.Error("ClientId should be set")
}
if s.ClientSecret.Value != "test_client_secret" {
t.Error("ClientSecret should be set")
}
}

View File

@@ -1 +1 @@
"1.4.385"
"1.4.386"