// 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 }