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