podcast-go

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

main.go (20387B)


      1 package main
      2 
      3 import (
      4 	"crypto/sha1"
      5 	"encoding/hex"
      6 	"encoding/json"
      7 	"fmt"
      8 	"io"
      9 	"net/http"
     10 	"net/url"
     11 	"os"
     12 	"path/filepath"
     13 	"regexp"
     14 	"strconv"
     15 	"strings"
     16 	"sync"
     17 	"time"
     18 
     19 	"fyne.io/fyne/v2"
     20 	"fyne.io/fyne/v2/app"
     21 	"fyne.io/fyne/v2/container"
     22 	"fyne.io/fyne/v2/dialog"
     23 	"fyne.io/fyne/v2/theme"
     24 	"fyne.io/fyne/v2/widget"
     25 	"github.com/bogem/id3v2"
     26 	"github.com/mmcdole/gofeed"
     27 )
     28 
     29 // Data structures (shared with TUI)
     30 
     31 type PodcastInfo struct {
     32 	Name       string
     33 	Artist     string
     34 	FeedURL    string
     35 	ArtworkURL string
     36 	ID         string
     37 }
     38 
     39 type SearchResult struct {
     40 	ID         string
     41 	Name       string
     42 	Artist     string
     43 	FeedURL    string
     44 	ArtworkURL string
     45 	Source     SearchProvider
     46 }
     47 
     48 type Episode struct {
     49 	Index       int
     50 	Title       string
     51 	Description string
     52 	AudioURL    string
     53 	PubDate     time.Time
     54 	Duration    string
     55 	Selected    bool
     56 }
     57 
     58 type iTunesResponse struct {
     59 	ResultCount int `json:"resultCount"`
     60 	Results     []struct {
     61 		CollectionID   int    `json:"collectionId"`
     62 		CollectionName string `json:"collectionName"`
     63 		ArtistName     string `json:"artistName"`
     64 		FeedURL        string `json:"feedUrl"`
     65 		ArtworkURL600  string `json:"artworkUrl600"`
     66 		ArtworkURL100  string `json:"artworkUrl100"`
     67 	} `json:"results"`
     68 }
     69 
     70 type podcastIndexResponse struct {
     71 	Status string `json:"status"`
     72 	Feeds  []struct {
     73 		ID          int    `json:"id"`
     74 		Title       string `json:"title"`
     75 		Author      string `json:"author"`
     76 		URL         string `json:"url"`
     77 		Image       string `json:"image"`
     78 		Description string `json:"description"`
     79 	} `json:"feeds"`
     80 	Count int `json:"count"`
     81 }
     82 
     83 type SearchProvider string
     84 
     85 const (
     86 	ProviderApple        SearchProvider = "apple"
     87 	ProviderPodcastIndex SearchProvider = "podcastindex"
     88 )
     89 
     90 // App holds the application state
     91 type App struct {
     92 	fyneApp    fyne.App
     93 	mainWindow fyne.Window
     94 
     95 	// UI components
     96 	searchEntry    *widget.Entry
     97 	searchButton   *widget.Button
     98 	resultsList    *widget.List
     99 	episodeList    *widget.List
    100 	progressBar    *widget.ProgressBar
    101 	statusLabel    *widget.Label
    102 	downloadButton *widget.Button
    103 	selectAllCheck *widget.Check
    104 	backButton     *widget.Button
    105 	outputDirEntry *widget.Entry
    106 	browseButton   *widget.Button
    107 
    108 	// Containers for switching views
    109 	mainContainer    *fyne.Container
    110 	searchView       *fyne.Container
    111 	episodeView      *fyne.Container
    112 	downloadView     *fyne.Container
    113 
    114 	// Header label for episode view
    115 	podcastHeader *widget.Label
    116 
    117 	// Data
    118 	searchResults  []SearchResult
    119 	episodes       []Episode
    120 	podcastInfo    PodcastInfo
    121 	outputDir      string
    122 	downloading    bool
    123 }
    124 
    125 func main() {
    126 	podApp := &App{
    127 		outputDir: ".",
    128 	}
    129 	podApp.Run()
    130 }
    131 
    132 func (a *App) Run() {
    133 	a.fyneApp = app.New()
    134 	a.mainWindow = a.fyneApp.NewWindow("Podcast Downloader")
    135 	a.mainWindow.Resize(fyne.NewSize(800, 600))
    136 
    137 	a.buildUI()
    138 	a.showSearchView()
    139 
    140 	a.mainWindow.ShowAndRun()
    141 }
    142 
    143 func (a *App) buildUI() {
    144 	// Search view components
    145 	a.searchEntry = widget.NewEntry()
    146 	a.searchEntry.SetPlaceHolder("Search podcasts or enter Apple Podcast ID...")
    147 	a.searchEntry.OnSubmitted = func(_ string) { a.doSearch() }
    148 
    149 	a.searchButton = widget.NewButtonWithIcon("Search", theme.SearchIcon(), a.doSearch)
    150 
    151 	a.resultsList = widget.NewList(
    152 		func() int { return len(a.searchResults) },
    153 		func() fyne.CanvasObject {
    154 			return container.NewVBox(
    155 				widget.NewLabel("Podcast Name"),
    156 				widget.NewLabel("Artist"),
    157 			)
    158 		},
    159 		func(id widget.ListItemID, obj fyne.CanvasObject) {
    160 			if id >= len(a.searchResults) {
    161 				return
    162 			}
    163 			result := a.searchResults[id]
    164 			vbox := obj.(*fyne.Container)
    165 			nameLabel := vbox.Objects[0].(*widget.Label)
    166 			artistLabel := vbox.Objects[1].(*widget.Label)
    167 			nameLabel.SetText(result.Name)
    168 			sourceTag := ""
    169 			if result.Source == ProviderPodcastIndex {
    170 				sourceTag = " [PI]"
    171 			}
    172 			artistLabel.SetText(result.Artist + sourceTag)
    173 		},
    174 	)
    175 	a.resultsList.OnSelected = func(id widget.ListItemID) {
    176 		if id < len(a.searchResults) {
    177 			a.loadPodcast(a.searchResults[id])
    178 		}
    179 	}
    180 
    181 	// Output directory selection
    182 	a.outputDirEntry = widget.NewEntry()
    183 	a.outputDirEntry.SetText(a.outputDir)
    184 	a.outputDirEntry.OnChanged = func(s string) { a.outputDir = s }
    185 
    186 	a.browseButton = widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
    187 		dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
    188 			if err != nil || uri == nil {
    189 				return
    190 			}
    191 			a.outputDir = uri.Path()
    192 			a.outputDirEntry.SetText(a.outputDir)
    193 		}, a.mainWindow)
    194 	})
    195 
    196 	outputRow := container.NewBorder(nil, nil, widget.NewLabel("Output:"), a.browseButton, a.outputDirEntry)
    197 
    198 	searchRow := container.NewBorder(nil, nil, nil, a.searchButton, a.searchEntry)
    199 
    200 	a.searchView = container.NewBorder(
    201 		container.NewVBox(
    202 			widget.NewLabel("Podcast Downloader"),
    203 			searchRow,
    204 			outputRow,
    205 			widget.NewSeparator(),
    206 		),
    207 		nil, nil, nil,
    208 		a.resultsList,
    209 	)
    210 
    211 	// Episode view components
    212 	a.backButton = widget.NewButtonWithIcon("Back", theme.NavigateBackIcon(), func() {
    213 		a.showSearchView()
    214 	})
    215 
    216 	a.selectAllCheck = widget.NewCheck("Select All", func(checked bool) {
    217 		for i := range a.episodes {
    218 			a.episodes[i].Selected = checked
    219 		}
    220 		a.episodeList.Refresh()
    221 		a.updateDownloadButton()
    222 	})
    223 
    224 	a.episodeList = widget.NewList(
    225 		func() int { return len(a.episodes) },
    226 		func() fyne.CanvasObject {
    227 			check := widget.NewCheck("", nil)
    228 			titleLabel := widget.NewLabel("Episode Title")
    229 			dateLabel := widget.NewLabel("2024-01-01")
    230 			durationLabel := widget.NewLabel("00:00")
    231 			return container.NewBorder(
    232 				nil, nil,
    233 				check,
    234 				container.NewHBox(dateLabel, durationLabel),
    235 				titleLabel,
    236 			)
    237 		},
    238 		func(id widget.ListItemID, obj fyne.CanvasObject) {
    239 			if id >= len(a.episodes) {
    240 				return
    241 			}
    242 			ep := a.episodes[id]
    243 			border := obj.(*fyne.Container)
    244 			check := border.Objects[1].(*widget.Check)
    245 			titleLabel := border.Objects[0].(*widget.Label)
    246 			rightBox := border.Objects[2].(*fyne.Container)
    247 			dateLabel := rightBox.Objects[0].(*widget.Label)
    248 			durationLabel := rightBox.Objects[1].(*widget.Label)
    249 
    250 			check.SetChecked(ep.Selected)
    251 			check.OnChanged = func(checked bool) {
    252 				a.episodes[id].Selected = checked
    253 				a.updateDownloadButton()
    254 			}
    255 
    256 			title := ep.Title
    257 			if len(title) > 60 {
    258 				title = title[:57] + "..."
    259 			}
    260 			titleLabel.SetText(fmt.Sprintf("[%d] %s", ep.Index, title))
    261 
    262 			if !ep.PubDate.IsZero() {
    263 				dateLabel.SetText(ep.PubDate.Format("2006-01-02"))
    264 			} else {
    265 				dateLabel.SetText("")
    266 			}
    267 			durationLabel.SetText(ep.Duration)
    268 		},
    269 	)
    270 
    271 	a.downloadButton = widget.NewButtonWithIcon("Download Selected", theme.DownloadIcon(), a.startDownload)
    272 	a.downloadButton.Importance = widget.HighImportance
    273 
    274 	a.podcastHeader = widget.NewLabel("")
    275 	a.podcastHeader.TextStyle = fyne.TextStyle{Bold: true}
    276 
    277 	a.episodeView = container.NewBorder(
    278 		container.NewVBox(
    279 			container.NewHBox(a.backButton, a.podcastHeader),
    280 			widget.NewSeparator(),
    281 			a.selectAllCheck,
    282 		),
    283 		container.NewVBox(
    284 			widget.NewSeparator(),
    285 			container.NewHBox(a.downloadButton),
    286 		),
    287 		nil, nil,
    288 		a.episodeList,
    289 	)
    290 
    291 	// Download view components
    292 	a.progressBar = widget.NewProgressBar()
    293 	a.statusLabel = widget.NewLabel("Ready")
    294 
    295 	a.downloadView = container.NewVBox(
    296 		widget.NewLabel("Downloading..."),
    297 		a.progressBar,
    298 		a.statusLabel,
    299 	)
    300 
    301 	// Main container with all views
    302 	a.mainContainer = container.NewStack(a.searchView, a.episodeView, a.downloadView)
    303 	a.mainWindow.SetContent(a.mainContainer)
    304 }
    305 
    306 func (a *App) showSearchView() {
    307 	a.searchView.Show()
    308 	a.episodeView.Hide()
    309 	a.downloadView.Hide()
    310 }
    311 
    312 func (a *App) showEpisodeView() {
    313 	a.searchView.Hide()
    314 	a.episodeView.Show()
    315 	a.downloadView.Hide()
    316 
    317 	// Update header
    318 	a.podcastHeader.SetText(fmt.Sprintf("%s - %d episodes", a.podcastInfo.Name, len(a.episodes)))
    319 }
    320 
    321 func (a *App) showDownloadView() {
    322 	a.searchView.Hide()
    323 	a.episodeView.Hide()
    324 	a.downloadView.Show()
    325 }
    326 
    327 func (a *App) updateDownloadButton() {
    328 	count := 0
    329 	for _, ep := range a.episodes {
    330 		if ep.Selected {
    331 			count++
    332 		}
    333 	}
    334 	if count > 0 {
    335 		a.downloadButton.SetText(fmt.Sprintf("Download %d Episode(s)", count))
    336 		a.downloadButton.Enable()
    337 	} else {
    338 		a.downloadButton.SetText("Download Selected")
    339 		a.downloadButton.Disable()
    340 	}
    341 }
    342 
    343 func (a *App) doSearch() {
    344 	query := strings.TrimSpace(a.searchEntry.Text)
    345 	if query == "" {
    346 		return
    347 	}
    348 
    349 	a.searchButton.Disable()
    350 	a.statusLabel.SetText("Searching...")
    351 
    352 	go func() {
    353 		var results []SearchResult
    354 		var err error
    355 
    356 		if isNumeric(query) {
    357 			// Direct podcast ID lookup
    358 			info, episodes, loadErr := loadPodcastByID(query)
    359 			if loadErr != nil {
    360 				fyne.Do(func() {
    361 					a.showError("Failed to load podcast", loadErr)
    362 					a.searchButton.Enable()
    363 				})
    364 				return
    365 			}
    366 			fyne.Do(func() {
    367 				a.podcastInfo = info
    368 				a.episodes = episodes
    369 				a.searchButton.Enable()
    370 				a.episodeList.Refresh()
    371 				a.updateDownloadButton()
    372 				a.showEpisodeView()
    373 			})
    374 			return
    375 		}
    376 
    377 		// Search both sources if credentials available
    378 		if hasPodcastIndexCredentials() {
    379 			results, err = searchBoth(query)
    380 		} else {
    381 			results, err = searchAppleResults(query)
    382 		}
    383 
    384 		if err != nil {
    385 			fyne.Do(func() {
    386 				a.showError("Search failed", err)
    387 				a.searchButton.Enable()
    388 			})
    389 			return
    390 		}
    391 
    392 		fyne.Do(func() {
    393 			a.searchResults = results
    394 			a.resultsList.Refresh()
    395 			a.searchButton.Enable()
    396 			a.statusLabel.SetText(fmt.Sprintf("Found %d podcasts", len(results)))
    397 		})
    398 	}()
    399 }
    400 
    401 func (a *App) loadPodcast(result SearchResult) {
    402 	a.statusLabel.SetText(fmt.Sprintf("Loading %s...", result.Name))
    403 
    404 	go func() {
    405 		var info PodcastInfo
    406 		var episodes []Episode
    407 		var err error
    408 
    409 		if result.Source == ProviderPodcastIndex {
    410 			info, episodes, err = loadPodcastFromFeed(result.FeedURL, result.Name, result.Artist, result.ArtworkURL)
    411 		} else {
    412 			info, episodes, err = loadPodcastByID(result.ID)
    413 		}
    414 
    415 		if err != nil {
    416 			fyne.Do(func() {
    417 				a.showError("Failed to load podcast", err)
    418 			})
    419 			return
    420 		}
    421 
    422 		fyne.Do(func() {
    423 			a.podcastInfo = info
    424 			a.episodes = episodes
    425 			a.selectAllCheck.SetChecked(false)
    426 			a.episodeList.Refresh()
    427 			a.updateDownloadButton()
    428 			a.showEpisodeView()
    429 			a.statusLabel.SetText("Ready")
    430 		})
    431 	}()
    432 }
    433 
    434 func (a *App) startDownload() {
    435 	if a.downloading {
    436 		return
    437 	}
    438 
    439 	selected := a.getSelectedEpisodes()
    440 	if len(selected) == 0 {
    441 		return
    442 	}
    443 
    444 	a.downloading = true
    445 	a.showDownloadView()
    446 
    447 	// Create output directory
    448 	podcastFolder := sanitizeFilename(a.podcastInfo.Name)
    449 	outputDir := filepath.Join(a.outputDir, podcastFolder)
    450 	os.MkdirAll(outputDir, 0755)
    451 
    452 	go func() {
    453 		defer func() { a.downloading = false }()
    454 
    455 		for i, ep := range selected {
    456 			filename := fmt.Sprintf("%03d - %s.mp3", ep.Index, sanitizeFilename(ep.Title))
    457 			filePath := filepath.Join(outputDir, filename)
    458 
    459 			fyne.Do(func() {
    460 				a.statusLabel.SetText(fmt.Sprintf("Downloading %d/%d: %s", i+1, len(selected), ep.Title))
    461 				a.progressBar.SetValue(0)
    462 			})
    463 
    464 			err := downloadFileWithProgress(filePath, ep.AudioURL, func(progress float64) {
    465 				fyne.Do(func() {
    466 					a.progressBar.SetValue(progress)
    467 				})
    468 			})
    469 
    470 			if err != nil {
    471 				fyne.Do(func() {
    472 					a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
    473 				})
    474 				continue
    475 			}
    476 
    477 			// Add ID3 tags
    478 			addID3Tags(filePath, ep, a.podcastInfo)
    479 		}
    480 
    481 		fyne.Do(func() {
    482 			a.statusLabel.SetText(fmt.Sprintf("Downloaded %d episodes to %s", len(selected), outputDir))
    483 			a.progressBar.SetValue(1)
    484 
    485 			// Show completion dialog
    486 			dialog.ShowInformation("Download Complete",
    487 				fmt.Sprintf("Successfully downloaded %d episode(s) to:\n%s", len(selected), outputDir),
    488 				a.mainWindow)
    489 
    490 			a.showEpisodeView()
    491 		})
    492 	}()
    493 }
    494 
    495 func (a *App) getSelectedEpisodes() []Episode {
    496 	var selected []Episode
    497 	for _, ep := range a.episodes {
    498 		if ep.Selected {
    499 			selected = append(selected, ep)
    500 		}
    501 	}
    502 	return selected
    503 }
    504 
    505 func (a *App) showError(title string, err error) {
    506 	dialog.ShowError(fmt.Errorf("%s: %v", title, err), a.mainWindow)
    507 	a.statusLabel.SetText("Error: " + err.Error())
    508 }
    509 
    510 // Core functions (reused from TUI)
    511 
    512 func isNumeric(s string) bool {
    513 	for _, c := range s {
    514 		if c < '0' || c > '9' {
    515 			return false
    516 		}
    517 	}
    518 	return len(s) > 0
    519 }
    520 
    521 func hasPodcastIndexCredentials() bool {
    522 	apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY"))
    523 	apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET"))
    524 	return apiKey != "" && apiSecret != ""
    525 }
    526 
    527 func sanitizeFilename(name string) string {
    528 	re := regexp.MustCompile(`[<>:"/\\|?*]`)
    529 	name = re.ReplaceAllString(name, "")
    530 	name = strings.TrimSpace(name)
    531 	if len(name) > 100 {
    532 		name = name[:100]
    533 	}
    534 	if name == "" {
    535 		return "episode"
    536 	}
    537 	return name
    538 }
    539 
    540 func loadPodcastByID(podcastID string) (PodcastInfo, []Episode, error) {
    541 	podcastID = strings.TrimPrefix(strings.ToLower(podcastID), "id")
    542 
    543 	url := fmt.Sprintf("https://itunes.apple.com/lookup?id=%s&entity=podcast", podcastID)
    544 	resp, err := http.Get(url)
    545 	if err != nil {
    546 		return PodcastInfo{}, nil, fmt.Errorf("failed to lookup podcast: %w", err)
    547 	}
    548 	defer resp.Body.Close()
    549 
    550 	var result iTunesResponse
    551 	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    552 		return PodcastInfo{}, nil, fmt.Errorf("failed to parse response: %w", err)
    553 	}
    554 
    555 	if result.ResultCount == 0 {
    556 		return PodcastInfo{}, nil, fmt.Errorf("no podcast found with ID: %s", podcastID)
    557 	}
    558 
    559 	r := result.Results[0]
    560 	info := PodcastInfo{
    561 		Name:       r.CollectionName,
    562 		Artist:     r.ArtistName,
    563 		FeedURL:    r.FeedURL,
    564 		ArtworkURL: r.ArtworkURL600,
    565 		ID:         podcastID,
    566 	}
    567 
    568 	if info.ArtworkURL == "" {
    569 		info.ArtworkURL = r.ArtworkURL100
    570 	}
    571 
    572 	if info.FeedURL == "" {
    573 		return PodcastInfo{}, nil, fmt.Errorf("no RSS feed URL found for this podcast")
    574 	}
    575 
    576 	episodes, err := parseRSSFeed(info.FeedURL)
    577 	if err != nil {
    578 		return PodcastInfo{}, nil, err
    579 	}
    580 
    581 	return info, episodes, nil
    582 }
    583 
    584 func loadPodcastFromFeed(feedURL, name, artist, artworkURL string) (PodcastInfo, []Episode, error) {
    585 	info := PodcastInfo{
    586 		Name:       name,
    587 		Artist:     artist,
    588 		FeedURL:    feedURL,
    589 		ArtworkURL: artworkURL,
    590 	}
    591 
    592 	fp := gofeed.NewParser()
    593 	feed, err := fp.ParseURL(feedURL)
    594 	if err != nil {
    595 		return PodcastInfo{}, nil, fmt.Errorf("failed to parse RSS feed: %w", err)
    596 	}
    597 
    598 	if info.Name == "" && feed.Title != "" {
    599 		info.Name = feed.Title
    600 	}
    601 	if info.Artist == "" && feed.Author != nil {
    602 		info.Artist = feed.Author.Name
    603 	}
    604 	if info.ArtworkURL == "" && feed.Image != nil {
    605 		info.ArtworkURL = feed.Image.URL
    606 	}
    607 
    608 	episodes, err := parseRSSFeedItems(feed.Items)
    609 	if err != nil {
    610 		return PodcastInfo{}, nil, err
    611 	}
    612 
    613 	return info, episodes, nil
    614 }
    615 
    616 func parseRSSFeed(feedURL string) ([]Episode, error) {
    617 	fp := gofeed.NewParser()
    618 	feed, err := fp.ParseURL(feedURL)
    619 	if err != nil {
    620 		return nil, fmt.Errorf("failed to parse RSS feed: %w", err)
    621 	}
    622 	return parseRSSFeedItems(feed.Items)
    623 }
    624 
    625 func parseRSSFeedItems(items []*gofeed.Item) ([]Episode, error) {
    626 	var episodes []Episode
    627 	for i, item := range items {
    628 		audioURL := ""
    629 		for _, enc := range item.Enclosures {
    630 			if strings.Contains(enc.Type, "audio") || strings.HasSuffix(enc.URL, ".mp3") {
    631 				audioURL = enc.URL
    632 				break
    633 			}
    634 		}
    635 		if audioURL == "" {
    636 			continue
    637 		}
    638 
    639 		var pubDate time.Time
    640 		if item.PublishedParsed != nil {
    641 			pubDate = *item.PublishedParsed
    642 		}
    643 
    644 		duration := ""
    645 		if item.ITunesExt != nil {
    646 			duration = item.ITunesExt.Duration
    647 		}
    648 
    649 		episodes = append(episodes, Episode{
    650 			Index:       i + 1,
    651 			Title:       item.Title,
    652 			Description: item.Description,
    653 			AudioURL:    audioURL,
    654 			PubDate:     pubDate,
    655 			Duration:    duration,
    656 		})
    657 	}
    658 
    659 	if len(episodes) == 0 {
    660 		return nil, fmt.Errorf("no downloadable episodes found")
    661 	}
    662 
    663 	return episodes, nil
    664 }
    665 
    666 func searchAppleResults(query string) ([]SearchResult, error) {
    667 	encodedQuery := strings.ReplaceAll(query, " ", "+")
    668 	apiURL := fmt.Sprintf("https://itunes.apple.com/search?term=%s&media=podcast&limit=25", encodedQuery)
    669 
    670 	resp, err := http.Get(apiURL)
    671 	if err != nil {
    672 		return nil, err
    673 	}
    674 	defer resp.Body.Close()
    675 
    676 	var result iTunesResponse
    677 	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    678 		return nil, err
    679 	}
    680 
    681 	var results []SearchResult
    682 	for _, r := range result.Results {
    683 		if r.FeedURL == "" {
    684 			continue
    685 		}
    686 		results = append(results, SearchResult{
    687 			ID:         strconv.Itoa(r.CollectionID),
    688 			Name:       r.CollectionName,
    689 			Artist:     r.ArtistName,
    690 			FeedURL:    r.FeedURL,
    691 			ArtworkURL: r.ArtworkURL600,
    692 			Source:     ProviderApple,
    693 		})
    694 	}
    695 	return results, nil
    696 }
    697 
    698 func searchPodcastIndexResults(query string) ([]SearchResult, error) {
    699 	apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY"))
    700 	apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET"))
    701 
    702 	apiHeaderTime := strconv.FormatInt(time.Now().Unix(), 10)
    703 	hashInput := apiKey + apiSecret + apiHeaderTime
    704 	h := sha1.New()
    705 	h.Write([]byte(hashInput))
    706 	authHash := hex.EncodeToString(h.Sum(nil))
    707 
    708 	encodedQuery := url.QueryEscape(query)
    709 	apiURL := fmt.Sprintf("https://api.podcastindex.org/api/1.0/search/byterm?q=%s&max=25", encodedQuery)
    710 
    711 	req, err := http.NewRequest("GET", apiURL, nil)
    712 	if err != nil {
    713 		return nil, err
    714 	}
    715 
    716 	req.Header.Set("User-Agent", "PodcastDownload/1.0")
    717 	req.Header.Set("X-Auth-Key", apiKey)
    718 	req.Header.Set("X-Auth-Date", apiHeaderTime)
    719 	req.Header.Set("Authorization", authHash)
    720 
    721 	client := &http.Client{Timeout: 30 * time.Second}
    722 	resp, err := client.Do(req)
    723 	if err != nil {
    724 		return nil, err
    725 	}
    726 	defer resp.Body.Close()
    727 
    728 	if resp.StatusCode != http.StatusOK {
    729 		body, _ := io.ReadAll(resp.Body)
    730 		return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
    731 	}
    732 
    733 	var result podcastIndexResponse
    734 	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    735 		return nil, err
    736 	}
    737 
    738 	var results []SearchResult
    739 	for _, feed := range result.Feeds {
    740 		if feed.URL == "" {
    741 			continue
    742 		}
    743 		results = append(results, SearchResult{
    744 			ID:         strconv.Itoa(feed.ID),
    745 			Name:       feed.Title,
    746 			Artist:     feed.Author,
    747 			FeedURL:    feed.URL,
    748 			ArtworkURL: feed.Image,
    749 			Source:     ProviderPodcastIndex,
    750 		})
    751 	}
    752 	return results, nil
    753 }
    754 
    755 func searchBoth(query string) ([]SearchResult, error) {
    756 	var wg sync.WaitGroup
    757 	var appleResults, piResults []SearchResult
    758 	var appleErr, piErr error
    759 
    760 	wg.Add(2)
    761 
    762 	go func() {
    763 		defer wg.Done()
    764 		appleResults, appleErr = searchAppleResults(query)
    765 	}()
    766 
    767 	go func() {
    768 		defer wg.Done()
    769 		piResults, piErr = searchPodcastIndexResults(query)
    770 	}()
    771 
    772 	wg.Wait()
    773 
    774 	if appleErr != nil && piErr != nil {
    775 		return nil, fmt.Errorf("search failed: Apple: %v, Podcast Index: %v", appleErr, piErr)
    776 	}
    777 
    778 	var combined []SearchResult
    779 	seenFeedURLs := make(map[string]bool)
    780 
    781 	if appleErr == nil {
    782 		for _, r := range appleResults {
    783 			normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/"))
    784 			if !seenFeedURLs[normalizedURL] {
    785 				seenFeedURLs[normalizedURL] = true
    786 				combined = append(combined, r)
    787 			}
    788 		}
    789 	}
    790 	if piErr == nil {
    791 		for _, r := range piResults {
    792 			normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/"))
    793 			if !seenFeedURLs[normalizedURL] {
    794 				seenFeedURLs[normalizedURL] = true
    795 				combined = append(combined, r)
    796 			}
    797 		}
    798 	}
    799 
    800 	return combined, nil
    801 }
    802 
    803 func downloadFileWithProgress(filepath string, fileURL string, progressCallback func(float64)) error {
    804 	// Check if already exists
    805 	if _, err := os.Stat(filepath); err == nil {
    806 		progressCallback(1.0)
    807 		return nil
    808 	}
    809 
    810 	resp, err := http.Get(fileURL)
    811 	if err != nil {
    812 		return err
    813 	}
    814 	defer resp.Body.Close()
    815 
    816 	out, err := os.Create(filepath)
    817 	if err != nil {
    818 		return err
    819 	}
    820 	defer out.Close()
    821 
    822 	totalSize := resp.ContentLength
    823 	downloaded := int64(0)
    824 	lastPercent := float64(0)
    825 
    826 	buf := make([]byte, 32*1024)
    827 	for {
    828 		n, err := resp.Body.Read(buf)
    829 		if n > 0 {
    830 			out.Write(buf[:n])
    831 			downloaded += int64(n)
    832 			if totalSize > 0 {
    833 				percent := float64(downloaded) / float64(totalSize)
    834 				if percent-lastPercent >= 0.01 || percent >= 1.0 {
    835 					lastPercent = percent
    836 					progressCallback(percent)
    837 				}
    838 			}
    839 		}
    840 		if err == io.EOF {
    841 			break
    842 		}
    843 		if err != nil {
    844 			return err
    845 		}
    846 	}
    847 
    848 	return nil
    849 }
    850 
    851 func addID3Tags(filepath string, ep Episode, info PodcastInfo) error {
    852 	tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
    853 	if err != nil {
    854 		tag = id3v2.NewEmptyTag()
    855 	}
    856 	defer tag.Close()
    857 
    858 	tag.SetTitle(ep.Title)
    859 	tag.SetArtist(info.Artist)
    860 	tag.SetAlbum(info.Name)
    861 
    862 	trackFrame := id3v2.TextFrame{
    863 		Encoding: id3v2.EncodingUTF8,
    864 		Text:     strconv.Itoa(ep.Index),
    865 	}
    866 	tag.AddFrame(tag.CommonID("Track number/Position in set"), trackFrame)
    867 
    868 	return tag.Save()
    869 }