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 }