commit 88fc6c8d7a7d8c4362ffd054177738cf119e2411 Author: Mattias Wadman Date: Mon Jul 22 09:08:59 2019 +0200 init diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7efef48 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +sudo: required +services: + - docker + +language: go +go: + - "1.12" + +# a bit strange but want to reuse the test Dockerfile +script: docker build --build-arg GO_VERSION=$(go version | cut -d' ' -f 3 | cut -b 3-) . diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a004a24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +ARG GO_VERSION=1.12 +ARG YDL_VERSION=2019.07.16 + +FROM golang:$GO_VERSION +ARG YDL_VERSION + +RUN \ + curl -L -o /usr/local/bin/youtube-dl https://yt-dl.org/downloads/$YDL_VERSION/youtube-dl && \ + chmod a+x /usr/local/bin/youtube-dl + +WORKDIR /src +COPY go.* *.go ./ +COPY cmd cmd +RUN \ + go mod download && \ + go build ./cmd/goutubedl && \ + go test -v -race -cover diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc5f1e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Mattias Wadman + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bbcb3d --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +## goutubedl + +Go wrapper for [youtube-dl](https://github.com/ytdl-org/youtube-dl). + +[API Documentation](https://godoc.org/github.com/wader/goutubedl) can be found godoc. + +See [youtube-dl documentation](https://github.com/ytdl-org/youtube-dl#do-i-need-any-other-programs) +for what is recommended to install in addition to youtube-dl. + +### Usage + +```go +result, err := goutubedl.New(context.Background(), URL, goutubedl.Options{}) +downloadResult, err := result.Download(context.Background(), FormatID) +io.Copy(ioutil.Discard, downloadResult) +dr.Close() +``` + +See [goutubedl cmd tool](cmd/goutubedl/main.go) or [ydls](https://github.com/wader/ydls) +for usage examples. diff --git a/cmd/goutubedl/main.go b/cmd/goutubedl/main.go new file mode 100644 index 0000000..924bd64 --- /dev/null +++ b/cmd/goutubedl/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "io" + "log" + "os" + + "github.com/wader/goutubedl" +) + +var dumpFlag = flag.Bool("J", false, "Dump JSON") + +func main() { + log.SetFlags(0) + flag.Parse() + + result, err := goutubedl.New(context.Background(), flag.Arg(0), goutubedl.Options{}) + if err != nil { + log.Fatal(err) + } + + if *dumpFlag { + json.NewEncoder(os.Stdout).Encode(result.Info) + return + } + + filter := flag.Arg(1) + if filter == "" { + filter = "best" + } + + dr, err := result.Download(context.Background(), filter) + if err != nil { + log.Fatal(err) + } + + f, err := os.Create(filter) + if err != nil { + log.Fatal(err) + } + defer f.Close() + io.Copy(f, dr) + dr.Close() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4686561 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/wader/goutubedl + +go 1.12 + +require ( + github.com/fortytw2/leaktest v1.3.0 + github.com/wader/osleaktest v0.0.0-20190723190525-c53af4cfc4a3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e34cdbe --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/wader/osleaktest v0.0.0-20190723190525-c53af4cfc4a3 h1:4WTMRnBIat+s9D1PIpzh2UUZrEjxzRjJI85IdWePjCI= +github.com/wader/osleaktest v0.0.0-20190723190525-c53af4cfc4a3/go.mod h1:XD6emOFPHVzb0+qQpiNOdPL2XZ0SRUM0N5JHuq6OmXo= diff --git a/goutubedl.go b/goutubedl.go new file mode 100644 index 0000000..379e993 --- /dev/null +++ b/goutubedl.go @@ -0,0 +1,423 @@ +// Package goutubedl provides a wrapper for youtube-dl. +package goutubedl + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path" + "strconv" + "strings" +) + +// Path to youtube-dl binary. Default look for "youtube-dl" in PATH. +var Path = "youtube-dl" + +// Printer is something that can print +type Printer interface { + Print(v ...interface{}) +} + +type nopPrinter struct{} + +func (nopPrinter) Print(v ...interface{}) {} + +// Error youtube-dl specific error +type Error string + +func (e Error) Error() string { + return string(e) +} + +// Info youtube-dl info +type Info struct { + // Generated from youtube-dl README using: + // sed -e 's/ - `\(.*\)` (\(.*\)): \(.*\)/\1 \2 `json:"\1"` \/\/ \3/' | sed -e 's/numeric/float64/' | sed -e 's/boolean/bool/' | sed -e 's/_id/ID/' | sed -e 's/_count/Count/'| sed -e 's/_uploader/Uploader/' | sed -e 's/_key/Key/' | sed -e 's/_year/Year/' | sed -e 's/_title/Title/' | sed -e 's/_rating/Rating/' | sed -e 's/_number/Number/' | awk '{print toupper(substr($0, 0, 1)) substr($0, 2)}' + ID string `json:"id"` // Video identifier + Title string `json:"title"` // Video title + URL string `json:"url"` // Video URL + AltTitle string `json:"alt_title"` // A secondary title of the video + DisplayID string `json:"display_id"` // An alternative identifier for the video + Uploader string `json:"uploader"` // Full name of the video uploader + License string `json:"license"` // License name the video is licensed under + Creator string `json:"creator"` // The creator of the video + ReleaseDate string `json:"release_date"` // The date (YYYYMMDD) when the video was released + Timestamp float64 `json:"timestamp"` // UNIX timestamp of the moment the video became available + UploadDate string `json:"upload_date"` // Video upload date (YYYYMMDD) + UploaderID string `json:"uploader_id"` // Nickname or id of the video uploader + Channel string `json:"channel"` // Full name of the channel the video is uploaded on + ChannelID string `json:"channel_id"` // Id of the channel + Location string `json:"location"` // Physical location where the video was filmed + Duration float64 `json:"duration"` // Length of the video in seconds + ViewCount float64 `json:"view_count"` // How many users have watched the video on the platform + LikeCount float64 `json:"like_count"` // Number of positive ratings of the video + DislikeCount float64 `json:"dislike_count"` // Number of negative ratings of the video + RepostCount float64 `json:"repost_count"` // Number of reposts of the video + AverageRating float64 `json:"average_rating"` // Average rating give by users, the scale used depends on the webpage + CommentCount float64 `json:"comment_count"` // Number of comments on the video + AgeLimit float64 `json:"age_limit"` // Age restriction for the video (years) + IsLive bool `json:"is_live"` // Whether this video is a live stream or a fixed-length video + StartTime float64 `json:"start_time"` // Time in seconds where the reproduction should start, as specified in the URL + EndTime float64 `json:"end_time"` // Time in seconds where the reproduction should end, as specified in the URL + Extractor string `json:"extractor"` // Name of the extractor + ExtractorKey string `json:"extractor_key"` // Key name of the extractor + Epoch float64 `json:"epoch"` // Unix epoch when creating the file + Autonumber float64 `json:"autonumber"` // Five-digit number that will be increased with each download, starting at zero + Playlist string `json:"playlist"` // Name or id of the playlist that contains the video + PlaylistIndex float64 `json:"playlist_index"` // Index of the video in the playlist padded with leading zeros according to the total length of the playlist + PlaylistID string `json:"playlist_id"` // Playlist identifier + PlaylistTitle string `json:"playlist_title"` // Playlist title + PlaylistUploader string `json:"playlist_uploader"` // Full name of the playlist uploader + PlaylistUploaderID string `json:"playlist_uploader_id"` // Nickname or id of the playlist uploader + + // Available for the video that belongs to some logical chapter or section: + Chapter string `json:"chapter"` // Name or title of the chapter the video belongs to + ChapterNumber float64 `json:"chapter_number"` // Number of the chapter the video belongs to + ChapterID string `json:"chapter_id"` // Id of the chapter the video belongs to + + // Available for the video that is an episode of some series or programme: + Series string `json:"series"` // Title of the series or programme the video episode belongs to + Season string `json:"season"` // Title of the season the video episode belongs to + SeasonNumber float64 `json:"season_number"` // Number of the season the video episode belongs to + SeasonID string `json:"season_id"` // Id of the season the video episode belongs to + Episode string `json:"episode"` // Title of the video episode + EpisodeNumber float64 `json:"episode_number"` // Number of the video episode within a season + EpisodeID string `json:"episode_id"` // Id of the video episode + + // Available for the media that is a track or a part of a music album: + Track string `json:"track"` // Title of the track + TrackNumber float64 `json:"track_number"` // Number of the track within an album or a disc + TrackID string `json:"track_id"` // Id of the track + Artist string `json:"artist"` // Artist(s) of the track + Genre string `json:"genre"` // Genre(s) of the track + Album string `json:"album"` // Title of the album the track belongs to + AlbumType string `json:"album_type"` // Type of the album + AlbumArtist string `json:"album_artist"` // List of all artists appeared on the album + DiscNumber float64 `json:"disc_number"` // Number of the disc or other physical medium the track belongs to + ReleaseYear float64 `json:"release_year"` // Year (YYYY) when the album was released + + Type string `json:"_type"` + Direct bool `json:"direct"` + WebpageURL string `json:"webpage_url"` + Description string `json:"description"` + Thumbnail string `json:"thumbnail"` + // not unmarshalled, populated from image thumbnail file + ThumbnailBytes []byte `json:"-"` + + Formats []Format `json:"formats"` + Subtitles map[string][]Subtitle `json:"subtitles"` + + // Playlist entries if _type is playlist + Entries []Info `json:"entries"` + + // Info can also be a mix of Info and one Format + Format +} + +// Format youtube-dl downloadable format +type Format struct { + Ext string `json:"ext"` // Video filename extension + Format string `json:"format"` // A human-readable description of the format + FormatID string `json:"format_id"` // Format code specified by `--format` + FormatNote string `json:"format_note"` // Additional info about the format + Width float64 `json:"width"` // Width of the video + Height float64 `json:"height"` // Height of the video + Resolution string `json:"resolution"` // Textual description of width and height + TBR float64 `json:"tbr"` // Average bitrate of audio and video in KBit/s + ABR float64 `json:"abr"` // Average audio bitrate in KBit/s + ACodec string `json:"acodec"` // Name of the audio codec in use + ASR float64 `json:"asr"` // Audio sampling rate in Hertz + VBR float64 `json:"vbr"` // Average video bitrate in KBit/s + FPS float64 `json:"fps"` // Frame rate + VCodec string `json:"vcodec"` // Name of the video codec in use + Container string `json:"container"` // Name of the container format + Filesize float64 `json:"filesize"` // The number of bytes, if known in advance + FilesizeApprox float64 `json:"filesize_approx"` // An estimate for the number of bytes + Protocol string `json:"protocol"` // The protocol that will be used for the actual download +} + +// Subtitle youtube-dl subtitle +type Subtitle struct { + URL string `json:"url"` + Ext string `json:"ext"` + Language string `json:"-"` + // not unmarshalled, populated from subtitle file + Bytes []byte `json:"-"` +} + +func (f Format) String() string { + return fmt.Sprintf("%s:%s:%s abr:%f vbr:%f tbr:%f", + f.FormatID, + f.Protocol, + f.Ext, + f.ABR, + f.VBR, + f.TBR, + ) +} + +// Options for New() +type Options struct { + YesPlaylist bool // --yes-playlist + PlaylistStart uint // --playlist-start + PlaylistEnd uint // --playlist-end + DownloadThumbnail bool + DownloadSubtitles bool + DebugLog Printer + StderrFn func(cmd *exec.Cmd) io.Writer // if not nil, function to get Writer for stderr + HTTPClient *http.Client // Client for download thumbnail and subtitles (nil use http.DefaultClient) +} + +// Version of youtube-dl. +// Might be a good idea to call at start to assert that youtube-dl can be found. +func Version(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, Path, "--version") + versionBytes, cmdErr := cmd.Output() + if cmdErr != nil { + return "", cmdErr + } + + return strings.TrimSpace(string(versionBytes)), nil +} + +// New downloads metadata for URL +func New(ctx context.Context, rawURL string, options Options) (result Result, err error) { + if options.DebugLog == nil { + options.DebugLog = nopPrinter{} + } + if options.HTTPClient == nil { + options.HTTPClient = http.DefaultClient + } + + info, rawJSON, err := infoFromURL(ctx, rawURL, options) + if err != nil { + return Result{}, err + } + + rawJSONCopy := make([]byte, len(rawJSON)) + copy(rawJSONCopy, rawJSON) + + return Result{ + Info: info, + RawJSON: rawJSONCopy, + Options: options, + }, nil +} + +func infoFromURL(ctx context.Context, rawURL string, options Options) (info Info, rawJSON []byte, err error) { + cmd := exec.CommandContext( + ctx, + Path, + "--no-call-home", + "--no-cache-dir", + "--skip-download", + "--restrict-filenames", + // provide URL via stdin for security, youtube-dl has some run command args + "--batch-file", "-", + "-J", + ) + if options.YesPlaylist { + cmd.Args = append(cmd.Args, "--yes-playlist") + + if options.PlaylistStart > 0 { + cmd.Args = append(cmd.Args, + "--playlist-start", strconv.Itoa(int(options.PlaylistStart)), + ) + } + if options.PlaylistEnd > 0 { + cmd.Args = append(cmd.Args, + "--playlist-end", strconv.Itoa(int(options.PlaylistEnd)), + ) + } + } else { + if options.DownloadSubtitles { + cmd.Args = append(cmd.Args, + "--all-subs", + ) + } + cmd.Args = append(cmd.Args, + "--no-playlist", + ) + } + + tempPath, _ := ioutil.TempDir("", "ydls") + defer os.RemoveAll(tempPath) + + stdoutBuf := &bytes.Buffer{} + stderrBuf := &bytes.Buffer{} + stderrWriter := ioutil.Discard + if options.StderrFn != nil { + stderrWriter = options.StderrFn(cmd) + } + + cmd.Dir = tempPath + cmd.Stdout = stdoutBuf + cmd.Stderr = io.MultiWriter(stderrBuf, stderrWriter) + cmd.Stdin = bytes.NewBufferString(rawURL + "\n") + + options.DebugLog.Print("cmd", " ", cmd.Args) + cmdErr := cmd.Run() + + stderrLineScanner := bufio.NewScanner(stderrBuf) + errMessage := "" + for stderrLineScanner.Scan() { + const errorPrefix = "ERROR: " + line := stderrLineScanner.Text() + if strings.HasPrefix(line, errorPrefix) { + errMessage = line[len(errorPrefix):] + } + } + + if errMessage != "" { + return Info{}, nil, Error(errMessage) + } else if cmdErr != nil { + return Info{}, nil, cmdErr + } + + if infoErr := json.Unmarshal(stdoutBuf.Bytes(), &info); infoErr != nil { + return Info{}, nil, infoErr + } + + if options.YesPlaylist && (info.Type != "playlist" || info.Type == "multi_video") { + return Info{}, nil, fmt.Errorf("not a playlist") + } + + // TODO: use headers from youtube-dl info for thumbnail and subtitle download? + if options.DownloadThumbnail && info.Thumbnail != "" { + resp, respErr := options.HTTPClient.Get(info.Thumbnail) + if respErr == nil { + buf, _ := ioutil.ReadAll(resp.Body) + resp.Body.Close() + info.ThumbnailBytes = buf + } + } + + for language, subtitles := range info.Subtitles { + for i := range subtitles { + subtitles[i].Language = language + } + } + + if options.DownloadSubtitles { + for _, subtitles := range info.Subtitles { + for i, subtitle := range subtitles { + resp, respErr := options.HTTPClient.Get(subtitle.URL) + if respErr == nil { + buf, _ := ioutil.ReadAll(resp.Body) + resp.Body.Close() + subtitles[i].Bytes = buf + } + } + } + } + + return info, stdoutBuf.Bytes(), nil +} + +// Result metadata for a URL +type Result struct { + Info Info + RawJSON []byte // saved raw JSON. Used later when downloading + Options Options // options passed to New +} + +// DownloadResult download result +type DownloadResult struct { + reader io.ReadCloser + waitCh chan struct{} +} + +// Download format matched by filter (usually a format id or "best"). +// Filter should not be a combine filter like "1+2" as then youtube-dl +// won't write to stdout. +func (result Result) Download(ctx context.Context, filter string) (*DownloadResult, error) { + debugLog := result.Options.DebugLog + + if result.Info.Type == "playlist" || result.Info.Type == "multi_video" { + return nil, fmt.Errorf("is a playlist") + } + + tempPath, tempErr := ioutil.TempDir("", "ydls") + if tempErr != nil { + return nil, tempErr + } + jsonTempPath := path.Join(tempPath, "info.json") + if err := ioutil.WriteFile(jsonTempPath, result.RawJSON, 0600); err != nil { + os.RemoveAll(tempPath) + return nil, err + } + + dr := &DownloadResult{ + waitCh: make(chan struct{}), + } + + cmd := exec.CommandContext( + ctx, + Path, + "--no-call-home", + "--no-cache-dir", + "--ignore-errors", + "--newline", + "--restrict-filenames", + "--load-info", jsonTempPath, + "-o", "-", + ) + // don't need to specify if direct as there is only one + // also seems to be issues when using filter with generic extractor + if !result.Info.Direct { + cmd.Args = append(cmd.Args, "-f", filter) + } + + cmd.Dir = tempPath + var w io.WriteCloser + dr.reader, w = io.Pipe() + + stderrWriter := ioutil.Discard + if result.Options.StderrFn != nil { + stderrWriter = result.Options.StderrFn(cmd) + } + cmd.Stdout = w + cmd.Stderr = stderrWriter + + debugLog.Print("cmd", " ", cmd.Args) + if err := cmd.Start(); err != nil { + os.RemoveAll(tempPath) + return nil, err + } + + go func() { + cmd.Wait() + w.Close() + os.RemoveAll(tempPath) + close(dr.waitCh) + }() + + return dr, nil +} + +func (dr *DownloadResult) Read(p []byte) (n int, err error) { + return dr.reader.Read(p) +} + +// Close downloader and wait for resource cleanup +func (dr *DownloadResult) Close() error { + err := dr.reader.Close() + <-dr.waitCh + return err +} + +// Formats return all formats +// helper to take care of mixed info and format +func (result Result) Formats() []Format { + if len(result.Info.Formats) > 0 { + return result.Info.Formats + } + return []Format{result.Info.Format} +} diff --git a/goutubedl_test.go b/goutubedl_test.go new file mode 100644 index 0000000..46ba7ff --- /dev/null +++ b/goutubedl_test.go @@ -0,0 +1,228 @@ +package goutubedl + +// TOOD: currently the tests only run on linux as they use osleaktest which only +// has linux support + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os/exec" + "regexp" + "strings" + "testing" + + "github.com/fortytw2/leaktest" + "github.com/wader/osleaktest" +) + +const testVideoRawURL = "https://www.youtube.com/watch?v=C0DPdy98e4c" +const playlistRawURL = "https://soundcloud.com/mattheis/sets/kindred-phenomena" +const subtitlesTestVideoRawURL = "https://www.youtube.com/watch?v=QRS8MkLhQmM" + +func leakChecks(t *testing.T) func() { + leakFn := leaktest.Check(t) + osLeakFn := osleaktest.Check(t) + + return func() { + leakFn() + osLeakFn() + } +} + +func TestBinaryNotPath(t *testing.T) { + defer leakChecks(t)() + defer func(orig string) { Path = orig }(Path) + Path = "/non-existing" + + _, versionErr := Version(context.Background()) + if versionErr == nil || !strings.Contains(versionErr.Error(), "no such file or directory") { + t.Fatalf("err should be nil 'no such file or directory': %v", versionErr) + } +} + +func TestVersion(t *testing.T) { + defer leakChecks(t)() + + versionRe := regexp.MustCompile(`^\d{4}\.\d{2}.\d{2}$`) + version, versionErr := Version(context.Background()) + + if versionErr != nil { + t.Fatalf("err: %s", versionErr) + } + + if !versionRe.MatchString(version) { + t.Errorf("version %q does match %q", version, versionRe) + } +} + +func TestDownload(t *testing.T) { + defer leakChecks(t)() + + stderrBuf := &bytes.Buffer{} + r, err := New(context.Background(), testVideoRawURL, Options{ + StderrFn: func(cmd *exec.Cmd) io.Writer { + return stderrBuf + }, + }) + if err != nil { + t.Fatal(err) + } + dr, err := r.Download(context.Background(), r.Info.Formats[0].FormatID) + 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 < 29000 { + t.Errorf("should have copied at least 29000 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) { + for _, c := range []struct { + url string + expectedTitle string + }{ + {"https://soundcloud.com/avalonemerson/avalon-emerson-live-at-printworks-london-march-2017", "Avalon Emerson Live at Printworks London"}, + {"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"}, + } { + t.Run(c.url, func(t *testing.T) { + defer leakChecks(t)() + + ctx, cancelFn := context.WithCancel(context.Background()) + ydlResult, err := New(ctx, c.url, Options{ + DownloadThumbnail: true, + }) + if err != nil { + cancelFn() + t.Errorf("failed to parse: %v", err) + return + } + cancelFn() + + yi := ydlResult.Info + results := ydlResult.Formats() + + if yi.Title != c.expectedTitle { + t.Errorf("expected title %q got %q", c.expectedTitle, yi.Title) + } + + if yi.Thumbnail != "" && len(yi.ThumbnailBytes) == 0 { + t.Errorf("expected thumbnail bytes") + } + + var dummy map[string]interface{} + if err := json.Unmarshal(ydlResult.RawJSON, &dummy); err != nil { + t.Errorf("failed to parse RawJSON") + } + + if len(results) == 0 { + t.Errorf("expected formats") + } + + for _, f := range results { + if f.FormatID == "" { + t.Errorf("expected to have FormatID") + } + if f.Ext == "" { + t.Errorf("expected to have Ext") + } + if (f.ACodec == "" || f.ACodec == "none") && + (f.VCodec == "" || f.VCodec == "none") && + f.Ext == "" { + t.Errorf("expected to have some media: audio %q video %q ext %q", f.ACodec, f.VCodec, f.Ext) + } + } + }) + } +} + +func TestPlaylist(t *testing.T) { + defer leakChecks(t)() + + ydlResult, ydlResultErr := New(context.Background(), playlistRawURL, Options{ + YesPlaylist: true, + DownloadThumbnail: false, + }) + + if ydlResultErr != nil { + t.Errorf("failed to download: %s", ydlResultErr) + } + + expectedTitle := "Kindred Phenomena" + if ydlResult.Info.Title != expectedTitle { + t.Errorf("expected title %q got %q", expectedTitle, ydlResult.Info.Title) + } + + expectedEntries := 8 + if len(ydlResult.Info.Entries) != expectedEntries { + t.Errorf("expected %d entries got %d", expectedEntries, len(ydlResult.Info.Entries)) + } + + expectedTitleOne := "A1 Mattheis - Herds" + if ydlResult.Info.Entries[0].Title != expectedTitleOne { + t.Errorf("expected title %q got %q", expectedTitleOne, ydlResult.Info.Entries[0].Title) + } +} + +func TestPlaylistBadURL(t *testing.T) { + defer leakChecks(t)() + + // using a non-playlist url + _, ydlResultErr := New(context.Background(), testVideoRawURL, Options{ + YesPlaylist: true, + DownloadThumbnail: false, + }) + + if ydlResultErr == nil { + t.Error("expected error") + } +} + +func TestSubtitles(t *testing.T) { + defer leakChecks(t)() + + ydlResult, ydlResultErr := New( + context.Background(), + subtitlesTestVideoRawURL, + Options{ + DownloadSubtitles: true, + }) + + if ydlResultErr != nil { + t.Errorf("failed to download: %s", ydlResultErr) + } + + for _, subtitles := range ydlResult.Info.Subtitles { + for _, subtitle := range subtitles { + if subtitle.Ext == "" { + t.Errorf("%s: %s: expected extension", ydlResult.Info.URL, subtitle.Language) + } + if subtitle.Language == "" { + t.Errorf("%s: %s: expected language", ydlResult.Info.URL, subtitle.Language) + } + if subtitle.URL == "" { + t.Errorf("%s: %s: expected url", ydlResult.Info.URL, subtitle.Language) + } + if len(subtitle.Bytes) == 0 { + t.Errorf("%s: %s: expected bytes", ydlResult.Info.URL, subtitle.Language) + } + } + } +}