From 3f46bcc1bd91bed78c2df4025edf4f89f801cbe4 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Fri, 10 Oct 2025 21:01:34 +0200 Subject: [PATCH] feat: stream download cli --- .gitignore | 2 + README.md | 44 +++++ cmd/root.go | 29 ++++ go.mod | 10 ++ go.sum | 12 ++ internal/downloader/downloader.go | 271 ++++++++++++++++++++++++++++++ main.go | 7 + 7 files changed, 375 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/downloader/downloader.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44f0909 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +sdl +*.mp4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd81d5a --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# SDL - Stream Downloader CLI + +`sdl` is a simple command-line tool written in Go for downloading HTTP Live Streaming (HLS) video streams. + +## Installation + +```bash +go install ./... +``` + +This installs the `sdl` binary into your `GOBIN` (defaults to `~/go/bin`). Ensure this directory is on your `PATH`. + +## Usage + +```bash +sdl https://example.com/path/to/playlist.m3u8 +``` + +The downloader automatically picks the highest-bandwidth variant when given a master playlist, downloads the segments, and transmuxes them into an `.mp4` container. You can override the output filename: + +```bash +sdl -o myvideo.mp4 https://example.com/path/to/playlist.m3u8 +``` + +## Features + +- Follows HTTP redirects (up to 5 hops) +- Supports HLS master playlists (auto-select highest bandwidth variant) +- Produces `.mp4` output via `ffmpeg` +- Respects basic HTTP timeouts (30 seconds per request) + +## Limitations + +- Requires `ffmpeg` in `PATH` for transmuxing +- No retry logic beyond standard HTTP redirects + +## Development + +```bash +go build ./... +go test ./... +``` + +Contributions and issue reports are welcome! diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..3fdac39 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + "sdl/internal/downloader" +) + +var rootCmd = &cobra.Command{ + Use: "sdl [URL]", + Short: "Stream downloader for HTTP Live Streaming (HLS) content", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + url := args[0] + output, _ := cmd.Flags().GetString("output") + return downloader.Download(cmd.Context(), url, output) + }, +} + +func init() { + rootCmd.Flags().StringP("output", "o", "", "Output file name (defaults to basename of stream)") +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c1c221b --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module sdl + +go 1.24.3 + +require ( + github.com/grafov/m3u8 v0.12.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c72d27 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s= +github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go new file mode 100644 index 0000000..e9f92c9 --- /dev/null +++ b/internal/downloader/downloader.go @@ -0,0 +1,271 @@ +package downloader + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/grafov/m3u8" +) + +const ( + defaultClientTimeout = 30 * time.Second + maxRedirects = 5 +) + +var ( + errInvalidPlaylist = errors.New("unsupported playlist type. expected media playlist") + errUnsupportedMaster = errors.New("master playlist contains no playable variants") + errFFmpegMissing = errors.New("ffmpeg is required on PATH to transmux segments") +) + +type segmentInfo struct { + Sequence uint64 + URI string +} + +// Download downloads the video stream referenced by the given URL into a file named outputName. +// If outputName is empty, the base name of the stream URL is used with an .mp4 extension. +func Download(ctx context.Context, streamURL, outputName string) error { + if streamURL == "" { + return errors.New("stream URL must not be empty") + } + + parsed, err := url.Parse(streamURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + client := &http.Client{Timeout: defaultClientTimeout} + playlistBody, finalURL, err := fetchWithRedirects(ctx, client, parsed, maxRedirects) + if err != nil { + return fmt.Errorf("fetch playlist: %w", err) + } + defer playlistBody.Close() + + playlist, listType, err := m3u8.DecodeFrom(bufio.NewReader(playlistBody), true) + if err != nil { + return fmt.Errorf("parse playlist: %w", err) + } + + var segments []segmentInfo + switch listType { + case m3u8.MEDIA: + mediaPlaylist, ok := playlist.(*m3u8.MediaPlaylist) + if !ok { + return errInvalidPlaylist + } + segments = collectSegments(mediaPlaylist) + case m3u8.MASTER: + masterPlaylist, ok := playlist.(*m3u8.MasterPlaylist) + if !ok { + return errInvalidPlaylist + } + variantURL, err := selectVariant(masterPlaylist, finalURL) + if err != nil { + return err + } + return Download(ctx, variantURL, outputName) + default: + return errInvalidPlaylist + } + + if len(segments) == 0 { + return errors.New("playlist contains no segments") + } + + if outputName == "" { + outputName = inferOutputName(finalURL) + } + + tempTS, err := os.CreateTemp("", "sdl-*.ts") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + defer func() { + tempTS.Close() + os.Remove(tempTS.Name()) + }() + + for i, segment := range segments { + if err := downloadSegment(ctx, client, finalURL, segment, tempTS); err != nil { + return fmt.Errorf("download segment %d: %w", i, err) + } + } + + if _, err := tempTS.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("rewind temp file: %w", err) + } + + mp4Name := ensureMP4Extension(outputName) + if err := transmuxToMP4(ctx, tempTS.Name(), mp4Name); err != nil { + return err + } + + return nil +} + +func collectSegments(playlist *m3u8.MediaPlaylist) []segmentInfo { + segments := make([]segmentInfo, 0, len(playlist.Segments)) + for _, segment := range playlist.Segments { + if segment == nil || segment.URI == "" { + continue + } + segments = append(segments, segmentInfo{Sequence: segment.SeqId, URI: segment.URI}) + } + + sort.Slice(segments, func(i, j int) bool { + return segments[i].Sequence < segments[j].Sequence + }) + + return segments +} + +func selectVariant(master *m3u8.MasterPlaylist, base *url.URL) (string, error) { + var chosen *m3u8.Variant + for _, variant := range master.Variants { + if variant == nil || variant.URI == "" { + continue + } + if chosen == nil || variant.Bandwidth > chosen.Bandwidth { + chosen = variant + } + } + + if chosen == nil { + return "", errUnsupportedMaster + } + + resolved, err := base.Parse(chosen.URI) + if err != nil { + return "", fmt.Errorf("parse variant URL: %w", err) + } + + return resolved.String(), nil +} + +func downloadSegment(ctx context.Context, client *http.Client, base *url.URL, segment segmentInfo, output io.Writer) error { + segmentURL, err := base.Parse(segment.URI) + if err != nil { + return fmt.Errorf("parse segment URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, segmentURL.String(), nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("fetch segment: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %s", resp.Status) + } + + if _, err := io.Copy(output, resp.Body); err != nil { + return fmt.Errorf("write segment: %w", err) + } + + return nil +} + +func fetchWithRedirects(ctx context.Context, client *http.Client, streamURL *url.URL, redirects int) (io.ReadCloser, *url.URL, error) { + currentURL := streamURL + for count := 0; count <= redirects; count++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL.String(), nil) + if err != nil { + return nil, nil, fmt.Errorf("create request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("http get: %w", err) + } + switch { + case resp.StatusCode >= 300 && resp.StatusCode < 400: + location := resp.Header.Get("Location") + resp.Body.Close() + if location == "" { + return nil, nil, errors.New("redirect without location header") + } + nextURL, err := currentURL.Parse(location) + if err != nil { + return nil, nil, fmt.Errorf("parse redirect URL: %w", err) + } + currentURL = nextURL + continue + case resp.StatusCode == http.StatusOK: + return resp.Body, currentURL, nil + default: + resp.Body.Close() + return nil, nil, fmt.Errorf("unexpected status %s", resp.Status) + } + } + + return nil, nil, errors.New("too many redirects") +} + +func inferOutputName(streamURL *url.URL) string { + base := path.Base(streamURL.Path) + if base == "." || base == "/" || base == "" { + base = "download" + } + trimmed := strings.TrimSuffix(base, path.Ext(base)) + if trimmed == "" { + trimmed = "download" + } + return trimmed + ".mp4" +} + +func ensureMP4Extension(name string) string { + lower := strings.ToLower(name) + if strings.HasSuffix(lower, ".mp4") { + return name + } + ext := filepath.Ext(name) + trimmed := strings.TrimSuffix(name, ext) + if trimmed == "" { + trimmed = "output" + } + return trimmed + ".mp4" +} + +func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error { + if _, err := exec.LookPath("ffmpeg"); err != nil { + return errFFmpegMissing + } + + if err := os.MkdirAll(filepath.Dir(mp4Path), 0o755); err != nil { + return fmt.Errorf("ensure output directory: %w", err) + } + + cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", tsPath, "-c", "copy", "-movflags", "+faststart", mp4Path) + cmd.Stdout = io.Discard + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + os.Remove(mp4Path) + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("ffmpeg: %s", msg) + } + return fmt.Errorf("ffmpeg: %w", err) + } + + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c667289 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "sdl/cmd" + +func main() { + cmd.Execute() +}