summaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-21 06:50:42 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-21 06:50:42 +0000
commit293a627358553c9f61802caf9d4bc76af9c2e239 (patch)
treee4769ac949659953bd5e09cebea9d0697e82548f /main.go
podkit: playlist URL to private podcast feed
One Go binary with fetch / tag / feed / serve / all. The stages run separately on purpose: the download is the slow part and should not be repeated while adjusting titles or the base URL. Built from a shell + Python + Go sketch. The pieces that needed fixing on the way, all of which produce a feed that loads wrong or not at all: - Enclosure URLs were unescaped. Track titles routinely contain spaces and ampersands, which make both the URL and the XML invalid — the likeliest way for a feed to fail to load entirely. Only the final path segment is escaped; escaping the base would mangle scheme and host. - Ordering came from filename sort, which is correct only while zero-padding is applied consistently. One unpadded file puts lecture 10 after lecture 1. Order now comes from the parsed track number. - No pubDate and no itunes:type. Podcast clients default to newest-first, which starts a lecture course at the final lecture. Items now carry distinct ascending dates and the feed is marked serial. - Missing channel description and link, which several clients reject outright. - A struct field with no xml tag emitted a stray <EnclosureURL> element. - guid had no isPermaLink="false", so a reader may treat it as a fetchable URL. - Errors from ReadDir, Info and Marshal were discarded, so a missing directory produced an empty feed that looked like a working setup with nothing in it. - os.Args indexed without a length check. Beyond the sketch: a download archive so an interrupted series resumes, an up-front check for yt-dlp and ffmpeg before the long download rather than a post-processing failure after the bytes are on disk, non-audio files excluded by extension so cover art and .part files cannot become episodes, and a serve command with token gating that 404s rather than 401s. Found by the first live run against a LibriVox collection: sources whose own titles begin with a track number came out as "01 - 01 - April.mp3", because the output template prepends the playlist index regardless. Collapsed, but only when the two numbers agree — "01 - 1964 - Lecture" is a year and "05 - 12 Angry Men" is a title, and neither is a repeat. 47 tests. Verified end to end against public-domain audio: downloaded, converted, tagged, served, and the delivered file is a playable 128kbps MP3 with correct ID3 title/artist/album/track frames.
Diffstat (limited to 'main.go')
-rw-r--r--main.go319
1 files changed, 319 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..2bb3a2c
--- /dev/null
+++ b/main.go
@@ -0,0 +1,319 @@
+// podkit turns a playlist or video URL into a private podcast feed.
+//
+// podkit all "<url>" --dir ./audio --title "Character of Physical Law" \
+// --artist "Richard Feynman" --base-url https://host/audio/
+// podkit serve --dir ./audio --addr :8080 --token <token>
+//
+// Each stage is also runnable on its own, because the slow one (fetch) should
+// not have to be repeated while iterating on the others.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "podkit/internal/feed"
+ "podkit/internal/fetch"
+ "podkit/internal/serve"
+ "podkit/internal/tag"
+)
+
+const usage = `podkit — build a private podcast feed from a playlist or video URL
+
+Usage:
+ podkit fetch <url> [flags] download and extract audio (needs yt-dlp + ffmpeg)
+ podkit tag [flags] write ID3 tags from filenames
+ podkit feed [flags] generate feed.xml
+ podkit serve [flags] host the directory and feed over HTTP
+ podkit all <url> [flags] fetch, tag and feed in one go
+
+Run "podkit <command> -h" for that command's flags.
+
+Typical run:
+ podkit all "<playlist url>" \
+ --dir ./audio \
+ --title "Character of Physical Law" \
+ --artist "Richard Feynman" \
+ --base-url "https://your-host/audio/"
+ podkit serve --dir ./audio --addr :8080 --token "$(openssl rand -hex 16)"
+`
+
+func main() {
+ if len(os.Args) < 2 {
+ fmt.Fprint(os.Stderr, usage)
+ os.Exit(2)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ var err error
+ switch os.Args[1] {
+ case "fetch":
+ err = cmdFetch(ctx, os.Args[2:])
+ case "tag":
+ err = cmdTag(os.Args[2:])
+ case "feed":
+ err = cmdFeed(os.Args[2:])
+ case "serve":
+ err = cmdServe(ctx, os.Args[2:])
+ case "all":
+ err = cmdAll(ctx, os.Args[2:])
+ case "-h", "--help", "help":
+ fmt.Print(usage)
+ return
+ default:
+ fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usage)
+ os.Exit(2)
+ }
+
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "\npodkit: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+// metaFlags are the fields shared by tag, feed and all.
+type metaFlags struct {
+ dir string
+ title string
+ artist string
+ album string
+ desc string
+ baseURL string
+ lang string
+ year string
+}
+
+func (m *metaFlags) register(fs *flag.FlagSet) {
+ fs.StringVar(&m.dir, "dir", "./audio", "directory holding the audio files")
+ fs.StringVar(&m.title, "title", "", "feed title (also used as the album)")
+ fs.StringVar(&m.artist, "artist", "", "artist / author")
+ fs.StringVar(&m.album, "album", "", "album (defaults to --title)")
+ fs.StringVar(&m.desc, "description", "", "feed description (defaults to --title)")
+ fs.StringVar(&m.baseURL, "base-url", "", "public URL the audio directory is served from")
+ fs.StringVar(&m.lang, "lang", "en", "feed language")
+ fs.StringVar(&m.year, "year", "", "year tag")
+}
+
+func (m *metaFlags) albumName() string {
+ if m.album != "" {
+ return m.album
+ }
+ return m.title
+}
+
+func cmdFetch(ctx context.Context, args []string) error {
+ fs := flag.NewFlagSet("fetch", flag.ExitOnError)
+ dir := fs.String("dir", "./audio", "directory to download into")
+ format := fs.String("format", "mp3", "audio format")
+ noArchive := fs.Bool("no-archive", false, "do not keep a download archive (re-downloads everything)")
+ noRename := fs.Bool("no-rename", false, "keep yt-dlp's restricted filenames")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if fs.NArg() < 1 {
+ return errors.New("a playlist or video URL is required: podkit fetch <url>")
+ }
+
+ fmt.Printf("==> Downloading into %s\n", *dir)
+ if err := fetch.Run(ctx, fetch.Options{
+ URL: fs.Arg(0), Dir: *dir, Format: *format, Archive: !*noArchive,
+ }); err != nil {
+ return err
+ }
+
+ if !*noRename {
+ renamed, err := fetch.Rename(*dir)
+ if err != nil {
+ return err
+ }
+ if len(renamed) > 0 {
+ fmt.Printf("==> Tidied %d filename(s)\n", len(renamed))
+ }
+ }
+ return nil
+}
+
+func cmdTag(args []string) error {
+ fs := flag.NewFlagSet("tag", flag.ExitOnError)
+ var m metaFlags
+ m.register(fs)
+ genre := fs.String("genre", "", "genre tag")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ results, err := tag.WriteDir(m.dir, tag.Metadata{
+ Album: m.albumName(),
+ Artist: m.artist,
+ AlbumArtist: m.artist,
+ Genre: *genre,
+ Year: m.year,
+ })
+ if err != nil {
+ return err
+ }
+
+ var failed int
+ for _, r := range results {
+ if r.Err != nil {
+ failed++
+ fmt.Fprintf(os.Stderr, " FAILED %s: %v\n", r.File, r.Err)
+ }
+ }
+ fmt.Printf("==> Tagged %d file(s)", len(results)-failed)
+ if failed > 0 {
+ fmt.Printf(", %d failed", failed)
+ }
+ fmt.Println()
+ if failed > 0 {
+ return fmt.Errorf("%d file(s) could not be tagged", failed)
+ }
+ return nil
+}
+
+func cmdFeed(args []string) error {
+ fs := flag.NewFlagSet("feed", flag.ExitOnError)
+ var m metaFlags
+ m.register(fs)
+ out := fs.String("out", "", `where to write the feed (default "<dir>/feed.xml", "-" for stdout)`)
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if m.title == "" {
+ return errors.New("--title is required")
+ }
+ if m.baseURL == "" {
+ return errors.New("--base-url is required: it is the public URL the audio is served from")
+ }
+
+ r, err := feed.Build(feed.Options{
+ Dir: m.dir,
+ BaseURL: m.baseURL,
+ Title: m.title,
+ Description: m.desc,
+ Author: m.artist,
+ Language: m.lang,
+ Epoch: time.Date(2000, time.January, 1, 12, 0, 0, 0, time.UTC),
+ }, tag.Read)
+ if err != nil {
+ return err
+ }
+
+ data, err := feed.Marshal(r)
+ if err != nil {
+ return err
+ }
+
+ target := *out
+ if target == "" {
+ target = filepath.Join(m.dir, "feed.xml")
+ }
+ if target == "-" {
+ _, err := os.Stdout.Write(data)
+ return err
+ }
+ if err := os.WriteFile(target, data, 0o644); err != nil {
+ return fmt.Errorf("writing %s: %w", target, err)
+ }
+ fmt.Printf("==> Wrote %s (%d episode(s))\n", target, len(r.Channel.Items))
+ return nil
+}
+
+func cmdServe(ctx context.Context, args []string) error {
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
+ dir := fs.String("dir", "./audio", "directory to serve")
+ addr := fs.String("addr", ":8080", "listen address")
+ token := fs.String("token", "", "shared secret required on every request (recommended)")
+ newToken := fs.Bool("new-token", false, "generate a token and use it")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ tok := *token
+ if *newToken {
+ var err error
+ if tok, err = serve.NewToken(); err != nil {
+ return err
+ }
+ }
+
+ srv := &http.Server{
+ Addr: *addr,
+ Handler: serve.Handler(serve.Options{Dir: *dir, Token: tok}),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+
+ fmt.Printf("==> Serving %s on %s\n", *dir, *addr)
+ if tok != "" {
+ fmt.Printf(" Feed URL: http://<host>%s/feed.xml?k=%s\n", *addr, tok)
+ fmt.Println(" Anyone with that URL has access; it is a shared secret, not a login.")
+ } else {
+ fmt.Printf(" Feed URL: http://<host>%s/feed.xml\n", *addr)
+ fmt.Println(" No token set — anyone who can reach this port can read the feed.")
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ errCh <- err
+ }
+ }()
+
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ fmt.Println("\n==> Shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+ }
+}
+
+func cmdAll(ctx context.Context, args []string) error {
+ fs := flag.NewFlagSet("all", flag.ExitOnError)
+ var m metaFlags
+ m.register(fs)
+ genre := fs.String("genre", "", "genre tag")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if fs.NArg() < 1 {
+ return errors.New("a playlist or video URL is required: podkit all <url>")
+ }
+ if m.title == "" {
+ return errors.New("--title is required")
+ }
+ if m.baseURL == "" {
+ return errors.New("--base-url is required: it is the public URL the audio is served from")
+ }
+
+ // Checked before the long download rather than after it.
+ if err := fetch.Check(); err != nil {
+ return err
+ }
+
+ shared := []string{
+ "--dir", m.dir, "--title", m.title, "--artist", m.artist,
+ "--album", m.album, "--description", m.desc,
+ "--base-url", m.baseURL, "--lang", m.lang, "--year", m.year,
+ }
+
+ if err := cmdFetch(ctx, []string{"--dir", m.dir, fs.Arg(0)}); err != nil {
+ return err
+ }
+ if err := cmdTag(append(shared, "--genre", *genre)); err != nil {
+ return err
+ }
+ return cmdFeed(shared)
+}