summaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go149
1 files changed, 144 insertions, 5 deletions
diff --git a/main.go b/main.go
index 2bb3a2c..fb8ddde 100644
--- a/main.go
+++ b/main.go
@@ -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 {