From 36411ee9629fcf1abb63e4333d1684406796afca Mon Sep 17 00:00:00 2001 From: Juan Llamas <38849891+xoltia@users.noreply.github.com> Date: Sun, 21 Apr 2024 23:22:02 -0500 Subject: [PATCH 1/5] Add thumbnail download command --- client.go | 30 ++++++- cmd/youtubedr/thumbnail.go | 138 +++++++++++++++++++++++++++++ response_data.go | 2 - thumbnails.go | 177 +++++++++++++++++++++++++++++++++++++ 4 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 cmd/youtubedr/thumbnail.go create mode 100644 thumbnails.go diff --git a/client.go b/client.go index 289401b..291cb60 100644 --- a/client.go +++ b/client.go @@ -7,13 +7,12 @@ import ( "errors" "fmt" "io" + "log/slog" "math/rand" "net/http" "net/url" "strconv" "sync/atomic" - - "log/slog" ) const ( @@ -25,7 +24,8 @@ const ( ) var ( - ErrNoFormat = errors.New("no video format provided") + ErrNoFormat = errors.New("no video format provided") + ErrNoThumbnail = errors.New("no thumbnail found") ) // DefaultClient type to use. No reason to change but you could if you wanted to. @@ -72,6 +72,30 @@ func (c *Client) GetVideoContext(ctx context.Context, url string) (*Video, error return c.videoFromID(ctx, id) } +// GetThumbnail returns the thumbnail image for a video. +func (c *Client) GetThumbnail(thumbnails Thumbnails) (io.ReadCloser, string, error) { + return c.GetThumbnailContext(context.Background(), thumbnails) +} + +// GetThumbnailContext returns the thumbnail image for a video with a context. +func (c *Client) GetThumbnailContext(ctx context.Context, thumbnails Thumbnails) (io.ReadCloser, string, error) { + c.assureClient() + + for _, thumbnail := range thumbnails { + resp, err := c.httpGet(ctx, thumbnail.URL) + if errors.Is(err, ErrUnexpectedStatusCode(http.StatusNotFound)) { + continue + } + if err != nil { + return nil, "", err + } + + return resp.Body, resp.Header.Get("Content-Type"), nil + } + + return nil, "", ErrNoThumbnail +} + func (c *Client) videoFromID(ctx context.Context, id string) (*Video, error) { c.assureClient() diff --git a/cmd/youtubedr/thumbnail.go b/cmd/youtubedr/thumbnail.go new file mode 100644 index 0000000..6485014 --- /dev/null +++ b/cmd/youtubedr/thumbnail.go @@ -0,0 +1,138 @@ +package main + +import ( + "fmt" + "io" + "math" + "os" + "os/exec" + "path/filepath" + + "github.com/kkdai/youtube/v2" + "github.com/spf13/cobra" +) + +var thumbnailCmd = &cobra.Command{ + Use: "thumbnail", + Short: "Downloads a thumbnail from youtube", + Example: `youtubedr thumbnail -x 720 -o thumbnail.png https://www.youtube.com/watch\?v\=TGqoAUaivOY`, + Args: cobra.ExactArgs(1), + Run: func(_ *cobra.Command, args []string) { + exitOnError(downloadThumbnail(args[0])) + }, +} + +var ( + minRes uint + maxRes uint + noExtend bool +) + +func init() { + rootCmd.AddCommand(thumbnailCmd) + + thumbnailCmd.Flags().StringVarP(&outputFile, "filename", "o", "", "The output file, the default is generated by the video title.") + thumbnailCmd.Flags().StringVarP(&outputDir, "directory", "d", ".", "The output directory.") + thumbnailCmd.Flags().UintVarP(&minRes, "min-resolution", "n", 0, "The minimum resolution.") + thumbnailCmd.Flags().UintVarP(&maxRes, "max-resolution", "x", math.MaxUint, "The maximum resolution.") + thumbnailCmd.Flags().BoolVarP(&noExtend, "known-only", "k", false, "Whether to only try thumbnails received in video response (lower quality).") +} + +func downloadThumbnail(url string) error { + downloader := getDownloader() + video, err := downloader.GetVideo(url) + + if err != nil { + return err + } + + thumbnails := video.Thumbnails + if !noExtend { + thumbnails = thumbnails.Extended(video.ID) + } + thumbnails = thumbnails.MinHeight(minRes) + thumbnails = thumbnails.MaxHeight(maxRes) + thumbnails.Sort() + + fmt.Println(thumbnails) + fmt.Println(minRes, maxRes) + if outputFile == "" { + return downloadAnyFormat(video.ID, thumbnails) + } + + ext := filepath.Ext(outputFile) + switch ext { + case ".jpg", ".jpeg": + thumbnails = thumbnails.FilterExt(".jpg") + case ".webp": + thumbnails = thumbnails.FilterExt(".webp") + default: + return downloadAndEncode(thumbnails) + } + + return downloadAnyFormat(video.ID, thumbnails) +} + +func downloadAnyFormat(videoID string, thumbnails youtube.Thumbnails) error { + image, mimeType, err := downloader.GetThumbnail(thumbnails) + + if err != nil { + return err + } + + defer image.Close() + + var f *os.File + + if outputFile == "" { + switch mimeType { + case "image/jpeg": + f, err = os.Create(filepath.Join(outputDir, fmt.Sprintf("%s.jpg", videoID))) + case "image/webp": + f, err = os.Create(filepath.Join(outputDir, fmt.Sprintf("%s.webp", videoID))) + default: + return fmt.Errorf("unknown content type: %s", mimeType) + } + } else { + f, err = os.Create(filepath.Join(outputDir, outputFile)) + } + + if err != nil { + return err + } + + defer f.Close() + + if _, err := io.Copy(f, image); err != nil { + return err + } + + return nil +} + +func downloadAndEncode(thumbnails youtube.Thumbnails) error { + if err := checkFFMPEG(); err != nil { + return err + } + + image, _, err := downloader.GetThumbnail(thumbnails) + if err != nil { + return err + } + + defer image.Close() + + ffmpeg := exec.Command("ffmpeg", + "-i", "-", + "-update", "true", + "-frames:v", "1", + filepath.Join(outputDir, outputFile), + "-loglevel", "warning", + ) + + ffmpeg.Stdin = image + ffmpeg.Stdout = os.Stdout + ffmpeg.Stderr = os.Stderr + + return ffmpeg.Run() +} diff --git a/response_data.go b/response_data.go index 154a572..e297231 100644 --- a/response_data.go +++ b/response_data.go @@ -133,8 +133,6 @@ func (f *Format) LanguageDisplayName() string { return f.AudioTrack.DisplayName } -type Thumbnails []Thumbnail - type Thumbnail struct { URL string Width uint diff --git a/thumbnails.go b/thumbnails.go new file mode 100644 index 0000000..d9df7e8 --- /dev/null +++ b/thumbnails.go @@ -0,0 +1,177 @@ +package youtube + +import ( + "fmt" + "net/url" + "path" + "slices" + "strings" +) + +type Thumbnails []Thumbnail + +// Possible thumbnail names in order of preference. +var thumbnailNames = [...]string{ + "maxresdefault", + "hq720", + "sddefault", + "hqdefault", + "0", + "mqdefault", + "default", + "sd1", + "sd2", + "sd3", + "hq1", + "hq2", + "hq3", + "mq1", + "mq2", + "mq3", + "1", + "2", + "3", +} + +// Resolutions of potential thumbnails. +// See: https://stackoverflow.com/a/20542029 +var thumbnailResolutions = map[string][2]uint{ + "maxresdefault": {1920, 1080}, + "hq720": {1280, 720}, + "sddefault": {640, 480}, + "sd3": {640, 480}, + "sd2": {640, 480}, + "sd1": {640, 480}, + "hqdefault": {480, 360}, + "hq3": {480, 360}, + "hq2": {480, 360}, + "hq1": {480, 360}, + "0": {480, 360}, + "mqdefault": {320, 180}, + "mq3": {320, 180}, + "mq2": {320, 180}, + "mq1": {320, 180}, + "default": {120, 90}, + "1": {120, 90}, + "2": {120, 90}, + "3": {120, 90}, +} + +var thumbnailExtensions = [...]string{ + "_live.webp", + "_live.jpg", + ".webp", + ".jpg", +} + +// PossibleThumbnails returns a list of known possible thumbnail URLs. +func PossibleThumbnails(videoID string) Thumbnails { + thumbnails := make(Thumbnails, 0, len(thumbnailNames)*len(thumbnailExtensions)) + for _, name := range thumbnailNames { + for _, ext := range thumbnailExtensions { + thumbnailSize := thumbnailResolutions[name] + thumbnail := Thumbnail{ + Width: thumbnailSize[0], + Height: thumbnailSize[1], + } + + if strings.HasSuffix(ext, ".webp") { + thumbnail.URL = fmt.Sprintf("https://i.ytimg.com/vi_webp/%s/%s%s", videoID, name, ext) + } else { + thumbnail.URL = fmt.Sprintf("https://i.ytimg.com/vi/%s/%s%s", videoID, name, ext) + } + + thumbnails = append(thumbnails, thumbnail) + } + } + return thumbnails +} + +// Extended returns an extended list of possible thumbnails including some not +// returned in the video response. These additional thumbnails may or may +// not be available, and resolution information may not be accurate. +func (t Thumbnails) Extended(videoID string) Thumbnails { + possible := PossibleThumbnails(videoID) + extended := make([]Thumbnail, len(t)+len(possible)) + copy(extended, t) + copy(extended[len(t):], possible) + return extended +} + +// Sort sorts the thumbnail list, abiding by the same sorting as used by yt-dlp. +func (t Thumbnails) Sort() { + slices.SortStableFunc(t, cmpThumbnails) +} + +// FilterExt removes thumbnails that do not have the given extension. +func (t Thumbnails) FilterExt(ext ...string) Thumbnails { + return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { + u, err := url.Parse(thumbnail.URL) + if err != nil { + return true + } + return !slices.Contains(ext, path.Ext(u.Path)) + }) +} + +// FilterLive removes thumbnails that do not match the provided live status. +func (t Thumbnails) FilterLive(live bool) Thumbnails { + return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { + name := path.Base(thumbnail.URL) + parts := strings.SplitN(name, "_", 2) + l := len(parts) > 1 && strings.HasPrefix(parts[1], "live") + return l != live + }) +} + +// MinWidth filters out thumbnails with greater than desired width. +func (t Thumbnails) MinWidth(w uint) Thumbnails { + return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { + return thumbnail.Width < w + }) +} + +// MaxWidth filters out thumbnails with less than desired width. +func (t Thumbnails) MaxWidth(w uint) Thumbnails { + return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { + return thumbnail.Width > w + }) +} + +// MinHeight filters out thumbnails with greater than desired height. +func (t Thumbnails) MinHeight(w uint) Thumbnails { + return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { + return thumbnail.Height < w + }) +} + +// MaxHeight filters out thumbnails with less than desired height. +func (t Thumbnails) MaxHeight(w uint) Thumbnails { + return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { + return thumbnail.Height > w + }) +} + +func cmpThumbnails(t1, t2 Thumbnail) int { + score1 := scoreThumbnail(t1) + score2 := scoreThumbnail(t2) + return score2 - score1 +} + +func scoreThumbnail(t Thumbnail) int { + nameIndex := 0 + for i, name := range thumbnailNames { + if strings.Contains(t.URL, name) { + nameIndex = i + break + } + } + + score := -2 * nameIndex + + if strings.Contains(t.URL, ".webp") { + score += 1 + } + + return score +} From b7c88726559c1756442a7714f842f18826ddcb26 Mon Sep 17 00:00:00 2001 From: Juan Llamas <38849891+xoltia@users.noreply.github.com> Date: Mon, 22 Apr 2024 00:50:45 -0500 Subject: [PATCH 2/5] Remove debug println --- cmd/youtubedr/thumbnail.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/youtubedr/thumbnail.go b/cmd/youtubedr/thumbnail.go index 6485014..63a89d9 100644 --- a/cmd/youtubedr/thumbnail.go +++ b/cmd/youtubedr/thumbnail.go @@ -54,8 +54,6 @@ func downloadThumbnail(url string) error { thumbnails = thumbnails.MaxHeight(maxRes) thumbnails.Sort() - fmt.Println(thumbnails) - fmt.Println(minRes, maxRes) if outputFile == "" { return downloadAnyFormat(video.ID, thumbnails) } From 82e93e87dee01b8f999683de224043ad0c1f0dd5 Mon Sep 17 00:00:00 2001 From: Juan Llamas <38849891+xoltia@users.noreply.github.com> Date: Mon, 22 Apr 2024 00:53:01 -0500 Subject: [PATCH 3/5] Fix typo in ffmpeg check message --- cmd/youtubedr/download.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/youtubedr/download.go b/cmd/youtubedr/download.go index 4425ee8..818a49d 100644 --- a/cmd/youtubedr/download.go +++ b/cmd/youtubedr/download.go @@ -56,7 +56,7 @@ func download(id string) error { func checkFFMPEG() error { fmt.Println("check ffmpeg is installed....") if err := exec.Command("ffmpeg", "-version").Run(); err != nil { - ffmpegCheck = fmt.Errorf("please check ffmpegCheck is installed correctly") + ffmpegCheck = fmt.Errorf("please check that ffmpeg is installed correctly") } return ffmpegCheck From 61825185b84a4c8a436e3acb21b9c8b692b14e5d Mon Sep 17 00:00:00 2001 From: Juan Llamas <38849891+xoltia@users.noreply.github.com> Date: Mon, 22 Apr 2024 00:56:31 -0500 Subject: [PATCH 4/5] Fix lint errors --- cmd/youtubedr/thumbnail.go | 1 + thumbnails.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/youtubedr/thumbnail.go b/cmd/youtubedr/thumbnail.go index 63a89d9..360edae 100644 --- a/cmd/youtubedr/thumbnail.go +++ b/cmd/youtubedr/thumbnail.go @@ -120,6 +120,7 @@ func downloadAndEncode(thumbnails youtube.Thumbnails) error { defer image.Close() + //nolint:gosec ffmpeg := exec.Command("ffmpeg", "-i", "-", "-update", "true", diff --git a/thumbnails.go b/thumbnails.go index d9df7e8..02943aa 100644 --- a/thumbnails.go +++ b/thumbnails.go @@ -170,7 +170,7 @@ func scoreThumbnail(t Thumbnail) int { score := -2 * nameIndex if strings.Contains(t.URL, ".webp") { - score += 1 + score++ } return score From 5f72c864723d57fdfd4298ec41c41a43130a6a05 Mon Sep 17 00:00:00 2001 From: Juan Llamas <38849891+xoltia@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:45:04 -0500 Subject: [PATCH 5/5] Change thumbnail download flags to use name instead of res --- cmd/youtubedr/thumbnail.go | 22 ++++++++++------------ thumbnails.go | 34 +++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/cmd/youtubedr/thumbnail.go b/cmd/youtubedr/thumbnail.go index 360edae..34226e5 100644 --- a/cmd/youtubedr/thumbnail.go +++ b/cmd/youtubedr/thumbnail.go @@ -3,7 +3,6 @@ package main import ( "fmt" "io" - "math" "os" "os/exec" "path/filepath" @@ -15,7 +14,7 @@ import ( var thumbnailCmd = &cobra.Command{ Use: "thumbnail", Short: "Downloads a thumbnail from youtube", - Example: `youtubedr thumbnail -x 720 -o thumbnail.png https://www.youtube.com/watch\?v\=TGqoAUaivOY`, + Example: `youtubedr thumbnail -n maxresdefault -o thumbnail.png https://www.youtube.com/watch\?v\=TGqoAUaivOY`, Args: cobra.ExactArgs(1), Run: func(_ *cobra.Command, args []string) { exitOnError(downloadThumbnail(args[0])) @@ -23,19 +22,17 @@ var thumbnailCmd = &cobra.Command{ } var ( - minRes uint - maxRes uint - noExtend bool + thumbnailName string + noExtend bool ) func init() { rootCmd.AddCommand(thumbnailCmd) - thumbnailCmd.Flags().StringVarP(&outputFile, "filename", "o", "", "The output file, the default is generated by the video title.") - thumbnailCmd.Flags().StringVarP(&outputDir, "directory", "d", ".", "The output directory.") - thumbnailCmd.Flags().UintVarP(&minRes, "min-resolution", "n", 0, "The minimum resolution.") - thumbnailCmd.Flags().UintVarP(&maxRes, "max-resolution", "x", math.MaxUint, "The maximum resolution.") - thumbnailCmd.Flags().BoolVarP(&noExtend, "known-only", "k", false, "Whether to only try thumbnails received in video response (lower quality).") + thumbnailCmd.Flags().StringVarP(&outputFile, "filename", "o", "", "the output file, the default is generated by the video title") + thumbnailCmd.Flags().StringVarP(&outputDir, "directory", "d", ".", "the output directory") + thumbnailCmd.Flags().StringVarP(&thumbnailName, "name", "n", "", "the thumbnail name (ex. \"maxresdefault\")") + thumbnailCmd.Flags().BoolVarP(&noExtend, "known-only", "k", false, "only try thumbnails received in video response (lower quality)") } func downloadThumbnail(url string) error { @@ -50,8 +47,9 @@ func downloadThumbnail(url string) error { if !noExtend { thumbnails = thumbnails.Extended(video.ID) } - thumbnails = thumbnails.MinHeight(minRes) - thumbnails = thumbnails.MaxHeight(maxRes) + if thumbnailName != "" { + thumbnails = thumbnails.FilterName(thumbnailName) + } thumbnails.Sort() if outputFile == "" { diff --git a/thumbnails.go b/thumbnails.go index 02943aa..9b64c5d 100644 --- a/thumbnails.go +++ b/thumbnails.go @@ -117,38 +117,54 @@ func (t Thumbnails) FilterExt(ext ...string) Thumbnails { // FilterLive removes thumbnails that do not match the provided live status. func (t Thumbnails) FilterLive(live bool) Thumbnails { return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { - name := path.Base(thumbnail.URL) + u, err := url.Parse(thumbnail.URL) + if err != nil { + return true + } + name := path.Base(u.Path) parts := strings.SplitN(name, "_", 2) l := len(parts) > 1 && strings.HasPrefix(parts[1], "live") return l != live }) } -// MinWidth filters out thumbnails with greater than desired width. +// MinWidth filters out thumbnails with less than desired width. func (t Thumbnails) MinWidth(w uint) Thumbnails { return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { return thumbnail.Width < w }) } -// MaxWidth filters out thumbnails with less than desired width. +// MaxWidth filters out thumbnails with greater desired width. func (t Thumbnails) MaxWidth(w uint) Thumbnails { return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { return thumbnail.Width > w }) } -// MinHeight filters out thumbnails with greater than desired height. -func (t Thumbnails) MinHeight(w uint) Thumbnails { +// MinHeight filters out thumbnails with less than desired height. +func (t Thumbnails) MinHeight(h uint) Thumbnails { + return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { + return thumbnail.Height < h + }) +} + +// MaxHeight filters out thumbnails with greater than desired height. +func (t Thumbnails) MaxHeight(h uint) Thumbnails { return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { - return thumbnail.Height < w + return thumbnail.Height > h }) } -// MaxHeight filters out thumbnails with less than desired height. -func (t Thumbnails) MaxHeight(w uint) Thumbnails { +func (t Thumbnails) FilterName(names ...string) Thumbnails { return slices.DeleteFunc(t, func(thumbnail Thumbnail) bool { - return thumbnail.Height > w + u, err := url.Parse(thumbnail.URL) + if err != nil { + return true + } + fileName := path.Base(u.Path) + nameNoExt := strings.TrimSuffix(fileName, path.Ext(fileName)) + return !slices.Contains(names, nameNoExt) }) }