feat: convert on exit
This commit is contained in:
@@ -13,10 +13,10 @@ This installs the `sdl` binary into your `GOBIN` (defaults to `~/go/bin`). Ensur
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sdl https://example.com/path/to/playlist.m3u8
|
sdl https://example.com/page-with-video
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
If the target URL is a webpage, `sdl` scans it for embedded HLS playlists (`.m3u8`) and downloads each one it finds. You can override the output filename (or prefix when multiple videos exist):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sdl -o myvideo.mp4 https://example.com/path/to/playlist.m3u8
|
sdl -o myvideo.mp4 https://example.com/path/to/playlist.m3u8
|
||||||
@@ -25,6 +25,7 @@ sdl -o myvideo.mp4 https://example.com/path/to/playlist.m3u8
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Follows HTTP redirects (up to 5 hops)
|
- Follows HTTP redirects (up to 5 hops)
|
||||||
|
- Scrapes webpages for embedded HLS playlists (`<video>`, `<source>`, data attributes, script references)
|
||||||
- Supports HLS master playlists (auto-select highest bandwidth variant)
|
- Supports HLS master playlists (auto-select highest bandwidth variant)
|
||||||
- Produces `.mp4` output via `ffmpeg`
|
- Produces `.mp4` output via `ffmpeg`
|
||||||
- Respects basic HTTP timeouts (30 seconds per request)
|
- Respects basic HTTP timeouts (30 seconds per request)
|
||||||
@@ -32,6 +33,7 @@ sdl -o myvideo.mp4 https://example.com/path/to/playlist.m3u8
|
|||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Requires `ffmpeg` in `PATH` for transmuxing
|
- Requires `ffmpeg` in `PATH` for transmuxing
|
||||||
|
- Webpage scraping is best-effort; dynamic players that load playlists via JS APIs may not be detected
|
||||||
- No retry logic beyond standard HTTP redirects
|
- No retry logic beyond standard HTTP redirects
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -3,8 +3,11 @@ module sdl
|
|||||||
go 1.24.3
|
go 1.24.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/grafov/m3u8 v0.12.1 // indirect
|
github.com/grafov/m3u8 v0.12.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/spf13/cobra v1.10.1 // indirect
|
github.com/spf13/cobra v1.10.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
golang.org/x/net v0.39.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
71
go.sum
71
go.sum
@@ -1,4 +1,9 @@
|
|||||||
|
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
@@ -8,5 +13,71 @@ 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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -13,22 +13,26 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/grafov/m3u8"
|
"github.com/grafov/m3u8"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultClientTimeout = 30 * time.Second
|
defaultClientTimeout = 30 * time.Second
|
||||||
maxRedirects = 5
|
maxRedirects = 5
|
||||||
|
playlistPollInterval = 2 * time.Second // Poll interval for livestreams
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errInvalidPlaylist = errors.New("unsupported playlist type. expected media playlist")
|
errInvalidPlaylist = errors.New("unsupported playlist type. expected media playlist")
|
||||||
errUnsupportedMaster = errors.New("master playlist contains no playable variants")
|
errUnsupportedMaster = errors.New("master playlist contains no playable variants")
|
||||||
errFFmpegMissing = errors.New("ffmpeg is required on PATH to transmux segments")
|
errFFmpegMissing = errors.New("ffmpeg is required on PATH to transmux segments")
|
||||||
|
errNoStreamsFound = errors.New("no HLS playlists found on page")
|
||||||
)
|
)
|
||||||
|
|
||||||
type segmentInfo struct {
|
type segmentInfo struct {
|
||||||
@@ -36,38 +40,121 @@ type segmentInfo struct {
|
|||||||
URI string
|
URI string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download downloads the video stream referenced by the given URL into a file named outputName.
|
// Download downloads the video stream(s) referenced by the given URL into files derived from outputName.
|
||||||
// If outputName is empty, the base name of the stream URL is used with an .mp4 extension.
|
// If outputName is empty, a base name is inferred from the source URL.
|
||||||
func Download(ctx context.Context, streamURL, outputName string) error {
|
func Download(ctx context.Context, sourceURL, outputName string) error {
|
||||||
if streamURL == "" {
|
if sourceURL == "" {
|
||||||
return errors.New("stream URL must not be empty")
|
return errors.New("stream URL must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := url.Parse(streamURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: defaultClientTimeout}
|
client := &http.Client{Timeout: defaultClientTimeout}
|
||||||
playlistBody, finalURL, err := fetchWithRedirects(ctx, client, parsed, maxRedirects)
|
|
||||||
|
contentType, err := peekContentType(ctx, client, parsed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("detect content type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(contentType, "text/html") {
|
||||||
|
streams, err := extractPlaylistsFromHTML(ctx, client, parsed)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(streams) == 0 {
|
||||||
|
return errNoStreamsFound
|
||||||
|
}
|
||||||
|
for idx, stream := range streams {
|
||||||
|
name := outputName
|
||||||
|
if name == "" {
|
||||||
|
name = inferOutputNameMust(stream)
|
||||||
|
}
|
||||||
|
if len(streams) > 1 {
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
base := strings.TrimSuffix(name, ext)
|
||||||
|
if base == "" {
|
||||||
|
base = fmt.Sprintf("video-%d", idx+1)
|
||||||
|
}
|
||||||
|
name = base + fmt.Sprintf("-%02d", idx+1) + ext
|
||||||
|
}
|
||||||
|
if err := Download(ctx, stream, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For livestreams, set up keyboard input handler for "q" key
|
||||||
|
// Check if this might be a livestream by creating a cancellable context
|
||||||
|
playlistCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Start keyboard input handler in a goroutine
|
||||||
|
// This will work for both livestreams and VOD, but only matters for livestreams
|
||||||
|
go watchForQuitKey(cancel)
|
||||||
|
|
||||||
|
return downloadPlaylist(playlistCtx, client, parsed, outputName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchForQuitKey watches for "q" keypress and cancels the context
|
||||||
|
func watchForQuitKey(cancel context.CancelFunc) {
|
||||||
|
// Check if stdin is a terminal
|
||||||
|
if !isTerminal(os.Stdin) {
|
||||||
|
return // Not a terminal, skip keyboard input
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print instruction once
|
||||||
|
fmt.Fprintf(os.Stderr, "Press 'q' and Enter to stop downloading and convert to MP4\n")
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
// EOF or error, stop watching
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check if line starts with 'q' or 'Q' (allows "q" or "q\n" or "quit\n", etc.)
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if len(line) > 0 && (line[0] == 'q' || line[0] == 'Q') {
|
||||||
|
fmt.Fprintf(os.Stderr, "\nStopping download and converting to MP4...\n")
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTerminal checks if the file descriptor is a terminal
|
||||||
|
func isTerminal(f *os.File) bool {
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (stat.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadPlaylist(ctx context.Context, client *http.Client, parsed *url.URL, outputName string) error {
|
||||||
|
body, finalURL, err := fetchWithRedirects(ctx, client, parsed, maxRedirects)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fetch playlist: %w", err)
|
return fmt.Errorf("fetch playlist: %w", err)
|
||||||
}
|
}
|
||||||
defer playlistBody.Close()
|
defer body.Close()
|
||||||
|
|
||||||
playlist, listType, err := m3u8.DecodeFrom(bufio.NewReader(playlistBody), true)
|
playlist, listType, err := m3u8.DecodeFrom(bufio.NewReader(body), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse playlist: %w", err)
|
return fmt.Errorf("parse playlist: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var segments []segmentInfo
|
var mediaPlaylist *m3u8.MediaPlaylist
|
||||||
switch listType {
|
switch listType {
|
||||||
case m3u8.MEDIA:
|
case m3u8.MEDIA:
|
||||||
mediaPlaylist, ok := playlist.(*m3u8.MediaPlaylist)
|
mp, ok := playlist.(*m3u8.MediaPlaylist)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errInvalidPlaylist
|
return errInvalidPlaylist
|
||||||
}
|
}
|
||||||
segments = collectSegments(mediaPlaylist)
|
mediaPlaylist = mp
|
||||||
case m3u8.MASTER:
|
case m3u8.MASTER:
|
||||||
masterPlaylist, ok := playlist.(*m3u8.MasterPlaylist)
|
masterPlaylist, ok := playlist.(*m3u8.MasterPlaylist)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -82,23 +169,42 @@ func Download(ctx context.Context, streamURL, outputName string) error {
|
|||||||
return errInvalidPlaylist
|
return errInvalidPlaylist
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(segments) == 0 {
|
if len(mediaPlaylist.Segments) == 0 {
|
||||||
return errors.New("playlist contains no segments")
|
return errors.New("playlist contains no segments")
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputName == "" {
|
name := outputName
|
||||||
outputName = inferOutputName(finalURL)
|
if name == "" {
|
||||||
|
name = inferOutputName(finalURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a livestream (playlist is not closed)
|
||||||
|
isLiveStream := !mediaPlaylist.Closed
|
||||||
|
|
||||||
|
if isLiveStream {
|
||||||
|
// For livestreams, write to a permanent .ts file
|
||||||
|
tsName := ensureTSExtension(name)
|
||||||
|
tsFile, err := os.Create(tsName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create output file: %w", err)
|
||||||
|
}
|
||||||
|
// downloadLiveStream handles file closing
|
||||||
|
// Note: keyboard input handler is already set up in Download()
|
||||||
|
return downloadLiveStream(ctx, client, finalURL, mediaPlaylist, tsFile, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For VOD (Video on Demand), use temp file and convert to MP4
|
||||||
tempTS, err := os.CreateTemp("", "sdl-*.ts")
|
tempTS, err := os.CreateTemp("", "sdl-*.ts")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create temp file: %w", err)
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
tempPath := tempTS.Name()
|
||||||
defer func() {
|
defer func() {
|
||||||
tempTS.Close()
|
tempTS.Close()
|
||||||
os.Remove(tempTS.Name())
|
os.Remove(tempPath)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
segments := collectSegments(mediaPlaylist)
|
||||||
for i, segment := range segments {
|
for i, segment := range segments {
|
||||||
if err := downloadSegment(ctx, client, finalURL, segment, tempTS); err != nil {
|
if err := downloadSegment(ctx, client, finalURL, segment, tempTS); err != nil {
|
||||||
return fmt.Errorf("download segment %d: %w", i, err)
|
return fmt.Errorf("download segment %d: %w", i, err)
|
||||||
@@ -109,14 +215,268 @@ func Download(ctx context.Context, streamURL, outputName string) error {
|
|||||||
return fmt.Errorf("rewind temp file: %w", err)
|
return fmt.Errorf("rewind temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mp4Name := ensureMP4Extension(outputName)
|
mp4Name := ensureMP4Extension(name)
|
||||||
if err := transmuxToMP4(ctx, tempTS.Name(), mp4Name); err != nil {
|
if err := transmuxToMP4(ctx, tempPath, mp4Name); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadLiveStream(ctx context.Context, client *http.Client, playlistURL *url.URL, initialPlaylist *m3u8.MediaPlaylist, output *os.File, outputName string) error {
|
||||||
|
// Track downloaded segments by sequence number
|
||||||
|
downloadedSeqs := make(map[uint64]bool)
|
||||||
|
tsPath := output.Name()
|
||||||
|
|
||||||
|
// Download initial segments
|
||||||
|
initialSegments := collectSegments(initialPlaylist)
|
||||||
|
for _, segment := range initialSegments {
|
||||||
|
if err := downloadSegment(ctx, client, playlistURL, segment, output); err != nil {
|
||||||
|
return fmt.Errorf("download initial segment %d: %w", segment.Sequence, err)
|
||||||
|
}
|
||||||
|
// Sync after each segment to ensure data is written to disk
|
||||||
|
if err := output.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("sync file after segment %d: %w", segment.Sequence, err)
|
||||||
|
}
|
||||||
|
downloadedSeqs[segment.Sequence] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuously poll for new segments
|
||||||
|
ticker := time.NewTicker(playlistPollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Context cancelled (Ctrl+C or "q" key) - finalize and convert
|
||||||
|
return finalizeAndConvert(output, tsPath, outputName)
|
||||||
|
case <-ticker.C:
|
||||||
|
// Fetch updated playlist
|
||||||
|
body, _, err := fetchWithRedirects(ctx, client, playlistURL, maxRedirects)
|
||||||
|
if err != nil {
|
||||||
|
// If we can't fetch the playlist, continue trying
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist, listType, err := m3u8.DecodeFrom(bufio.NewReader(body), true)
|
||||||
|
body.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if listType != m3u8.MEDIA {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaPlaylist, ok := playlist.(*m3u8.MediaPlaylist)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if stream has ended
|
||||||
|
if mediaPlaylist.Closed {
|
||||||
|
// Download any remaining segments
|
||||||
|
segments := collectSegments(mediaPlaylist)
|
||||||
|
for _, segment := range segments {
|
||||||
|
if !downloadedSeqs[segment.Sequence] {
|
||||||
|
if err := downloadSegment(ctx, client, playlistURL, segment, output); err != nil {
|
||||||
|
// Log error but continue with finalization
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Sync after each segment
|
||||||
|
if err := output.Sync(); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
downloadedSeqs[segment.Sequence] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stream ended - finalize and convert
|
||||||
|
return finalizeAndConvert(output, tsPath, outputName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download new segments
|
||||||
|
segments := collectSegments(mediaPlaylist)
|
||||||
|
for _, segment := range segments {
|
||||||
|
if !downloadedSeqs[segment.Sequence] {
|
||||||
|
if err := downloadSegment(ctx, client, playlistURL, segment, output); err != nil {
|
||||||
|
// Log error but continue polling
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Sync after each segment to ensure data is written to disk
|
||||||
|
if err := output.Sync(); err != nil {
|
||||||
|
// Log error but continue
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
downloadedSeqs[segment.Sequence] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeOutput(ctx context.Context, tempPath, outputName string) error {
|
||||||
|
mp4Name := ensureMP4Extension(outputName)
|
||||||
|
if err := transmuxToMP4(ctx, tempPath, mp4Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file after successful transmux
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeAndConvert(output *os.File, tsPath, outputName string) error {
|
||||||
|
// Sync and close the file before conversion
|
||||||
|
output.Sync() // Flush any pending writes
|
||||||
|
output.Close()
|
||||||
|
|
||||||
|
// Convert to MP4
|
||||||
|
fmt.Fprintf(os.Stderr, "Converting to MP4...\n")
|
||||||
|
if err := convertTSToMP4(context.Background(), tsPath, outputName); err != nil {
|
||||||
|
// Don't fail if conversion fails - the .ts file is still valid
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: MP4 conversion failed, but .ts file is saved: %v\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mp4Name := ensureMP4Extension(outputName)
|
||||||
|
fmt.Fprintf(os.Stderr, "Conversion complete: %s\n", mp4Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertTSToMP4(ctx context.Context, tsPath, outputName string) error {
|
||||||
|
// Only convert if ffmpeg is available, but don't fail if it's not
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
mp4Name := ensureMP4Extension(outputName)
|
||||||
|
// Use background context for conversion to ensure it completes even if main context is cancelled
|
||||||
|
if err := transmuxToMP4(ctx, tsPath, mp4Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func peekContentType(ctx context.Context, client *http.Client, target *url.URL) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, target.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create HEAD request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("head request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", fmt.Errorf("head status %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Header.Get("Content-Type"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPlaylistsFromHTML(ctx context.Context, client *http.Client, target *url.URL) ([]string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse html: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]string, 0)
|
||||||
|
doc.Find("video,source").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
if src, ok := sel.Attr("src"); ok {
|
||||||
|
if resolved := resolveURL(target, src); resolved != "" {
|
||||||
|
results = append(results, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
attrPatterns := []string{"data-src", "data-hls", "data-hls-src"}
|
||||||
|
doc.Find("*[data-src], *[data-hls], *[data-hls-src]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
for _, attr := range attrPatterns {
|
||||||
|
if val, ok := sel.Attr(attr); ok {
|
||||||
|
if resolved := resolveURL(target, val); resolved != "" {
|
||||||
|
results = append(results, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hrefPattern := regexp.MustCompile(`(?i)\.m3u8(\?.*)?$`)
|
||||||
|
doc.Find("a[href], link[href]").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
if href, ok := sel.Attr("href"); ok && hrefPattern.MatchString(href) {
|
||||||
|
if resolved := resolveURL(target, href); resolved != "" {
|
||||||
|
results = append(results, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
results = append(results, findPlaylistInScripts(target, doc)...)
|
||||||
|
|
||||||
|
uniq := uniqueStrings(results)
|
||||||
|
return uniq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveURL(baseURL *url.URL, ref string) string {
|
||||||
|
if ref == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ref, "data:") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := baseURL.Parse(ref)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func findPlaylistInScripts(baseURL *url.URL, doc *goquery.Document) []string {
|
||||||
|
var results []string
|
||||||
|
pattern := regexp.MustCompile(`https?://[^"'\\\s]+\.m3u8[^"'\\\s]*`)
|
||||||
|
doc.Find("script").Each(func(_ int, sel *goquery.Selection) {
|
||||||
|
text := sel.Text()
|
||||||
|
for _, match := range pattern.FindAllString(text, -1) {
|
||||||
|
if resolved := resolveURL(baseURL, match); resolved != "" {
|
||||||
|
results = append(results, resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(in []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(in))
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, item := range in {
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[item]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item] = struct{}{}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func collectSegments(playlist *m3u8.MediaPlaylist) []segmentInfo {
|
func collectSegments(playlist *m3u8.MediaPlaylist) []segmentInfo {
|
||||||
segments := make([]segmentInfo, 0, len(playlist.Segments))
|
segments := make([]segmentInfo, 0, len(playlist.Segments))
|
||||||
for _, segment := range playlist.Segments {
|
for _, segment := range playlist.Segments {
|
||||||
@@ -244,6 +604,27 @@ func ensureMP4Extension(name string) string {
|
|||||||
return trimmed + ".mp4"
|
return trimmed + ".mp4"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureTSExtension(name string) string {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if strings.HasSuffix(lower, ".ts") {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
trimmed := strings.TrimSuffix(name, ext)
|
||||||
|
if trimmed == "" {
|
||||||
|
trimmed = "output"
|
||||||
|
}
|
||||||
|
return trimmed + ".ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferOutputNameMust(rawURL string) string {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return ensureMP4Extension("download")
|
||||||
|
}
|
||||||
|
return inferOutputName(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error {
|
func transmuxToMP4(ctx context.Context, tsPath, mp4Path string) error {
|
||||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
return errFFmpegMissing
|
return errFFmpegMissing
|
||||||
|
|||||||
Reference in New Issue
Block a user