diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-21 06:50:42 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-21 06:50:42 +0000 |
| commit | 293a627358553c9f61802caf9d4bc76af9c2e239 (patch) | |
| tree | e4769ac949659953bd5e09cebea9d0697e82548f /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.go | 319 |
1 files changed, 319 insertions, 0 deletions
@@ -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) +} |
