podcast-go

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

commit 27ab48db56062f29ef98755c7309e04d6618ee2d
parent 7c7bdb8bf3a4ff34436bd4122cac0f488cb28546
Author: Erik Loualiche <eloualiche@users.noreply.github.com>
Date:   Thu,  5 Feb 2026 19:59:49 -0600

Merge pull request #1 from eloualiche/fix-acast-download-add-tests

Fix Acast download and add CI with tests
Diffstat:
A.github/workflows/go.yml | 46++++++++++++++++++++++++++++++++++++++++++++++
M.gitignore | 6++++--
Ainternal/podcast/utils.go | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/podcast/utils_test.go | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmain.go | 92++++++++++---------------------------------------------------------------------
Dpodcastdownload | 0
6 files changed, 521 insertions(+), 83 deletions(-)

diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml @@ -0,0 +1,46 @@ +name: Go + +on: + push: + branches: [main] + paths: + - '**.go' + - 'go.mod' + - 'go.sum' + - '.github/workflows/go.yml' + pull_request: + branches: [main] + paths: + - '**.go' + - 'go.mod' + - 'go.sum' + - '.github/workflows/go.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: coverage.out + fail_ci_if_error: false + continue-on-error: true diff --git a/.gitignore b/.gitignore @@ -1,7 +1,10 @@ # Build output podcastdownload +podcast-gui +podcastdownload # Claude Code local settings .claude/ .DS_Store -*.tape- \ No newline at end of file +*.tape +podcastdownload diff --git a/internal/podcast/utils.go b/internal/podcast/utils.go @@ -0,0 +1,182 @@ +package podcast + +import ( + "fmt" + "html" + "io" + "net/http" + "os" + "regexp" + "strings" +) + +// UserAgent is used for HTTP requests. Some podcast hosts (like Acast) block +// Go's default User-Agent. +const UserAgent = "PodcastDownloader/1.0" + +// httpClient is a shared HTTP client with proper User-Agent +var httpClient = &http.Client{} + +// httpGet performs an HTTP GET with proper User-Agent +func httpGet(url string) (*http.Response, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", UserAgent) + return httpClient.Do(req) +} + +// IsNumeric checks if a string is all digits (podcast ID) +func IsNumeric(s string) bool { + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return len(s) > 0 +} + +// SanitizeFilename removes invalid characters from a filename +func SanitizeFilename(name string) string { + // Remove invalid characters + re := regexp.MustCompile(`[<>:"/\\|?*]`) + name = re.ReplaceAllString(name, "") + name = strings.TrimSpace(name) + + // Limit length + if len(name) > 100 { + name = name[:100] + } + + if name == "" { + return "episode" + } + return name +} + +// ProgressCallback is called during downloads with the current progress (0.0-1.0) +type ProgressCallback func(percent float64) + +// DownloadFile downloads a file from a URL with progress reporting +// It handles text-based redirects used by some podcast hosts (like Acast) +func DownloadFile(filepath string, url string, onProgress ProgressCallback) error { + // Check if already exists + if _, err := os.Stat(filepath); err == nil { + return nil + } + + resp, err := httpGet(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check for HTTP errors + if resp.StatusCode >= 400 { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + // Some podcast hosts (like Acast) return a 200 with text body containing redirect URL + // instead of using proper HTTP redirects. Detect and follow these. + if resp.ContentLength > 0 && resp.ContentLength < 1000 && + strings.Contains(resp.Header.Get("Content-Type"), "text/plain") { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + bodyStr := string(body) + if strings.HasPrefix(bodyStr, "Redirecting to ") { + redirectURL := strings.TrimPrefix(bodyStr, "Redirecting to ") + redirectURL = strings.TrimSpace(redirectURL) + // Unescape HTML entities like &amp; -> & + redirectURL = html.UnescapeString(redirectURL) + return DownloadFile(filepath, redirectURL, onProgress) + } + } + + 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 && onProgress != nil { + percent := float64(downloaded) / float64(totalSize) + // Only send updates every 1% to avoid flooding + if percent-lastPercent >= 0.01 || percent >= 1.0 { + lastPercent = percent + onProgress(percent) + } + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + + return nil +} + +// ParseEpisodeSpec parses an episode specification string and returns episode indices. +// Supports: "all", "latest", single numbers "5", ranges "1-5", and comma-separated "1,3,5" +func ParseEpisodeSpec(spec string, total int) []int { + if spec == "" || spec == "all" { + result := make([]int, total) + for i := range result { + result[i] = i + 1 + } + return result + } + + if spec == "latest" { + return []int{1} + } + + var result []int + parts := strings.Split(spec, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.Contains(part, "-") { + rangeParts := strings.Split(part, "-") + if len(rangeParts) == 2 { + start := parseInt(strings.TrimSpace(rangeParts[0])) + end := parseInt(strings.TrimSpace(rangeParts[1])) + if start > 0 && end > 0 { + for i := start; i <= end; i++ { + result = append(result, i) + } + } + } + } else { + if num := parseInt(part); num > 0 { + result = append(result, num) + } + } + } + return result +} + +func parseInt(s string) int { + var n int + for _, c := range s { + if c < '0' || c > '9' { + return 0 + } + n = n*10 + int(c-'0') + } + return n +} diff --git a/internal/podcast/utils_test.go b/internal/podcast/utils_test.go @@ -0,0 +1,278 @@ +package podcast + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsNumeric(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"123456", true}, + {"1200361736", true}, + {"0", true}, + {"", false}, + {"abc", false}, + {"123abc", false}, + {"12.34", false}, + {"-123", false}, + {"id123", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := IsNumeric(tt.input) + if result != tt.expected { + t.Errorf("IsNumeric(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestSanitizeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Simple Title", "Simple Title"}, + {"Title: With Colon", "Title With Colon"}, + {"Title/With/Slashes", "TitleWithSlashes"}, + {"Title\\With\\Backslashes", "TitleWithBackslashes"}, + {"Title<With>Brackets", "TitleWithBrackets"}, + {"Title|With|Pipes", "TitleWithPipes"}, + {"Title?With?Questions", "TitleWithQuestions"}, + {"Title*With*Stars", "TitleWithStars"}, + {"Title\"With\"Quotes", "TitleWithQuotes"}, + {" Spaces Around ", "Spaces Around"}, + {"", "episode"}, + {" ", "episode"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := SanitizeFilename(tt.input) + if result != tt.expected { + t.Errorf("SanitizeFilename(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestSanitizeFilename_LongName(t *testing.T) { + longName := strings.Repeat("a", 150) + result := SanitizeFilename(longName) + if len(result) > 100 { + t.Errorf("SanitizeFilename should truncate to 100 chars, got %d", len(result)) + } + if len(result) != 100 { + t.Errorf("SanitizeFilename(%d chars) = %d chars, want 100", len(longName), len(result)) + } +} + +func TestDownloadFile_TextRedirect(t *testing.T) { + // Mock server that returns a text-based redirect + redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "audio/mpeg") + w.Write([]byte("fake audio content")) + })) + defer redirectServer.Close() + + mainServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Redirecting to " + redirectServer.URL)) + })) + defer mainServer.Close() + + // Create temp file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.mp3") + + err := DownloadFile(tmpFile, mainServer.URL, nil) + if err != nil { + t.Fatalf("DownloadFile failed: %v", err) + } + + // Verify file was created with correct content + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(content) != "fake audio content" { + t.Errorf("File content = %q, want %q", string(content), "fake audio content") + } +} + +func TestDownloadFile_HTMLEntityDecode(t *testing.T) { + // Mock server that returns a text-based redirect with HTML entities + redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the query params were properly decoded + if r.URL.Query().Get("foo") != "bar" { + t.Errorf("Query param foo = %q, want %q", r.URL.Query().Get("foo"), "bar") + } + w.Header().Set("Content-Type", "audio/mpeg") + w.Write([]byte("audio with params")) + })) + defer redirectServer.Close() + + mainServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + // Simulate Acast's HTML-encoded ampersands + w.Write([]byte("Redirecting to " + redirectServer.URL + "?foo=bar&amp;baz=qux")) + })) + defer mainServer.Close() + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.mp3") + + err := DownloadFile(tmpFile, mainServer.URL, nil) + if err != nil { + t.Fatalf("DownloadFile failed: %v", err) + } + + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(content) != "audio with params" { + t.Errorf("File content = %q, want %q", string(content), "audio with params") + } +} + +func TestDownloadFile_DirectDownload(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "audio/mpeg") + w.Header().Set("Content-Length", "13") + w.Write([]byte("audio content")) + })) + defer server.Close() + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.mp3") + + err := DownloadFile(tmpFile, server.URL, nil) + if err != nil { + t.Fatalf("DownloadFile failed: %v", err) + } + + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(content) != "audio content" { + t.Errorf("File content = %q, want %q", string(content), "audio content") + } +} + +func TestDownloadFile_SkipExisting(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Write([]byte("new content")) + })) + defer server.Close() + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "existing.mp3") + + // Create existing file + err := os.WriteFile(tmpFile, []byte("original content"), 0644) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + } + + err = DownloadFile(tmpFile, server.URL, nil) + if err != nil { + t.Fatalf("DownloadFile failed: %v", err) + } + + // Should not have made a request + if callCount != 0 { + t.Errorf("Server was called %d times, want 0 (should skip existing)", callCount) + } + + // Content should be unchanged + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + if string(content) != "original content" { + t.Errorf("File content = %q, want %q", string(content), "original content") + } +} + +func TestDownloadFile_ProgressCallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "audio/mpeg") + w.Header().Set("Content-Length", "100") + // Write 100 bytes + w.Write([]byte(strings.Repeat("x", 100))) + })) + defer server.Close() + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.mp3") + + var lastProgress float64 + progressCalled := false + + err := DownloadFile(tmpFile, server.URL, func(percent float64) { + progressCalled = true + lastProgress = percent + }) + if err != nil { + t.Fatalf("DownloadFile failed: %v", err) + } + + if !progressCalled { + t.Error("Progress callback was never called") + } + + if lastProgress < 0.99 { + t.Errorf("Last progress = %v, want >= 0.99", lastProgress) + } +} + +func TestParseEpisodeSpec(t *testing.T) { + tests := []struct { + spec string + total int + expected []int + }{ + {"", 5, []int{1, 2, 3, 4, 5}}, + {"all", 3, []int{1, 2, 3}}, + {"latest", 10, []int{1}}, + {"1", 10, []int{1}}, + {"5", 10, []int{5}}, + {"1,3,5", 10, []int{1, 3, 5}}, + {"1-3", 10, []int{1, 2, 3}}, + {"1-5", 10, []int{1, 2, 3, 4, 5}}, + {"1,3-5,7", 10, []int{1, 3, 4, 5, 7}}, + {"1, 2, 3", 10, []int{1, 2, 3}}, // spaces + } + + for _, tt := range tests { + t.Run(tt.spec, func(t *testing.T) { + result := ParseEpisodeSpec(tt.spec, tt.total) + if len(result) != len(tt.expected) { + t.Errorf("ParseEpisodeSpec(%q, %d) = %v, want %v", tt.spec, tt.total, result, tt.expected) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("ParseEpisodeSpec(%q, %d) = %v, want %v", tt.spec, tt.total, result, tt.expected) + break + } + } + }) + } +} diff --git a/main.go b/main.go @@ -11,12 +11,13 @@ import ( "net/url" "os" "path/filepath" - "regexp" "strconv" "strings" "sync" "time" + "podcastdownload/internal/podcast" + "github.com/bogem/id3v2" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" @@ -193,15 +194,6 @@ type selectSearchResultMsg struct { result SearchResult } -// isNumeric checks if a string is all digits (podcast ID) -func isNumeric(s string) bool { - for _, c := range s { - if c < '0' || c > '9' { - return false - } - } - return len(s) > 0 -} func initialModel(input string, baseDir string, provider SearchProvider) model { s := spinner.New() @@ -210,7 +202,7 @@ func initialModel(input string, baseDir string, provider SearchProvider) model { p := progress.New(progress.WithDefaultGradient()) - isID := isNumeric(input) + isID := podcast.IsNumeric(input) m := model{ state: stateLoading, @@ -496,7 +488,7 @@ func (m model) handleSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.state = stateDownloading m.downloadTotal = len(selected) m.downloadIndex = 0 - podcastFolder := sanitizeFilename(m.podcastInfo.Name) + podcastFolder := podcast.SanitizeFilename(m.podcastInfo.Name) m.outputDir = filepath.Join(m.baseDir, podcastFolder) os.MkdirAll(m.outputDir, 0755) return m, func() tea.Msg { return startDownloadMsg{} } @@ -529,7 +521,7 @@ func (m model) downloadNextCmd() tea.Cmd { } ep := selected[m.downloadIndex] - currentFile := fmt.Sprintf("%03d - %s.mp3", ep.Index, sanitizeFilename(ep.Title)) + currentFile := fmt.Sprintf("%03d - %s.mp3", ep.Index, podcast.SanitizeFilename(ep.Title)) outputDir := m.outputDir podcastInfo := m.podcastInfo @@ -537,7 +529,11 @@ func (m model) downloadNextCmd() tea.Cmd { filePath := filepath.Join(outputDir, currentFile) // Download with progress callback that sends to program - err := downloadFileWithProgress(filePath, ep.AudioURL) + err := podcast.DownloadFile(filePath, ep.AudioURL, func(percent float64) { + if program != nil { + program.Send(downloadProgressMsg(percent)) + } + }) if err != nil { return errorMsg{err: err} } @@ -821,7 +817,7 @@ func (m model) viewDownloading() string { selected := m.getSelectedEpisodes() if m.downloadIndex < len(selected) { ep := selected[m.downloadIndex] - currentFile = fmt.Sprintf("%03d - %s.mp3", ep.Index, sanitizeFilename(ep.Title)) + currentFile = fmt.Sprintf("%03d - %s.mp3", ep.Index, podcast.SanitizeFilename(ep.Title)) } b.WriteString(fmt.Sprintf(" Episode %d of %d\n", m.downloadIndex+1, m.downloadTotal)) @@ -954,56 +950,6 @@ func loadPodcast(podcastID string) tea.Cmd { } } -func downloadFileWithProgress(filepath string, url string) error { - // Check if already exists - if _, err := os.Stat(filepath); err == nil { - return nil - } - - resp, err := http.Get(url) - 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) - // Only send updates every 1% to avoid flooding - if percent-lastPercent >= 0.01 || percent >= 1.0 { - lastPercent = percent - if program != nil { - program.Send(downloadProgressMsg(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 { @@ -1026,22 +972,6 @@ func addID3Tags(filepath string, ep Episode, info PodcastInfo) error { return tag.Save() } -func sanitizeFilename(name string) string { - // Remove invalid characters - re := regexp.MustCompile(`[<>:"/\\|?*]`) - name = re.ReplaceAllString(name, "") - name = strings.TrimSpace(name) - - // Limit length - if len(name) > 100 { - name = name[:100] - } - - if name == "" { - return "episode" - } - return name -} // searchPodcasts searches for podcasts using Apple's Search API func searchPodcasts(query string) tea.Cmd { diff --git a/podcastdownload b/podcastdownload Binary files differ.