podcast-go

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

commit f86c9227255b9892d40073b6d60f39a2f1971dae
Author: Erik Loualiche <[email protected]>
Date:   Sun, 11 Jan 2026 09:03:27 -0600

Initial commit: Podcast Downloader TUI

Diffstat:
AMakefile | 10++++++++++
AREADME.md | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 39+++++++++++++++++++++++++++++++++++++++
Ago.sum | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amain.go | 906+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apodcastdownload | 0
6 files changed, 1107 insertions(+), 0 deletions(-)

diff --git a/Makefile b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build clean run + +build: + go build -o podcastdownload main.go + +clean: + rm -f podcastdownload + +run: build + ./podcastdownload diff --git a/README.md b/README.md @@ -0,0 +1,67 @@ +# Podcast Downloader + +A TUI (Terminal User Interface) application for downloading podcast episodes from Apple Podcasts. + +## Features + +- Search podcasts by name or lookup by Apple Podcast ID +- Interactive episode selection with keyboard navigation +- Progress bar for downloads +- Automatic ID3v2 tag writing (title, artist, album, track number) +- Colorful terminal interface using Bubble Tea + +## Installation + +```bash +go build -o podcastdownload main.go +``` + +Or with a local GOPATH: + +```bash +GOPATH=$(pwd)/.go go build -o podcastdownload main.go +``` + +## Usage + +```bash +# Search by podcast name +./podcastdownload "the daily" +./podcastdownload "new york times" + +# Lookup by Apple Podcast ID +./podcastdownload 1200361736 +``` + +Find the podcast ID in the Apple Podcasts URL: +``` +https://podcasts.apple.com/us/podcast/the-daily/id1200361736 + ^^^^^^^^^^ +``` + +## Controls + +### Search Results Screen +- `↑/↓` or `k/j` - Navigate results +- `Enter` - Select podcast +- `q` - Quit + +### Episode Selection Screen +- `↑/↓` or `k/j` - Navigate episodes +- `Space` or `x` - Toggle selection +- `a` - Select/deselect all +- `PgUp/PgDn` - Page navigation +- `Enter` - Start download +- `q` - Quit + +## Dependencies + +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework +- [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling +- [Bubbles](https://github.com/charmbracelet/bubbles) - TUI components +- [gofeed](https://github.com/mmcdole/gofeed) - RSS parsing +- [id3v2](https://github.com/bogem/id3v2) - ID3 tag writing + +## License + +MIT diff --git a/go.mod b/go.mod @@ -0,0 +1,39 @@ +module podcastdownload + +go 1.25.5 + +require ( + github.com/bogem/id3v2 v1.2.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mmcdole/gofeed v1.3.0 +) + +require ( + 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 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/json-iterator/go v1.1.12 // 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 + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + 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/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/net v0.4.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.5.0 // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,85 @@ +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= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI= +github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +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/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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/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= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +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/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/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/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= +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/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/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= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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/main.go b/main.go @@ -0,0 +1,906 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/bogem/id3v2" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mmcdole/gofeed" +) + +// Global program reference for sending messages from goroutines +var program *tea.Program + +// Styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("205")). + MarginBottom(1) + + subtitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + selectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")). + Bold(true) + + normalStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + dimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + checkboxStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + MarginTop(1) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("82")). + Bold(true) +) + +// PodcastInfo holds metadata from Apple's API +type PodcastInfo struct { + Name string + Artist string + FeedURL string + ArtworkURL string + ID string +} + +// SearchResult holds a podcast from search results +type SearchResult struct { + ID string + Name string + Artist string + FeedURL string + ArtworkURL string +} + +// Episode holds episode data from RSS feed +type Episode struct { + Index int + Title string + Description string + AudioURL string + PubDate time.Time + Duration string + Selected bool +} + +// iTunesResponse represents Apple's lookup API response +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"` +} + +// App states +type state int + +const ( + stateLoading state = iota + stateSearchResults + stateSelecting + stateDownloading + stateDone + stateError +) + +// Model is our Bubble Tea model +type model struct { + state state + podcastID string + searchQuery string + searchResults []SearchResult + podcastInfo PodcastInfo + episodes []Episode + cursor int + offset int + windowHeight int + spinner spinner.Model + progress progress.Model + loadingMsg string + errorMsg string + downloadIndex int + downloadTotal int + outputDir string + downloaded []string + percent float64 +} + +// Messages +type searchResultsMsg struct { + results []SearchResult +} + +type podcastLoadedMsg struct { + info PodcastInfo + episodes []Episode +} + +type errorMsg struct { + err error +} + +type downloadProgressMsg float64 + +type downloadCompleteMsg struct { + filename string +} + +type startDownloadMsg struct{} + +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) model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + p := progress.New(progress.WithDefaultGradient()) + + // Determine if input is a podcast ID or search query + isID := isNumeric(input) + + m := model{ + state: stateLoading, + spinner: s, + progress: p, + windowHeight: 24, + } + + if isID { + m.podcastID = input + m.loadingMsg = "Looking up podcast..." + } else { + m.searchQuery = input + m.loadingMsg = "Searching podcasts..." + } + + return m +} + +func (m model) Init() tea.Cmd { + if m.searchQuery != "" { + return tea.Batch( + m.spinner.Tick, + searchPodcasts(m.searchQuery), + ) + } + return tea.Batch( + m.spinner.Tick, + loadPodcast(m.podcastID), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch m.state { + case stateSearchResults: + return m.handleSearchResultsKeys(msg) + case stateSelecting: + return m.handleSelectionKeys(msg) + case stateDone, stateError: + if msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == "enter" { + return m, tea.Quit + } + default: + if msg.String() == "ctrl+c" || msg.String() == "q" { + return m, tea.Quit + } + } + + case tea.WindowSizeMsg: + m.windowHeight = msg.Height + m.progress.Width = msg.Width - 10 + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case searchResultsMsg: + m.searchResults = msg.results + if len(msg.results) == 0 { + m.state = stateError + m.errorMsg = fmt.Sprintf("No podcasts found for: %s", m.searchQuery) + return m, nil + } + m.state = stateSearchResults + m.cursor = 0 + m.offset = 0 + return m, nil + + case selectSearchResultMsg: + m.state = stateLoading + m.loadingMsg = fmt.Sprintf("Loading %s...", msg.result.Name) + m.podcastID = msg.result.ID + return m, loadPodcast(msg.result.ID) + + case podcastLoadedMsg: + m.state = stateSelecting + m.podcastInfo = msg.info + m.episodes = msg.episodes + m.cursor = 0 + m.offset = 0 + return m, nil + + case errorMsg: + m.state = stateError + m.errorMsg = msg.err.Error() + return m, nil + + case downloadProgressMsg: + m.percent = float64(msg) + cmd := m.progress.SetPercent(m.percent) + return m, cmd + + case progress.FrameMsg: + progressModel, cmd := m.progress.Update(msg) + m.progress = progressModel.(progress.Model) + return m, cmd + + case startDownloadMsg: + return m, m.downloadNextCmd() + + case downloadCompleteMsg: + m.downloaded = append(m.downloaded, msg.filename) + m.downloadIndex++ + m.percent = 0 + if m.downloadIndex < m.downloadTotal { + return m, m.downloadNextCmd() + } + m.state = stateDone + return m, nil + } + + return m, nil +} + +func (m model) handleSearchResultsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + visibleItems := m.windowHeight - 10 + if visibleItems < 5 { + visibleItems = 5 + } + + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.offset { + m.offset = m.cursor + } + } + + case "down", "j": + if m.cursor < len(m.searchResults)-1 { + m.cursor++ + if m.cursor >= m.offset+visibleItems { + m.offset = m.cursor - visibleItems + 1 + } + } + + case "enter": + if m.cursor < len(m.searchResults) { + result := m.searchResults[m.cursor] + return m, func() tea.Msg { return selectSearchResultMsg{result: result} } + } + } + + return m, nil +} + +func (m model) handleSelectionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + visibleItems := m.windowHeight - 12 + if visibleItems < 5 { + visibleItems = 5 + } + + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + if m.cursor < m.offset { + m.offset = m.cursor + } + } + + case "down", "j": + if m.cursor < len(m.episodes)-1 { + m.cursor++ + if m.cursor >= m.offset+visibleItems { + m.offset = m.cursor - visibleItems + 1 + } + } + + case "pgup": + m.cursor -= visibleItems + if m.cursor < 0 { + m.cursor = 0 + } + m.offset = m.cursor + + case "pgdown": + m.cursor += visibleItems + if m.cursor >= len(m.episodes) { + m.cursor = len(m.episodes) - 1 + } + if m.cursor >= m.offset+visibleItems { + m.offset = m.cursor - visibleItems + 1 + } + + case " ", "x": + m.episodes[m.cursor].Selected = !m.episodes[m.cursor].Selected + + case "a": + allSelected := true + for _, ep := range m.episodes { + if !ep.Selected { + allSelected = false + break + } + } + for i := range m.episodes { + m.episodes[i].Selected = !allSelected + } + + case "enter": + selected := m.getSelectedEpisodes() + if len(selected) > 0 { + m.state = stateDownloading + m.downloadTotal = len(selected) + m.downloadIndex = 0 + m.outputDir = sanitizeFilename(m.podcastInfo.Name) + os.MkdirAll(m.outputDir, 0755) + return m, func() tea.Msg { return startDownloadMsg{} } + } + } + + return m, nil +} + +func (m model) getSelectedEpisodes() []Episode { + var selected []Episode + for _, ep := range m.episodes { + if ep.Selected { + selected = append(selected, ep) + } + } + return selected +} + +func (m model) downloadNextCmd() tea.Cmd { + selected := m.getSelectedEpisodes() + if m.downloadIndex >= len(selected) { + return nil + } + + ep := selected[m.downloadIndex] + currentFile := fmt.Sprintf("%03d - %s.mp3", ep.Index, sanitizeFilename(ep.Title)) + outputDir := m.outputDir + podcastInfo := m.podcastInfo + + return func() tea.Msg { + filePath := filepath.Join(outputDir, currentFile) + + // Download with progress callback that sends to program + err := downloadFileWithProgress(filePath, ep.AudioURL) + if err != nil { + return errorMsg{err: err} + } + + // Add ID3 tags + addID3Tags(filePath, ep, podcastInfo) + + return downloadCompleteMsg{filename: filePath} + } +} + +func (m model) View() string { + switch m.state { + case stateLoading: + return m.viewLoading() + case stateSearchResults: + return m.viewSearchResults() + case stateSelecting: + return m.viewSelecting() + case stateDownloading: + return m.viewDownloading() + case stateDone: + return m.viewDone() + case stateError: + return m.viewError() + } + return "" +} + +func (m model) viewLoading() string { + return fmt.Sprintf("\n %s %s\n", m.spinner.View(), m.loadingMsg) +} + +func (m model) viewSearchResults() string { + var b strings.Builder + + // Header + b.WriteString("\n") + b.WriteString(titleStyle.Render(fmt.Sprintf("Search Results: \"%s\"", m.searchQuery))) + b.WriteString("\n") + b.WriteString(subtitleStyle.Render(fmt.Sprintf("Found %d podcasts", len(m.searchResults)))) + b.WriteString("\n\n") + + // Calculate visible items + visibleItems := m.windowHeight - 10 + if visibleItems < 5 { + visibleItems = 5 + } + + // Results list + end := m.offset + visibleItems + if end > len(m.searchResults) { + end = len(m.searchResults) + } + + for i := m.offset; i < end; i++ { + result := m.searchResults[i] + cursor := " " + if i == m.cursor { + cursor = "▸ " + } + + // Truncate name + name := result.Name + if len(name) > 50 { + name = name[:47] + "..." + } + + // Truncate artist + artist := result.Artist + if len(artist) > 25 { + artist = artist[:22] + "..." + } + + line := fmt.Sprintf("%s%-50s %s", cursor, name, dimStyle.Render(artist)) + + if i == m.cursor { + b.WriteString(selectedStyle.Render(line)) + } else { + b.WriteString(normalStyle.Render(line)) + } + b.WriteString("\n") + } + + // Scroll indicator + if len(m.searchResults) > visibleItems { + b.WriteString(dimStyle.Render(fmt.Sprintf("\n Showing %d-%d of %d", m.offset+1, end, len(m.searchResults)))) + } + + // Help + b.WriteString(helpStyle.Render("\n\n ↑/↓ navigate • enter select • q quit")) + + return b.String() +} + +func (m model) viewSelecting() string { + var b strings.Builder + + // Header + b.WriteString("\n") + b.WriteString(titleStyle.Render(m.podcastInfo.Name)) + b.WriteString("\n") + b.WriteString(subtitleStyle.Render(fmt.Sprintf("by %s • %d episodes", m.podcastInfo.Artist, len(m.episodes)))) + b.WriteString("\n\n") + + // Calculate visible items + visibleItems := m.windowHeight - 12 + if visibleItems < 5 { + visibleItems = 5 + } + + // Episode list + end := m.offset + visibleItems + if end > len(m.episodes) { + end = len(m.episodes) + } + + for i := m.offset; i < end; i++ { + ep := m.episodes[i] + cursor := " " + if i == m.cursor { + cursor = "▸ " + } + + checkbox := "○" + if ep.Selected { + checkbox = "●" + } + + // Format date + dateStr := "" + if !ep.PubDate.IsZero() { + dateStr = ep.PubDate.Format("2006-01-02") + } + + // Truncate title + title := ep.Title + if len(title) > 45 { + title = title[:42] + "..." + } + + line := fmt.Sprintf("%s%s [%3d] %-45s %s %s", + cursor, + checkboxStyle.Render(checkbox), + ep.Index, + title, + dimStyle.Render(dateStr), + dimStyle.Render(ep.Duration), + ) + + if i == m.cursor { + b.WriteString(selectedStyle.Render(line)) + } else if ep.Selected { + b.WriteString(normalStyle.Render(line)) + } else { + b.WriteString(dimStyle.Render(line)) + } + b.WriteString("\n") + } + + // Scroll indicator + if len(m.episodes) > visibleItems { + b.WriteString(dimStyle.Render(fmt.Sprintf("\n Showing %d-%d of %d", m.offset+1, end, len(m.episodes)))) + } + + // Selection count + selectedCount := 0 + for _, ep := range m.episodes { + if ep.Selected { + selectedCount++ + } + } + b.WriteString(dimStyle.Render(fmt.Sprintf(" • %d selected", selectedCount))) + + // Help + b.WriteString(helpStyle.Render("\n\n ↑/↓ navigate • space select • a toggle all • enter download • q quit")) + + return b.String() +} + +func (m model) viewDownloading() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(titleStyle.Render("Downloading...")) + b.WriteString("\n\n") + + // Get current episode name + currentFile := "" + selected := m.getSelectedEpisodes() + if m.downloadIndex < len(selected) { + ep := selected[m.downloadIndex] + currentFile = fmt.Sprintf("%03d - %s.mp3", ep.Index, sanitizeFilename(ep.Title)) + } + + b.WriteString(fmt.Sprintf(" Episode %d of %d\n", m.downloadIndex+1, m.downloadTotal)) + b.WriteString(fmt.Sprintf(" %s\n\n", currentFile)) + b.WriteString(" " + m.progress.View() + "\n") + + if len(m.downloaded) > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf("\n ✓ %d completed", len(m.downloaded)))) + } + + return b.String() +} + +func (m model) viewDone() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(successStyle.Render("✓ Download Complete!")) + b.WriteString("\n\n") + + b.WriteString(fmt.Sprintf(" Downloaded %d episode(s) to:\n", len(m.downloaded))) + b.WriteString(fmt.Sprintf(" %s/\n\n", m.outputDir)) + + for _, f := range m.downloaded { + b.WriteString(dimStyle.Render(fmt.Sprintf(" • %s\n", filepath.Base(f)))) + } + + b.WriteString(helpStyle.Render("\n Press enter or q to exit")) + + return b.String() +} + +func (m model) viewError() string { + return fmt.Sprintf("\n%s\n\n %s\n\n%s", + errorStyle.Render("Error"), + m.errorMsg, + helpStyle.Render(" Press q to exit"), + ) +} + +// Fetch podcast info from Apple's API +func loadPodcast(podcastID string) tea.Cmd { + return func() tea.Msg { + // Remove "id" prefix if present + podcastID = strings.TrimPrefix(strings.ToLower(podcastID), "id") + + // Fetch from iTunes API + url := fmt.Sprintf("https://itunes.apple.com/lookup?id=%s&entity=podcast", podcastID) + resp, err := http.Get(url) + if err != nil { + return errorMsg{err: 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 errorMsg{err: fmt.Errorf("failed to parse response: %w", err)} + } + + if result.ResultCount == 0 { + return errorMsg{err: 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, + } + + if info.ArtworkURL == "" { + info.ArtworkURL = r.ArtworkURL100 + } + + if info.FeedURL == "" { + return errorMsg{err: fmt.Errorf("no RSS feed URL found for this podcast")} + } + + // Parse RSS feed + fp := gofeed.NewParser() + feed, err := fp.ParseURL(info.FeedURL) + if err != nil { + return errorMsg{err: fmt.Errorf("failed to parse RSS feed: %w", err)} + } + + var episodes []Episode + for i, item := range feed.Items { + audioURL := "" + + // Find audio enclosure + 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 errorMsg{err: fmt.Errorf("no downloadable episodes found")} + } + + return podcastLoadedMsg{info: info, episodes: episodes} + } +} + +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 { + // Create new tag if file doesn't have one + tag = id3v2.NewEmptyTag() + } + defer tag.Close() + + tag.SetTitle(ep.Title) + tag.SetArtist(info.Artist) + tag.SetAlbum(info.Name) + + // Set track number + trackFrame := id3v2.TextFrame{ + Encoding: id3v2.EncodingUTF8, + Text: strconv.Itoa(ep.Index), + } + tag.AddFrame(tag.CommonID("Track number/Position in set"), trackFrame) + + 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 { + return func() tea.Msg { + // URL encode the query + encodedQuery := strings.ReplaceAll(query, " ", "+") + url := fmt.Sprintf("https://itunes.apple.com/search?term=%s&media=podcast&limit=25", encodedQuery) + + resp, err := http.Get(url) + if err != nil { + return errorMsg{err: fmt.Errorf("failed to search podcasts: %w", err)} + } + defer resp.Body.Close() + + var result iTunesResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return errorMsg{err: fmt.Errorf("failed to parse search results: %w", err)} + } + + var results []SearchResult + for _, r := range result.Results { + if r.FeedURL == "" { + continue // Skip podcasts without RSS feed + } + + results = append(results, SearchResult{ + ID: strconv.Itoa(r.CollectionID), + Name: r.CollectionName, + Artist: r.ArtistName, + FeedURL: r.FeedURL, + ArtworkURL: r.ArtworkURL600, + }) + } + + return searchResultsMsg{results: results} + } +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("Podcast Downloader") + fmt.Println() + fmt.Println("Usage: podcastdownload <podcast_id_or_search_query>") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" podcastdownload 1200361736 # Download by Apple Podcast ID") + fmt.Println(" podcastdownload \"the daily\" # Search for podcasts by name") + fmt.Println() + fmt.Println("Find the ID in the Apple Podcasts URL:") + fmt.Println(" https://podcasts.apple.com/us/podcast/the-daily/id1200361736") + fmt.Println(" ^^^^^^^^^^") + os.Exit(1) + } + + input := strings.Join(os.Args[1:], " ") + + program = tea.NewProgram(initialModel(input), tea.WithAltScreen()) + if _, err := program.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +} diff --git a/podcastdownload b/podcastdownload Binary files differ.