commit f86c9227255b9892d40073b6d60f39a2f1971dae
Author: Erik Loualiche <[email protected]>
Date: Sun, 11 Jan 2026 09:03:27 -0600
Initial commit: Podcast Downloader TUI
Diffstat:
| A | Makefile | | | 10 | ++++++++++ |
| A | README.md | | | 67 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | go.mod | | | 39 | +++++++++++++++++++++++++++++++++++++++ |
| A | go.sum | | | 85 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | main.go | | | 906 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | podcastdownload | | | 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.