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 /internal/push/push.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 'internal/push/push.go')
| -rw-r--r-- | internal/push/push.go | 216 |
1 files changed, 216 insertions, 0 deletions
diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..b640127 --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,216 @@ +// Package push uploads locally-downloaded audio to a remote podkit server. +// +// This exists because the download and the hosting do not have to happen on the +// same machine, and usually shouldn't. Downloading is something a person does +// from their own connection; hosting is something a server does. Splitting them +// means the server never needs credentials for, or a working route to, whatever +// the audio came from. +package push + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "podkit/internal/feed" +) + +// Options configure an upload run. +type Options struct { + Dir string // local directory holding the audio + Server string // base URL of the remote podkit server + Token string // the remote server's token + Force bool // upload even when the server already has a matching file + Client *http.Client +} + +// Result describes what happened to one file. +type Result struct { + Name string + Bytes int64 + Skipped bool + Err error +} + +// Run uploads every audio file in Dir that the server does not already have. +func Run(ctx context.Context, opts Options) ([]Result, error) { + if opts.Server == "" { + return nil, fmt.Errorf("a server URL is required") + } + base, err := url.Parse(opts.Server) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("server %q needs a scheme and host, e.g. https://host/podkit", opts.Server) + } + + client := opts.Client + if client == nil { + // No overall timeout: a large episode on a slow uplink is a legitimate + // long request. Cancellation comes from ctx instead. + client = &http.Client{Timeout: 0} + } + + local, err := localFiles(opts.Dir) + if err != nil { + return nil, err + } + if len(local) == 0 { + return nil, fmt.Errorf("no audio files in %s", opts.Dir) + } + + remote := map[string]int64{} + if !opts.Force { + remote, err = fetchManifest(ctx, client, base, opts.Token) + if err != nil { + return nil, err + } + } + + var results []Result + for _, f := range local { + if size, ok := remote[f.name]; ok && size == f.size { + results = append(results, Result{Name: f.name, Bytes: f.size, Skipped: true}) + continue + } + err := upload(ctx, client, base, opts.Token, opts.Dir, f.name, f.size) + results = append(results, Result{Name: f.name, Bytes: f.size, Err: err}) + if err != nil && ctx.Err() != nil { + // Interrupted rather than rejected; stop instead of hammering + // through the rest of the list with a dead context. + break + } + } + return results, nil +} + +type localFile struct { + name string + size int64 +} + +func localFiles(dir string) ([]localFile, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", dir, err) + } + var out []localFile + for _, e := range entries { + if e.IsDir() || !feed.IsAudio(e.Name()) { + continue + } + info, err := e.Info() + if err != nil { + return nil, fmt.Errorf("stat %s: %w", e.Name(), err) + } + out = append(out, localFile{e.Name(), info.Size()}) + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out, nil +} + +func fetchManifest(ctx context.Context, c *http.Client, base *url.URL, token string) (map[string]int64, error) { + u := *base + u.Path = path.Join(u.Path, "manifest") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + auth(req, token) + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("contacting %s: %w", u.String(), err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // The server 404s both a wrong token and a server without --ingest, on + // purpose. Say so, rather than reporting a bare 404 the user then has + // to guess the meaning of. + return nil, fmt.Errorf("manifest request returned %s — check the token, and that the server was started with --ingest", + resp.Status) + } + + var m struct { + Files map[string]int64 `json:"files"` + } + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + return nil, fmt.Errorf("parsing manifest from %s: %w", u.String(), err) + } + if m.Files == nil { + m.Files = map[string]int64{} + } + return m.Files, nil +} + +func upload(ctx context.Context, c *http.Client, base *url.URL, token, dir, name string, size int64) error { + f, err := os.Open(filepath.Join(dir, name)) + if err != nil { + return err + } + defer f.Close() + + u := *base + u.Path = path.Join(u.Path, "ingest") + q := u.Query() + q.Set("name", name) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), f) + if err != nil { + return err + } + auth(req, token) + req.Header.Set("Content-Type", feed.MIMEType(name)) + // Set explicitly so the server sees a length rather than a chunked body, + // and so MaxBytesReader can reject an oversized file before the transfer. + req.ContentLength = size + + resp, err := c.Do(req) + if err != nil { + return fmt.Errorf("uploading %s: %w", name, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = resp.Status + } + return fmt.Errorf("uploading %s: %s", name, msg) + } + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +func auth(req *http.Request, token string) { + if token == "" { + return + } + // Header rather than query string: it keeps the secret out of the server's + // access log, and push is not a podcast client so it has the choice. + req.Header.Set("Authorization", "Bearer "+token) +} + +// Summarise renders results for the terminal. +func Summarise(results []Result) (uploaded, skipped, failed int) { + for _, r := range results { + switch { + case r.Err != nil: + failed++ + case r.Skipped: + skipped++ + default: + uploaded++ + } + } + return +} |
