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 }