1 Commits

Author SHA1 Message Date
8408e12a2a Add HTTPChunkSize option (--http-chunk-size)
Switch to _test test package to make sure to test external API.
Also don't ignore download process wait error
2021-06-28 20:09:32 +02:00
5 changed files with 102 additions and 607 deletions

View File

@ -1,23 +1,17 @@
# bump: golang /GOLANG_VERSION=([\d.]+)/ docker:golang|^1 # bump: golang /GOLANG_VERSION=([\d.]+)/ docker:golang|^1
# bump: golang link "Release notes" https://golang.org/doc/devel/release.html # bump: golang link "Release notes" https://golang.org/doc/devel/release.html
ARG GOLANG_VERSION=1.22.1 ARG GOLANG_VERSION=1.16.5
# bump: yt-dlp /YT_DLP=([\d.-]+)/ https://github.com/yt-dlp/yt-dlp.git|/^\d/|sort # bump: youtube-dl /YDL_VERSION=([\d.]+)/ https://github.com/ytdl-org/youtube-dl.git|/^\d/|sort
# bump: yt-dlp link "Release notes" https://github.com/yt-dlp/yt-dlp/releases/tag/$LATEST # bump: youtube-dl link "Release notes" https://github.com/ytdl-org/youtube-dl/releases/tag/$LATEST
ARG YT_DLP=2024.03.10 ARG YDL_VERSION=2021.06.06
FROM golang:$GOLANG_VERSION AS base FROM golang:$GOLANG_VERSION
ARG YT_DLP ARG YDL_VERSION
RUN \ RUN \
apt-get update -q && \ curl -L -o /usr/local/bin/youtube-dl https://yt-dl.org/downloads/$YDL_VERSION/youtube-dl && \
apt-get install -y -q python-is-python3 && \ chmod a+x /usr/local/bin/youtube-dl
curl -L https://github.com/yt-dlp/yt-dlp/releases/download/$YT_DLP/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+x /usr/local/bin/yt-dlp && \
apt-get install -y ffmpeg
FROM base AS dev
FROM base
WORKDIR /src WORKDIR /src
COPY go.* *.go ./ COPY go.* *.go ./
COPY cmd cmd COPY cmd cmd

View File

@ -1,19 +1,15 @@
## goutubedl ## goutubedl
Go wrapper for Go wrapper for [youtube-dl](https://github.com/ytdl-org/youtube-dl). API documentation can be found at [godoc.org](https://pkg.go.dev/github.com/wader/goutubedl?tab=doc).
[youtube-dl](https://github.com/ytdl-org/youtube-dl) and
[yt-dlp](https://github.com/yt-dlp/yt-dlp).
Currently only tested and developed using yt-dlp.
API documentation can be found at [godoc.org](https://pkg.go.dev/github.com/wader/goutubedl?tab=doc).
See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp) for how to See [youtube-dl documentation](https://github.com/ytdl-org/youtube-dl) for how to
install and what is recommended to install in addition to yt-dl. install and what is recommended to install in addition to youtube-dl.
goutubedl default uses `PATH` to find `youtube-dl` or `yt-dlp` (in that order) but can be configured with the goutubedl default uses `PATH` to find youtube-dl but it can be configured with the `goutubedl.Path`
`goutubedl.Path` variable. variable.
Due to the nature of and frequent updates of yt-dl only the latest version is tested. Due to the nature and frequent updates of youtube-dl only the latest version
But it seems to work well with older versions also. is tested. But it seems to work well with older versions also.
### Usage ### Usage
@ -56,7 +52,7 @@ for usage examples.
### Development ### Development
```sh ```sh
docker build --target dev -t goutubedl-dev . docker build -t goutubedl-dev .
docker run --rm -ti -v "$PWD:$PWD" -w "$PWD" goutubedl-dev docker run --rm -ti -v "$PWD:$PWD" -w "$PWD" goutubedl-dev
go test -v -race -cover go test -v -race -cover
``` ```

View File

@ -7,7 +7,6 @@ import (
"io" "io"
"log" "log"
"os" "os"
"os/exec"
"github.com/wader/goutubedl" "github.com/wader/goutubedl"
) )
@ -16,27 +15,17 @@ var dumpFlag = flag.Bool("J", false, "Dump JSON")
var typeFlag = flag.String("t", "any", "Type") var typeFlag = flag.String("t", "any", "Type")
func main() { func main() {
goutubedl.Path = "yt-dlp"
log.SetFlags(0) log.SetFlags(0)
flag.Parse() flag.Parse()
optType := goutubedl.TypeFromString[*typeFlag] optType, _ := goutubedl.TypeFromString[*typeFlag]
result, err := goutubedl.New( result, err := goutubedl.New(context.Background(), flag.Arg(0), goutubedl.Options{Type: optType})
context.Background(),
flag.Arg(0),
goutubedl.Options{
Type: optType,
DebugLog: log.Default(),
StderrFn: func(cmd *exec.Cmd) io.Writer { return os.Stderr },
},
)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if *dumpFlag { if *dumpFlag {
_ = json.NewEncoder(os.Stdout).Encode(result.Info) json.NewEncoder(os.Stdout).Encode(result.Info)
return return
} }
@ -55,8 +44,6 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
defer f.Close() defer f.Close()
if _, err := io.Copy(f, dr); err != nil { io.Copy(f, dr)
log.Fatal(err)
}
dr.Close() dr.Close()
} }

View File

@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@ -17,22 +18,8 @@ import (
"strings" "strings"
) )
// Path to youtube-dl binary. If not set look for "youtube-dl" then "yt-dlp" in PATH. // Path to youtube-dl binary. Default look for "youtube-dl" in PATH.
var Path = "" var Path = "youtube-dl"
func ProbePath() string {
if Path != "" {
return Path
}
for _, n := range []string{"youtube-dl", "yt-dlp"} {
if p, err := exec.LookPath(n); err == nil {
return p
}
}
return "youtube-dl"
}
// Printer is something that can print // Printer is something that can print
type Printer interface { type Printer interface {
@ -128,7 +115,7 @@ type Info struct {
WebpageURL string `json:"webpage_url"` WebpageURL string `json:"webpage_url"`
Description string `json:"description"` Description string `json:"description"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
// don't unmarshal, populated from image thumbnail file // not unmarshalled, populated from image thumbnail file
ThumbnailBytes []byte `json:"-"` ThumbnailBytes []byte `json:"-"`
Thumbnails []Thumbnail `json:"thumbnails"` Thumbnails []Thumbnail `json:"thumbnails"`
@ -179,7 +166,7 @@ type Subtitle struct {
URL string `json:"url"` URL string `json:"url"`
Ext string `json:"ext"` Ext string `json:"ext"`
Language string `json:"-"` Language string `json:"-"`
// don't unmarshal, populated from subtitle file // not unmarshalled, populated from subtitle file
Bytes []byte `json:"-"` Bytes []byte `json:"-"`
} }
@ -204,15 +191,12 @@ const (
TypeSingle TypeSingle
// TypePlaylist playlist with multiple tracks, files etc // TypePlaylist playlist with multiple tracks, files etc
TypePlaylist TypePlaylist
// TypeChannel channel containing one or more playlists, which will be flattened
TypeChannel
) )
var TypeFromString = map[string]Type{ var TypeFromString = map[string]Type{
"any": TypeAny, "any": TypeAny,
"single": TypeSingle, "single": TypeSingle,
"playlist": TypePlaylist, "playlist": TypePlaylist,
"channel": TypeChannel,
} }
// Options for New() // Options for New()
@ -220,27 +204,18 @@ type Options struct {
Type Type Type Type
PlaylistStart uint // --playlist-start PlaylistStart uint // --playlist-start
PlaylistEnd uint // --playlist-end PlaylistEnd uint // --playlist-end
Downloader string // --downloader
DownloadThumbnail bool DownloadThumbnail bool
DownloadSubtitles bool DownloadSubtitles bool
DownloadSections string // --download-sections
ProxyUrl string // --proxy URL http://host:port or socks5://host:port
CookiesFromBrowser string // --cookies-from-browser BROWSER[:FOLDER]
DebugLog Printer DebugLog Printer
StderrFn func(cmd *exec.Cmd) io.Writer // if not nil, function to get Writer for stderr StderrFn func(cmd *exec.Cmd) io.Writer // if not nil, function to get Writer for stderr
HTTPChunkSize uint // --http-chunk-size
HTTPClient *http.Client // Client for download thumbnail and subtitles (nil use http.DefaultClient) HTTPClient *http.Client // Client for download thumbnail and subtitles (nil use http.DefaultClient)
MergeOutputFormat string // --merge-output-format
SortingFormat string // --format-sort
// Set to true if you don't want to use the result.Info structure after the goutubedl.New() call,
// so the given URL will be downloaded in a single pass in the DownloadResult.Download() call.
noInfoDownload bool
} }
// Version of youtube-dl. // Version of youtube-dl.
// Might be a good idea to call at start to assert that youtube-dl can be found. // Might be a good idea to call at start to assert that youtube-dl can be found.
func Version(ctx context.Context) (string, error) { func Version(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, ProbePath(), "--version") cmd := exec.CommandContext(ctx, Path, "--version")
versionBytes, cmdErr := cmd.Output() versionBytes, cmdErr := cmd.Output()
if cmdErr != nil { if cmdErr != nil {
return "", cmdErr return "", cmdErr
@ -249,35 +224,12 @@ func Version(ctx context.Context) (string, error) {
return strings.TrimSpace(string(versionBytes)), nil return strings.TrimSpace(string(versionBytes)), nil
} }
// Downloads given URL using the given options and filter (usually a format id or quality designator).
// If filter is empty, then youtube-dl will use its default format selector.
func Download(
ctx context.Context,
rawURL string,
options Options,
filter string,
) (*DownloadResult, error) {
options.noInfoDownload = true
d, err := New(ctx, rawURL, options)
if err != nil {
return nil, err
}
return d.Download(ctx, filter)
}
// New downloads metadata for URL // New downloads metadata for URL
func New(ctx context.Context, rawURL string, options Options) (result Result, err error) { func New(ctx context.Context, rawURL string, options Options) (result Result, err error) {
if options.DebugLog == nil { if options.DebugLog == nil {
options.DebugLog = nopPrinter{} options.DebugLog = nopPrinter{}
} }
if options.noInfoDownload {
return Result{
RawURL: rawURL,
Options: options,
}, nil
}
info, rawJSON, err := infoFromURL(ctx, rawURL, options) info, rawJSON, err := infoFromURL(ctx, rawURL, options)
if err != nil { if err != nil {
return Result{}, err return Result{}, err
@ -288,20 +240,15 @@ func New(ctx context.Context, rawURL string, options Options) (result Result, er
return Result{ return Result{
Info: info, Info: info,
RawURL: rawURL,
RawJSON: rawJSONCopy, RawJSON: rawJSONCopy,
Options: options, Options: options,
}, nil }, nil
} }
func infoFromURL( func infoFromURL(ctx context.Context, rawURL string, options Options) (info Info, rawJSON []byte, err error) {
ctx context.Context,
rawURL string,
options Options,
) (info Info, rawJSON []byte, err error) {
cmd := exec.CommandContext( cmd := exec.CommandContext(
ctx, ctx,
ProbePath(), Path,
// see comment below about ignoring errors for playlists // see comment below about ignoring errors for playlists
"--ignore-errors", "--ignore-errors",
"--no-call-home", "--no-call-home",
@ -312,21 +259,7 @@ func infoFromURL(
"--batch-file", "-", "--batch-file", "-",
"-J", "-J",
) )
if options.Type == TypePlaylist {
if options.ProxyUrl != "" {
cmd.Args = append(cmd.Args, "--proxy", options.ProxyUrl)
}
if options.Downloader != "" {
cmd.Args = append(cmd.Args, "--downloader", options.Downloader)
}
if options.CookiesFromBrowser != "" {
cmd.Args = append(cmd.Args, "--cookies-from-browser", options.CookiesFromBrowser)
}
switch options.Type {
case TypePlaylist, TypeChannel:
cmd.Args = append(cmd.Args, "--yes-playlist") cmd.Args = append(cmd.Args, "--yes-playlist")
if options.PlaylistStart > 0 { if options.PlaylistStart > 0 {
@ -339,7 +272,7 @@ func infoFromURL(
"--playlist-end", strconv.Itoa(int(options.PlaylistEnd)), "--playlist-end", strconv.Itoa(int(options.PlaylistEnd)),
) )
} }
case TypeSingle: } else {
if options.DownloadSubtitles { if options.DownloadSubtitles {
cmd.Args = append(cmd.Args, cmd.Args = append(cmd.Args,
"--all-subs", "--all-subs",
@ -348,18 +281,14 @@ func infoFromURL(
cmd.Args = append(cmd.Args, cmd.Args = append(cmd.Args,
"--no-playlist", "--no-playlist",
) )
case TypeAny:
break
default:
return Info{}, nil, fmt.Errorf("unhandled options type value: %d", options.Type)
} }
tempPath, _ := os.MkdirTemp("", "ydls") tempPath, _ := ioutil.TempDir("", "ydls")
defer os.RemoveAll(tempPath) defer os.RemoveAll(tempPath)
stdoutBuf := &bytes.Buffer{} stdoutBuf := &bytes.Buffer{}
stderrBuf := &bytes.Buffer{} stderrBuf := &bytes.Buffer{}
stderrWriter := io.Discard stderrWriter := ioutil.Discard
if options.StderrFn != nil { if options.StderrFn != nil {
stderrWriter = options.StderrFn(cmd) stderrWriter = options.StderrFn(cmd)
} }
@ -433,7 +362,7 @@ func infoFromURL(
if options.DownloadThumbnail && info.Thumbnail != "" { if options.DownloadThumbnail && info.Thumbnail != "" {
resp, respErr := get(info.Thumbnail) resp, respErr := get(info.Thumbnail)
if respErr == nil { if respErr == nil {
buf, _ := io.ReadAll(resp.Body) buf, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
info.ThumbnailBytes = buf info.ThumbnailBytes = buf
} }
@ -450,7 +379,7 @@ func infoFromURL(
for i, subtitle := range subtitles { for i, subtitle := range subtitles {
resp, respErr := get(subtitle.URL) resp, respErr := get(subtitle.URL)
if respErr == nil { if respErr == nil {
buf, _ := io.ReadAll(resp.Body) buf, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
subtitles[i].Bytes = buf subtitles[i].Bytes = buf
} }
@ -459,31 +388,15 @@ func infoFromURL(
} }
// as we ignore errors for playlists some entries might show up as null // as we ignore errors for playlists some entries might show up as null
// if options.Type == TypePlaylist {
// note: instead of doing full recursion, we assume entries in var filteredEntrise []Info
// playlists and channels are at most 2 levels deep, and we just
// collect entries from both levels.
//
// the following cases have not been tested:
//
// - entries that are more than 2 levels deep (will be missed)
// - the ability to restrict entries to a single level (we include both levels)
if options.Type == TypePlaylist || options.Type == TypeChannel {
var filteredEntries []Info
for _, e := range info.Entries { for _, e := range info.Entries {
if e.Type == "playlist" { if e.ID == "" {
for _, ee := range e.Entries {
if ee.ID == "" {
continue continue
} }
filteredEntries = append(filteredEntries, ee) filteredEntrise = append(filteredEntrise, e)
} }
continue info.Entries = filteredEntrise
} else if e.ID != "" {
filteredEntries = append(filteredEntries, e)
}
}
info.Entries = filteredEntries
} }
return info, stdoutBuf.Bytes(), nil return info, stdoutBuf.Bytes(), nil
@ -492,7 +405,6 @@ func infoFromURL(
// Result metadata for a URL // Result metadata for a URL
type Result struct { type Result struct {
Info Info Info Info
RawURL string
RawJSON []byte // saved raw JSON. Used later when downloading RawJSON []byte // saved raw JSON. Used later when downloading
Options Options // options passed to New Options Options // options passed to New
} }
@ -503,54 +415,25 @@ type DownloadResult struct {
waitCh chan struct{} waitCh chan struct{}
} }
// Download format matched by filter (usually a format id or quality designator). // Download format matched by filter (usually a format id or "best").
// If filter is empty, then youtube-dl will use its default format selector. // Filter should not be a combine filter like "1+2" as then youtube-dl
// It's a shortcut of DownloadWithOptions where the options use the default value // won't write to stdout.
func (result Result) Download(ctx context.Context, filter string) (*DownloadResult, error) { func (result Result) Download(ctx context.Context, filter string) (*DownloadResult, error) {
return result.DownloadWithOptions(ctx, DownloadOptions{
Filter: filter,
})
}
type DownloadOptions struct {
// Download format matched by filter (usually a format id or quality designator).
// If filter is empty, then youtube-dl will use its default format selector.
Filter string
// The index of the entry to download from the playlist that would be
// passed to youtube-dl via --playlist-items. The index value starts at 1
PlaylistIndex int
}
func (result Result) DownloadWithOptions(
ctx context.Context,
options DownloadOptions,
) (*DownloadResult, error) {
debugLog := result.Options.DebugLog debugLog := result.Options.DebugLog
if !result.Options.noInfoDownload { if result.Info.Type == "playlist" || result.Info.Type == "multi_video" {
if (result.Info.Type == "playlist" || return nil, fmt.Errorf("can't download a playlist")
result.Info.Type == "multi_video" ||
result.Info.Type == "channel") &&
options.PlaylistIndex == 0 {
return nil, fmt.Errorf(
"can't download a playlist when the playlist index options is not set",
)
}
} }
tempPath, tempErr := os.MkdirTemp("", "ydls") tempPath, tempErr := ioutil.TempDir("", "ydls")
if tempErr != nil { if tempErr != nil {
return nil, tempErr return nil, tempErr
} }
jsonTempPath := path.Join(tempPath, "info.json")
var jsonTempPath string if err := ioutil.WriteFile(jsonTempPath, result.RawJSON, 0600); err != nil {
if !result.Options.noInfoDownload {
jsonTempPath = path.Join(tempPath, "info.json")
if err := os.WriteFile(jsonTempPath, result.RawJSON, 0600); err != nil {
os.RemoveAll(tempPath) os.RemoveAll(tempPath)
return nil, err return nil, err
} }
}
dr := &DownloadResult{ dr := &DownloadResult{
waitCh: make(chan struct{}), waitCh: make(chan struct{}),
@ -558,92 +441,34 @@ func (result Result) DownloadWithOptions(
cmd := exec.CommandContext( cmd := exec.CommandContext(
ctx, ctx,
ProbePath(), Path,
"--no-call-home", "--no-call-home",
"--no-cache-dir", "--no-cache-dir",
"--ignore-errors", "--ignore-errors",
"--newline", "--newline",
"--restrict-filenames", "--restrict-filenames",
"--load-info", jsonTempPath,
"-o", "-", "-o", "-",
) )
if result.Options.noInfoDownload {
// provide URL via stdin for security, youtube-dl has some run command args
cmd.Args = append(cmd.Args, "--batch-file", "-")
cmd.Stdin = bytes.NewBufferString(result.RawURL + "\n")
if result.Options.Type == TypePlaylist {
cmd.Args = append(cmd.Args, "--yes-playlist")
if result.Options.PlaylistStart > 0 {
cmd.Args = append(cmd.Args,
"--playlist-start", strconv.Itoa(int(result.Options.PlaylistStart)),
)
}
if result.Options.PlaylistEnd > 0 {
cmd.Args = append(cmd.Args,
"--playlist-end", strconv.Itoa(int(result.Options.PlaylistEnd)),
)
}
} else {
cmd.Args = append(cmd.Args,
"--no-playlist",
)
}
} else {
cmd.Args = append(cmd.Args, "--load-info", jsonTempPath)
}
// don't need to specify if direct as there is only one // don't need to specify if direct as there is only one
// also seems to be issues when using filter with generic extractor // also seems to be issues when using filter with generic extractor
if !result.Info.Direct && options.Filter != "" { if !result.Info.Direct {
cmd.Args = append(cmd.Args, "-f", options.Filter) cmd.Args = append(cmd.Args, "-f", filter)
} }
if result.Options.HTTPChunkSize != 0 {
if options.PlaylistIndex > 0 { cmd.Args = append(cmd.Args, "--http-chunk-size", fmt.Sprintf("%d", result.Options.HTTPChunkSize))
cmd.Args = append(cmd.Args, "--playlist-items", fmt.Sprint(options.PlaylistIndex))
}
if result.Options.ProxyUrl != "" {
cmd.Args = append(cmd.Args, "--proxy", result.Options.ProxyUrl)
}
if result.Options.Downloader != "" {
cmd.Args = append(cmd.Args, "--downloader", result.Options.Downloader)
}
if result.Options.DownloadSections != "" {
cmd.Args = append(cmd.Args, "--download-sections", result.Options.DownloadSections)
}
if result.Options.CookiesFromBrowser != "" {
cmd.Args = append(cmd.Args, "--cookies-from-browser", result.Options.CookiesFromBrowser)
}
if result.Options.MergeOutputFormat != "" {
cmd.Args = append(cmd.Args,
"--merge-output-format", result.Options.MergeOutputFormat,
)
}
if result.Options.SortingFormat != "" {
cmd.Args = append(cmd.Args,
"--format-sort", result.Options.SortingFormat,
)
} }
cmd.Dir = tempPath cmd.Dir = tempPath
var stdoutW io.WriteCloser var w io.WriteCloser
var stderrW io.WriteCloser dr.reader, w = io.Pipe()
var stderrR io.Reader
dr.reader, stdoutW = io.Pipe() stderrWriter := ioutil.Discard
stderrR, stderrW = io.Pipe()
optStderrWriter := io.Discard
if result.Options.StderrFn != nil { if result.Options.StderrFn != nil {
optStderrWriter = result.Options.StderrFn(cmd) stderrWriter = result.Options.StderrFn(cmd)
} }
cmd.Stdout = stdoutW cmd.Stdout = w
cmd.Stderr = io.MultiWriter(optStderrWriter, stderrW) cmd.Stderr = stderrWriter
debugLog.Print("cmd", " ", cmd.Args) debugLog.Print("cmd", " ", cmd.Args)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
@ -651,34 +476,16 @@ func (result Result) DownloadWithOptions(
return nil, err return nil, err
} }
var waitErr error
go func() { go func() {
_ = cmd.Wait() waitErr = cmd.Wait()
stdoutW.Close() w.Close()
stderrW.Close()
os.RemoveAll(tempPath) os.RemoveAll(tempPath)
close(dr.waitCh) close(dr.waitCh)
}() }()
// blocks return until yt-dlp is downloading or has errored return dr, waitErr
ytErrCh := make(chan error)
go func() {
stderrLineScanner := bufio.NewScanner(stderrR)
for stderrLineScanner.Scan() {
const downloadPrefix = "[download]"
const errorPrefix = "ERROR: "
line := stderrLineScanner.Text()
if strings.HasPrefix(line, downloadPrefix) {
break
} else if strings.HasPrefix(line, errorPrefix) {
ytErrCh <- errors.New(line[len(errorPrefix):])
return
}
}
ytErrCh <- nil
_, _ = io.Copy(io.Discard, stderrR)
}()
return dr, <-ytErrCh
} }
func (dr *DownloadResult) Read(p []byte) (n int, err error) { func (dr *DownloadResult) Read(p []byte) (n int, err error) {

View File

@ -7,12 +7,9 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"os"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv"
"strings" "strings"
"testing" "testing"
@ -21,12 +18,9 @@ import (
"github.com/wader/osleaktest" "github.com/wader/osleaktest"
) )
const ( const testVideoRawURL = "https://www.youtube.com/watch?v=C0DPdy98e4c"
testVideoRawURL = "https://www.youtube.com/watch?v=C0DPdy98e4c" const playlistRawURL = "https://soundcloud.com/mattheis/sets/kindred-phenomena"
playlistRawURL = "https://soundcloud.com/mattheis/sets/kindred-phenomena" const subtitlesTestVideoRawURL = "https://www.youtube.com/watch?v=QRS8MkLhQmM"
channelRawURL = "https://www.youtube.com/channel/UCHDm-DKoMyJxKVgwGmuTaQA"
subtitlesTestVideoRawURL = "https://www.youtube.com/watch?v=QRS8MkLhQmM"
)
func leakChecks(t *testing.T) func() { func leakChecks(t *testing.T) func() {
leakFn := leaktest.Check(t) leakFn := leaktest.Check(t)
@ -64,15 +58,21 @@ func TestVersion(t *testing.T) {
} }
} }
func TestDownload(t *testing.T) { func testDownload(t *testing.T, rawURL string, optionsFn func(options *goutubedl.Options)) {
defer leakChecks(t)() defer leakChecks(t)()
stderrBuf := &bytes.Buffer{} stderrBuf := &bytes.Buffer{}
r, err := goutubedl.New(context.Background(), testVideoRawURL, goutubedl.Options{
options := goutubedl.Options{
StderrFn: func(cmd *exec.Cmd) io.Writer { StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf return stderrBuf
}, },
}) }
if optionsFn != nil {
optionsFn(&options)
}
r, err := goutubedl.New(context.Background(), rawURL, options)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -91,8 +91,8 @@ func TestDownload(t *testing.T) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, downloadBuf.Len()) t.Errorf("copy n not equal to download buffer: %d!=%d", n, downloadBuf.Len())
} }
if n < 10000 { if n < 29000 {
t.Errorf("should have copied at least 10000 bytes: %d", n) t.Errorf("should have copied at least 29000 bytes: %d", n)
} }
if !strings.Contains(stderrBuf.String(), "Destination") { if !strings.Contains(stderrBuf.String(), "Destination") {
@ -100,36 +100,16 @@ func TestDownload(t *testing.T) {
} }
} }
func TestDownloadWithoutInfo(t *testing.T) { func TestDownload(t *testing.T) {
defer leakChecks(t)() defer leakChecks(t)()
testDownload(t, testVideoRawURL, nil)
}
stderrBuf := &bytes.Buffer{} func TestHTTPChunkSize(t *testing.T) {
dr, err := goutubedl.Download(context.Background(), testVideoRawURL, goutubedl.Options{ defer leakChecks(t)()
StderrFn: func(cmd *exec.Cmd) io.Writer { testDownload(t, testVideoRawURL, func(options *goutubedl.Options) {
return stderrBuf options.HTTPChunkSize = 1000000
}, })
}, "")
if err != nil {
t.Fatal(err)
}
downloadBuf := &bytes.Buffer{}
n, err := io.Copy(downloadBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
if n != int64(downloadBuf.Len()) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, downloadBuf.Len())
}
if n < 10000 {
t.Errorf("should have copied at least 10000 bytes: %d", n)
}
if !strings.Contains(stderrBuf.String(), "Destination") {
t.Errorf("did not find expected log message on stderr: %q", stderrBuf.String())
}
} }
func TestParseInfo(t *testing.T) { func TestParseInfo(t *testing.T) {
@ -138,7 +118,7 @@ func TestParseInfo(t *testing.T) {
expectedTitle string expectedTitle string
}{ }{
{"https://soundcloud.com/avalonemerson/avalon-emerson-live-at-printworks-london-march-2017", "Avalon Emerson Live at Printworks London 2017"}, {"https://soundcloud.com/avalonemerson/avalon-emerson-live-at-printworks-london-march-2017", "Avalon Emerson Live at Printworks London 2017"},
{"https://www.infoq.com/presentations/Simple-Made-Easy", "Simple Made Easy - InfoQ"}, {"https://www.infoq.com/presentations/Simple-Made-Easy", "Simple Made Easy"},
{"https://www.youtube.com/watch?v=uVYWQJ5BB_w", "A Radiolab Producer on the Making of a Podcast"}, {"https://www.youtube.com/watch?v=uVYWQJ5BB_w", "A Radiolab Producer on the Making of a Podcast"},
} { } {
t.Run(c.url, func(t *testing.T) { t.Run(c.url, func(t *testing.T) {
@ -220,46 +200,14 @@ func TestPlaylist(t *testing.T) {
} }
} }
func TestChannel(t *testing.T) { func TestTestUnsupportedURL(t *testing.T) {
defer leakChecks(t)()
ydlResult, ydlResultErr := goutubedl.New(
context.Background(),
channelRawURL,
goutubedl.Options{
Type: goutubedl.TypeChannel,
DownloadThumbnail: false,
},
)
if ydlResultErr != nil {
t.Errorf("failed to download: %s", ydlResultErr)
}
expectedTitle := "Simon Yapp"
if ydlResult.Info.Title != expectedTitle {
t.Errorf("expected title %q got %q", expectedTitle, ydlResult.Info.Title)
}
expectedEntries := 5
if len(ydlResult.Info.Entries) != expectedEntries {
t.Errorf("expected %d entries got %d", expectedEntries, len(ydlResult.Info.Entries))
}
expectedTitleOne := "#RNLI Shoreham #LifeBoat demo of launch."
if ydlResult.Info.Entries[0].Title != expectedTitleOne {
t.Errorf("expected title %q got %q", expectedTitleOne, ydlResult.Info.Entries[0].Title)
}
}
func TestUnsupportedURL(t *testing.T) {
defer leaktest.Check(t)() defer leaktest.Check(t)()
_, ydlResultErr := goutubedl.New(context.Background(), "https://www.google.com", goutubedl.Options{}) _, ydlResultErr := goutubedl.New(context.Background(), "https://www.google.com", goutubedl.Options{})
if ydlResultErr == nil { if ydlResultErr == nil {
t.Errorf("expected unsupported url") t.Errorf("expected unsupported url")
} }
expectedErrPrefix := "Unsupported URL:" expectedErrPrefix := "Unsupported URL: https://www.google.com"
if ydlResultErr != nil && !strings.HasPrefix(ydlResultErr.Error(), expectedErrPrefix) { if ydlResultErr != nil && !strings.HasPrefix(ydlResultErr.Error(), expectedErrPrefix) {
t.Errorf("expected error prefix %q got %q", expectedErrPrefix, ydlResultErr.Error()) t.Errorf("expected error prefix %q got %q", expectedErrPrefix, ydlResultErr.Error())
@ -318,73 +266,6 @@ func TestSubtitles(t *testing.T) {
} }
} }
func TestDownloadSections(t *testing.T) {
defer leakChecks(t)()
fileName := "durationTestingFile"
duration := 5
cmd := exec.Command("ffmpeg", "-version")
_, err := cmd.Output()
if err != nil {
t.Errorf("failed to check ffmpeg installed: %s", err)
}
ydlResult, ydlResultErr := goutubedl.New(
context.Background(),
"https://www.youtube.com/watch?v=OyuL5biOQ94",
goutubedl.Options{
DownloadSections: fmt.Sprintf("*0:0-0:%d", duration),
})
if ydlResult.Options.DownloadSections != "*0:0-0:5" {
t.Errorf("failed to setup --download-sections")
}
if ydlResultErr != nil {
t.Errorf("failed to download: %s", ydlResultErr)
}
dr, err := ydlResult.Download(context.Background(), "best")
if err != nil {
t.Fatal(err)
}
f, err := os.Create(fileName)
if err != nil {
t.Fatal(err)
}
defer f.Close()
_, err = io.Copy(f, dr)
if err != nil {
t.Fatal(err)
}
cmd = exec.Command("ffprobe", "-v", "quiet", "-show_entries", "format=duration", fileName)
stdout, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
var gotDurationString string
output := string(stdout)
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, "duration") {
if d, found := strings.CutPrefix(line, "duration="); found {
gotDurationString = d
}
}
}
gotDuration, err := strconv.ParseFloat(gotDurationString, 32)
if err != nil {
t.Fatal(err)
}
seconds := int(gotDuration)
if seconds != duration {
t.Fatalf("did not get expected duration of %d, but got %d", duration, seconds)
}
dr.Close()
}
func TestErrorNotAPlaylist(t *testing.T) { func TestErrorNotAPlaylist(t *testing.T) {
defer leakChecks(t)() defer leakChecks(t)()
@ -405,176 +286,6 @@ func TestErrorNotASingleEntry(t *testing.T) {
DownloadThumbnail: false, DownloadThumbnail: false,
}) })
if ydlResultErr != goutubedl.ErrNotASingleEntry { if ydlResultErr != goutubedl.ErrNotASingleEntry {
t.Fatalf("expected is single entry error, got %s", ydlResultErr) t.Errorf("expected is single entry error, got %s", ydlResultErr)
}
}
func TestOptionDownloader(t *testing.T) {
defer leakChecks(t)()
ydlResult, ydlResultErr := goutubedl.New(
context.Background(),
testVideoRawURL,
goutubedl.Options{
Downloader: "ffmpeg",
})
if ydlResultErr != nil {
t.Fatalf("failed to download: %s", ydlResultErr)
}
dr, err := ydlResult.Download(context.Background(), ydlResult.Info.Formats[0].FormatID)
if err != nil {
t.Fatal(err)
}
downloadBuf := &bytes.Buffer{}
_, err = io.Copy(downloadBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
}
func TestInvalidOptionTypeField(t *testing.T) {
defer leakChecks(t)()
_, err := goutubedl.New(context.Background(), playlistRawURL, goutubedl.Options{
Type: 42,
})
if err == nil {
t.Error("should have failed")
}
}
func TestDownloadPlaylistEntry(t *testing.T) {
defer leakChecks(t)()
// Download file by specifying the playlist index
stderrBuf := &bytes.Buffer{}
r, err := goutubedl.New(context.Background(), playlistRawURL, goutubedl.Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
})
if err != nil {
t.Fatal(err)
}
expectedTitle := "Kindred Phenomena"
if r.Info.Title != expectedTitle {
t.Errorf("expected title %q got %q", expectedTitle, r.Info.Title)
}
expectedEntries := 8
if len(r.Info.Entries) != expectedEntries {
t.Errorf("expected %d entries got %d", expectedEntries, len(r.Info.Entries))
}
expectedTitleOne := "B1 Mattheis - Ben M"
playlistIndex := 2
if r.Info.Entries[playlistIndex].Title != expectedTitleOne {
t.Errorf("expected title %q got %q", expectedTitleOne, r.Info.Entries[playlistIndex].Title)
}
dr, err := r.DownloadWithOptions(context.Background(), goutubedl.DownloadOptions{
PlaylistIndex: int(r.Info.Entries[playlistIndex].PlaylistIndex),
Filter: r.Info.Entries[playlistIndex].Formats[0].FormatID,
})
if err != nil {
t.Fatal(err)
}
playlistBuf := &bytes.Buffer{}
n, err := io.Copy(playlistBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
if n != int64(playlistBuf.Len()) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, playlistBuf.Len())
}
if n < 10000 {
t.Errorf("should have copied at least 10000 bytes: %d", n)
}
if !strings.Contains(stderrBuf.String(), "Destination") {
t.Errorf("did not find expected log message on stderr: %q", stderrBuf.String())
}
// Download the same file but with the direct link
url := "https://soundcloud.com/mattheis/b1-mattheis-ben-m"
stderrBuf = &bytes.Buffer{}
r, err = goutubedl.New(context.Background(), url, goutubedl.Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
})
if err != nil {
t.Fatal(err)
}
if r.Info.Title != expectedTitleOne {
t.Errorf("expected title %q got %q", expectedTitleOne, r.Info.Title)
}
expectedEntries = 0
if len(r.Info.Entries) != expectedEntries {
t.Errorf("expected %d entries got %d", expectedEntries, len(r.Info.Entries))
}
dr, err = r.Download(context.Background(), r.Info.Formats[0].FormatID)
if err != nil {
t.Fatal(err)
}
directLinkBuf := &bytes.Buffer{}
n, err = io.Copy(directLinkBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
if n != int64(directLinkBuf.Len()) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, directLinkBuf.Len())
}
if n < 10000 {
t.Errorf("should have copied at least 10000 bytes: %d", n)
}
if !strings.Contains(stderrBuf.String(), "Destination") {
t.Errorf("did not find expected log message on stderr: %q", stderrBuf.String())
}
if directLinkBuf.Len() != playlistBuf.Len() {
t.Errorf("not the same content size between the playlist index entry and the direct link entry: %d != %d", playlistBuf.Len(), directLinkBuf.Len())
}
if !bytes.Equal(directLinkBuf.Bytes(), playlistBuf.Bytes()) {
t.Error("not the same content between the playlist index entry and the direct link entry")
}
}
func TestFormatDownloadError(t *testing.T) {
defer leaktest.Check(t)()
ydl, ydlErr := goutubedl.New(
context.Background(),
"https://www.reddit.com/r/newsbabes/s/92rflI0EB0",
goutubedl.Options{},
)
if ydlErr != nil {
// reddit seems to not like github action hosts
if strings.Contains(ydlErr.Error(), "HTTPError 403: Blocked") {
t.Skip()
}
t.Error(ydlErr)
}
// no pre-muxed audio/video format available
_, ytDlErr := ydl.Download(context.Background(), "best")
expectedErr := "Requested format is not available"
if ydlErr != nil && !strings.Contains(ytDlErr.Error(), expectedErr) {
t.Errorf("expected error prefix %q got %q", expectedErr, ytDlErr.Error())
} }
} }