// podkit turns a playlist or video URL into a private podcast feed. // // podkit all "" --dir ./audio --title "Character of Physical Law" \ // --artist "Richard Feynman" --base-url https://host/audio/ // podkit serve --dir ./audio --addr :8080 --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 [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 [flags] fetch, tag and feed in one go Run "podkit -h" for that command's flags. Typical run: podkit all "" \ --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 ") } 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 "/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://%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://%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 ") } 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) }