diff options
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) +} |
