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:
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 & -> &
+ 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&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.