diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 07:05:24 +0000 |
| commit | e3ecae12d7a309eb5a6c8e62910257096cb99375 (patch) | |
| tree | 4f13a1e67dff0af95e5ff49126c75e56e49d83c6 /main.go | |
| parent | 293a627358553c9f61802caf9d4bc76af9c2e239 (diff) | |
The machine that hosts a feed and the machine that downloads the audio do
not have to be the same, and usually shouldn't be: a datacentre IP is
treated very differently from a home connection by the larger video sites.
Downloading where the person actually is sidesteps that entirely, and means
the server needs no credentials for or route to the source.
- push/ingest: `podkit push` uploads a local library to a remote
`serve --ingest`, asking for a manifest first so only the difference is
sent. Uploads land via a temp file and rename, so an interrupted transfer
cannot be picked up as a short episode. Ingest is off by default and
refuses to start without a token.
- get: downloads a direct audio URL or every enclosure in an RSS feed,
with no extractor involved. Numbers RSS items in reverse so a mirrored
series plays in order rather than backwards.
Two bugs found by running it rather than by testing it:
- A tokened server produced enclosure URLs with no token. The feed loaded,
listed every episode, and all of them 404'd in a real client — the whole
point of the tool, broken in the default configuration. Feeds now carry
the token; regression test asserts enclosures from a tokened feed
actually resolve against a tokened server.
- Ingest ran filepath.Base before validating, so "../evil.mp3" was stored
as "evil.mp3" — contained, but under a name the client never asked for,
leaving push and the server disagreeing about the library. Names are now
validated raw and rejected.
Also: identify with a real user-agent (libsyn 403s Go's default), write
feed.xml via rename so a polling client never reads a truncated feed, and
match ingested file permissions to downloaded ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 149 |
1 files changed, 144 insertions, 5 deletions
@@ -17,11 +17,13 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" "podkit/internal/feed" "podkit/internal/fetch" + "podkit/internal/push" "podkit/internal/serve" "podkit/internal/tag" ) @@ -30,9 +32,12 @@ const usage = `podkit — build a private podcast feed from a playlist or video Usage: podkit fetch <url> [flags] download and extract audio (needs yt-dlp + ffmpeg) + podkit get <url> [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 <url> [flags] fetch, tag and feed in one go Run "podkit <command> -h" for that command's flags. @@ -59,6 +64,10 @@ func main() { 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": @@ -91,6 +100,7 @@ type metaFlags struct { baseURL string lang string year string + token string } func (m *metaFlags) register(fs *flag.FlagSet) { @@ -102,6 +112,9 @@ func (m *metaFlags) register(fs *flag.FlagSet) { 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 { @@ -143,6 +156,81 @@ func cmdFetch(ctx context.Context, args []string) error { 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 <audio-url|rss-url>") + } + + 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 @@ -195,7 +283,13 @@ func cmdFeed(args []string) error { 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, @@ -203,6 +297,7 @@ func cmdFeed(args []string) error { 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 { @@ -214,7 +309,7 @@ func cmdFeed(args []string) error { return err } - target := *out + target := out if target == "" { target = filepath.Join(m.dir, "feed.xml") } @@ -222,7 +317,14 @@ func cmdFeed(args []string) error { _, err := os.Stdout.Write(data) return err } - if err := os.WriteFile(target, data, 0o644); err != nil { + // 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)) @@ -235,6 +337,12 @@ func cmdServe(ctx context.Context, args []string) error { 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 } @@ -247,20 +355,41 @@ func cmdServe(ctx context.Context, args []string) error { } } + 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(serve.Options{Dir: *dir, Token: tok}), + Handler: serve.Handler(opts), 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.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: http://<host>%s/feed.xml\n", *addr) + 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() { @@ -280,6 +409,15 @@ func cmdServe(ctx context.Context, args []string) error { } } +// 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://<host>" + addr + } + return "http://" + addr +} + func cmdAll(ctx context.Context, args []string) error { fs := flag.NewFlagSet("all", flag.ExitOnError) var m metaFlags @@ -307,6 +445,7 @@ func cmdAll(ctx context.Context, args []string) error { "--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 { |
