podcast-go

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

commit 7c7bdb8bf3a4ff34436bd4122cac0f488cb28546
parent f22847b91a15537d4724f9fcf1bb156bed1dce83
Author: Erik Loualiche <eloualic@umn.edu>
Date:   Sun, 11 Jan 2026 14:57:21 -0600

Add navigation, preview, and unified search features

- Add back navigation (esc/b) from episode selection and download screens
- Add metadata preview (v key) for podcasts and episodes
- Add unified search: automatically searches both Apple and Podcast Index
  when credentials are configured
- Deduplicate results that appear in both indexes
- Update keyboard controls and documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
MREADME.md | 32+++++++++++++++++++++++++-------
Mmain.go | 316++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 337 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md @@ -13,8 +13,11 @@ - **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) +- **Unified search**: Automatically searches both Apple and Podcast Index when credentials are configured (with deduplication) - **Lookup by ID**: Direct lookup using Apple Podcast ID for faster access - **Interactive selection**: Browse and select specific episodes to download +- **Preview metadata**: View detailed podcast/episode metadata before downloading +- **Back navigation**: Navigate back through screens without restarting - **Batch downloads**: Select multiple episodes at once with visual progress tracking - **ID3 tagging**: Automatically writes ID3v2 tags (title, artist, album, track number) - **Smart file naming**: Episodes are saved with track numbers for proper ordering @@ -107,13 +110,18 @@ export PODCASTINDEX_API_SECRET='your_api_secret' #### Usage +When Podcast Index credentials are configured, searches automatically query **both** Apple and Podcast Index, with duplicate results removed: + ```bash -# Search Podcast Index +# Unified search (searches both Apple + Podcast Index automatically) +./podcastdownload "france inter" + +# Force search only 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" +# Force search only Apple +./podcastdownload --index apple "the daily" ``` ### Finding a Podcast ID @@ -162,7 +170,7 @@ by The New York Times • 2847 episodes Showing 1-20 of 2847 • 2 selected - ↑/↓ navigate • space select • a toggle all • enter download • q quit + ↑/↓ navigate • space select • a toggle all • v preview • enter download • esc/b back • q quit ``` ### 3. Download @@ -206,6 +214,7 @@ Each file includes ID3 tags: | `↑` / `k` | Move cursor up | | `↓` / `j` | Move cursor down | | `Enter` | Select podcast | +| `v` | Preview podcast metadata | | `q` / `Ctrl+C` | Quit | ### Episode Selection Screen @@ -218,15 +227,24 @@ Each file includes ID3 tags: | `a` | Select/deselect all episodes | | `PgUp` | Page up | | `PgDn` | Page down | +| `v` | Preview episode metadata | | `Enter` | Start downloading selected | +| `Esc` / `b` | Go back to search results | | `q` / `Ctrl+C` | Quit | -### Download/Complete Screen +### Download Screen + +| Key | Action | +|-----|--------| +| `Esc` / `b` | Go back to episode selection | +| `q` / `Ctrl+C` | Cancel and quit | + +### Complete/Error Screen | Key | Action | |-----|--------| -| `Enter` / `q` | Exit (when complete) | -| `Ctrl+C` | Cancel download | +| `Enter` / `q` | Exit | +| `Ctrl+C` | Exit | ## Build Commands diff --git a/main.go b/main.go @@ -14,6 +14,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/bogem/id3v2" @@ -134,7 +135,9 @@ type state int const ( stateLoading state = iota stateSearchResults + statePreviewPodcast stateSelecting + statePreviewEpisode stateDownloading stateDone stateError @@ -223,9 +226,13 @@ func initialModel(input string, baseDir string, provider SearchProvider) model { m.loadingMsg = "Looking up podcast..." } else { m.searchQuery = input - providerName := "Apple Podcasts" + var providerName string if provider == ProviderPodcastIndex { providerName = "Podcast Index" + } else if hasPodcastIndexCredentials() { + providerName = "Apple + Podcast Index" + } else { + providerName = "Apple Podcasts" } m.loadingMsg = fmt.Sprintf("Searching %s...", providerName) } @@ -236,7 +243,10 @@ func initialModel(input string, baseDir string, provider SearchProvider) model { func (m model) Init() tea.Cmd { if m.searchQuery != "" { var searchCmd tea.Cmd - if m.searchProvider == ProviderPodcastIndex { + // If credentials are available and no specific provider was forced, search both + if hasPodcastIndexCredentials() && m.searchProvider == ProviderApple { + searchCmd = searchBoth(m.searchQuery) + } else if m.searchProvider == ProviderPodcastIndex { searchCmd = searchPodcastIndex(m.searchQuery) } else { searchCmd = searchPodcasts(m.searchQuery) @@ -258,8 +268,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case stateSearchResults: return m.handleSearchResultsKeys(msg) + case statePreviewPodcast: + if msg.String() == "esc" || msg.String() == "b" || msg.String() == "v" { + m.state = stateSearchResults + return m, nil + } + if msg.String() == "ctrl+c" || msg.String() == "q" { + return m, tea.Quit + } case stateSelecting: return m.handleSelectionKeys(msg) + case statePreviewEpisode: + if msg.String() == "esc" || msg.String() == "b" || msg.String() == "v" { + m.state = stateSelecting + return m, nil + } + if msg.String() == "ctrl+c" || msg.String() == "q" { + return m, tea.Quit + } + case stateDownloading: + if msg.String() == "esc" || msg.String() == "b" { + // Go back to episode selection + m.state = stateSelecting + m.downloadIndex = 0 + m.downloadTotal = 0 + m.percent = 0 + m.downloaded = nil + return m, nil + } + if msg.String() == "ctrl+c" || msg.String() == "q" { + return m, tea.Quit + } case stateDone, stateError: if msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == "enter" { return m, tea.Quit @@ -372,6 +411,12 @@ func (m model) handleSearchResultsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { result := m.searchResults[m.cursor] return m, func() tea.Msg { return selectSearchResultMsg{result: result} } } + + case "v": + if m.cursor < len(m.searchResults) { + m.state = statePreviewPodcast + return m, nil + } } return m, nil @@ -387,6 +432,17 @@ func (m model) handleSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+c", "q": return m, tea.Quit + case "esc", "b": + // Go back to search results if available + if len(m.searchResults) > 0 { + m.state = stateSearchResults + m.cursor = 0 + m.offset = 0 + return m, nil + } + // If no search results (direct podcast ID), quit + return m, tea.Quit + case "up", "k": if m.cursor > 0 { m.cursor-- @@ -445,6 +501,12 @@ func (m model) handleSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { os.MkdirAll(m.outputDir, 0755) return m, func() tea.Msg { return startDownloadMsg{} } } + + case "v": + if m.cursor < len(m.episodes) { + m.state = statePreviewEpisode + return m, nil + } } return m, nil @@ -493,8 +555,12 @@ func (m model) View() string { return m.viewLoading() case stateSearchResults: return m.viewSearchResults() + case statePreviewPodcast: + return m.viewPreviewPodcast() case stateSelecting: return m.viewSelecting() + case statePreviewEpisode: + return m.viewPreviewEpisode() case stateDownloading: return m.viewDownloading() case stateDone: @@ -566,7 +632,37 @@ func (m model) viewSearchResults() string { } // Help - b.WriteString(helpStyle.Render("\n\n ↑/↓ navigate • enter select • q quit")) + b.WriteString(helpStyle.Render("\n\n ↑/↓ navigate • enter select • v preview • q quit")) + + return b.String() +} + +func (m model) viewPreviewPodcast() string { + var b strings.Builder + + if m.cursor >= len(m.searchResults) { + return "" + } + result := m.searchResults[m.cursor] + + b.WriteString("\n") + b.WriteString(titleStyle.Render("Podcast Details")) + b.WriteString("\n\n") + + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Name:"), result.Name)) + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Artist:"), result.Artist)) + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Source:"), string(result.Source))) + if result.ID != "" { + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("ID:"), result.ID)) + } + if result.FeedURL != "" { + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Feed URL:"), result.FeedURL)) + } + if result.ArtworkURL != "" { + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Artwork:"), result.ArtworkURL)) + } + + b.WriteString(helpStyle.Render("\n\n esc/b/v back • q quit")) return b.String() } @@ -651,7 +747,64 @@ func (m model) viewSelecting() string { b.WriteString(dimStyle.Render(fmt.Sprintf(" • %d selected", selectedCount))) // Help - b.WriteString(helpStyle.Render("\n\n ↑/↓ navigate • space select • a toggle all • enter download • q quit")) + b.WriteString(helpStyle.Render("\n\n ↑/↓ navigate • space select • a toggle all • v preview • enter download • esc/b back • q quit")) + + return b.String() +} + +func (m model) viewPreviewEpisode() string { + var b strings.Builder + + if m.cursor >= len(m.episodes) { + return "" + } + ep := m.episodes[m.cursor] + + b.WriteString("\n") + b.WriteString(titleStyle.Render("Episode Details")) + b.WriteString("\n\n") + + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Title:"), ep.Title)) + b.WriteString(fmt.Sprintf(" %s %d\n", subtitleStyle.Render("Episode #:"), ep.Index)) + if !ep.PubDate.IsZero() { + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Published:"), ep.PubDate.Format("January 2, 2006"))) + } + if ep.Duration != "" { + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Duration:"), ep.Duration)) + } + if ep.AudioURL != "" { + b.WriteString(fmt.Sprintf(" %s %s\n", subtitleStyle.Render("Audio URL:"), ep.AudioURL)) + } + + // Description with word wrap + if ep.Description != "" { + b.WriteString(fmt.Sprintf("\n %s\n", subtitleStyle.Render("Description:"))) + desc := ep.Description + // Limit description length for display + if len(desc) > 500 { + desc = desc[:497] + "..." + } + // Simple word wrap at ~70 chars + words := strings.Fields(desc) + line := " " + for _, word := range words { + if len(line)+len(word)+1 > 72 { + b.WriteString(line + "\n") + line = " " + word + } else { + if line == " " { + line += word + } else { + line += " " + word + } + } + } + if line != " " { + b.WriteString(line + "\n") + } + } + + b.WriteString(helpStyle.Render("\n\n esc/b/v back • q quit")) return b.String() } @@ -679,6 +832,8 @@ func (m model) viewDownloading() string { b.WriteString(dimStyle.Render(fmt.Sprintf("\n ✓ %d completed", len(m.downloaded)))) } + b.WriteString(helpStyle.Render("\n\n esc/b back • q quit")) + return b.String() } @@ -995,6 +1150,159 @@ func searchPodcastIndex(query string) tea.Cmd { } } +// hasPodcastIndexCredentials checks if Podcast Index API credentials are set +func hasPodcastIndexCredentials() bool { + apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY")) + apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET")) + return apiKey != "" && apiSecret != "" +} + +// searchAppleResults performs Apple search and returns results directly (for use in combined search) +func searchAppleResults(query string) ([]SearchResult, error) { + encodedQuery := strings.ReplaceAll(query, " ", "+") + url := fmt.Sprintf("https://itunes.apple.com/search?term=%s&media=podcast&limit=25", encodedQuery) + + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result iTunesResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + var results []SearchResult + for _, r := range result.Results { + if r.FeedURL == "" { + continue + } + results = append(results, SearchResult{ + ID: strconv.Itoa(r.CollectionID), + Name: r.CollectionName, + Artist: r.ArtistName, + FeedURL: r.FeedURL, + ArtworkURL: r.ArtworkURL600, + Source: ProviderApple, + }) + } + return results, nil +} + +// searchPodcastIndexResults performs Podcast Index search and returns results directly (for use in combined search) +func searchPodcastIndexResults(query string) ([]SearchResult, error) { + apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY")) + apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET")) + + apiHeaderTime := strconv.FormatInt(time.Now().Unix(), 10) + hashInput := apiKey + apiSecret + apiHeaderTime + h := sha1.New() + h.Write([]byte(hashInput)) + authHash := hex.EncodeToString(h.Sum(nil)) + + 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 nil, err + } + + 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 nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) + } + + var result podcastIndexResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, 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, + }) + } + return results, nil +} + +// searchBoth searches both Apple and Podcast Index APIs concurrently and combines results +func searchBoth(query string) tea.Cmd { + return func() tea.Msg { + var wg sync.WaitGroup + var appleResults, piResults []SearchResult + var appleErr, piErr error + + wg.Add(2) + + // Search Apple + go func() { + defer wg.Done() + appleResults, appleErr = searchAppleResults(query) + }() + + // Search Podcast Index + go func() { + defer wg.Done() + piResults, piErr = searchPodcastIndexResults(query) + }() + + wg.Wait() + + // If both failed, return error + if appleErr != nil && piErr != nil { + return errorMsg{err: fmt.Errorf("search failed: Apple: %v, Podcast Index: %v", appleErr, piErr)} + } + + // Combine results - Apple first, then Podcast Index (deduplicated by feed URL) + var combined []SearchResult + seenFeedURLs := make(map[string]bool) + + if appleErr == nil { + for _, r := range appleResults { + normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/")) + if !seenFeedURLs[normalizedURL] { + seenFeedURLs[normalizedURL] = true + combined = append(combined, r) + } + } + } + if piErr == nil { + for _, r := range piResults { + normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/")) + if !seenFeedURLs[normalizedURL] { + seenFeedURLs[normalizedURL] = true + combined = append(combined, r) + } + } + } + + return searchResultsMsg{results: combined} + } +} + // loadPodcastFromFeed loads a podcast directly from its RSS feed URL func loadPodcastFromFeed(feedURL, name, artist, artworkURL string) tea.Cmd { return func() tea.Msg {