diff --git a/.vscode/settings.json b/.vscode/settings.json index 70dcdd85..efbd1593 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -252,5 +252,8 @@ }, "[json]": { "editor.formatOnSave": false + }, + "gopls": { + "build.buildFlags": ["-tags=integration"] } } diff --git a/completions/_fabric b/completions/_fabric index c38167c9..045e3eba 100644 --- a/completions/_fabric +++ b/completions/_fabric @@ -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:' } diff --git a/completions/fabric.bash b/completions/fabric.bash index 83feef04..47d864b9 100644 --- a/completions/fabric.bash +++ b/completions/fabric.bash @@ -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 diff --git a/completions/fabric.fish b/completions/fabric.fish index c7837f8b..492b4bc8 100755 --- a/completions/fabric.fish +++ b/completions/fabric.fish @@ -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 diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 274c856c..a3762834 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -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"` diff --git a/internal/cli/tools.go b/internal/cli/tools.go index 7df377a4..c9ab2c05 100644 --- a/internal/cli/tools.go +++ b/internal/cli/tools.go @@ -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 } diff --git a/internal/core/plugin_registry.go b/internal/core/plugin_registry.go index 9bc702c8..40f8004c 100644 --- a/internal/core/plugin_registry.go +++ b/internal/core/plugin_registry.go @@ -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 } diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index fcc0103e..b29864f9 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -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", diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 28b54125..5ba624af 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -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'", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 08c1386e..6afd6ec8 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -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.", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index ed297ee4..8bc765b9 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -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'", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 4896e9a1..d53d3797 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -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", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index c3d83f32..b1319cd3 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -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'", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index 09b624a9..f21afb96 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -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'", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index 387962a4..1a5a3b1d 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -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'", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 873a650d..b21ca8d3 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -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 是播放列表,而不是视频", diff --git a/internal/tools/spotify/spotify.go b/internal/tools/spotify/spotify.go new file mode 100644 index 00000000..15aba08e --- /dev/null +++ b/internal/tools/spotify/spotify.go @@ -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() +} diff --git a/internal/tools/spotify/spotify_integration_test.go b/internal/tools/spotify/spotify_integration_test.go new file mode 100644 index 00000000..c10e86de --- /dev/null +++ b/internal/tools/spotify/spotify_integration_test.go @@ -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") + } +} diff --git a/internal/tools/spotify/spotify_test.go b/internal/tools/spotify/spotify_test.go new file mode 100644 index 00000000..d0f900f7 --- /dev/null +++ b/internal/tools/spotify/spotify_test.go @@ -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") + } +}