// 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" "strings" "syscall" "time" "podkit/internal/feed" "podkit/internal/fetch" "podkit/internal/push" "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 get [flags] download a direct audio URL or an RSS feed's enclosures (no yt-dlp, no 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 push [flags] upload local audio to a remote podkit server 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 "get": err = cmdGet(ctx, os.Args[2:]) case "push": err = cmdPush(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 token 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") // Must match the serving token. Podcast clients send nothing but the URL, // so without this the enclosures are unreachable behind a tokened server. fs.StringVar(&m.token, "token", "", "server token, embedded in every enclosure URL") } 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 } // cmdGet downloads without an extractor: a direct audio URL, or every // enclosure in an RSS feed. func cmdGet(ctx context.Context, args []string) error { fs := flag.NewFlagSet("get", flag.ExitOnError) dir := fs.String("dir", "./audio", "directory to download into") if err := fs.Parse(args); err != nil { return err } if fs.NArg() < 1 { return errors.New("a URL is required: podkit get ") } sources, title, err := fetch.Discover(ctx, fs.Arg(0), nil) if err != nil { return err } if title != "" { fmt.Printf("==> %s\n", title) } fmt.Printf("==> %d file(s) into %s\n", len(sources), *dir) if err := fetch.Download(ctx, sources, *dir, nil, os.Stdout); err != nil { return err } if title != "" { fmt.Printf("\n Suggested: --title %q\n", title) } return nil } // cmdPush uploads a local library to a remote podkit server, so the download // can happen on one machine and the hosting on another. func cmdPush(ctx context.Context, args []string) error { fs := flag.NewFlagSet("push", flag.ExitOnError) dir := fs.String("dir", "./audio", "local directory to upload from") server := fs.String("server", "", "remote podkit base URL, e.g. https://host/podkit") token := fs.String("token", "", "the remote server's token") force := fs.Bool("force", false, "re-upload files the server already has") if err := fs.Parse(args); err != nil { return err } if *server == "" { return errors.New("--server is required: the base URL of the remote podkit server") } fmt.Printf("==> Pushing %s to %s\n", *dir, *server) results, err := push.Run(ctx, push.Options{ Dir: *dir, Server: *server, Token: *token, Force: *force, }) if err != nil { return err } for _, r := range results { switch { case r.Err != nil: fmt.Fprintf(os.Stderr, " FAILED %s: %v\n", r.Name, r.Err) case r.Skipped: fmt.Printf(" have %s\n", r.Name) default: fmt.Printf(" sent %s\n", r.Name) } } uploaded, skipped, failed := push.Summarise(results) fmt.Printf("==> %d uploaded, %d already present", uploaded, skipped) if failed > 0 { fmt.Printf(", %d failed", failed) } fmt.Println() if failed > 0 { return fmt.Errorf("%d file(s) failed to upload", failed) } 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") } return writeFeed(m, *out) } // writeFeed builds and writes feed.xml. Shared by `podkit feed` and the rebuild // that runs after an ingest, so an uploaded episode goes through exactly the // same generator as a locally-built one. func writeFeed(m metaFlags, out string) error { r, err := feed.Build(feed.Options{ Dir: m.dir, BaseURL: m.baseURL, Title: m.title, Description: m.desc, Author: m.artist, Language: m.lang, Token: m.token, 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 } // Written via a temporary file and renamed, so a podcast client polling // mid-write never reads a truncated feed. tmp := target + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return fmt.Errorf("writing %s: %w", tmp, err) } if err := os.Rename(tmp, target); err != nil { os.Remove(tmp) 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") ingest := fs.Bool("ingest", false, "accept uploads from `podkit push` and rebuild the feed") title := fs.String("title", "", "feed title (required with --ingest)") artist := fs.String("artist", "", "artist / author (used with --ingest)") desc := fs.String("description", "", "feed description (used with --ingest)") baseURL := fs.String("base-url", "", "public URL of the audio directory (required with --ingest)") lang := fs.String("lang", "en", "feed language") 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 } } opts := serve.Options{Dir: *dir, Token: tok, AllowIngest: *ingest} if *ingest { // An upload that cannot be turned into a feed entry is useless, so the // metadata is required up front rather than discovered at upload time. if *title == "" || *baseURL == "" { return errors.New("--ingest needs --title and --base-url: an uploaded file has to be turned into a feed entry") } if tok == "" { return errors.New("--ingest without a token would let anyone write to the library; pass --token or --new-token") } opts.Rebuild = func() error { return writeFeed(metaFlags{ dir: *dir, title: *title, artist: *artist, desc: *desc, baseURL: *baseURL, lang: *lang, token: tok, }, "") } } srv := &http.Server{ Addr: *addr, Handler: serve.Handler(opts), ReadHeaderTimeout: 10 * time.Second, } fmt.Printf("==> Serving %s on %s\n", *dir, *addr) if tok != "" { fmt.Printf(" Feed URL: %s/feed.xml?k=%s\n", displayHost(*addr), tok) fmt.Println(" Anyone with that URL has access; it is a shared secret, not a login.") } else { fmt.Printf(" Feed URL: %s/feed.xml\n", displayHost(*addr)) fmt.Println(" No token set — anyone who can reach this port can read the feed.") } if *ingest { fmt.Println(" Accepting uploads from `podkit push`.") } 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) } } // displayHost turns a listen address into something printable. A bare ":8080" // has no host to show, so it gets a placeholder; "127.0.0.1:8080" does not. func displayHost(addr string) string { if strings.HasPrefix(addr, ":") { return "http://" + addr } return "http://" + addr } 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, "--token", m.token, } 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) }