commit a8cd27fd24a5276ed9fab3b05d571aba541b97bf
parent 27ab48db56062f29ef98755c7309e04d6618ee2d
Author: Erik Loualiche <eloualic@umn.edu>
Date: Thu, 5 Feb 2026 20:08:40 -0600
Merge remote-tracking branch 'origin/fyne-gui'
# Conflicts:
# .gitignore
Diffstat:
| M | .gitignore | | | 8 | ++++---- |
| A | cmd/gui/main.go | | | 869 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | go.mod | | | 34 | ++++++++++++++++++++++++++++++++-- |
| M | go.sum | | | 78 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ |
| M | justfile | | | 17 | ++++++++++++++--- |
5 files changed, 991 insertions(+), 15 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,10 +1,10 @@
# Build output
podcastdownload
podcast-gui
-podcastdownload
-# Claude Code local settings
-.claude/
+# Claude Code local settings (but keep claude.md for project docs)
+.claude/*
+!.claude/claude.md
+
.DS_Store
*.tape
-podcastdownload
diff --git a/cmd/gui/main.go b/cmd/gui/main.go
@@ -0,0 +1,869 @@
+package main
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/app"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/theme"
+ "fyne.io/fyne/v2/widget"
+ "github.com/bogem/id3v2"
+ "github.com/mmcdole/gofeed"
+)
+
+// Data structures (shared with TUI)
+
+type PodcastInfo struct {
+ Name string
+ Artist string
+ FeedURL string
+ ArtworkURL string
+ ID string
+}
+
+type SearchResult struct {
+ ID string
+ Name string
+ Artist string
+ FeedURL string
+ ArtworkURL string
+ Source SearchProvider
+}
+
+type Episode struct {
+ Index int
+ Title string
+ Description string
+ AudioURL string
+ PubDate time.Time
+ Duration string
+ Selected bool
+}
+
+type iTunesResponse struct {
+ ResultCount int `json:"resultCount"`
+ Results []struct {
+ CollectionID int `json:"collectionId"`
+ CollectionName string `json:"collectionName"`
+ ArtistName string `json:"artistName"`
+ FeedURL string `json:"feedUrl"`
+ ArtworkURL600 string `json:"artworkUrl600"`
+ ArtworkURL100 string `json:"artworkUrl100"`
+ } `json:"results"`
+}
+
+type podcastIndexResponse struct {
+ Status string `json:"status"`
+ Feeds []struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Author string `json:"author"`
+ URL string `json:"url"`
+ Image string `json:"image"`
+ Description string `json:"description"`
+ } `json:"feeds"`
+ Count int `json:"count"`
+}
+
+type SearchProvider string
+
+const (
+ ProviderApple SearchProvider = "apple"
+ ProviderPodcastIndex SearchProvider = "podcastindex"
+)
+
+// App holds the application state
+type App struct {
+ fyneApp fyne.App
+ mainWindow fyne.Window
+
+ // UI components
+ searchEntry *widget.Entry
+ searchButton *widget.Button
+ resultsList *widget.List
+ episodeList *widget.List
+ progressBar *widget.ProgressBar
+ statusLabel *widget.Label
+ downloadButton *widget.Button
+ selectAllCheck *widget.Check
+ backButton *widget.Button
+ outputDirEntry *widget.Entry
+ browseButton *widget.Button
+
+ // Containers for switching views
+ mainContainer *fyne.Container
+ searchView *fyne.Container
+ episodeView *fyne.Container
+ downloadView *fyne.Container
+
+ // Header label for episode view
+ podcastHeader *widget.Label
+
+ // Data
+ searchResults []SearchResult
+ episodes []Episode
+ podcastInfo PodcastInfo
+ outputDir string
+ downloading bool
+}
+
+func main() {
+ podApp := &App{
+ outputDir: ".",
+ }
+ podApp.Run()
+}
+
+func (a *App) Run() {
+ a.fyneApp = app.New()
+ a.mainWindow = a.fyneApp.NewWindow("Podcast Downloader")
+ a.mainWindow.Resize(fyne.NewSize(800, 600))
+
+ a.buildUI()
+ a.showSearchView()
+
+ a.mainWindow.ShowAndRun()
+}
+
+func (a *App) buildUI() {
+ // Search view components
+ a.searchEntry = widget.NewEntry()
+ a.searchEntry.SetPlaceHolder("Search podcasts or enter Apple Podcast ID...")
+ a.searchEntry.OnSubmitted = func(_ string) { a.doSearch() }
+
+ a.searchButton = widget.NewButtonWithIcon("Search", theme.SearchIcon(), a.doSearch)
+
+ a.resultsList = widget.NewList(
+ func() int { return len(a.searchResults) },
+ func() fyne.CanvasObject {
+ return container.NewVBox(
+ widget.NewLabel("Podcast Name"),
+ widget.NewLabel("Artist"),
+ )
+ },
+ func(id widget.ListItemID, obj fyne.CanvasObject) {
+ if id >= len(a.searchResults) {
+ return
+ }
+ result := a.searchResults[id]
+ vbox := obj.(*fyne.Container)
+ nameLabel := vbox.Objects[0].(*widget.Label)
+ artistLabel := vbox.Objects[1].(*widget.Label)
+ nameLabel.SetText(result.Name)
+ sourceTag := ""
+ if result.Source == ProviderPodcastIndex {
+ sourceTag = " [PI]"
+ }
+ artistLabel.SetText(result.Artist + sourceTag)
+ },
+ )
+ a.resultsList.OnSelected = func(id widget.ListItemID) {
+ if id < len(a.searchResults) {
+ a.loadPodcast(a.searchResults[id])
+ }
+ }
+
+ // Output directory selection
+ a.outputDirEntry = widget.NewEntry()
+ a.outputDirEntry.SetText(a.outputDir)
+ a.outputDirEntry.OnChanged = func(s string) { a.outputDir = s }
+
+ a.browseButton = widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
+ dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
+ if err != nil || uri == nil {
+ return
+ }
+ a.outputDir = uri.Path()
+ a.outputDirEntry.SetText(a.outputDir)
+ }, a.mainWindow)
+ })
+
+ outputRow := container.NewBorder(nil, nil, widget.NewLabel("Output:"), a.browseButton, a.outputDirEntry)
+
+ searchRow := container.NewBorder(nil, nil, nil, a.searchButton, a.searchEntry)
+
+ a.searchView = container.NewBorder(
+ container.NewVBox(
+ widget.NewLabel("Podcast Downloader"),
+ searchRow,
+ outputRow,
+ widget.NewSeparator(),
+ ),
+ nil, nil, nil,
+ a.resultsList,
+ )
+
+ // Episode view components
+ a.backButton = widget.NewButtonWithIcon("Back", theme.NavigateBackIcon(), func() {
+ a.showSearchView()
+ })
+
+ a.selectAllCheck = widget.NewCheck("Select All", func(checked bool) {
+ for i := range a.episodes {
+ a.episodes[i].Selected = checked
+ }
+ a.episodeList.Refresh()
+ a.updateDownloadButton()
+ })
+
+ a.episodeList = widget.NewList(
+ func() int { return len(a.episodes) },
+ func() fyne.CanvasObject {
+ check := widget.NewCheck("", nil)
+ titleLabel := widget.NewLabel("Episode Title")
+ dateLabel := widget.NewLabel("2024-01-01")
+ durationLabel := widget.NewLabel("00:00")
+ return container.NewBorder(
+ nil, nil,
+ check,
+ container.NewHBox(dateLabel, durationLabel),
+ titleLabel,
+ )
+ },
+ func(id widget.ListItemID, obj fyne.CanvasObject) {
+ if id >= len(a.episodes) {
+ return
+ }
+ ep := a.episodes[id]
+ border := obj.(*fyne.Container)
+ check := border.Objects[1].(*widget.Check)
+ titleLabel := border.Objects[0].(*widget.Label)
+ rightBox := border.Objects[2].(*fyne.Container)
+ dateLabel := rightBox.Objects[0].(*widget.Label)
+ durationLabel := rightBox.Objects[1].(*widget.Label)
+
+ check.SetChecked(ep.Selected)
+ check.OnChanged = func(checked bool) {
+ a.episodes[id].Selected = checked
+ a.updateDownloadButton()
+ }
+
+ title := ep.Title
+ if len(title) > 60 {
+ title = title[:57] + "..."
+ }
+ titleLabel.SetText(fmt.Sprintf("[%d] %s", ep.Index, title))
+
+ if !ep.PubDate.IsZero() {
+ dateLabel.SetText(ep.PubDate.Format("2006-01-02"))
+ } else {
+ dateLabel.SetText("")
+ }
+ durationLabel.SetText(ep.Duration)
+ },
+ )
+
+ a.downloadButton = widget.NewButtonWithIcon("Download Selected", theme.DownloadIcon(), a.startDownload)
+ a.downloadButton.Importance = widget.HighImportance
+
+ a.podcastHeader = widget.NewLabel("")
+ a.podcastHeader.TextStyle = fyne.TextStyle{Bold: true}
+
+ a.episodeView = container.NewBorder(
+ container.NewVBox(
+ container.NewHBox(a.backButton, a.podcastHeader),
+ widget.NewSeparator(),
+ a.selectAllCheck,
+ ),
+ container.NewVBox(
+ widget.NewSeparator(),
+ container.NewHBox(a.downloadButton),
+ ),
+ nil, nil,
+ a.episodeList,
+ )
+
+ // Download view components
+ a.progressBar = widget.NewProgressBar()
+ a.statusLabel = widget.NewLabel("Ready")
+
+ a.downloadView = container.NewVBox(
+ widget.NewLabel("Downloading..."),
+ a.progressBar,
+ a.statusLabel,
+ )
+
+ // Main container with all views
+ a.mainContainer = container.NewStack(a.searchView, a.episodeView, a.downloadView)
+ a.mainWindow.SetContent(a.mainContainer)
+}
+
+func (a *App) showSearchView() {
+ a.searchView.Show()
+ a.episodeView.Hide()
+ a.downloadView.Hide()
+}
+
+func (a *App) showEpisodeView() {
+ a.searchView.Hide()
+ a.episodeView.Show()
+ a.downloadView.Hide()
+
+ // Update header
+ a.podcastHeader.SetText(fmt.Sprintf("%s - %d episodes", a.podcastInfo.Name, len(a.episodes)))
+}
+
+func (a *App) showDownloadView() {
+ a.searchView.Hide()
+ a.episodeView.Hide()
+ a.downloadView.Show()
+}
+
+func (a *App) updateDownloadButton() {
+ count := 0
+ for _, ep := range a.episodes {
+ if ep.Selected {
+ count++
+ }
+ }
+ if count > 0 {
+ a.downloadButton.SetText(fmt.Sprintf("Download %d Episode(s)", count))
+ a.downloadButton.Enable()
+ } else {
+ a.downloadButton.SetText("Download Selected")
+ a.downloadButton.Disable()
+ }
+}
+
+func (a *App) doSearch() {
+ query := strings.TrimSpace(a.searchEntry.Text)
+ if query == "" {
+ return
+ }
+
+ a.searchButton.Disable()
+ a.statusLabel.SetText("Searching...")
+
+ go func() {
+ var results []SearchResult
+ var err error
+
+ if isNumeric(query) {
+ // Direct podcast ID lookup
+ info, episodes, loadErr := loadPodcastByID(query)
+ if loadErr != nil {
+ fyne.Do(func() {
+ a.showError("Failed to load podcast", loadErr)
+ a.searchButton.Enable()
+ })
+ return
+ }
+ fyne.Do(func() {
+ a.podcastInfo = info
+ a.episodes = episodes
+ a.searchButton.Enable()
+ a.episodeList.Refresh()
+ a.updateDownloadButton()
+ a.showEpisodeView()
+ })
+ return
+ }
+
+ // Search both sources if credentials available
+ if hasPodcastIndexCredentials() {
+ results, err = searchBoth(query)
+ } else {
+ results, err = searchAppleResults(query)
+ }
+
+ if err != nil {
+ fyne.Do(func() {
+ a.showError("Search failed", err)
+ a.searchButton.Enable()
+ })
+ return
+ }
+
+ fyne.Do(func() {
+ a.searchResults = results
+ a.resultsList.Refresh()
+ a.searchButton.Enable()
+ a.statusLabel.SetText(fmt.Sprintf("Found %d podcasts", len(results)))
+ })
+ }()
+}
+
+func (a *App) loadPodcast(result SearchResult) {
+ a.statusLabel.SetText(fmt.Sprintf("Loading %s...", result.Name))
+
+ go func() {
+ var info PodcastInfo
+ var episodes []Episode
+ var err error
+
+ if result.Source == ProviderPodcastIndex {
+ info, episodes, err = loadPodcastFromFeed(result.FeedURL, result.Name, result.Artist, result.ArtworkURL)
+ } else {
+ info, episodes, err = loadPodcastByID(result.ID)
+ }
+
+ if err != nil {
+ fyne.Do(func() {
+ a.showError("Failed to load podcast", err)
+ })
+ return
+ }
+
+ fyne.Do(func() {
+ a.podcastInfo = info
+ a.episodes = episodes
+ a.selectAllCheck.SetChecked(false)
+ a.episodeList.Refresh()
+ a.updateDownloadButton()
+ a.showEpisodeView()
+ a.statusLabel.SetText("Ready")
+ })
+ }()
+}
+
+func (a *App) startDownload() {
+ if a.downloading {
+ return
+ }
+
+ selected := a.getSelectedEpisodes()
+ if len(selected) == 0 {
+ return
+ }
+
+ a.downloading = true
+ a.showDownloadView()
+
+ // Create output directory
+ podcastFolder := sanitizeFilename(a.podcastInfo.Name)
+ outputDir := filepath.Join(a.outputDir, podcastFolder)
+ os.MkdirAll(outputDir, 0755)
+
+ go func() {
+ defer func() { a.downloading = false }()
+
+ for i, ep := range selected {
+ filename := fmt.Sprintf("%03d - %s.mp3", ep.Index, sanitizeFilename(ep.Title))
+ filePath := filepath.Join(outputDir, filename)
+
+ fyne.Do(func() {
+ a.statusLabel.SetText(fmt.Sprintf("Downloading %d/%d: %s", i+1, len(selected), ep.Title))
+ a.progressBar.SetValue(0)
+ })
+
+ err := downloadFileWithProgress(filePath, ep.AudioURL, func(progress float64) {
+ fyne.Do(func() {
+ a.progressBar.SetValue(progress)
+ })
+ })
+
+ if err != nil {
+ fyne.Do(func() {
+ a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
+ })
+ continue
+ }
+
+ // Add ID3 tags
+ addID3Tags(filePath, ep, a.podcastInfo)
+ }
+
+ fyne.Do(func() {
+ a.statusLabel.SetText(fmt.Sprintf("Downloaded %d episodes to %s", len(selected), outputDir))
+ a.progressBar.SetValue(1)
+
+ // Show completion dialog
+ dialog.ShowInformation("Download Complete",
+ fmt.Sprintf("Successfully downloaded %d episode(s) to:\n%s", len(selected), outputDir),
+ a.mainWindow)
+
+ a.showEpisodeView()
+ })
+ }()
+}
+
+func (a *App) getSelectedEpisodes() []Episode {
+ var selected []Episode
+ for _, ep := range a.episodes {
+ if ep.Selected {
+ selected = append(selected, ep)
+ }
+ }
+ return selected
+}
+
+func (a *App) showError(title string, err error) {
+ dialog.ShowError(fmt.Errorf("%s: %v", title, err), a.mainWindow)
+ a.statusLabel.SetText("Error: " + err.Error())
+}
+
+// Core functions (reused from TUI)
+
+func isNumeric(s string) bool {
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ return false
+ }
+ }
+ return len(s) > 0
+}
+
+func hasPodcastIndexCredentials() bool {
+ apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY"))
+ apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET"))
+ return apiKey != "" && apiSecret != ""
+}
+
+func sanitizeFilename(name string) string {
+ re := regexp.MustCompile(`[<>:"/\\|?*]`)
+ name = re.ReplaceAllString(name, "")
+ name = strings.TrimSpace(name)
+ if len(name) > 100 {
+ name = name[:100]
+ }
+ if name == "" {
+ return "episode"
+ }
+ return name
+}
+
+func loadPodcastByID(podcastID string) (PodcastInfo, []Episode, error) {
+ podcastID = strings.TrimPrefix(strings.ToLower(podcastID), "id")
+
+ url := fmt.Sprintf("https://itunes.apple.com/lookup?id=%s&entity=podcast", podcastID)
+ resp, err := http.Get(url)
+ if err != nil {
+ return PodcastInfo{}, nil, fmt.Errorf("failed to lookup podcast: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var result iTunesResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return PodcastInfo{}, nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if result.ResultCount == 0 {
+ return PodcastInfo{}, nil, fmt.Errorf("no podcast found with ID: %s", podcastID)
+ }
+
+ r := result.Results[0]
+ info := PodcastInfo{
+ Name: r.CollectionName,
+ Artist: r.ArtistName,
+ FeedURL: r.FeedURL,
+ ArtworkURL: r.ArtworkURL600,
+ ID: podcastID,
+ }
+
+ if info.ArtworkURL == "" {
+ info.ArtworkURL = r.ArtworkURL100
+ }
+
+ if info.FeedURL == "" {
+ return PodcastInfo{}, nil, fmt.Errorf("no RSS feed URL found for this podcast")
+ }
+
+ episodes, err := parseRSSFeed(info.FeedURL)
+ if err != nil {
+ return PodcastInfo{}, nil, err
+ }
+
+ return info, episodes, nil
+}
+
+func loadPodcastFromFeed(feedURL, name, artist, artworkURL string) (PodcastInfo, []Episode, error) {
+ info := PodcastInfo{
+ Name: name,
+ Artist: artist,
+ FeedURL: feedURL,
+ ArtworkURL: artworkURL,
+ }
+
+ fp := gofeed.NewParser()
+ feed, err := fp.ParseURL(feedURL)
+ if err != nil {
+ return PodcastInfo{}, nil, fmt.Errorf("failed to parse RSS feed: %w", err)
+ }
+
+ if info.Name == "" && feed.Title != "" {
+ info.Name = feed.Title
+ }
+ if info.Artist == "" && feed.Author != nil {
+ info.Artist = feed.Author.Name
+ }
+ if info.ArtworkURL == "" && feed.Image != nil {
+ info.ArtworkURL = feed.Image.URL
+ }
+
+ episodes, err := parseRSSFeedItems(feed.Items)
+ if err != nil {
+ return PodcastInfo{}, nil, err
+ }
+
+ return info, episodes, nil
+}
+
+func parseRSSFeed(feedURL string) ([]Episode, error) {
+ fp := gofeed.NewParser()
+ feed, err := fp.ParseURL(feedURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse RSS feed: %w", err)
+ }
+ return parseRSSFeedItems(feed.Items)
+}
+
+func parseRSSFeedItems(items []*gofeed.Item) ([]Episode, error) {
+ var episodes []Episode
+ for i, item := range items {
+ audioURL := ""
+ for _, enc := range item.Enclosures {
+ if strings.Contains(enc.Type, "audio") || strings.HasSuffix(enc.URL, ".mp3") {
+ audioURL = enc.URL
+ break
+ }
+ }
+ if audioURL == "" {
+ continue
+ }
+
+ var pubDate time.Time
+ if item.PublishedParsed != nil {
+ pubDate = *item.PublishedParsed
+ }
+
+ duration := ""
+ if item.ITunesExt != nil {
+ duration = item.ITunesExt.Duration
+ }
+
+ episodes = append(episodes, Episode{
+ Index: i + 1,
+ Title: item.Title,
+ Description: item.Description,
+ AudioURL: audioURL,
+ PubDate: pubDate,
+ Duration: duration,
+ })
+ }
+
+ if len(episodes) == 0 {
+ return nil, fmt.Errorf("no downloadable episodes found")
+ }
+
+ return episodes, nil
+}
+
+func searchAppleResults(query string) ([]SearchResult, error) {
+ encodedQuery := strings.ReplaceAll(query, " ", "+")
+ apiURL := fmt.Sprintf("https://itunes.apple.com/search?term=%s&media=podcast&limit=25", encodedQuery)
+
+ resp, err := http.Get(apiURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var result iTunesResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+
+ var results []SearchResult
+ for _, r := range result.Results {
+ if r.FeedURL == "" {
+ continue
+ }
+ results = append(results, SearchResult{
+ ID: strconv.Itoa(r.CollectionID),
+ Name: r.CollectionName,
+ Artist: r.ArtistName,
+ FeedURL: r.FeedURL,
+ ArtworkURL: r.ArtworkURL600,
+ Source: ProviderApple,
+ })
+ }
+ return results, nil
+}
+
+func searchPodcastIndexResults(query string) ([]SearchResult, error) {
+ apiKey := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_KEY"))
+ apiSecret := strings.TrimSpace(os.Getenv("PODCASTINDEX_API_SECRET"))
+
+ apiHeaderTime := strconv.FormatInt(time.Now().Unix(), 10)
+ hashInput := apiKey + apiSecret + apiHeaderTime
+ h := sha1.New()
+ h.Write([]byte(hashInput))
+ authHash := hex.EncodeToString(h.Sum(nil))
+
+ encodedQuery := url.QueryEscape(query)
+ apiURL := fmt.Sprintf("https://api.podcastindex.org/api/1.0/search/byterm?q=%s&max=25", encodedQuery)
+
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("User-Agent", "PodcastDownload/1.0")
+ req.Header.Set("X-Auth-Key", apiKey)
+ req.Header.Set("X-Auth-Date", apiHeaderTime)
+ req.Header.Set("Authorization", authHash)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
+ }
+
+ var result podcastIndexResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+
+ var results []SearchResult
+ for _, feed := range result.Feeds {
+ if feed.URL == "" {
+ continue
+ }
+ results = append(results, SearchResult{
+ ID: strconv.Itoa(feed.ID),
+ Name: feed.Title,
+ Artist: feed.Author,
+ FeedURL: feed.URL,
+ ArtworkURL: feed.Image,
+ Source: ProviderPodcastIndex,
+ })
+ }
+ return results, nil
+}
+
+func searchBoth(query string) ([]SearchResult, error) {
+ var wg sync.WaitGroup
+ var appleResults, piResults []SearchResult
+ var appleErr, piErr error
+
+ wg.Add(2)
+
+ go func() {
+ defer wg.Done()
+ appleResults, appleErr = searchAppleResults(query)
+ }()
+
+ go func() {
+ defer wg.Done()
+ piResults, piErr = searchPodcastIndexResults(query)
+ }()
+
+ wg.Wait()
+
+ if appleErr != nil && piErr != nil {
+ return nil, fmt.Errorf("search failed: Apple: %v, Podcast Index: %v", appleErr, piErr)
+ }
+
+ var combined []SearchResult
+ seenFeedURLs := make(map[string]bool)
+
+ if appleErr == nil {
+ for _, r := range appleResults {
+ normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/"))
+ if !seenFeedURLs[normalizedURL] {
+ seenFeedURLs[normalizedURL] = true
+ combined = append(combined, r)
+ }
+ }
+ }
+ if piErr == nil {
+ for _, r := range piResults {
+ normalizedURL := strings.ToLower(strings.TrimSuffix(r.FeedURL, "/"))
+ if !seenFeedURLs[normalizedURL] {
+ seenFeedURLs[normalizedURL] = true
+ combined = append(combined, r)
+ }
+ }
+ }
+
+ return combined, nil
+}
+
+func downloadFileWithProgress(filepath string, fileURL string, progressCallback func(float64)) error {
+ // Check if already exists
+ if _, err := os.Stat(filepath); err == nil {
+ progressCallback(1.0)
+ return nil
+ }
+
+ resp, err := http.Get(fileURL)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ out, err := os.Create(filepath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ totalSize := resp.ContentLength
+ downloaded := int64(0)
+ lastPercent := float64(0)
+
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := resp.Body.Read(buf)
+ if n > 0 {
+ out.Write(buf[:n])
+ downloaded += int64(n)
+ if totalSize > 0 {
+ percent := float64(downloaded) / float64(totalSize)
+ if percent-lastPercent >= 0.01 || percent >= 1.0 {
+ lastPercent = percent
+ progressCallback(percent)
+ }
+ }
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func addID3Tags(filepath string, ep Episode, info PodcastInfo) error {
+ tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
+ if err != nil {
+ tag = id3v2.NewEmptyTag()
+ }
+ defer tag.Close()
+
+ tag.SetTitle(ep.Title)
+ tag.SetArtist(info.Artist)
+ tag.SetAlbum(info.Name)
+
+ trackFrame := id3v2.TextFrame{
+ Encoding: id3v2.EncodingUTF8,
+ Text: strconv.Itoa(ep.Index),
+ }
+ tag.AddFrame(tag.CommonID("Track number/Position in set"), trackFrame)
+
+ return tag.Save()
+}
diff --git a/go.mod b/go.mod
@@ -3,6 +3,7 @@ module podcastdownload
go 1.25.5
require (
+ fyne.io/fyne/v2 v2.7.2
github.com/bogem/id3v2 v1.2.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
@@ -11,6 +12,8 @@ require (
)
require (
+ fyne.io/systray v1.12.0 // indirect
+ github.com/BurntSushi/toml v1.5.0 // indirect
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -19,8 +22,25 @@ require (
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/fredbi/uri v1.1.1 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/fyne-io/gl-js v0.2.0 // indirect
+ github.com/fyne-io/glfw-js v0.3.0 // indirect
+ github.com/fyne-io/image v0.1.1 // indirect
+ github.com/fyne-io/oksvg v0.2.0 // indirect
+ github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
+ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
+ github.com/go-text/render v0.2.0 // indirect
+ github.com/go-text/typesetting v0.2.1 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/hack-pad/go-indexeddb v0.3.2 // indirect
+ github.com/hack-pad/safejs v0.1.0 // indirect
+ github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/json-iterator/go v1.1.12 // indirect
+ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
+ github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -31,9 +51,19 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
+ github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/rymdport/portal v0.4.2 // indirect
+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- golang.org/x/net v0.4.0 // indirect
+ github.com/yuin/goldmark v1.7.8 // indirect
+ golang.org/x/image v0.24.0 // indirect
+ golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.36.0 // indirect
- golang.org/x/text v0.5.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
@@ -1,3 +1,9 @@
+fyne.io/fyne/v2 v2.7.2 h1:XiNpWkn0PzX43ZCjbb0QYGg1RCxVbugwfVgikWZBCMw=
+fyne.io/fyne/v2 v2.7.2/go.mod h1:PXbqY3mQmJV3J1NRUR2VbVgUUx3vgvhuFJxyjRK/4Ug=
+fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
+fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
@@ -22,14 +28,53 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
+github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
+github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
+github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
+github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
+github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
+github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
+github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
+github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
+github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
+github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
+github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
+github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
+github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
+github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
+github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
+github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
+github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
+github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
+github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
+github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
+github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
+github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
+github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -53,22 +98,40 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
+github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
+github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
+github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
+golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
-golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -78,8 +141,11 @@ golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
-golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/justfile b/justfile
@@ -1,11 +1,22 @@
-# Build the podcast downloader
+# Build the TUI podcast downloader
build:
go build -o podcastdownload main.go
+# Build the GUI podcast downloader
+build-gui:
+ go build -o podcast-gui ./cmd/gui/
+
+# Build both versions
+build-all: build build-gui
+
# Remove build artifacts
clean:
- rm -f podcastdownload
+ rm -f podcastdownload podcast-gui
-# Build and run the application
+# Build and run the TUI application
run: build
./podcastdownload
+
+# Build and run the GUI application
+run-gui: build-gui
+ ./podcast-gui