Skip to content

Commit 4ed9067

Browse files
authored
M3u8 downloader (#17)
1 parent 09613be commit 4ed9067

File tree

4 files changed

+267
-0
lines changed

4 files changed

+267
-0
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ This section gives a quick peek at the capabilities and the extremely simple com
3636
GDRIVE_API_KEY="your_key" danzo "https://drive.google.com/file/d/abc123/view" # (static Key only for publicly shared files)
3737
GDRIVE_CREDENTIALS=service-acc-key.json danzo "https://drive.google.com/file/d/abc123/view" # (OAuth device code flow for private files)
3838
```
39+
- Download streamed output from an m3u8-manifest
40+
```bash
41+
danzo "m3u8://https://example.com/manifest.m3u8" -o video.mp4
42+
```
3943
- Download an S3 object or folder
4044
```bash
4145
AWS_PROFILE=myprofile danzo "s3://mybucket/path/to/file.zip"
@@ -135,6 +139,7 @@ Follow these links to quickly jump to the relevant provider:
135139
- [HTTP(S) Downloads](#https-downloads)
136140
- [Google Drive Downloads](#google-drive-downloads)
137141
- [Youtube Downloads](#youtube-downloads)
142+
- [M3U8 Stream Downloads](#m3u8-stream-downloads)
138143
- [AWS S3 Downloads](#aws-s3-downloads)
139144
- [GitHub Release Downloads](#github-release-downloads)
140145
- [Git Repository Cloning](#git-repository-cloning)
@@ -303,6 +308,22 @@ danzo "https://youtu.be/JJpFTUP6fIo||music:apple:1800533191"
303308
danzo "https://youtu.be/JJpFTUP6fIo||music:deezer:3271607031"
304309
```
305310

311+
### M3U8 Stream Downloads
312+
313+
Danzo supports downloading streamed content from M3U8 manifests. This is commonly used for video streaming services, live broadcasts, and VOD content.
314+
315+
Danzo downloads the M3U8 manifest, parses the playlist (supports both master and media playlists), downloads all segments, and merges them into a single file.
316+
317+
> [!NOTE]
318+
> Danzo requires `ffmpeg` to be installed for merging the segments.
319+
320+
```bash
321+
danzo "m3u8://https://example.com/path/to/playlist.m3u8" -o video.mp4
322+
323+
# With default output name (stream_[timestamp].mp4)
324+
danzo "m3u8://https://example.com/video/master.m3u8"
325+
```
326+
306327
### AWS S3 Downloads
307328

308329
There are 2 ways of downloading objects from S3:

downloaders/m3u8/downloader.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package m3u8
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"os/exec"
11+
"path/filepath"
12+
"strings"
13+
14+
"github.com/tanq16/danzo/utils"
15+
)
16+
17+
func parseM3U8URL(rawURL string) (string, error) {
18+
actualURL := rawURL[7:]
19+
_, err := url.Parse(actualURL)
20+
if err != nil {
21+
return "", fmt.Errorf("invalid URL after m3u8:// prefix: %v", err)
22+
}
23+
return actualURL, nil
24+
}
25+
26+
func getM3U8Contents(manifestURL string, client *http.Client) (string, error) {
27+
req, err := http.NewRequest("GET", manifestURL, nil)
28+
if err != nil {
29+
return "", fmt.Errorf("error creating request: %v", err)
30+
}
31+
resp, err := client.Do(req)
32+
if err != nil {
33+
return "", fmt.Errorf("error fetching m3u8 manifest: %v", err)
34+
}
35+
defer resp.Body.Close()
36+
if resp.StatusCode != http.StatusOK {
37+
return "", fmt.Errorf("server returned status code %d", resp.StatusCode)
38+
}
39+
content, err := io.ReadAll(resp.Body)
40+
if err != nil {
41+
return "", fmt.Errorf("error reading manifest content: %v", err)
42+
}
43+
return string(content), nil
44+
}
45+
46+
func processM3U8Content(content, manifestURL string, client *http.Client) ([]string, error) {
47+
baseURL, err := url.Parse(manifestURL)
48+
if err != nil {
49+
return nil, fmt.Errorf("error parsing manifest URL: %v", err)
50+
}
51+
scanner := bufio.NewScanner(strings.NewReader(content))
52+
var segmentURLs []string
53+
var masterPlaylistURLs []string
54+
var isMasterPlaylist bool
55+
for scanner.Scan() {
56+
line := strings.TrimSpace(scanner.Text())
57+
// Skip empty lines and comments (except EXT-X-STREAM-INF)
58+
if line == "" || (strings.HasPrefix(line, "#") && !strings.Contains(line, "#EXT-X-STREAM-INF")) {
59+
continue
60+
}
61+
if strings.Contains(line, "#EXT-X-STREAM-INF") {
62+
isMasterPlaylist = true
63+
continue
64+
}
65+
// If not a comment, consider it a URL
66+
if !strings.HasPrefix(line, "#") {
67+
segmentURL, err := resolveURL(baseURL, line)
68+
if err != nil {
69+
return nil, fmt.Errorf("error resolving URL: %v", err)
70+
}
71+
if isMasterPlaylist {
72+
masterPlaylistURLs = append(masterPlaylistURLs, segmentURL)
73+
} else {
74+
segmentURLs = append(segmentURLs, segmentURL)
75+
}
76+
}
77+
}
78+
if err := scanner.Err(); err != nil {
79+
return nil, fmt.Errorf("error scanning m3u8 content: %v", err)
80+
}
81+
82+
// For a master playlist, fetch the first playlist for highest quality
83+
if isMasterPlaylist && len(masterPlaylistURLs) > 0 {
84+
subContent, err := getM3U8Contents(masterPlaylistURLs[0], client)
85+
if err != nil {
86+
return nil, fmt.Errorf("error fetching sub-playlist: %v", err)
87+
}
88+
// Recursively process the sub-playlist
89+
return processM3U8Content(subContent, masterPlaylistURLs[0], client)
90+
}
91+
return segmentURLs, nil
92+
}
93+
94+
func resolveURL(baseURL *url.URL, urlStr string) (string, error) {
95+
if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") {
96+
return urlStr, nil // Already an absolute URL
97+
}
98+
relURL, err := url.Parse(urlStr)
99+
if err != nil {
100+
return "", err
101+
}
102+
absURL := baseURL.ResolveReference(relURL)
103+
return absURL.String(), nil
104+
}
105+
106+
func downloadM3U8Segments(segmentURLs []string, outputDir string, client *http.Client, streamCh chan<- string) ([]string, error) {
107+
if err := os.MkdirAll(outputDir, 0755); err != nil {
108+
return nil, fmt.Errorf("error creating output directory: %v", err)
109+
}
110+
var downloadedFiles []string
111+
for i, segmentURL := range segmentURLs {
112+
outputPath := filepath.Join(outputDir, fmt.Sprintf("segment_%04d.ts", i))
113+
req, err := http.NewRequest("GET", segmentURL, nil)
114+
if err != nil {
115+
return downloadedFiles, fmt.Errorf("error creating request for segment %d: %v", i, err)
116+
}
117+
resp, err := client.Do(req)
118+
if err != nil {
119+
return downloadedFiles, fmt.Errorf("error downloading segment %d: %v", i, err)
120+
}
121+
outFile, err := os.Create(outputPath)
122+
if err != nil {
123+
resp.Body.Close()
124+
return downloadedFiles, fmt.Errorf("error creating output file for segment %d: %v", i, err)
125+
}
126+
n, err := io.Copy(outFile, resp.Body)
127+
resp.Body.Close()
128+
outFile.Close()
129+
if err != nil {
130+
return downloadedFiles, fmt.Errorf("error writing segment %d: %v", i, err)
131+
}
132+
streamCh <- fmt.Sprintf("Downloaded segment %d of %d", i+1, len(segmentURLs))
133+
streamCh <- fmt.Sprintf("Downloaded %s", utils.FormatBytes(uint64(n)))
134+
downloadedFiles = append(downloadedFiles, outputPath)
135+
}
136+
return downloadedFiles, nil
137+
}
138+
139+
func mergeSegments(segmentFiles []string, outputPath string) error {
140+
tempListFile := filepath.Join(filepath.Dir(outputPath), ".segment_list.txt")
141+
f, err := os.Create(tempListFile)
142+
if err != nil {
143+
return fmt.Errorf("error creating segment list file: %v", err)
144+
}
145+
for _, file := range segmentFiles {
146+
fmt.Fprintf(f, "file '%s'\n", file)
147+
}
148+
f.Close()
149+
cmd := exec.Command(
150+
"ffmpeg",
151+
"-f", "concat",
152+
"-safe", "0",
153+
"-i", tempListFile,
154+
"-c", "copy",
155+
"-y", // Overwrite output files without asking
156+
outputPath,
157+
)
158+
output, err := cmd.CombinedOutput()
159+
if err != nil {
160+
return fmt.Errorf("ffmpeg error: %v\nOutput: %s", err, string(output))
161+
}
162+
os.Remove(tempListFile)
163+
return nil
164+
}
165+
166+
func cleanupSegments(segmentFiles []string) {
167+
for _, file := range segmentFiles {
168+
os.Remove(file)
169+
}
170+
if len(segmentFiles) > 0 {
171+
dir := filepath.Dir(segmentFiles[0])
172+
os.Remove(dir) // only succeeds if empty
173+
}
174+
}
175+
176+
func PerformM3U8Download(config utils.DownloadConfig, client *http.Client, streamCh chan<- string) error {
177+
manifestURL, err := parseM3U8URL(config.URL)
178+
if err != nil {
179+
return fmt.Errorf("error parsing m3u8 URL: %v", err)
180+
}
181+
streamCh <- "Fetching M3U8 manifest"
182+
manifestContent, err := getM3U8Contents(manifestURL, client)
183+
if err != nil {
184+
return fmt.Errorf("error getting m3u8 manifest: %v", err)
185+
}
186+
streamCh <- "Processing M3U8 manifest..."
187+
segmentURLs, err := processM3U8Content(manifestContent, manifestURL, client)
188+
if err != nil {
189+
return fmt.Errorf("error processing m3u8 content: %v", err)
190+
}
191+
streamCh <- fmt.Sprintf("Found %d segments to download", len(segmentURLs))
192+
tempDir := filepath.Join(filepath.Dir(config.OutputPath), ".danzo-temp")
193+
streamCh <- "Downloading segments..."
194+
segmentFiles, err := downloadM3U8Segments(segmentURLs, tempDir, client, streamCh)
195+
if err != nil {
196+
return fmt.Errorf("error downloading segments: %v", err)
197+
}
198+
streamCh <- "Merging segments..."
199+
if err := mergeSegments(segmentFiles, config.OutputPath); err != nil {
200+
return fmt.Errorf("error merging segments: %v", err)
201+
}
202+
streamCh <- "Cleaning up temporary files..."
203+
cleanupSegments(segmentFiles)
204+
return nil
205+
}

internal/downloader.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
danzogitc "github.com/tanq16/danzo/downloaders/gitclone"
1414
danzogitr "github.com/tanq16/danzo/downloaders/gitrelease"
1515
danzohttp "github.com/tanq16/danzo/downloaders/http"
16+
danzom3u8 "github.com/tanq16/danzo/downloaders/m3u8"
1617
danzos3 "github.com/tanq16/danzo/downloaders/s3"
1718
danzoyoutube "github.com/tanq16/danzo/downloaders/youtube"
1819
"github.com/tanq16/danzo/utils"
@@ -482,6 +483,44 @@ func BatchDownload(entries []utils.DownloadEntry, numLinks, connectionsPerLink i
482483
}
483484
close(progressCh)
484485
progressWg.Wait()
486+
487+
// M3U8 Stream download
488+
// =================================================================================================================
489+
case "m3u8":
490+
close(progressCh) // Not needed for M3U8 downloads
491+
outputMgr.SetMessage(entryFunctionId, fmt.Sprintf("Processing M3U8 stream: %s", entry.OutputPath))
492+
if config.OutputPath == "" {
493+
config.OutputPath = fmt.Sprintf("stream_%s.mp4", time.Now().Format("2006-01-02_15-04"))
494+
entry.OutputPath = config.OutputPath
495+
} else {
496+
existingFile, _ := os.Stat(config.OutputPath)
497+
if existingFile != nil {
498+
config.OutputPath = utils.RenewOutputPath(config.OutputPath)
499+
entry.OutputPath = config.OutputPath
500+
}
501+
}
502+
client := utils.CreateHTTPClient(httpClientConfig, false)
503+
streamCh := make(chan string)
504+
505+
// Goroutine to stream output to the manager
506+
var streamWg sync.WaitGroup
507+
streamWg.Add(1)
508+
go func(outputPath string, streamCh <-chan string) {
509+
defer streamWg.Done()
510+
for streamOutput := range streamCh {
511+
outputMgr.AddStreamLine(entryFunctionId, streamOutput)
512+
}
513+
}(entry.OutputPath, streamCh)
514+
515+
err := danzom3u8.PerformM3U8Download(config, client, streamCh)
516+
close(streamCh)
517+
if err != nil {
518+
outputMgr.ReportError(entryFunctionId, fmt.Errorf("error downloading M3U8 stream: %v", err))
519+
outputMgr.SetMessage(entryFunctionId, fmt.Sprintf("Error downloading M3U8 stream: %s", entry.OutputPath))
520+
} else {
521+
outputMgr.Complete(entryFunctionId, fmt.Sprintf("M3U8 stream downloaded: %s", entry.OutputPath))
522+
}
523+
streamWg.Wait()
485524
}
486525
}
487526
}(i + 1)

utils/functions.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ func DetermineDownloadType(url string) string {
3737
return "gitrelease"
3838
} else if strings.HasPrefix(url, "github.com") || strings.HasPrefix(url, "gitlab.com") || strings.HasPrefix(url, "bitbucket.org") || strings.HasPrefix(url, "git.com") {
3939
return "gitclone"
40+
} else if strings.HasPrefix(url, "m3u8://") {
41+
return "m3u8"
4042
}
4143
return "http"
4244
}

0 commit comments

Comments
 (0)