diff --git a/client.go b/client.go index 8d58ae41..7d2f0974 100644 --- a/client.go +++ b/client.go @@ -72,7 +72,7 @@ func (c *Client) GetPlaylist(url string) (*Playlist, error) { // for these videos. Playlist entries cannot be downloaded, as they lack all the required metadata, but // can be used to enumerate all IDs, Authors, Titles, etc. func (c *Client) GetPlaylistContext(ctx context.Context, url string) (*Playlist, error) { - id, err := extractPlaylistID(url) + id, err := ExtractPlaylistID(url) if err != nil { return nil, fmt.Errorf("extractPlaylistID failed: %w", err) } diff --git a/cmd/youtubedr/download.go b/cmd/youtubedr/download.go index 93aeb49a..e5d6a106 100644 --- a/cmd/youtubedr/download.go +++ b/cmd/youtubedr/download.go @@ -5,19 +5,65 @@ import ( "fmt" "log" "os/exec" + "strconv" "strings" + "github.com/kkdai/youtube/v2" "github.com/spf13/cobra" ) +var ( + errNotID = fmt.Errorf("cannot detect ID in given input") +) + // downloadCmd represents the download command var downloadCmd = &cobra.Command{ Use: "download", - Short: "Downloads a video from youtube", + Short: "Downloads a video or a playlist from youtube", Example: `youtubedr -o "Campaign Diary".mp4 https://www.youtube.com/watch\?v\=XbNghLqsVwU`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - exitOnError(download(args[0])) + if playlistID, err := youtube.ExtractPlaylistID(args[0]); err != nil { + videoID, err := youtube.ExtractVideoID(args[0]) + if err != nil { + exitOnError(errNotID) + } + log.Printf( + "download video %s to directory %s\n", + videoID, + outputDir, + ) + exitOnError(download(videoID)) + } else { + playlist, err := getDownloader().GetPlaylist(playlistID) + if err != nil { + exitOnError(err) + } + log.Printf( + "download %d videos from playlist %s to directory %s\n", + len(playlist.Videos), + playlist.ID, + outputDir, + ) + var errors []error + outputFileOrigin := outputFile + for i, v := range playlist.Videos { + if len(outputFileOrigin) != 0 { + index := v.Index + if len(index) == 0 { + // In case video does not contain index + index = strconv.Itoa(i) + } + outputFile = fmt.Sprintf("%s-%s", outputFile, index) + } + if err := download(v.ID); err != nil { + errors = append(errors, err) + } + } + if len(errors) > 0 { + exitOnErrors(errors) + } + } }, } @@ -30,7 +76,7 @@ var ( func init() { rootCmd.AddCommand(downloadCmd) - downloadCmd.Flags().StringVarP(&outputFile, "filename", "o", "", "The output file, the default is genated by the video title.") + downloadCmd.Flags().StringVarP(&outputFile, "filename", "o", "", "The output file, the default is generated from the video title.") downloadCmd.Flags().StringVarP(&outputDir, "directory", "d", ".", "The output directory.") addQualityFlag(downloadCmd.Flags()) addMimeTypeFlag(downloadCmd.Flags()) @@ -42,8 +88,6 @@ func download(id string) error { return err } - log.Println("download to directory", outputDir) - if strings.HasPrefix(outputQuality, "hd") { if err := checkFFMPEG(); err != nil { return err diff --git a/cmd/youtubedr/main.go b/cmd/youtubedr/main.go index 4a40e9c3..ff046512 100644 --- a/cmd/youtubedr/main.go +++ b/cmd/youtubedr/main.go @@ -15,3 +15,12 @@ func exitOnError(err error) { os.Exit(1) } } + +func exitOnErrors(errors []error) { + for _, err := range errors { + fmt.Fprintln(os.Stderr, err) + } + if len(errors) != 0 { + os.Exit(1) + } +} diff --git a/playlist.go b/playlist.go index 6d38914c..790f3f1a 100644 --- a/playlist.go +++ b/playlist.go @@ -34,12 +34,13 @@ type Playlist struct { type PlaylistEntry struct { ID string + Index string Title string Author string Duration time.Duration } -func extractPlaylistID(url string) (string, error) { +func ExtractPlaylistID(url string) (string, error) { if playlistIDRegex.Match([]byte(url)) { return url, nil } @@ -128,10 +129,11 @@ func (p *Playlist) UnmarshalJSON(b []byte) (err error) { type videosJSONExtractor struct { Renderer *struct { - ID string `json:"videoId"` - Title withRuns `json:"title"` - Author withRuns `json:"shortBylineText"` - Duration string `json:"lengthSeconds"` + ID string `json:"videoId"` + Index indexText `json:"index"` + Title withRuns `json:"title"` + Author withRuns `json:"shortBylineText"` + Duration string `json:"lengthSeconds"` } `json:"playlistVideoRenderer"` } @@ -142,6 +144,7 @@ func (vje videosJSONExtractor) PlaylistEntry() *PlaylistEntry { } return &PlaylistEntry{ ID: vje.Renderer.ID, + Index: vje.Renderer.Index.String(), Title: vje.Renderer.Title.String(), Author: vje.Renderer.Author.String(), Duration: time.Second * time.Duration(ds), @@ -154,9 +157,17 @@ type withRuns struct { } `json:"runs"` } +type indexText struct { + SimpleText string `json:"simpleText"` +} + func (wr withRuns) String() string { if len(wr.Runs) > 0 { return wr.Runs[0].Text } return "" } + +func (it indexText) String() string { + return it.SimpleText +} diff --git a/playlist_test.go b/playlist_test.go index 46f1e580..357fb36c 100644 --- a/playlist_test.go +++ b/playlist_test.go @@ -65,7 +65,7 @@ func TestYoutube_extractPlaylistID(t *testing.T) { for _, v := range tests { t.Run(v.name, func(t *testing.T) { - id, err := extractPlaylistID(v.url) + id, err := ExtractPlaylistID(v.url) assert.Equal(t, v.expectedError, err) assert.Equal(t, v.expectedID, id)