podcast-go

TUI podcast downloader for Apple Podcasts
Log | Files | Refs | README | LICENSE

commit f22847b91a15537d4724f9fcf1bb156bed1dce83
parent 56afb3fd19985927b299e08866fbe7d843fcc370
Author: Erik Loualiche <[email protected]>
Date:   Sun, 11 Jan 2026 14:03:30 -0600

Add Podcast Index integration for broader podcast coverage

- Add --index flag to choose search provider (apple or podcastindex)
- Implement Podcast Index API authentication (SHA1-based)
- Add loadPodcastFromFeed for direct RSS loading
- Update README with setup instructions and troubleshooting

Podcast Index provides access to 4M+ podcasts including many not
indexed by Apple (e.g., Radio France, European podcasts).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

Diffstat:
MREADME.md | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mmain.go | 270++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 315 insertions(+), 37 deletions(-)

diff --git a/README.md b/README.md @@ -12,6 +12,7 @@ ## Features - **Search by name**: Search for any podcast by name using Apple's podcast directory +- **Podcast Index support**: Search podcasts not in Apple's index (e.g., Radio France, European podcasts) - **Lookup by ID**: Direct lookup using Apple Podcast ID for faster access - **Interactive selection**: Browse and select specific episodes to download - **Batch downloads**: Select multiple episodes at once with visual progress tracking @@ -66,7 +67,7 @@ sudo mv podcastdownload /usr/local/bin/ ### Basic Commands ```bash -# Search for a podcast by name +# Search for a podcast by name (uses Apple Podcasts by default) ./podcastdownload "the daily" # Search with multiple words @@ -74,6 +75,45 @@ sudo mv podcastdownload /usr/local/bin/ # Lookup by Apple Podcast ID (faster, no search step) ./podcastdownload 1200361736 + +# Specify output directory +./podcastdownload -o ~/Music "the daily" +``` + +### Using Podcast Index + +Some podcasts (like Radio France, many European podcasts) are not indexed by Apple Podcasts. You can search these using [Podcast Index](https://podcastindex.org/), an open podcast directory with over 4 million podcasts. + +#### Setup + +1. Get free API credentials at https://api.podcastindex.org (instant, no approval needed) + +2. Set environment variables (**use single quotes** to preserve special characters): + +```bash +export PODCASTINDEX_API_KEY='your_api_key' +export PODCASTINDEX_API_SECRET='your_api_secret' +``` + +> **Important**: Many API secrets contain `$` characters. Using double quotes will cause the shell to interpret `$` as a variable, breaking authentication. Always use single quotes. + +3. Add to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to persist: + +```bash +# Podcast Index API credentials +export PODCASTINDEX_API_KEY='your_api_key' +export PODCASTINDEX_API_SECRET='your_api_secret' +``` + +#### Usage + +```bash +# Search Podcast Index +./podcastdownload --index podcastindex "france inter" +./podcastdownload --index pi "radio france" # shorthand + +# Search for European podcasts not on Apple +./podcastdownload --index podcastindex "arte radio" ``` ### Finding a Podcast ID @@ -212,11 +252,18 @@ Or use `just --list` to see all available commands. ## How It Works -1. **Search/Lookup**: Uses Apple's iTunes Search API to find podcasts +1. **Search/Lookup**: Uses Apple's iTunes Search API or Podcast Index API to find podcasts 2. **Feed Parsing**: Fetches and parses the podcast's RSS feed using gofeed 3. **Download**: Downloads MP3 files from the enclosure URLs in the RSS feed 4. **Tagging**: Writes ID3v2 tags to each downloaded file +### Search Providers + +| Provider | Flag | Coverage | Notes | +|----------|------|----------|-------| +| Apple Podcasts | `--index apple` (default) | Large, US-centric | No API key needed | +| Podcast Index | `--index podcastindex` | 4M+ podcasts, open | Free API key required | + ## Troubleshooting ### "No RSS feed URL found" @@ -231,6 +278,37 @@ The podcast's RSS feed doesn't contain audio enclosures, or uses a format not re Some podcast CDNs may be slow. The progress bar updates every 1% of download progress. For large files on slow connections, this may take a moment. +### Podcast Index: "Authorization header doesn't match" + +This usually means your API secret contains special characters that got mangled. Check: + +1. **Use single quotes** when setting environment variables: + ```bash + # Wrong - $ gets interpreted as variable + export PODCASTINDEX_API_SECRET="secret$with$dollars" + + # Correct - single quotes preserve literal value + export PODCASTINDEX_API_SECRET='secret$with$dollars' + ``` + +2. **Verify your credentials** are set correctly: + ```bash + echo "Key: [$PODCASTINDEX_API_KEY]" + echo "Secret: [$PODCASTINDEX_API_SECRET]" + ``` + +3. **Check for trailing whitespace** - copy credentials carefully from the email. + +### Podcast Index: "API credentials not set" + +Set the environment variables before running: +```bash +export PODCASTINDEX_API_KEY='your_key' +export PODCASTINDEX_API_SECRET='your_secret' +``` + +Get free credentials at https://api.podcastindex.org + ## License MIT diff --git a/main.go b/main.go @@ -1,11 +1,14 @@ package main import ( + "crypto/sha1" + "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -76,6 +79,7 @@ type SearchResult struct { Artist string FeedURL string ArtworkURL string + Source SearchProvider // which index this result came from } // Episode holds episode data from RSS feed @@ -102,6 +106,28 @@ type iTunesResponse struct { } `json:"results"` } +// podcastIndexResponse represents Podcast Index API search response +type podcastIndexResponse struct { + Status string `json:"status"` + Feeds []struct { + ID int `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + URL string `json:"url"` + Image string `json:"image"` + Description string `json:"description"` + } `json:"feeds"` + Count int `json:"count"` +} + +// SearchProvider indicates which podcast index to use +type SearchProvider string + +const ( + ProviderApple SearchProvider = "apple" + ProviderPodcastIndex SearchProvider = "podcastindex" +) + // App states type state int @@ -116,25 +142,26 @@ const ( // Model is our Bubble Tea model type model struct { - state state - podcastID string - searchQuery string - searchResults []SearchResult - podcastInfo PodcastInfo - episodes []Episode - cursor int - offset int - windowHeight int - spinner spinner.Model - progress progress.Model - loadingMsg string - errorMsg string - downloadIndex int - downloadTotal int - outputDir string - baseDir string - downloaded []string - percent float64 + state state + podcastID string + searchQuery string + searchResults []SearchResult + podcastInfo PodcastInfo + episodes []Episode + cursor int + offset int + windowHeight int + spinner spinner.Model + progress progress.Model + loadingMsg string + errorMsg string + downloadIndex int + downloadTotal int + outputDir string + baseDir string + downloaded []string + percent float64 + searchProvider SearchProvider } // Messages @@ -173,7 +200,7 @@ func isNumeric(s string) bool { return len(s) > 0 } -func initialModel(input string, baseDir string) model { +func initialModel(input string, baseDir string, provider SearchProvider) model { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) @@ -183,11 +210,12 @@ func initialModel(input string, baseDir string) model { isID := isNumeric(input) m := model{ - state: stateLoading, - spinner: s, - progress: p, - windowHeight: 24, - baseDir: baseDir, + state: stateLoading, + spinner: s, + progress: p, + windowHeight: 24, + baseDir: baseDir, + searchProvider: provider, } if isID { @@ -195,7 +223,11 @@ func initialModel(input string, baseDir string) model { m.loadingMsg = "Looking up podcast..." } else { m.searchQuery = input - m.loadingMsg = "Searching podcasts..." + providerName := "Apple Podcasts" + if provider == ProviderPodcastIndex { + providerName = "Podcast Index" + } + m.loadingMsg = fmt.Sprintf("Searching %s...", providerName) } return m @@ -203,9 +235,15 @@ func initialModel(input string, baseDir string) model { func (m model) Init() tea.Cmd { if m.searchQuery != "" { + var searchCmd tea.Cmd + if m.searchProvider == ProviderPodcastIndex { + searchCmd = searchPodcastIndex(m.searchQuery) + } else { + searchCmd = searchPodcasts(m.searchQuery) + } return tea.Batch( m.spinner.Tick, - searchPodcasts(m.searchQuery), + searchCmd, ) } return tea.Batch( @@ -256,6 +294,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case selectSearchResultMsg: m.state = stateLoading m.loadingMsg = fmt.Sprintf("Loading %s...", msg.result.Name) + if msg.result.Source == ProviderPodcastIndex { + // Load directly from RSS feed URL for Podcast Index results + return m, loadPodcastFromFeed(msg.result.FeedURL, msg.result.Name, msg.result.Artist, msg.result.ArtworkURL) + } m.podcastID = msg.result.ID return m, loadPodcast(msg.result.ID) @@ -876,6 +918,76 @@ func searchPodcasts(query string) tea.Cmd { Artist: r.ArtistName, FeedURL: r.FeedURL, ArtworkURL: r.ArtworkURL600, + Source: ProviderApple, + }) + } + + return searchResultsMsg{results: results} + } +} + +// searchPodcastIndex searches using Podcast Index API +func searchPodcastIndex(query string) tea.Cmd { + return func() tea.Msg { + apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY")) + apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET")) + + if apiKey == "" || apiSecret == "" { + return errorMsg{err: fmt.Errorf("Podcast Index API credentials not set.\nSet PODCASTINDEX_API_KEY and PODCASTINDEX_API_SECRET environment variables.\nGet free API keys at: https://api.podcastindex.org")} + } + + // Build authentication headers (hash = sha1(apiKey + apiSecret + unixTime)) + apiHeaderTime := strconv.FormatInt(time.Now().Unix(), 10) + hashInput := apiKey + apiSecret + apiHeaderTime + h := sha1.New() + h.Write([]byte(hashInput)) + authHash := hex.EncodeToString(h.Sum(nil)) + + // URL encode the query + encodedQuery := url.QueryEscape(query) + apiURL := fmt.Sprintf("https://api.podcastindex.org/api/1.0/search/byterm?q=%s&max=25", encodedQuery) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return errorMsg{err: fmt.Errorf("failed to create request: %w", err)} + } + + // Set required headers + req.Header.Set("User-Agent", "PodcastDownload/1.0") + req.Header.Set("X-Auth-Key", apiKey) + req.Header.Set("X-Auth-Date", apiHeaderTime) + req.Header.Set("Authorization", authHash) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return errorMsg{err: fmt.Errorf("failed to search Podcast Index: %w", err)} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return errorMsg{err: fmt.Errorf("Podcast Index API error (%d): %s", resp.StatusCode, string(body))} + } + + var result podcastIndexResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return errorMsg{err: fmt.Errorf("failed to parse search results: %w", err)} + } + + var results []SearchResult + for _, feed := range result.Feeds { + if feed.URL == "" { + continue + } + + results = append(results, SearchResult{ + ID: strconv.Itoa(feed.ID), + Name: feed.Title, + Artist: feed.Author, + FeedURL: feed.URL, + ArtworkURL: feed.Image, + Source: ProviderPodcastIndex, }) } @@ -883,22 +995,110 @@ func searchPodcasts(query string) tea.Cmd { } } +// loadPodcastFromFeed loads a podcast directly from its RSS feed URL +func loadPodcastFromFeed(feedURL, name, artist, artworkURL string) tea.Cmd { + return func() tea.Msg { + info := PodcastInfo{ + Name: name, + Artist: artist, + FeedURL: feedURL, + ArtworkURL: artworkURL, + } + + // Parse RSS feed + fp := gofeed.NewParser() + feed, err := fp.ParseURL(feedURL) + if err != nil { + return errorMsg{err: fmt.Errorf("failed to parse RSS feed: %w", err)} + } + + // Use feed title/author if not provided + if info.Name == "" && feed.Title != "" { + info.Name = feed.Title + } + if info.Artist == "" && feed.Author != nil { + info.Artist = feed.Author.Name + } + if info.ArtworkURL == "" && feed.Image != nil { + info.ArtworkURL = feed.Image.URL + } + + var episodes []Episode + for i, item := range feed.Items { + audioURL := "" + + // Find audio enclosure + for _, enc := range item.Enclosures { + if strings.Contains(enc.Type, "audio") || strings.HasSuffix(enc.URL, ".mp3") { + audioURL = enc.URL + break + } + } + + if audioURL == "" { + continue + } + + var pubDate time.Time + if item.PublishedParsed != nil { + pubDate = *item.PublishedParsed + } + + duration := "" + if item.ITunesExt != nil { + duration = item.ITunesExt.Duration + } + + episodes = append(episodes, Episode{ + Index: i + 1, + Title: item.Title, + Description: item.Description, + AudioURL: audioURL, + PubDate: pubDate, + Duration: duration, + }) + } + + if len(episodes) == 0 { + return errorMsg{err: fmt.Errorf("no downloadable episodes found")} + } + + return podcastLoadedMsg{info: info, episodes: episodes} + } +} + func main() { - // Define the -o flag. Defaults to "." (current directory) + // Define flags baseDir := flag.String("o", ".", "Base directory where the podcast folder will be created") - + indexFlag := flag.String("index", "apple", "Search provider: 'apple' (default) or 'podcastindex'") + // Custom usage message flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: %s [flags] <podcast_id_or_search_query>\n\n", os.Args[0]) - fmt.Println("Flags:") + fmt.Fprintln(os.Stderr, "Flags:") flag.PrintDefaults() - fmt.Println("\nExamples:") - fmt.Println(" podcastdownload -o ~/Music \"the daily\"") - fmt.Println(" podcastdownload 1200361736") + fmt.Fprintln(os.Stderr, "\nExamples:") + fmt.Fprintln(os.Stderr, " podcastdownload -o ~/Music \"the daily\"") + fmt.Fprintln(os.Stderr, " podcastdownload 1200361736") + fmt.Fprintln(os.Stderr, " podcastdownload --index podcastindex \"france inter\"") + fmt.Fprintln(os.Stderr, "\nPodcast Index:") + fmt.Fprintln(os.Stderr, " To use Podcast Index, set these environment variables:") + fmt.Fprintln(os.Stderr, " PODCASTINDEX_API_KEY=your_key") + fmt.Fprintln(os.Stderr, " PODCASTINDEX_API_SECRET=your_secret") + fmt.Fprintln(os.Stderr, " Get free API keys at: https://api.podcastindex.org") } flag.Parse() + // Parse the index flag + var provider SearchProvider + switch strings.ToLower(*indexFlag) { + case "podcastindex", "pi": + provider = ProviderPodcastIndex + default: + provider = ProviderApple + } + // Check if we have arguments left after parsing flags (the search query) if flag.NArg() < 1 { flag.Usage() @@ -908,8 +1108,8 @@ func main() { // Join remaining arguments to form the search query input := strings.Join(flag.Args(), " ") - // Pass the baseDir to initialModel - program = tea.NewProgram(initialModel(input, *baseDir), tea.WithAltScreen()) + // Pass the baseDir and provider to initialModel + program = tea.NewProgram(initialModel(input, *baseDir, provider), tea.WithAltScreen()) if _, err := program.Run(); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1)