podcast-go

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

main.go (33843B)


      1 package main
      2 
      3 import (
      4 	"crypto/sha1"
      5 	"encoding/hex"
      6 	"encoding/json"
      7 	"flag"
      8 	"fmt"
      9 	"io"
     10 	"net/http"
     11 	"net/url"
     12 	"os"
     13 	"path/filepath"
     14 	"strconv"
     15 	"strings"
     16 	"sync"
     17 	"time"
     18 
     19 	"podcastdownload/internal/podcast"
     20 
     21 	"github.com/bogem/id3v2"
     22 	"github.com/charmbracelet/bubbles/progress"
     23 	"github.com/charmbracelet/bubbles/spinner"
     24 	tea "github.com/charmbracelet/bubbletea"
     25 	"github.com/charmbracelet/lipgloss"
     26 	"github.com/mmcdole/gofeed"
     27 )
     28 
     29 // Global program reference for sending messages from goroutines
     30 var program *tea.Program
     31 
     32 // Styles
     33 var (
     34 	titleStyle = lipgloss.NewStyle().
     35 			Bold(true).
     36 			Foreground(lipgloss.Color("205")).
     37 			MarginBottom(1)
     38 
     39 	subtitleStyle = lipgloss.NewStyle().
     40 			Foreground(lipgloss.Color("240"))
     41 
     42 	selectedStyle = lipgloss.NewStyle().
     43 			Foreground(lipgloss.Color("205")).
     44 			Bold(true)
     45 
     46 	normalStyle = lipgloss.NewStyle().
     47 			Foreground(lipgloss.Color("252"))
     48 
     49 	dimStyle = lipgloss.NewStyle().
     50 			Foreground(lipgloss.Color("240"))
     51 
     52 	checkboxStyle = lipgloss.NewStyle().
     53 			Foreground(lipgloss.Color("205"))
     54 
     55 	helpStyle = lipgloss.NewStyle().
     56 			Foreground(lipgloss.Color("241")).
     57 			MarginTop(1)
     58 
     59 	errorStyle = lipgloss.NewStyle().
     60 			Foreground(lipgloss.Color("196")).
     61 			Bold(true)
     62 
     63 	successStyle = lipgloss.NewStyle().
     64 			Foreground(lipgloss.Color("82")).
     65 			Bold(true)
     66 )
     67 
     68 // PodcastInfo holds metadata from Apple's API
     69 type PodcastInfo struct {
     70 	Name       string
     71 	Artist     string
     72 	FeedURL    string
     73 	ArtworkURL string
     74 	ID         string
     75 }
     76 
     77 // SearchResult holds a podcast from search results
     78 type SearchResult struct {
     79 	ID         string
     80 	Name       string
     81 	Artist     string
     82 	FeedURL    string
     83 	ArtworkURL string
     84 	Source     SearchProvider // which index this result came from
     85 }
     86 
     87 // Episode holds episode data from RSS feed
     88 type Episode struct {
     89 	Index       int
     90 	Title       string
     91 	Description string
     92 	AudioURL    string
     93 	PubDate     time.Time
     94 	Duration    string
     95 	Selected    bool
     96 }
     97 
     98 // iTunesResponse represents Apple's lookup API response
     99 type iTunesResponse struct {
    100 	ResultCount int `json:"resultCount"`
    101 	Results     []struct {
    102 		CollectionID   int    `json:"collectionId"`
    103 		CollectionName string `json:"collectionName"`
    104 		ArtistName     string `json:"artistName"`
    105 		FeedURL        string `json:"feedUrl"`
    106 		ArtworkURL600  string `json:"artworkUrl600"`
    107 		ArtworkURL100  string `json:"artworkUrl100"`
    108 	} `json:"results"`
    109 }
    110 
    111 // podcastIndexResponse represents Podcast Index API search response
    112 type podcastIndexResponse struct {
    113 	Status string `json:"status"`
    114 	Feeds  []struct {
    115 		ID          int    `json:"id"`
    116 		Title       string `json:"title"`
    117 		Author      string `json:"author"`
    118 		URL         string `json:"url"`
    119 		Image       string `json:"image"`
    120 		Description string `json:"description"`
    121 	} `json:"feeds"`
    122 	Count int `json:"count"`
    123 }
    124 
    125 // SearchProvider indicates which podcast index to use
    126 type SearchProvider string
    127 
    128 const (
    129 	ProviderApple        SearchProvider = "apple"
    130 	ProviderPodcastIndex SearchProvider = "podcastindex"
    131 )
    132 
    133 // App states
    134 type state int
    135 
    136 const (
    137 	stateLoading state = iota
    138 	stateSearchResults
    139 	statePreviewPodcast
    140 	stateSelecting
    141 	statePreviewEpisode
    142 	stateDownloading
    143 	stateDone
    144 	stateError
    145 )
    146 
    147 // Model is our Bubble Tea model
    148 type model struct {
    149 	state          state
    150 	podcastID      string
    151 	searchQuery    string
    152 	searchResults  []SearchResult
    153 	podcastInfo    PodcastInfo
    154 	episodes       []Episode
    155 	cursor         int
    156 	offset         int
    157 	windowHeight   int
    158 	spinner        spinner.Model
    159 	progress       progress.Model
    160 	loadingMsg     string
    161 	errorMsg       string
    162 	downloadIndex  int
    163 	downloadTotal  int
    164 	outputDir      string
    165 	baseDir        string
    166 	downloaded     []string
    167 	percent        float64
    168 	searchProvider SearchProvider
    169 }
    170 
    171 // Messages
    172 type searchResultsMsg struct {
    173 	results []SearchResult
    174 }
    175 
    176 type podcastLoadedMsg struct {
    177 	info     PodcastInfo
    178 	episodes []Episode
    179 }
    180 
    181 type errorMsg struct {
    182 	err error
    183 }
    184 
    185 type downloadProgressMsg float64
    186 
    187 type downloadCompleteMsg struct {
    188 	filename string
    189 }
    190 
    191 type startDownloadMsg struct{}
    192 
    193 type selectSearchResultMsg struct {
    194 	result SearchResult
    195 }
    196 
    197 
    198 func initialModel(input string, baseDir string, provider SearchProvider) model {
    199 	s := spinner.New()
    200 	s.Spinner = spinner.Dot
    201 	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    202 
    203 	p := progress.New(progress.WithDefaultGradient())
    204 
    205 	isID := podcast.IsNumeric(input)
    206 
    207 	m := model{
    208 		state:          stateLoading,
    209 		spinner:        s,
    210 		progress:       p,
    211 		windowHeight:   24,
    212 		baseDir:        baseDir,
    213 		searchProvider: provider,
    214 	}
    215 
    216 	if isID {
    217 		m.podcastID = input
    218 		m.loadingMsg = "Looking up podcast..."
    219 	} else {
    220 		m.searchQuery = input
    221 		var providerName string
    222 		if provider == ProviderPodcastIndex {
    223 			providerName = "Podcast Index"
    224 		} else if hasPodcastIndexCredentials() {
    225 			providerName = "Apple + Podcast Index"
    226 		} else {
    227 			providerName = "Apple Podcasts"
    228 		}
    229 		m.loadingMsg = fmt.Sprintf("Searching %s...", providerName)
    230 	}
    231 
    232 	return m
    233 }
    234 
    235 func (m model) Init() tea.Cmd {
    236 	if m.searchQuery != "" {
    237 		var searchCmd tea.Cmd
    238 		// If credentials are available and no specific provider was forced, search both
    239 		if hasPodcastIndexCredentials() && m.searchProvider == ProviderApple {
    240 			searchCmd = searchBoth(m.searchQuery)
    241 		} else if m.searchProvider == ProviderPodcastIndex {
    242 			searchCmd = searchPodcastIndex(m.searchQuery)
    243 		} else {
    244 			searchCmd = searchPodcasts(m.searchQuery)
    245 		}
    246 		return tea.Batch(
    247 			m.spinner.Tick,
    248 			searchCmd,
    249 		)
    250 	}
    251 	return tea.Batch(
    252 		m.spinner.Tick,
    253 		loadPodcast(m.podcastID),
    254 	)
    255 }
    256 
    257 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    258 	switch msg := msg.(type) {
    259 	case tea.KeyMsg:
    260 		switch m.state {
    261 		case stateSearchResults:
    262 			return m.handleSearchResultsKeys(msg)
    263 		case statePreviewPodcast:
    264 			if msg.String() == "esc" || msg.String() == "b" || msg.String() == "v" {
    265 				m.state = stateSearchResults
    266 				return m, nil
    267 			}
    268 			if msg.String() == "ctrl+c" || msg.String() == "q" {
    269 				return m, tea.Quit
    270 			}
    271 		case stateSelecting:
    272 			return m.handleSelectionKeys(msg)
    273 		case statePreviewEpisode:
    274 			if msg.String() == "esc" || msg.String() == "b" || msg.String() == "v" {
    275 				m.state = stateSelecting
    276 				return m, nil
    277 			}
    278 			if msg.String() == "ctrl+c" || msg.String() == "q" {
    279 				return m, tea.Quit
    280 			}
    281 		case stateDownloading:
    282 			if msg.String() == "esc" || msg.String() == "b" {
    283 				// Go back to episode selection
    284 				m.state = stateSelecting
    285 				m.downloadIndex = 0
    286 				m.downloadTotal = 0
    287 				m.percent = 0
    288 				m.downloaded = nil
    289 				return m, nil
    290 			}
    291 			if msg.String() == "ctrl+c" || msg.String() == "q" {
    292 				return m, tea.Quit
    293 			}
    294 		case stateDone, stateError:
    295 			if msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == "enter" {
    296 				return m, tea.Quit
    297 			}
    298 		default:
    299 			if msg.String() == "ctrl+c" || msg.String() == "q" {
    300 				return m, tea.Quit
    301 			}
    302 		}
    303 
    304 	case tea.WindowSizeMsg:
    305 		m.windowHeight = msg.Height
    306 		m.progress.Width = msg.Width - 10
    307 
    308 	case spinner.TickMsg:
    309 		var cmd tea.Cmd
    310 		m.spinner, cmd = m.spinner.Update(msg)
    311 		return m, cmd
    312 
    313 	case searchResultsMsg:
    314 		m.searchResults = msg.results
    315 		if len(msg.results) == 0 {
    316 			m.state = stateError
    317 			m.errorMsg = fmt.Sprintf("No podcasts found for: %s", m.searchQuery)
    318 			return m, nil
    319 		}
    320 		m.state = stateSearchResults
    321 		m.cursor = 0
    322 		m.offset = 0
    323 		return m, nil
    324 
    325 	case selectSearchResultMsg:
    326 		m.state = stateLoading
    327 		m.loadingMsg = fmt.Sprintf("Loading %s...", msg.result.Name)
    328 		if msg.result.Source == ProviderPodcastIndex {
    329 			// Load directly from RSS feed URL for Podcast Index results
    330 			return m, loadPodcastFromFeed(msg.result.FeedURL, msg.result.Name, msg.result.Artist, msg.result.ArtworkURL)
    331 		}
    332 		m.podcastID = msg.result.ID
    333 		return m, loadPodcast(msg.result.ID)
    334 
    335 	case podcastLoadedMsg:
    336 		m.state = stateSelecting
    337 		m.podcastInfo = msg.info
    338 		m.episodes = msg.episodes
    339 		m.cursor = 0
    340 		m.offset = 0
    341 		return m, nil
    342 
    343 	case errorMsg:
    344 		m.state = stateError
    345 		m.errorMsg = msg.err.Error()
    346 		return m, nil
    347 
    348 	case downloadProgressMsg:
    349 		m.percent = float64(msg)
    350 		cmd := m.progress.SetPercent(m.percent)
    351 		return m, cmd
    352 
    353 	case progress.FrameMsg:
    354 		progressModel, cmd := m.progress.Update(msg)
    355 		m.progress = progressModel.(progress.Model)
    356 		return m, cmd
    357 
    358 	case startDownloadMsg:
    359 		return m, m.downloadNextCmd()
    360 
    361 	case downloadCompleteMsg:
    362 		m.downloaded = append(m.downloaded, msg.filename)
    363 		m.downloadIndex++
    364 		m.percent = 0
    365 		if m.downloadIndex < m.downloadTotal {
    366 			return m, m.downloadNextCmd()
    367 		}
    368 		m.state = stateDone
    369 		return m, nil
    370 	}
    371 
    372 	return m, nil
    373 }
    374 
    375 func (m model) handleSearchResultsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
    376 	visibleItems := m.windowHeight - 10
    377 	if visibleItems < 5 {
    378 		visibleItems = 5
    379 	}
    380 
    381 	switch msg.String() {
    382 	case "ctrl+c", "q":
    383 		return m, tea.Quit
    384 
    385 	case "up", "k":
    386 		if m.cursor > 0 {
    387 			m.cursor--
    388 			if m.cursor < m.offset {
    389 				m.offset = m.cursor
    390 			}
    391 		}
    392 
    393 	case "down", "j":
    394 		if m.cursor < len(m.searchResults)-1 {
    395 			m.cursor++
    396 			if m.cursor >= m.offset+visibleItems {
    397 				m.offset = m.cursor - visibleItems + 1
    398 			}
    399 		}
    400 
    401 	case "enter":
    402 		if m.cursor < len(m.searchResults) {
    403 			result := m.searchResults[m.cursor]
    404 			return m, func() tea.Msg { return selectSearchResultMsg{result: result} }
    405 		}
    406 
    407 	case "v":
    408 		if m.cursor < len(m.searchResults) {
    409 			m.state = statePreviewPodcast
    410 			return m, nil
    411 		}
    412 	}
    413 
    414 	return m, nil
    415 }
    416 
    417 func (m model) handleSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
    418 	visibleItems := m.windowHeight - 12
    419 	if visibleItems < 5 {
    420 		visibleItems = 5
    421 	}
    422 
    423 	switch msg.String() {
    424 	case "ctrl+c", "q":
    425 		return m, tea.Quit
    426 
    427 	case "esc", "b":
    428 		// Go back to search results if available
    429 		if len(m.searchResults) > 0 {
    430 			m.state = stateSearchResults
    431 			m.cursor = 0
    432 			m.offset = 0
    433 			return m, nil
    434 		}
    435 		// If no search results (direct podcast ID), quit
    436 		return m, tea.Quit
    437 
    438 	case "up", "k":
    439 		if m.cursor > 0 {
    440 			m.cursor--
    441 			if m.cursor < m.offset {
    442 				m.offset = m.cursor
    443 			}
    444 		}
    445 
    446 	case "down", "j":
    447 		if m.cursor < len(m.episodes)-1 {
    448 			m.cursor++
    449 			if m.cursor >= m.offset+visibleItems {
    450 				m.offset = m.cursor - visibleItems + 1
    451 			}
    452 		}
    453 
    454 	case "pgup":
    455 		m.cursor -= visibleItems
    456 		if m.cursor < 0 {
    457 			m.cursor = 0
    458 		}
    459 		m.offset = m.cursor
    460 
    461 	case "pgdown":
    462 		m.cursor += visibleItems
    463 		if m.cursor >= len(m.episodes) {
    464 			m.cursor = len(m.episodes) - 1
    465 		}
    466 		if m.cursor >= m.offset+visibleItems {
    467 			m.offset = m.cursor - visibleItems + 1
    468 		}
    469 
    470 	case " ", "x":
    471 		m.episodes[m.cursor].Selected = !m.episodes[m.cursor].Selected
    472 
    473 	case "a":
    474 		allSelected := true
    475 		for _, ep := range m.episodes {
    476 			if !ep.Selected {
    477 				allSelected = false
    478 				break
    479 			}
    480 		}
    481 		for i := range m.episodes {
    482 			m.episodes[i].Selected = !allSelected
    483 		}
    484 
    485 	case "enter":
    486 		selected := m.getSelectedEpisodes()
    487 		if len(selected) > 0 {
    488 			m.state = stateDownloading
    489 			m.downloadTotal = len(selected)
    490 			m.downloadIndex = 0
    491 			podcastFolder := podcast.SanitizeFilename(m.podcastInfo.Name)
    492 			m.outputDir = filepath.Join(m.baseDir, podcastFolder)
    493 			os.MkdirAll(m.outputDir, 0755)
    494 			return m, func() tea.Msg { return startDownloadMsg{} }
    495 		}
    496 
    497 	case "v":
    498 		if m.cursor < len(m.episodes) {
    499 			m.state = statePreviewEpisode
    500 			return m, nil
    501 		}
    502 	}
    503 
    504 	return m, nil
    505 }
    506 
    507 func (m model) getSelectedEpisodes() []Episode {
    508 	var selected []Episode
    509 	for _, ep := range m.episodes {
    510 		if ep.Selected {
    511 			selected = append(selected, ep)
    512 		}
    513 	}
    514 	return selected
    515 }
    516 
    517 func (m model) downloadNextCmd() tea.Cmd {
    518 	selected := m.getSelectedEpisodes()
    519 	if m.downloadIndex >= len(selected) {
    520 		return nil
    521 	}
    522 
    523 	ep := selected[m.downloadIndex]
    524 	currentFile := fmt.Sprintf("%03d - %s.mp3", ep.Index, podcast.SanitizeFilename(ep.Title))
    525 	outputDir := m.outputDir
    526 	podcastInfo := m.podcastInfo
    527 
    528 	return func() tea.Msg {
    529 		filePath := filepath.Join(outputDir, currentFile)
    530 
    531 		// Download with progress callback that sends to program
    532 		err := podcast.DownloadFile(filePath, ep.AudioURL, func(percent float64) {
    533 			if program != nil {
    534 				program.Send(downloadProgressMsg(percent))
    535 			}
    536 		})
    537 		if err != nil {
    538 			return errorMsg{err: err}
    539 		}
    540 
    541 		// Add ID3 tags
    542 		addID3Tags(filePath, ep, podcastInfo)
    543 
    544 		return downloadCompleteMsg{filename: filePath}
    545 	}
    546 }
    547 
    548 func (m model) View() string {
    549 	switch m.state {
    550 	case stateLoading:
    551 		return m.viewLoading()
    552 	case stateSearchResults:
    553 		return m.viewSearchResults()
    554 	case statePreviewPodcast:
    555 		return m.viewPreviewPodcast()
    556 	case stateSelecting:
    557 		return m.viewSelecting()
    558 	case statePreviewEpisode:
    559 		return m.viewPreviewEpisode()
    560 	case stateDownloading:
    561 		return m.viewDownloading()
    562 	case stateDone:
    563 		return m.viewDone()
    564 	case stateError:
    565 		return m.viewError()
    566 	}
    567 	return ""
    568 }
    569 
    570 func (m model) viewLoading() string {
    571 	return fmt.Sprintf("\n  %s %s\n", m.spinner.View(), m.loadingMsg)
    572 }
    573 
    574 func (m model) viewSearchResults() string {
    575 	var b strings.Builder
    576 
    577 	// Header
    578 	b.WriteString("\n")
    579 	b.WriteString(titleStyle.Render(fmt.Sprintf("Search Results: \"%s\"", m.searchQuery)))
    580 	b.WriteString("\n")
    581 	b.WriteString(subtitleStyle.Render(fmt.Sprintf("Found %d podcasts", len(m.searchResults))))
    582 	b.WriteString("\n\n")
    583 
    584 	// Calculate visible items
    585 	visibleItems := m.windowHeight - 10
    586 	if visibleItems < 5 {
    587 		visibleItems = 5
    588 	}
    589 
    590 	// Results list
    591 	end := m.offset + visibleItems
    592 	if end > len(m.searchResults) {
    593 		end = len(m.searchResults)
    594 	}
    595 
    596 	for i := m.offset; i < end; i++ {
    597 		result := m.searchResults[i]
    598 		cursor := "  "
    599 		if i == m.cursor {
    600 			cursor = "▸ "
    601 		}
    602 
    603 		// Truncate name
    604 		name := result.Name
    605 		if len(name) > 50 {
    606 			name = name[:47] + "..."
    607 		}
    608 
    609 		// Truncate artist
    610 		artist := result.Artist
    611 		if len(artist) > 25 {
    612 			artist = artist[:22] + "..."
    613 		}
    614 
    615 		line := fmt.Sprintf("%s%-50s  %s", cursor, name, dimStyle.Render(artist))
    616 
    617 		if i == m.cursor {
    618 			b.WriteString(selectedStyle.Render(line))
    619 		} else {
    620 			b.WriteString(normalStyle.Render(line))
    621 		}
    622 		b.WriteString("\n")
    623 	}
    624 
    625 	// Scroll indicator
    626 	if len(m.searchResults) > visibleItems {
    627 		b.WriteString(dimStyle.Render(fmt.Sprintf("\n  Showing %d-%d of %d", m.offset+1, end, len(m.searchResults))))
    628 	}
    629 
    630 	// Help
    631 	b.WriteString(helpStyle.Render("\n\n  ↑/↓ navigate • enter select • v preview • q quit"))
    632 
    633 	return b.String()
    634 }
    635 
    636 func (m model) viewPreviewPodcast() string {
    637 	var b strings.Builder
    638 
    639 	if m.cursor >= len(m.searchResults) {
    640 		return ""
    641 	}
    642 	result := m.searchResults[m.cursor]
    643 
    644 	b.WriteString("\n")
    645 	b.WriteString(titleStyle.Render("Podcast Details"))
    646 	b.WriteString("\n\n")
    647 
    648 	b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Name:"), result.Name))
    649 	b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Artist:"), result.Artist))
    650 	b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Source:"), string(result.Source)))
    651 	if result.ID != "" {
    652 		b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("ID:"), result.ID))
    653 	}
    654 	if result.FeedURL != "" {
    655 		b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Feed URL:"), result.FeedURL))
    656 	}
    657 	if result.ArtworkURL != "" {
    658 		b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Artwork:"), result.ArtworkURL))
    659 	}
    660 
    661 	b.WriteString(helpStyle.Render("\n\n  esc/b/v back • q quit"))
    662 
    663 	return b.String()
    664 }
    665 
    666 func (m model) viewSelecting() string {
    667 	var b strings.Builder
    668 
    669 	// Header
    670 	b.WriteString("\n")
    671 	b.WriteString(titleStyle.Render(m.podcastInfo.Name))
    672 	b.WriteString("\n")
    673 	b.WriteString(subtitleStyle.Render(fmt.Sprintf("by %s • %d episodes", m.podcastInfo.Artist, len(m.episodes))))
    674 	b.WriteString("\n\n")
    675 
    676 	// Calculate visible items
    677 	visibleItems := m.windowHeight - 12
    678 	if visibleItems < 5 {
    679 		visibleItems = 5
    680 	}
    681 
    682 	// Episode list
    683 	end := m.offset + visibleItems
    684 	if end > len(m.episodes) {
    685 		end = len(m.episodes)
    686 	}
    687 
    688 	for i := m.offset; i < end; i++ {
    689 		ep := m.episodes[i]
    690 		cursor := "  "
    691 		if i == m.cursor {
    692 			cursor = "▸ "
    693 		}
    694 
    695 		checkbox := "○"
    696 		if ep.Selected {
    697 			checkbox = "●"
    698 		}
    699 
    700 		// Format date
    701 		dateStr := ""
    702 		if !ep.PubDate.IsZero() {
    703 			dateStr = ep.PubDate.Format("2006-01-02")
    704 		}
    705 
    706 		// Truncate title
    707 		title := ep.Title
    708 		if len(title) > 45 {
    709 			title = title[:42] + "..."
    710 		}
    711 
    712 		line := fmt.Sprintf("%s%s [%3d] %-45s %s  %s",
    713 			cursor,
    714 			checkboxStyle.Render(checkbox),
    715 			ep.Index,
    716 			title,
    717 			dimStyle.Render(dateStr),
    718 			dimStyle.Render(ep.Duration),
    719 		)
    720 
    721 		if i == m.cursor {
    722 			b.WriteString(selectedStyle.Render(line))
    723 		} else if ep.Selected {
    724 			b.WriteString(normalStyle.Render(line))
    725 		} else {
    726 			b.WriteString(dimStyle.Render(line))
    727 		}
    728 		b.WriteString("\n")
    729 	}
    730 
    731 	// Scroll indicator
    732 	if len(m.episodes) > visibleItems {
    733 		b.WriteString(dimStyle.Render(fmt.Sprintf("\n  Showing %d-%d of %d", m.offset+1, end, len(m.episodes))))
    734 	}
    735 
    736 	// Selection count
    737 	selectedCount := 0
    738 	for _, ep := range m.episodes {
    739 		if ep.Selected {
    740 			selectedCount++
    741 		}
    742 	}
    743 	b.WriteString(dimStyle.Render(fmt.Sprintf("  •  %d selected", selectedCount)))
    744 
    745 	// Help
    746 	b.WriteString(helpStyle.Render("\n\n  ↑/↓ navigate • space select • a toggle all • v preview • enter download • esc/b back • q quit"))
    747 
    748 	return b.String()
    749 }
    750 
    751 func (m model) viewPreviewEpisode() string {
    752 	var b strings.Builder
    753 
    754 	if m.cursor >= len(m.episodes) {
    755 		return ""
    756 	}
    757 	ep := m.episodes[m.cursor]
    758 
    759 	b.WriteString("\n")
    760 	b.WriteString(titleStyle.Render("Episode Details"))
    761 	b.WriteString("\n\n")
    762 
    763 	b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Title:"), ep.Title))
    764 	b.WriteString(fmt.Sprintf("  %s %d\n", subtitleStyle.Render("Episode #:"), ep.Index))
    765 	if !ep.PubDate.IsZero() {
    766 		b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Published:"), ep.PubDate.Format("January 2, 2006")))
    767 	}
    768 	if ep.Duration != "" {
    769 		b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Duration:"), ep.Duration))
    770 	}
    771 	if ep.AudioURL != "" {
    772 		b.WriteString(fmt.Sprintf("  %s %s\n", subtitleStyle.Render("Audio URL:"), ep.AudioURL))
    773 	}
    774 
    775 	// Description with word wrap
    776 	if ep.Description != "" {
    777 		b.WriteString(fmt.Sprintf("\n  %s\n", subtitleStyle.Render("Description:")))
    778 		desc := ep.Description
    779 		// Limit description length for display
    780 		if len(desc) > 500 {
    781 			desc = desc[:497] + "..."
    782 		}
    783 		// Simple word wrap at ~70 chars
    784 		words := strings.Fields(desc)
    785 		line := "  "
    786 		for _, word := range words {
    787 			if len(line)+len(word)+1 > 72 {
    788 				b.WriteString(line + "\n")
    789 				line = "  " + word
    790 			} else {
    791 				if line == "  " {
    792 					line += word
    793 				} else {
    794 					line += " " + word
    795 				}
    796 			}
    797 		}
    798 		if line != "  " {
    799 			b.WriteString(line + "\n")
    800 		}
    801 	}
    802 
    803 	b.WriteString(helpStyle.Render("\n\n  esc/b/v back • q quit"))
    804 
    805 	return b.String()
    806 }
    807 
    808 func (m model) viewDownloading() string {
    809 	var b strings.Builder
    810 
    811 	b.WriteString("\n")
    812 	b.WriteString(titleStyle.Render("Downloading..."))
    813 	b.WriteString("\n\n")
    814 
    815 	// Get current episode name
    816 	currentFile := ""
    817 	selected := m.getSelectedEpisodes()
    818 	if m.downloadIndex < len(selected) {
    819 		ep := selected[m.downloadIndex]
    820 		currentFile = fmt.Sprintf("%03d - %s.mp3", ep.Index, podcast.SanitizeFilename(ep.Title))
    821 	}
    822 
    823 	b.WriteString(fmt.Sprintf("  Episode %d of %d\n", m.downloadIndex+1, m.downloadTotal))
    824 	b.WriteString(fmt.Sprintf("  %s\n\n", currentFile))
    825 	b.WriteString("  " + m.progress.View() + "\n")
    826 
    827 	if len(m.downloaded) > 0 {
    828 		b.WriteString(dimStyle.Render(fmt.Sprintf("\n  ✓ %d completed", len(m.downloaded))))
    829 	}
    830 
    831 	b.WriteString(helpStyle.Render("\n\n  esc/b back • q quit"))
    832 
    833 	return b.String()
    834 }
    835 
    836 func (m model) viewDone() string {
    837 	var b strings.Builder
    838 
    839 	b.WriteString("\n")
    840 	b.WriteString(successStyle.Render("✓ Download Complete!"))
    841 	b.WriteString("\n\n")
    842 
    843 	b.WriteString(fmt.Sprintf("  Downloaded %d episode(s) to:\n", len(m.downloaded)))
    844 	b.WriteString(fmt.Sprintf("  %s/\n\n", m.outputDir))
    845 
    846 	for _, f := range m.downloaded {
    847 		b.WriteString(dimStyle.Render(fmt.Sprintf("  • %s\n", filepath.Base(f))))
    848 	}
    849 
    850 	b.WriteString(helpStyle.Render("\n  Press enter or q to exit"))
    851 
    852 	return b.String()
    853 }
    854 
    855 func (m model) viewError() string {
    856 	return fmt.Sprintf("\n%s\n\n  %s\n\n%s",
    857 		errorStyle.Render("Error"),
    858 		m.errorMsg,
    859 		helpStyle.Render("  Press q to exit"),
    860 	)
    861 }
    862 
    863 // Fetch podcast info from Apple's API
    864 func loadPodcast(podcastID string) tea.Cmd {
    865 	return func() tea.Msg {
    866 		// Remove "id" prefix if present
    867 		podcastID = strings.TrimPrefix(strings.ToLower(podcastID), "id")
    868 
    869 		// Fetch from iTunes API
    870 		url := fmt.Sprintf("https://itunes.apple.com/lookup?id=%s&entity=podcast", podcastID)
    871 		resp, err := http.Get(url)
    872 		if err != nil {
    873 			return errorMsg{err: fmt.Errorf("failed to lookup podcast: %w", err)}
    874 		}
    875 		defer resp.Body.Close()
    876 
    877 		var result iTunesResponse
    878 		if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    879 			return errorMsg{err: fmt.Errorf("failed to parse response: %w", err)}
    880 		}
    881 
    882 		if result.ResultCount == 0 {
    883 			return errorMsg{err: fmt.Errorf("no podcast found with ID: %s", podcastID)}
    884 		}
    885 
    886 		r := result.Results[0]
    887 		info := PodcastInfo{
    888 			Name:       r.CollectionName,
    889 			Artist:     r.ArtistName,
    890 			FeedURL:    r.FeedURL,
    891 			ArtworkURL: r.ArtworkURL600,
    892 		}
    893 
    894 		if info.ArtworkURL == "" {
    895 			info.ArtworkURL = r.ArtworkURL100
    896 		}
    897 
    898 		if info.FeedURL == "" {
    899 			return errorMsg{err: fmt.Errorf("no RSS feed URL found for this podcast")}
    900 		}
    901 
    902 		// Parse RSS feed
    903 		fp := gofeed.NewParser()
    904 		feed, err := fp.ParseURL(info.FeedURL)
    905 		if err != nil {
    906 			return errorMsg{err: fmt.Errorf("failed to parse RSS feed: %w", err)}
    907 		}
    908 
    909 		var episodes []Episode
    910 		for i, item := range feed.Items {
    911 			audioURL := ""
    912 
    913 			// Find audio enclosure
    914 			for _, enc := range item.Enclosures {
    915 				if strings.Contains(enc.Type, "audio") || strings.HasSuffix(enc.URL, ".mp3") {
    916 					audioURL = enc.URL
    917 					break
    918 				}
    919 			}
    920 
    921 			if audioURL == "" {
    922 				continue
    923 			}
    924 
    925 			var pubDate time.Time
    926 			if item.PublishedParsed != nil {
    927 				pubDate = *item.PublishedParsed
    928 			}
    929 
    930 			duration := ""
    931 			if item.ITunesExt != nil {
    932 				duration = item.ITunesExt.Duration
    933 			}
    934 
    935 			episodes = append(episodes, Episode{
    936 				Index:       i + 1,
    937 				Title:       item.Title,
    938 				Description: item.Description,
    939 				AudioURL:    audioURL,
    940 				PubDate:     pubDate,
    941 				Duration:    duration,
    942 			})
    943 		}
    944 
    945 		if len(episodes) == 0 {
    946 			return errorMsg{err: fmt.Errorf("no downloadable episodes found")}
    947 		}
    948 
    949 		return podcastLoadedMsg{info: info, episodes: episodes}
    950 	}
    951 }
    952 
    953 func addID3Tags(filepath string, ep Episode, info PodcastInfo) error {
    954 	tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
    955 	if err != nil {
    956 		// Create new tag if file doesn't have one
    957 		tag = id3v2.NewEmptyTag()
    958 	}
    959 	defer tag.Close()
    960 
    961 	tag.SetTitle(ep.Title)
    962 	tag.SetArtist(info.Artist)
    963 	tag.SetAlbum(info.Name)
    964 
    965 	// Set track number
    966 	trackFrame := id3v2.TextFrame{
    967 		Encoding: id3v2.EncodingUTF8,
    968 		Text:     strconv.Itoa(ep.Index),
    969 	}
    970 	tag.AddFrame(tag.CommonID("Track number/Position in set"), trackFrame)
    971 
    972 	return tag.Save()
    973 }
    974 
    975 
    976 // searchPodcasts searches for podcasts using Apple's Search API
    977 func searchPodcasts(query string) tea.Cmd {
    978 	return func() tea.Msg {
    979 		// URL encode the query
    980 		encodedQuery := strings.ReplaceAll(query, " ", "+")
    981 		url := fmt.Sprintf("https://itunes.apple.com/search?term=%s&media=podcast&limit=25", encodedQuery)
    982 
    983 		resp, err := http.Get(url)
    984 		if err != nil {
    985 			return errorMsg{err: fmt.Errorf("failed to search podcasts: %w", err)}
    986 		}
    987 		defer resp.Body.Close()
    988 
    989 		var result iTunesResponse
    990 		if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    991 			return errorMsg{err: fmt.Errorf("failed to parse search results: %w", err)}
    992 		}
    993 
    994 		var results []SearchResult
    995 		for _, r := range result.Results {
    996 			if r.FeedURL == "" {
    997 				continue // Skip podcasts without RSS feed
    998 			}
    999 
   1000 			results = append(results, SearchResult{
   1001 				ID:         strconv.Itoa(r.CollectionID),
   1002 				Name:       r.CollectionName,
   1003 				Artist:     r.ArtistName,
   1004 				FeedURL:    r.FeedURL,
   1005 				ArtworkURL: r.ArtworkURL600,
   1006 				Source:     ProviderApple,
   1007 			})
   1008 		}
   1009 
   1010 		return searchResultsMsg{results: results}
   1011 	}
   1012 }
   1013 
   1014 // searchPodcastIndex searches using Podcast Index API
   1015 func searchPodcastIndex(query string) tea.Cmd {
   1016 	return func() tea.Msg {
   1017 		apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY"))
   1018 		apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET"))
   1019 
   1020 		if apiKey == "" || apiSecret == "" {
   1021 			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")}
   1022 		}
   1023 
   1024 		// Build authentication headers (hash = sha1(apiKey + apiSecret + unixTime))
   1025 		apiHeaderTime := strconv.FormatInt(time.Now().Unix(), 10)
   1026 		hashInput := apiKey + apiSecret + apiHeaderTime
   1027 		h := sha1.New()
   1028 		h.Write([]byte(hashInput))
   1029 		authHash := hex.EncodeToString(h.Sum(nil))
   1030 
   1031 		// URL encode the query
   1032 		encodedQuery := url.QueryEscape(query)
   1033 		apiURL := fmt.Sprintf("https://api.podcastindex.org/api/1.0/search/byterm?q=%s&max=25", encodedQuery)
   1034 
   1035 		req, err := http.NewRequest("GET", apiURL, nil)
   1036 		if err != nil {
   1037 			return errorMsg{err: fmt.Errorf("failed to create request: %w", err)}
   1038 		}
   1039 
   1040 		// Set required headers
   1041 		req.Header.Set("User-Agent", "PodcastDownload/1.0")
   1042 		req.Header.Set("X-Auth-Key", apiKey)
   1043 		req.Header.Set("X-Auth-Date", apiHeaderTime)
   1044 		req.Header.Set("Authorization", authHash)
   1045 
   1046 		client := &http.Client{Timeout: 30 * time.Second}
   1047 		resp, err := client.Do(req)
   1048 		if err != nil {
   1049 			return errorMsg{err: fmt.Errorf("failed to search Podcast Index: %w", err)}
   1050 		}
   1051 		defer resp.Body.Close()
   1052 
   1053 		if resp.StatusCode != http.StatusOK {
   1054 			body, _ := io.ReadAll(resp.Body)
   1055 			return errorMsg{err: fmt.Errorf("Podcast Index API error (%d): %s", resp.StatusCode, string(body))}
   1056 		}
   1057 
   1058 		var result podcastIndexResponse
   1059 		if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
   1060 			return errorMsg{err: fmt.Errorf("failed to parse search results: %w", err)}
   1061 		}
   1062 
   1063 		var results []SearchResult
   1064 		for _, feed := range result.Feeds {
   1065 			if feed.URL == "" {
   1066 				continue
   1067 			}
   1068 
   1069 			results = append(results, SearchResult{
   1070 				ID:         strconv.Itoa(feed.ID),
   1071 				Name:       feed.Title,
   1072 				Artist:     feed.Author,
   1073 				FeedURL:    feed.URL,
   1074 				ArtworkURL: feed.Image,
   1075 				Source:     ProviderPodcastIndex,
   1076 			})
   1077 		}
   1078 
   1079 		return searchResultsMsg{results: results}
   1080 	}
   1081 }
   1082 
   1083 // hasPodcastIndexCredentials checks if Podcast Index API credentials are set
   1084 func hasPodcastIndexCredentials() bool {
   1085 	apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY"))
   1086 	apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET"))
   1087 	return apiKey != "" && apiSecret != ""
   1088 }
   1089 
   1090 // searchAppleResults performs Apple search and returns results directly (for use in combined search)
   1091 func searchAppleResults(query string) ([]SearchResult, error) {
   1092 	encodedQuery := strings.ReplaceAll(query, " ", "+")
   1093 	url := fmt.Sprintf("https://itunes.apple.com/search?term=%s&media=podcast&limit=25", encodedQuery)
   1094 
   1095 	resp, err := http.Get(url)
   1096 	if err != nil {
   1097 		return nil, err
   1098 	}
   1099 	defer resp.Body.Close()
   1100 
   1101 	var result iTunesResponse
   1102 	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
   1103 		return nil, err
   1104 	}
   1105 
   1106 	var results []SearchResult
   1107 	for _, r := range result.Results {
   1108 		if r.FeedURL == "" {
   1109 			continue
   1110 		}
   1111 		results = append(results, SearchResult{
   1112 			ID:         strconv.Itoa(r.CollectionID),
   1113 			Name:       r.CollectionName,
   1114 			Artist:     r.ArtistName,
   1115 			FeedURL:    r.FeedURL,
   1116 			ArtworkURL: r.ArtworkURL600,
   1117 			Source:     ProviderApple,
   1118 		})
   1119 	}
   1120 	return results, nil
   1121 }
   1122 
   1123 // searchPodcastIndexResults performs Podcast Index search and returns results directly (for use in combined search)
   1124 func searchPodcastIndexResults(query string) ([]SearchResult, error) {
   1125 	apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY"))
   1126 	apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET"))
   1127 
   1128 	apiHeaderTime := strconv.FormatInt(time.Now().Unix(), 10)
   1129 	hashInput := apiKey + apiSecret + apiHeaderTime
   1130 	h := sha1.New()
   1131 	h.Write([]byte(hashInput))
   1132 	authHash := hex.EncodeToString(h.Sum(nil))
   1133 
   1134 	encodedQuery := url.QueryEscape(query)
   1135 	apiURL := fmt.Sprintf("https://api.podcastindex.org/api/1.0/search/byterm?q=%s&max=25", encodedQuery)
   1136 
   1137 	req, err := http.NewRequest("GET", apiURL, nil)
   1138 	if err != nil {
   1139 		return nil, err
   1140 	}
   1141 
   1142 	req.Header.Set("User-Agent", "PodcastDownload/1.0")
   1143 	req.Header.Set("X-Auth-Key", apiKey)
   1144 	req.Header.Set("X-Auth-Date", apiHeaderTime)
   1145 	req.Header.Set("Authorization", authHash)
   1146 
   1147 	client := &http.Client{Timeout: 30 * time.Second}
   1148 	resp, err := client.Do(req)
   1149 	if err != nil {
   1150 		return nil, err
   1151 	}
   1152 	defer resp.Body.Close()
   1153 
   1154 	if resp.StatusCode != http.StatusOK {
   1155 		body, _ := io.ReadAll(resp.Body)
   1156 		return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
   1157 	}
   1158 
   1159 	var result podcastIndexResponse
   1160 	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
   1161 		return nil, err
   1162 	}
   1163 
   1164 	var results []SearchResult
   1165 	for _, feed := range result.Feeds {
   1166 		if feed.URL == "" {
   1167 			continue
   1168 		}
   1169 		results = append(results, SearchResult{
   1170 			ID:         strconv.Itoa(feed.ID),
   1171 			Name:       feed.Title,
   1172 			Artist:     feed.Author,
   1173 			FeedURL:    feed.URL,
   1174 			ArtworkURL: feed.Image,
   1175 			Source:     ProviderPodcastIndex,
   1176 		})
   1177 	}
   1178 	return results, nil
   1179 }
   1180 
   1181 // searchBoth searches both Apple and Podcast Index APIs concurrently and combines results
   1182 func searchBoth(query string) tea.Cmd {
   1183 	return func() tea.Msg {
   1184 		var wg sync.WaitGroup
   1185 		var appleResults, piResults []SearchResult
   1186 		var appleErr, piErr error
   1187 
   1188 		wg.Add(2)
   1189 
   1190 		// Search Apple
   1191 		go func() {
   1192 			defer wg.Done()
   1193 			appleResults, appleErr = searchAppleResults(query)
   1194 		}()
   1195 
   1196 		// Search Podcast Index
   1197 		go func() {
   1198 			defer wg.Done()
   1199 			piResults, piErr = searchPodcastIndexResults(query)
   1200 		}()
   1201 
   1202 		wg.Wait()
   1203 
   1204 		// If both failed, return error
   1205 		if appleErr != nil && piErr != nil {
   1206 			return errorMsg{err: fmt.Errorf("search failed: Apple: %v, Podcast Index: %v", appleErr, piErr)}
   1207 		}
   1208 
   1209 		// Combine results - Apple first, then Podcast Index (deduplicated by feed URL)
   1210 		var combined []SearchResult
   1211 		seenFeedURLs := make(map[string]bool)
   1212 
   1213 		if appleErr == nil {
   1214 			for _, r := range appleResults {
   1215 				normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/"))
   1216 				if !seenFeedURLs[normalizedURL] {
   1217 					seenFeedURLs[normalizedURL] = true
   1218 					combined = append(combined, r)
   1219 				}
   1220 			}
   1221 		}
   1222 		if piErr == nil {
   1223 			for _, r := range piResults {
   1224 				normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/"))
   1225 				if !seenFeedURLs[normalizedURL] {
   1226 					seenFeedURLs[normalizedURL] = true
   1227 					combined = append(combined, r)
   1228 				}
   1229 			}
   1230 		}
   1231 
   1232 		return searchResultsMsg{results: combined}
   1233 	}
   1234 }
   1235 
   1236 // loadPodcastFromFeed loads a podcast directly from its RSS feed URL
   1237 func loadPodcastFromFeed(feedURL, name, artist, artworkURL string) tea.Cmd {
   1238 	return func() tea.Msg {
   1239 		info := PodcastInfo{
   1240 			Name:       name,
   1241 			Artist:     artist,
   1242 			FeedURL:    feedURL,
   1243 			ArtworkURL: artworkURL,
   1244 		}
   1245 
   1246 		// Parse RSS feed
   1247 		fp := gofeed.NewParser()
   1248 		feed, err := fp.ParseURL(feedURL)
   1249 		if err != nil {
   1250 			return errorMsg{err: fmt.Errorf("failed to parse RSS feed: %w", err)}
   1251 		}
   1252 
   1253 		// Use feed title/author if not provided
   1254 		if info.Name == "" && feed.Title != "" {
   1255 			info.Name = feed.Title
   1256 		}
   1257 		if info.Artist == "" && feed.Author != nil {
   1258 			info.Artist = feed.Author.Name
   1259 		}
   1260 		if info.ArtworkURL == "" && feed.Image != nil {
   1261 			info.ArtworkURL = feed.Image.URL
   1262 		}
   1263 
   1264 		var episodes []Episode
   1265 		for i, item := range feed.Items {
   1266 			audioURL := ""
   1267 
   1268 			// Find audio enclosure
   1269 			for _, enc := range item.Enclosures {
   1270 				if strings.Contains(enc.Type, "audio") || strings.HasSuffix(enc.URL, ".mp3") {
   1271 					audioURL = enc.URL
   1272 					break
   1273 				}
   1274 			}
   1275 
   1276 			if audioURL == "" {
   1277 				continue
   1278 			}
   1279 
   1280 			var pubDate time.Time
   1281 			if item.PublishedParsed != nil {
   1282 				pubDate = *item.PublishedParsed
   1283 			}
   1284 
   1285 			duration := ""
   1286 			if item.ITunesExt != nil {
   1287 				duration = item.ITunesExt.Duration
   1288 			}
   1289 
   1290 			episodes = append(episodes, Episode{
   1291 				Index:       i + 1,
   1292 				Title:       item.Title,
   1293 				Description: item.Description,
   1294 				AudioURL:    audioURL,
   1295 				PubDate:     pubDate,
   1296 				Duration:    duration,
   1297 			})
   1298 		}
   1299 
   1300 		if len(episodes) == 0 {
   1301 			return errorMsg{err: fmt.Errorf("no downloadable episodes found")}
   1302 		}
   1303 
   1304 		return podcastLoadedMsg{info: info, episodes: episodes}
   1305 	}
   1306 }
   1307 
   1308 func main() {
   1309 	// Define flags
   1310 	baseDir := flag.String("o", ".", "Base directory where the podcast folder will be created")
   1311 	indexFlag := flag.String("index", "apple", "Search provider: 'apple' (default) or 'podcastindex'")
   1312 
   1313 	// Custom usage message
   1314 	flag.Usage = func() {
   1315 		fmt.Fprintf(os.Stderr, "Usage: %s [flags] <podcast_id_or_search_query>\n\n", os.Args[0])
   1316 		fmt.Fprintln(os.Stderr, "Flags:")
   1317 		flag.PrintDefaults()
   1318 		fmt.Fprintln(os.Stderr, "\nExamples:")
   1319 		fmt.Fprintln(os.Stderr, "  podcastdownload -o ~/Music \"the daily\"")
   1320 		fmt.Fprintln(os.Stderr, "  podcastdownload 1200361736")
   1321 		fmt.Fprintln(os.Stderr, "  podcastdownload --index podcastindex \"france inter\"")
   1322 		fmt.Fprintln(os.Stderr, "\nPodcast Index:")
   1323 		fmt.Fprintln(os.Stderr, "  To use Podcast Index, set these environment variables:")
   1324 		fmt.Fprintln(os.Stderr, "    PODCASTINDEX_API_KEY=your_key")
   1325 		fmt.Fprintln(os.Stderr, "    PODCASTINDEX_API_SECRET=your_secret")
   1326 		fmt.Fprintln(os.Stderr, "  Get free API keys at: https://api.podcastindex.org")
   1327 	}
   1328 
   1329 	flag.Parse()
   1330 
   1331 	// Parse the index flag
   1332 	var provider SearchProvider
   1333 	switch strings.ToLower(*indexFlag) {
   1334 	case "podcastindex", "pi":
   1335 		provider = ProviderPodcastIndex
   1336 	default:
   1337 		provider = ProviderApple
   1338 	}
   1339 
   1340 	// Check if we have arguments left after parsing flags (the search query)
   1341 	if flag.NArg() < 1 {
   1342 		flag.Usage()
   1343 		os.Exit(1)
   1344 	}
   1345 
   1346 	// Join remaining arguments to form the search query
   1347 	input := strings.Join(flag.Args(), " ")
   1348 
   1349 	// Pass the baseDir and provider to initialModel
   1350 	program = tea.NewProgram(initialModel(input, *baseDir, provider), tea.WithAltScreen())
   1351 	if _, err := program.Run(); err != nil {
   1352 		fmt.Printf("Error: %v\n", err)
   1353 		os.Exit(1)
   1354 	}
   1355 }