feat: stream download cli
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
sdl
|
||||||
|
*.mp4
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -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!
|
||||||
29
cmd/root.go
Normal file
29
cmd/root.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
12
go.sum
Normal file
12
go.sum
Normal file
@@ -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=
|
||||||
271
internal/downloader/downloader.go
Normal file
271
internal/downloader/downloader.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user