mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-21 04:07:59 -05:00
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
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -252,5 +252,8 @@
|
||||
},
|
||||
"[json]": {
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"gopls": {
|
||||
"build.buildFlags": ["-tags=integration"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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 是播放列表,而不是视频",
|
||||
|
||||
524
internal/tools/spotify/spotify.go
Normal file
524
internal/tools/spotify/spotify.go
Normal 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()
|
||||
}
|
||||
238
internal/tools/spotify/spotify_integration_test.go
Normal file
238
internal/tools/spotify/spotify_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
306
internal/tools/spotify/spotify_test.go
Normal file
306
internal/tools/spotify/spotify_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user