├── joiner ├── joiner.go ├── memory_joiner.go └── ffmepg_joiner.go ├── decrypter └── decrypter.go ├── go.mod ├── .github └── workflows │ └── release.yaml ├── README.MD ├── processbar └── processbar.go ├── ts └── ts.go ├── zhttp └── zhttp.go ├── go.sum └── main.go /joiner/joiner.go: -------------------------------------------------------------------------------- 1 | package joiner 2 | 3 | type Joiner interface { 4 | Add(id int, block []byte) error 5 | Merge() error 6 | } 7 | -------------------------------------------------------------------------------- /decrypter/decrypter.go: -------------------------------------------------------------------------------- 1 | package decrypter 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | ) 7 | 8 | func Decrypt(data, key, iv []byte) ([]byte, error) { 9 | block, err := aes.NewCipher(key) 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | cbc := cipher.NewCBCDecrypter(block, iv) 15 | cbc.CryptBlocks(data, data) 16 | 17 | return PKCS7UnPadding(data), nil 18 | } 19 | 20 | func PKCS7UnPadding(origData []byte) []byte { 21 | length := len(origData) 22 | unpadding := int(origData[length-1]) 23 | return origData[:(length - unpadding)] 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/greyh4t/m3u8-Downloader-Go 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/grafov/m3u8 v0.12.1 9 | github.com/greyh4t/hackpool v0.0.0-20231219120243-36876b128977 10 | github.com/guonaihong/clop v0.2.12 11 | ) 12 | 13 | require ( 14 | github.com/antlabs/strsim v0.0.3 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 16 | github.com/go-playground/locales v0.14.1 // indirect 17 | github.com/go-playground/universal-translator v0.18.1 // indirect 18 | github.com/go-playground/validator/v10 v10.17.0 // indirect 19 | github.com/leodido/go-urn v1.4.0 // indirect 20 | golang.org/x/crypto v0.36.0 // indirect 21 | golang.org/x/net v0.38.0 // indirect 22 | golang.org/x/sys v0.31.0 // indirect 23 | golang.org/x/text v0.23.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /joiner/memory_joiner.go: -------------------------------------------------------------------------------- 1 | package joiner 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | ) 7 | 8 | type MemoryJoiner struct { 9 | l sync.Mutex 10 | blocks map[int][]byte 11 | file *os.File 12 | index int 13 | } 14 | 15 | func NewMem(outFile string) (*MemoryJoiner, error) { 16 | f, err := os.OpenFile(outFile, os.O_CREATE|os.O_TRUNC|os.O_RDWR|os.O_APPEND, 0644) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | joiner := &MemoryJoiner{ 22 | blocks: map[int][]byte{}, 23 | file: f, 24 | } 25 | 26 | return joiner, nil 27 | } 28 | 29 | func (j *MemoryJoiner) Add(id int, block []byte) error { 30 | j.l.Lock() 31 | j.blocks[id] = block 32 | err := j.merge() 33 | j.l.Unlock() 34 | return err 35 | } 36 | 37 | func (j *MemoryJoiner) merge() error { 38 | for { 39 | block, ok := j.blocks[j.index] 40 | if ok { 41 | _, err := j.file.Write(block) 42 | if err != nil { 43 | return err 44 | } 45 | delete(j.blocks, j.index) 46 | j.index++ 47 | } else { 48 | break 49 | } 50 | } 51 | return nil 52 | } 53 | 54 | func (j *MemoryJoiner) Merge() error { 55 | return j.file.Close() 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | goos: [linux, windows, darwin] 11 | goarch: [amd64, arm64] 12 | exclude: 13 | - goarch: arm64 14 | goos: windows 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Setup Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: 1.21 21 | - name: Build 22 | env: 23 | GOOS: ${{ matrix.goos }} 24 | GOARCH: ${{ matrix.goarch }} 25 | run: | 26 | name=m3u8-Downloader-Go 27 | output=${name} 28 | if [ "${{ matrix.goos }}" == "windows" ]; then 29 | output=${name}.exe 30 | fi 31 | echo "TGZ_FILE=${name}_${{ matrix.goos }}_${{ matrix.goarch }}.tgz" >> $GITHUB_ENV 32 | echo "OUTPUT_FILE=${output}" >> $GITHUB_ENV 33 | go build -ldflags="-s -w" -o ${output} ./ 34 | - name: Pack 35 | run: tar czf ${{ env.TGZ_FILE }} ${{ env.OUTPUT_FILE }} 36 | - name: Upload 37 | uses: softprops/action-gh-release@v1 38 | with: 39 | files: ${{ env.TGZ_FILE }} 40 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # m3u8-Downloader-Go 2 | 3 | Download m3u8 media with multithreading, support decrypt 4 | 5 | # How to use 6 | 7 | `./m3u8-Downloader-Go -u "http://wwww.example.com/example.m3u8" -o video.ts` 8 | 9 | `./m3u8-Downloader-Go -f example.m3u8 -H Referer:http://www.example.com -H 'User-Agent:Chrome/83.0.4103.61 Safari/537.36'` 10 | 11 | ### Note 12 | 13 | When using the -f parameter, if the m3u8 file does not contain a specific link to the media, but only the media name, you must specify the -u parameter 14 | 15 | Some websites will add an image header at the beginning of the video file. The tool will attempt to remove these header. If there are issues with the downloaded video, please try using the `--nofix` parameter 16 | 17 | ``` 18 | ./m3u8-Downloader-Go -h 19 | 20 | Usage: 21 | ./m3u8-Downloader-Go [Flags] [Options] 22 | 23 | Flags: 24 | -m,--merge-with-ffmpeg merge with ffmpeg 25 | -n,--nofix don't try to remove the image header of the ts file 26 | -s,--skipverify skip verify server certificate 27 | 28 | Options: 29 | -F,--ffmpeg path of ffmpeg [default: ffmpeg] 30 | -H,--header http header. Example: Referer:http://www.example.com 31 | -V,--version print version information 32 | -c,--connections number of connections [default: 16] 33 | -f,--m3u8-file use local m3u8 file instead of downloading from url 34 | -h,--help print the help information 35 | -o,--out-file out file 36 | -p,--proxy proxy. Example: http://127.0.0.1:8080 37 | -r,--retry number of retries [default: 3] 38 | -t,--timeout timeout [default: 60s] 39 | -u,--url url of m3u8 file 40 | -d,--desired-resolution desired resolution. Example: 1920x1080 41 | ``` 42 | -------------------------------------------------------------------------------- /joiner/ffmepg_joiner.go: -------------------------------------------------------------------------------- 1 | package joiner 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "sync" 9 | ) 10 | 11 | type FFmepgJoiner struct { 12 | l sync.Mutex 13 | ffmpeg string 14 | outFile string 15 | cacheDir string 16 | blocks map[int]string 17 | } 18 | 19 | func NewFFmepg(ffmpeg string, outFile string) (*FFmepgJoiner, error) { 20 | joiner := &FFmepgJoiner{ 21 | ffmpeg: ffmpeg, 22 | outFile: outFile, 23 | blocks: map[int]string{}, 24 | } 25 | 26 | _, err := exec.LookPath(ffmpeg) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | dir, err := joiner.mkdir() 32 | if err != nil { 33 | return nil, err 34 | } 35 | joiner.cacheDir = dir 36 | 37 | return joiner, nil 38 | } 39 | 40 | func (j *FFmepgJoiner) mkdir() (string, error) { 41 | cache, err := os.MkdirTemp("./", "m3u8_cache_*") 42 | if err != nil { 43 | return "", err 44 | } 45 | return filepath.Abs(cache) 46 | } 47 | 48 | func (j *FFmepgJoiner) Add(id int, block []byte) error { 49 | file := filepath.Join(j.cacheDir, fmt.Sprintf("%d.ts", id)) 50 | err := os.WriteFile(file, block, 0644) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | j.l.Lock() 56 | j.blocks[id] = file 57 | j.l.Unlock() 58 | return err 59 | } 60 | 61 | func (j *FFmepgJoiner) merge(mergeFile string) error { 62 | cmd := exec.Command(j.ffmpeg, "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", mergeFile, "-c", "copy", j.outFile) 63 | cmd.Stdout = os.Stdout 64 | cmd.Stderr = os.Stderr 65 | return cmd.Run() 66 | } 67 | 68 | func (j *FFmepgJoiner) Merge() error { 69 | var text string 70 | i := 0 71 | for { 72 | file, ok := j.blocks[i] 73 | if !ok { 74 | break 75 | } 76 | text += fmt.Sprintf("file '%s'\n", file) 77 | i++ 78 | } 79 | 80 | mergeFile := filepath.Join(j.cacheDir, "merge_list.txt") 81 | err := os.WriteFile(mergeFile, []byte(text), 0644) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | err = j.merge(mergeFile) 87 | if err != nil { 88 | return fmt.Errorf("ffmpeg merge error: %w", err) 89 | } 90 | 91 | if j.cacheDir != "" { 92 | os.RemoveAll(j.cacheDir) 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /processbar/processbar.go: -------------------------------------------------------------------------------- 1 | package processbar 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "os" 8 | "strings" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | type Bar struct { 14 | ctx context.Context 15 | cancel context.CancelFunc 16 | total int 17 | count int 18 | percent int 19 | tag string 20 | format string 21 | startTime time.Time 22 | ticker *time.Ticker 23 | mut sync.Mutex 24 | } 25 | 26 | func New(total int) *Bar { 27 | bar := &Bar{ 28 | total: total, 29 | tag: "#", 30 | format: "\r[%-50s] %3d%% %" + fmt.Sprintf("%d", digitCount(total)) + "d/%d %-11s", 31 | } 32 | 33 | return bar 34 | } 35 | 36 | func (b *Bar) SetTag(tag string) *Bar { 37 | b.tag = tag 38 | return b 39 | } 40 | 41 | func (b *Bar) Incr() { 42 | b.mut.Lock() 43 | if b.startTime.IsZero() { 44 | b.startTime = time.Now() 45 | } 46 | b.count++ 47 | b.mut.Unlock() 48 | } 49 | 50 | func (b *Bar) Flush() { 51 | b.calculate() 52 | b.display() 53 | } 54 | 55 | func (b *Bar) AutoFlush(interval time.Duration) { 56 | b.stop() 57 | ctx, cancel := context.WithCancel(context.Background()) 58 | b.ctx = ctx 59 | b.cancel = cancel 60 | b.ticker = time.NewTicker(interval) 61 | b.Flush() 62 | go func() { 63 | for { 64 | select { 65 | case <-b.ticker.C: 66 | b.Flush() 67 | case <-b.ctx.Done(): 68 | b.ticker.Stop() 69 | return 70 | } 71 | } 72 | }() 73 | } 74 | 75 | func (b *Bar) stop() { 76 | if b.cancel != nil { 77 | b.cancel() 78 | } 79 | } 80 | 81 | func (b *Bar) calculate() { 82 | b.mut.Lock() 83 | if b.count >= b.total { 84 | b.percent = 100 85 | } else { 86 | b.percent = b.count * 100 / b.total 87 | } 88 | b.mut.Unlock() 89 | } 90 | 91 | func (b *Bar) display() { 92 | b.mut.Lock() 93 | if b.startTime.IsZero() { 94 | b.startTime = time.Now() 95 | } 96 | secs := time.Second * time.Duration(int(time.Since(b.startTime)/time.Second)) 97 | fmt.Fprintf(os.Stderr, b.format, strings.Repeat(b.tag, b.percent/2), b.percent, b.count, b.total, secs) 98 | b.mut.Unlock() 99 | } 100 | 101 | func (b *Bar) Finish() { 102 | b.stop() 103 | fmt.Fprintln(os.Stderr) 104 | } 105 | 106 | func digitCount(n int) int { 107 | return int(math.Floor(math.Log10(float64(n)))) + 1 108 | } 109 | -------------------------------------------------------------------------------- /ts/ts.go: -------------------------------------------------------------------------------- 1 | package ts 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | syncByte byte = 0x47 10 | packetLength int = 188 11 | ) 12 | 13 | var ( 14 | jpgHeader = []byte{0xFF, 0xD8, 0xFF} 15 | pngHeader = []byte{0x89, 0x50, 0x4e, 0x47} 16 | gifHeader = []byte{0x47, 0x49, 0x46, 0x38} 17 | bmpHeader = []byte{0x42, 0x4d} 18 | ) 19 | 20 | func CheckHead(data []byte) error { 21 | pkt, err := ReadPacket(data) 22 | if err != nil { 23 | return err 24 | } 25 | err = pkt.Check() 26 | if err != nil { 27 | return err 28 | } 29 | pid := pkt.PID() 30 | if pid != 0 && pid != 17 { 31 | return fmt.Errorf("bad pid %d", pid) 32 | } 33 | return nil 34 | } 35 | 36 | func ReadPacket(data []byte) (Packet, error) { 37 | if len(data) < packetLength { 38 | return nil, fmt.Errorf("data length too short") 39 | } 40 | pkt := Packet(data[:packetLength]) 41 | return pkt, nil 42 | } 43 | 44 | type Packet []byte 45 | 46 | func (p Packet) Check() error { 47 | if p.syncByte() != syncByte { 48 | return fmt.Errorf("invalid sync byte") 49 | } 50 | if p.transportScramblingControl() == 1 { 51 | return fmt.Errorf("invalid transport scrambling control option") 52 | } 53 | if p.adaptationFieldControl() == 0 { 54 | return fmt.Errorf("invalid packet length") 55 | } 56 | return nil 57 | } 58 | 59 | func (p Packet) syncByte() byte { 60 | return p[0] 61 | } 62 | 63 | func (p Packet) transportScramblingControl() byte { 64 | return (p[3] & 0xC0) >> 6 65 | } 66 | 67 | func (p Packet) adaptationFieldControl() byte { 68 | return (p[3] & 0x30) >> 4 69 | } 70 | 71 | func (p Packet) PID() int { 72 | return int(p[1]&0x1f)<<8 | int(p[2]) 73 | } 74 | 75 | func TryFix(data []byte) []byte { 76 | if len(data) == 0 { 77 | return data 78 | } 79 | 80 | if bytes.HasPrefix(data, jpgHeader) || bytes.HasPrefix(data, pngHeader) || bytes.HasPrefix(data, gifHeader) || bytes.HasPrefix(data, bmpHeader) { 81 | return Fix(data) 82 | } 83 | 84 | return data 85 | } 86 | 87 | func Fix(data []byte) []byte { 88 | backup := data 89 | for { 90 | index := bytes.IndexByte(data, syncByte) 91 | if index < 0 { 92 | return backup 93 | } 94 | 95 | if data[index+packetLength] == syncByte { 96 | err := CheckHead(data[index:]) 97 | if err == nil { 98 | return data[index:] 99 | } 100 | } 101 | 102 | data = data[index+1:] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /zhttp/zhttp.go: -------------------------------------------------------------------------------- 1 | package zhttp 2 | 3 | import ( 4 | "compress/gzip" 5 | "crypto/tls" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type Zhttp struct { 14 | client *http.Client 15 | } 16 | 17 | func New(timeout time.Duration, proxy string, skipVerify bool) (*Zhttp, error) { 18 | z := &Zhttp{ 19 | client: &http.Client{ 20 | Timeout: timeout, 21 | Transport: http.DefaultTransport.(*http.Transport).Clone(), 22 | }, 23 | } 24 | if skipVerify { 25 | z.client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 26 | } 27 | 28 | if proxy != "" { 29 | p, err := url.Parse(proxy) 30 | if err != nil { 31 | return nil, err 32 | } 33 | z.client.Transport.(*http.Transport).Proxy = http.ProxyURL(p) 34 | } 35 | 36 | return z, nil 37 | } 38 | 39 | func (z *Zhttp) Get(url string, headers map[string]string, retry int) (code int, body []byte, err error) { 40 | req, err := http.NewRequest("GET", url, nil) 41 | if err != nil { 42 | return 0, nil, err 43 | } 44 | 45 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") 46 | for k, v := range headers { 47 | req.Header.Set(k, v) 48 | } 49 | 50 | for retry > 0 { 51 | retry-- 52 | code, body, err = z.get(req) 53 | if err == nil { 54 | if code/100 == 2 { 55 | return code, body, err 56 | } 57 | } else if strings.Contains(err.Error(), "INTERNAL_ERROR") { 58 | z.resetConnection() 59 | } 60 | time.Sleep(time.Second * 2) 61 | } 62 | 63 | return 64 | } 65 | 66 | func (z *Zhttp) resetConnection() { 67 | t := z.client.Transport.(*http.Transport) 68 | t.CloseIdleConnections() 69 | z.client.Transport = t.Clone() 70 | } 71 | 72 | func (z *Zhttp) get(req *http.Request) (int, []byte, error) { 73 | resp, err := z.client.Do(req) 74 | if err != nil { 75 | return 0, nil, err 76 | } 77 | defer resp.Body.Close() 78 | 79 | r := resp.Body 80 | if equalFold(resp.Header.Get("Content-Encoding"), "gzip") && !resp.Uncompressed { 81 | r, err = gzip.NewReader(resp.Body) 82 | if err != nil { 83 | return 0, nil, err 84 | } 85 | defer r.Close() 86 | } 87 | data, err := io.ReadAll(r) 88 | if err != nil { 89 | return 0, nil, err 90 | } 91 | return resp.StatusCode, data, nil 92 | } 93 | 94 | // equalFold is strings.equalFold, ASCII only. It reports whether s and t 95 | // are equal, ASCII-case-insensitively. 96 | func equalFold(s, t string) bool { 97 | if len(s) != len(t) { 98 | return false 99 | } 100 | for i := 0; i < len(s); i++ { 101 | if lower(s[i]) != lower(t[i]) { 102 | return false 103 | } 104 | } 105 | return true 106 | } 107 | 108 | // lower returns the ASCII lowercase version of b. 109 | func lower(b byte) byte { 110 | if 'A' <= b && b <= 'Z' { 111 | return b + ('a' - 'A') 112 | } 113 | return b 114 | } 115 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/antlabs/strsim v0.0.2/go.mod h1:95XAAF2dJK9IiZMc0Ue6H9t477/i6fvYoMoeey8sEnc= 2 | github.com/antlabs/strsim v0.0.3 h1:J9AHxnybJZHKBoxeup1VZNWt3ST8QD+ieDJsm/nEpRo= 3 | github.com/antlabs/strsim v0.0.3/go.mod h1:bIcymn+2jtt01korFun0bs8PsYZeQa82aHoYMi7cm30= 4 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 9 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 10 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 11 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 12 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 13 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= 14 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 15 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 16 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= 17 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 18 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 19 | github.com/go-playground/validator/v10 v10.10.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= 20 | github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= 21 | github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 22 | github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s= 23 | github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= 24 | github.com/greyh4t/hackpool v0.0.0-20231219120243-36876b128977 h1:THL5TsahFGMGASjeZyeZKZUmlTavSux75qxYveP0QlM= 25 | github.com/greyh4t/hackpool v0.0.0-20231219120243-36876b128977/go.mod h1:Jw80xlqkuNer3FOzTRThn5AdcgLE9g1EYnT4cmN3C6M= 26 | github.com/guonaihong/clop v0.2.12 h1:pc9G3iOXr4aOEHA1JmGPhgOfwNdhQaP+308oFjjq42g= 27 | github.com/guonaihong/clop v0.2.12/go.mod h1:UKHLsZTl40VVQ31JcB8j9P9SM8NE78ZL6ePXyfeCmQE= 28 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 29 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 30 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 31 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 32 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 33 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 34 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= 35 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 36 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 37 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 38 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 39 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 40 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 41 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 42 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 43 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 45 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 46 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 47 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 48 | golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 49 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 50 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 51 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 52 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 53 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 54 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 59 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 60 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 61 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 62 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 63 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 64 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 65 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 67 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 69 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 70 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 71 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "fmt" 7 | "log" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/grafov/m3u8" 18 | "github.com/greyh4t/hackpool" 19 | "github.com/greyh4t/m3u8-Downloader-Go/decrypter" 20 | "github.com/greyh4t/m3u8-Downloader-Go/joiner" 21 | "github.com/greyh4t/m3u8-Downloader-Go/processbar" 22 | "github.com/greyh4t/m3u8-Downloader-Go/ts" 23 | "github.com/greyh4t/m3u8-Downloader-Go/zhttp" 24 | "github.com/guonaihong/clop" 25 | ) 26 | 27 | var ( 28 | ZHTTP *zhttp.Zhttp 29 | JOINER joiner.Joiner 30 | BAR *processbar.Bar 31 | conf *Conf 32 | keyCache = map[string][]byte{} 33 | keyCacheLock sync.Mutex 34 | ) 35 | 36 | type Conf struct { 37 | URL string `clop:"-u; --url" usage:"url of m3u8 file"` 38 | File string `clop:"-f; --m3u8-file" usage:"use local m3u8 file instead of downloading from url"` 39 | Connections int `clop:"-c; --connections" usage:"number of connections" default:"16"` 40 | OutFile string `clop:"-o; --out-file" usage:"out file"` 41 | Retry int `clop:"-r; --retry" usage:"number of retries" default:"3"` 42 | Timeout time.Duration `clop:"-t; --timeout" usage:"timeout" default:"60s"` 43 | Proxy string `clop:"-p; --proxy" usage:"proxy. Example: http://127.0.0.1:8080"` 44 | Headers []string `clop:"-H; --header; greedy" usage:"http header. Example: Referer:http://www.example.com"` 45 | NoFix bool `clop:"-n; --nofix" usage:"don't try to remove the image header of the ts file"` 46 | SkipVerify bool `clop:"-s; --skipverify" usage:"skip verify server certificate"` 47 | MergeWithFFmpeg bool `clop:"-m; --merge-with-ffmpeg" usage:"merge with ffmpeg"` 48 | FFmpeg string `clop:"-F; --ffmpeg" usage:"path of ffmpeg" default:"ffmpeg"` 49 | DesiredResolution string `clop:"-d; --desired-resolution" usage:"desired resolution. Example: 1920x1080"` 50 | ListResolution bool `clop:"-l; --list-resolution" usage:"list resolution"` 51 | headers map[string]string 52 | } 53 | 54 | func init() { 55 | conf = &Conf{} 56 | clop.CommandLine.SetExit(true) 57 | clop.SetVersion("1.5.3") 58 | clop.Bind(&conf) 59 | 60 | checkConf() 61 | 62 | if len(conf.Headers) > 0 { 63 | parseHeaders() 64 | } 65 | } 66 | 67 | func checkConf() { 68 | if conf.URL == "" && conf.File == "" { 69 | fmt.Println("You must set the -u or -f parameter") 70 | clop.Usage() 71 | } 72 | 73 | if conf.Connections <= 0 { 74 | conf.Connections = 10 75 | } 76 | 77 | if conf.Retry <= 0 { 78 | conf.Retry = 1 79 | } 80 | 81 | if conf.Timeout <= 0 { 82 | conf.Timeout = time.Second * 60 83 | } 84 | } 85 | 86 | func parseHeaders() { 87 | conf.headers = map[string]string{} 88 | for _, header := range conf.Headers { 89 | s := strings.SplitN(header, ":", 2) 90 | key := strings.TrimRight(s[0], " ") 91 | if len(s) == 2 { 92 | conf.headers[key] = strings.TrimLeft(s[1], " ") 93 | } else { 94 | conf.headers[key] = "" 95 | } 96 | } 97 | } 98 | 99 | func startDownload(mpl *m3u8.MediaPlaylist) { 100 | containMap := mpl.Map != nil && mpl.Map.URI != "" 101 | count := mpl.Count() 102 | if containMap { 103 | count += 1 104 | } 105 | 106 | BAR = processbar.New(int(count)) 107 | BAR.Flush() 108 | 109 | pool := hackpool.New(conf.Connections, download) 110 | 111 | go func() { 112 | if containMap { 113 | pool.Push(mpl.Map.URI, conf.headers, conf.Retry, callback(0, nil, nil)) 114 | } 115 | 116 | for i, segment := range mpl.GetAllSegments() { 117 | key, iv, err := getKey(i, segment.Key) 118 | if err != nil { 119 | log.Fatalln("[-] Download failed: %w", err) 120 | } 121 | if containMap { 122 | pool.Push(segment.URI, conf.headers, conf.Retry, callback(i+1, key, iv)) 123 | } else { 124 | pool.Push(segment.URI, conf.headers, conf.Retry, callback(i, key, iv)) 125 | } 126 | } 127 | pool.CloseQueue() 128 | }() 129 | 130 | pool.Run() 131 | 132 | BAR.Finish() 133 | } 134 | 135 | func callback(id int, key, iv []byte) func([]byte, error) { 136 | return func(data []byte, err error) { 137 | if err != nil { 138 | log.Fatalln("[-] Download failed:", id, err) 139 | } 140 | 141 | if key != nil { 142 | data, err = decrypter.Decrypt(data, key, iv) 143 | if err != nil { 144 | log.Fatalln("[-] Decrypt failed:", err) 145 | } 146 | } 147 | 148 | if !conf.NoFix { 149 | data = ts.TryFix(data) 150 | } 151 | 152 | err = JOINER.Add(id, data) 153 | if err != nil { 154 | log.Fatalln("[-] Write file failed:", err) 155 | } 156 | 157 | BAR.Incr() 158 | BAR.Flush() 159 | } 160 | } 161 | 162 | func getKey(id int, key *m3u8.Key) ([]byte, []byte, error) { 163 | if key != nil && key.URI != "" { 164 | var k, iv []byte 165 | k, err := fetchKey(key.URI) 166 | if err != nil { 167 | return nil, nil, fmt.Errorf("download key from %s error: %w", key.URI, err) 168 | } 169 | 170 | if key.IV != "" { 171 | iv, err = hex.DecodeString(strings.TrimPrefix(key.IV, "0x")) 172 | if err != nil { 173 | return nil, nil, fmt.Errorf("decode iv error: %w", err) 174 | } 175 | } else { 176 | iv = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, byte(id)} 177 | } 178 | return k, iv, nil 179 | } 180 | 181 | return nil, nil, nil 182 | } 183 | 184 | func downloadM3u8(m3u8URL string) ([]byte, error) { 185 | return get(m3u8URL, conf.headers, conf.Retry) 186 | } 187 | 188 | func fetchKey(url string) ([]byte, error) { 189 | keyCacheLock.Lock() 190 | defer keyCacheLock.Unlock() 191 | 192 | key := keyCache[url] 193 | if key != nil { 194 | return key, nil 195 | } 196 | 197 | key, err := get(url, conf.headers, conf.Retry) 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | keyCache[url] = key 203 | 204 | return key, nil 205 | } 206 | 207 | func download(args ...interface{}) { 208 | url := args[0].(string) 209 | headers := args[1].(map[string]string) 210 | retry := args[2].(int) 211 | fn := args[3].(func([]byte, error)) 212 | 213 | data, err := get(url, headers, retry) 214 | fn(data, err) 215 | } 216 | 217 | func formatURI(base string, uri string) (string, error) { 218 | if strings.HasPrefix(uri, "http") { 219 | return uri, nil 220 | } 221 | 222 | if base == "" { 223 | return "", fmt.Errorf("base url must be set") 224 | } 225 | 226 | u, err := url.Parse(base) 227 | if err != nil { 228 | return "", err 229 | } 230 | 231 | u, err = u.Parse(uri) 232 | if err != nil { 233 | return "", err 234 | } 235 | 236 | return u.String(), nil 237 | } 238 | 239 | func filename(u string, u1 string) string { 240 | obj, _ := url.Parse(u) 241 | _, filename := filepath.Split(obj.Path) 242 | if filename == "" { 243 | filename = "index_" + time.Now().Format("20060102150405") 244 | } 245 | ext := filepath.Ext(filename) 246 | lowerExt := strings.ToLower(ext) 247 | if lowerExt == ".ts" || lowerExt == ".mp4" { 248 | return filename 249 | } 250 | filename = strings.TrimSuffix(filename, ext) 251 | 252 | o1, _ := url.Parse(u1) 253 | _, f1 := filepath.Split(o1.Path) 254 | ext = filepath.Ext(f1) 255 | if ext == ".m4s" { 256 | ext = ".mp4" 257 | } 258 | 259 | return filename + ext 260 | } 261 | 262 | func get(url string, headers map[string]string, retry int) ([]byte, error) { 263 | statusCode, data, err := ZHTTP.Get(url, headers, retry) 264 | if err != nil { 265 | return nil, err 266 | } 267 | 268 | if statusCode/100 != 2 || len(data) == 0 { 269 | return nil, fmt.Errorf("http status code: %d", statusCode) 270 | } 271 | 272 | return data, nil 273 | } 274 | 275 | func parseM3u8(m3u8URL string, desiredResolution string, data []byte) (*m3u8.MediaPlaylist, error) { 276 | if data != nil { 277 | playlist, listType, err := m3u8.Decode(*bytes.NewBuffer(data), true) 278 | if err != nil { 279 | return nil, err 280 | } 281 | 282 | if listType == m3u8.MEDIA { 283 | mpl := playlist.(*m3u8.MediaPlaylist) 284 | 285 | if mpl.Map != nil && mpl.Map.URI != "" { 286 | uri, err := formatURI(m3u8URL, mpl.Map.URI) 287 | if err != nil { 288 | return nil, fmt.Errorf("format uri failed: %w", err) 289 | } 290 | mpl.Map.URI = uri 291 | } 292 | 293 | if mpl.Key != nil && mpl.Key.URI != "" { 294 | uri, err := formatURI(m3u8URL, mpl.Key.URI) 295 | if err != nil { 296 | return nil, fmt.Errorf("format uri failed: %w", err) 297 | } 298 | mpl.Key.URI = uri 299 | } 300 | 301 | for _, segment := range mpl.GetAllSegments() { 302 | uri, err := formatURI(m3u8URL, segment.URI) 303 | if err != nil { 304 | return nil, fmt.Errorf("format uri failed: %w", err) 305 | } 306 | segment.URI = uri 307 | 308 | if segment.Key == nil && mpl.Key != nil { 309 | segment.Key = mpl.Key 310 | } 311 | 312 | if segment.Key != nil && segment.Key.URI != "" { 313 | uri, err := formatURI(m3u8URL, segment.Key.URI) 314 | if err != nil { 315 | return nil, fmt.Errorf("format uri failed: %w", err) 316 | } 317 | segment.Key.URI = uri 318 | } 319 | } 320 | 321 | return mpl, nil 322 | // Master Playlist 323 | } else { 324 | mpl := playlist.(*m3u8.MasterPlaylist) 325 | variant, err := findVariant(mpl.Variants, desiredResolution) 326 | if err != nil { 327 | return nil, err 328 | } 329 | 330 | u, err := formatURI(m3u8URL, variant.URI) 331 | if err != nil { 332 | return nil, fmt.Errorf("format uri failed: %w", err) 333 | } 334 | return parseM3u8(u, desiredResolution, nil) 335 | } 336 | } 337 | 338 | data, err := downloadM3u8(m3u8URL) 339 | if err != nil { 340 | return nil, err 341 | } 342 | return parseM3u8(m3u8URL, desiredResolution, data) 343 | } 344 | 345 | func listResolution(m3u8URL string, data []byte) error { 346 | if data != nil { 347 | playlist, listType, err := m3u8.Decode(*bytes.NewBuffer(data), true) 348 | if err != nil { 349 | return err 350 | } 351 | 352 | if listType == m3u8.MEDIA { 353 | return fmt.Errorf("resource is not a playlist") 354 | } else { 355 | mpl := playlist.(*m3u8.MasterPlaylist) 356 | var list []string 357 | for _, v := range mpl.Variants { 358 | if v.Iframe { 359 | continue 360 | } 361 | list = append(list, fmt.Sprintf("Resolution: %-9s Bandwidth: %-8d FrameRate: %.2f Codecs: %s", v.Resolution, v.Bandwidth, v.FrameRate, v.Codecs)) 362 | } 363 | fmt.Println(strings.Join(list, "\n")) 364 | return nil 365 | } 366 | } 367 | 368 | data, err := downloadM3u8(m3u8URL) 369 | if err != nil { 370 | return err 371 | } 372 | return listResolution(m3u8URL, data) 373 | } 374 | 375 | func findVariant(variants []*m3u8.Variant, resolution string) (*m3u8.Variant, error) { 376 | if len(variants) == 0 { 377 | return nil, fmt.Errorf("variants not found") 378 | } 379 | 380 | sort.Slice(variants, func(i, j int) bool { 381 | if variants[i].Resolution != "" && variants[j].Resolution != "" { 382 | widthi, heighti := parseResolution(variants[i].Resolution) 383 | widthj, heightj := parseResolution(variants[j].Resolution) 384 | if widthi*heighti < widthj*heightj { 385 | return false 386 | } else if widthi*heighti > widthj*heightj { 387 | return true 388 | } 389 | } 390 | 391 | return variants[i].Bandwidth > variants[j].Bandwidth 392 | }) 393 | 394 | if resolution != "" { 395 | for _, v := range variants { 396 | if v.Iframe { 397 | continue 398 | } 399 | if v.Resolution == resolution { 400 | return v, nil 401 | } 402 | } 403 | 404 | return nil, fmt.Errorf("resolution %s not found", resolution) 405 | } 406 | 407 | return variants[0], nil 408 | } 409 | 410 | func parseResolution(resolution string) (uint64, uint64) { 411 | arr := strings.Split(resolution, "x") 412 | if len(arr) != 2 { 413 | return 0, 0 414 | } 415 | width, err := strconv.ParseUint(arr[0], 10, 64) 416 | if err != nil { 417 | return 0, 0 418 | } 419 | height, err := strconv.ParseUint(arr[1], 10, 64) 420 | if err != nil { 421 | return 0, 0 422 | } 423 | return width, height 424 | } 425 | 426 | func main() { 427 | var err error 428 | ZHTTP, err = zhttp.New(conf.Timeout, conf.Proxy, conf.SkipVerify) 429 | if err != nil { 430 | log.Fatalln("[-] Initialization failed:", err) 431 | } 432 | 433 | var data []byte 434 | if conf.File != "" { 435 | data, err = os.ReadFile(conf.File) 436 | if err != nil { 437 | log.Fatalln("[-] Load m3u8 file failed:", err) 438 | } 439 | } 440 | 441 | if conf.ListResolution { 442 | err := listResolution(conf.URL, data) 443 | if err != nil { 444 | log.Fatalln("[-] Parse m3u8 file failed:", err) 445 | } 446 | return 447 | } 448 | 449 | mpl, err := parseM3u8(conf.URL, conf.DesiredResolution, data) 450 | if err != nil { 451 | log.Fatalln("[-] Parse m3u8 file failed:", err) 452 | } 453 | 454 | outFile := conf.OutFile 455 | if outFile == "" { 456 | outFile = filename(conf.URL, mpl.Segments[0].URI) 457 | } 458 | 459 | if conf.MergeWithFFmpeg { 460 | JOINER, err = joiner.NewFFmepg(conf.FFmpeg, outFile) 461 | if err != nil { 462 | log.Fatalln("[-]", err) 463 | } 464 | } else { 465 | JOINER, err = joiner.NewMem(outFile) 466 | if err != nil { 467 | log.Fatalln("[-]", err) 468 | } 469 | } 470 | 471 | if mpl.Count() > 0 { 472 | startDownload(mpl) 473 | 474 | err = JOINER.Merge() 475 | if err != nil { 476 | log.Fatalln("[-] Saved to", outFile, "failed:", err) 477 | } else { 478 | log.Println("[+] Saved to", outFile) 479 | } 480 | } 481 | } 482 | --------------------------------------------------------------------------------