diff --git a/README.md b/README.md index 7ff8a13..46d2adb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ MumbleDJ [![Build Status](https://travis-ci.org/MichaelOultram/mumbledj.svg?bran * [Commands](#commands) * [Installation](#installation) * [YouTube API Keys](#youtube-api-keys) + * [Soundcloud API Keys](#soundcloud-api-keys) * [Setup Guide](#setup-guide) * [Update Guide](#update-guide) * [Troubleshooting](#troubleshooting) @@ -28,7 +29,6 @@ All commandline parameters are optional. Below are descriptions of all the avail * `-key`: Path to user PEM key. Defaults to no key. * `-insecure`: If included, the bot will not check the certs for the server. Try using this commandline flag if you are having connection issues. * `-accesstokens`: List of access tokens for the bot separated by spaces. Defaults to no access tokens. -* `-verbose`: Prints out song status into the console. ## FEATURES * Plays audio from both YouTube videos and YouTube playlists! @@ -87,6 +87,19 @@ Effective April 20th, 2015, all requests to YouTube's API must use v3 of their A **8)** Close your current terminal window and open another one up. You should be able to use MumbleDJ now! +###SOUNDCLOUD API KEYS +A soundcloud API key is required for soundcloud integration. If no soundcloud api key is found, then the service will be disabled (youtube links will still work however). + +**1)** Login/signup for a soundcloud account on [https://soundcloud.com](https://soundcloud.com) + +**2)** Now to get the API key create a new app here: [http://soundcloud.com/you/apps/new](http://soundcloud.com/you/apps/new) + +**3)** Copy the Client ID (not the Client Secret) + +**4)** Open up `~/.bashrc` with your favorite text editor (or `~/.zshrc` if you use `zsh`). Add the following line to the bottom: `export SOUNDCLOUD_API_KEY=""`. Replace \ with your API key. + +**5)** Close your current terminal window and open another one up. You should be able to use soundcloud on MumbleDJ now! + ###SETUP GUIDE **1)** Install and correctly configure [`Go`](https://golang.org/) (1.4 or higher). Specifically, make sure to follow [this guide](https://golang.org/doc/code.html) and set the `GOPATH` environment variable properly. diff --git a/circle.yml b/circle.yml index aeeb361..0d99b58 100644 --- a/circle.yml +++ b/circle.yml @@ -23,5 +23,5 @@ dependencies: test: override: - - mumbledj -server=$MUMBLE_IP -port=$MUMBLE_PORT -username=circleci -password=$MUMBLE_PASSWORD -verbose=true -test=true: + - mumbledj -server=$MUMBLE_IP -port=$MUMBLE_PORT -username=circleci -password=$MUMBLE_PASSWORD -test=true: timeout: 180 \ No newline at end of file diff --git a/commands.go b/commands.go index 5c52bb0..5d3d0de 100644 --- a/commands.go +++ b/commands.go @@ -152,13 +152,6 @@ func parseCommand(user *gumble.User, username, command string) { } else { dj.SendPrivateMessage(user, NO_PERMISSION_MSG) } - // Test command (WORKAROUND) - case "test": - if dj.HasPermission(username, dj.conf.Permissions.AdminKill) { - test.testYoutubeSong() - } else { - dj.SendPrivateMessage(user, NO_PERMISSION_MSG) - } default: dj.SendPrivateMessage(user, COMMAND_DOESNT_EXIST_MSG) } @@ -171,7 +164,7 @@ func add(user *gumble.User, url string) error { dj.SendPrivateMessage(user, NO_ARGUMENT_MSG) return errors.New("NO_ARGUMENT") } else { - err := findServiceAndAdd(user, url) + err := FindServiceAndAdd(user, url) if err != nil { dj.SendPrivateMessage(user, err.Error()) } diff --git a/main.go b/main.go index 69bd62f..3682ac7 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,6 @@ import ( "os" "os/user" "reflect" - "regexp" "strings" "time" @@ -37,7 +36,6 @@ type mumbledj struct { homeDir string playlistSkips map[string][]string cache *SongCache - verbose bool } // OnConnect event. First moves MumbleDJ into the default channel specified @@ -133,39 +131,44 @@ func (dj *mumbledj) SendPrivateMessage(user *gumble.User, message string) { } } -// PerformStartupChecks checks the MumbleDJ installation to ensure proper usage. -func PerformStartupChecks() { +// CheckAPIKeys enables the services with API keys in the environment varaibles +func CheckAPIKeys() { + anyDisabled := false + + // Checks YouTube API key if os.Getenv("YOUTUBE_API_KEY") == "" { - fmt.Printf("You do not have a YouTube API key defined in your environment variables.\n" + - "Please see the following link for info on how to fix this: https://github.com/matthieugrieger/mumbledj#youtube-api-keys\n") + anyDisabled = true + fmt.Printf("The youtube service has been disabled as you do not have a YouTube API key defined in your environment variables.\n") + } else { + services = append(services, YouTube{}) + } + + // Checks Soundcloud API key + if os.Getenv("SOUNDCLOUD_API_KEY") == "" { + anyDisabled = true + fmt.Printf("The soundcloud service has been disabled as you do not have a Soundcloud API key defined in your environment variables.\n") + } else { + services = append(services, SoundCloud{}) + } + + // Checks to see if any service was disabled + if anyDisabled { + fmt.Printf("Please see the following link for info on how to enable services: https://github.com/matthieugrieger/mumbledj\n") + } + + // Exits application if no services are enabled + if services == nil { + fmt.Printf("No services are enabled, and thus closing\n") os.Exit(1) } } -// Prints out messages only if verbose flag is true -func Verbose(msg string) { - if dj.verbose { - fmt.Printf(msg + "\n") - } -} - -// Checks to see if an object is nil +// isNil checks to see if an object is nil func isNil(a interface{}) bool { defer func() { recover() }() return a == nil || reflect.ValueOf(a).IsNil() } -func RegexpFromURL(url string, patterns []string) *regexp.Regexp { - for _, pattern := range patterns { - if re, err := regexp.Compile(pattern); err == nil { - if re.MatchString(url) { - return re - } - } - } - return nil -} - // dj variable declaration. This is done outside of main() to allow global use. var dj = mumbledj{ keepAlive: make(chan bool), @@ -178,7 +181,7 @@ var dj = mumbledj{ // args, sets up the gumble client and its listeners, and then connects to the server. func main() { - PerformStartupChecks() + CheckAPIKeys() if currentUser, err := user.Current(); err == nil { dj.homeDir = currentUser.HomeDir @@ -191,7 +194,7 @@ func main() { } var address, port, username, password, channel, pemCert, pemKey, accesstokens string - var insecure, verbose, testcode bool + var insecure, testcode bool flag.StringVar(&address, "server", "localhost", "address for Mumble server") flag.StringVar(&port, "port", "64738", "port for Mumble server") @@ -202,7 +205,6 @@ func main() { flag.StringVar(&pemKey, "key", "", "path to user PEM key for MumbleDJ") flag.StringVar(&accesstokens, "accesstokens", "", "list of access tokens for channel auth") flag.BoolVar(&insecure, "insecure", false, "skip certificate checking") - flag.BoolVar(&verbose, "verbose", false, "[debug] prints out debug messages to the console") flag.BoolVar(&testcode, "test", false, "[debug] tests the features of mumbledj") flag.Parse() @@ -230,7 +232,6 @@ func main() { } dj.defaultChannel = strings.Split(channel, "/") - dj.verbose = verbose dj.client.Attach(gumbleutil.Listener{ Connect: dj.OnConnect, @@ -246,7 +247,6 @@ func main() { } if testcode { - Verbose("Testing is enabled") Test(password, address, port, strings.Split(accesstokens, " ")) } <-dj.keepAlive diff --git a/service.go b/service.go index 15a5947..a8e400c 100644 --- a/service.go +++ b/service.go @@ -10,15 +10,15 @@ package main import ( "errors" "fmt" + "regexp" "github.com/layeh/gumble/gumble" ) -// Service interface. Each service should implement these functions +// Service interface. Each service will implement these functions type Service interface { - ServiceName() string - URLRegex(string) bool // Can service deal with URL - NewRequest(*gumble.User, string) (string, error) // Create song/playlist and add to the queue + URLRegex(string) bool + NewRequest(*gumble.User, string) (string, error) } // Song interface. Each service will implement these @@ -52,9 +52,9 @@ type Playlist interface { Title() string } -var services = []Service{YouTube{}, SoundCloud{}} +var services []Service -func findServiceAndAdd(user *gumble.User, url string) error { +func FindServiceAndAdd(user *gumble.User, url string) error { var urlService Service // Checks all services to see if any can take the URL @@ -65,8 +65,7 @@ func findServiceAndAdd(user *gumble.User, url string) error { } if urlService == nil { - Verbose("Invalid_URL") - return errors.New("INVALID_URL") + return errors.New(INVALID_URL_MSG) } else { oldLength := dj.queue.Len() var title string @@ -91,3 +90,15 @@ func findServiceAndAdd(user *gumble.User, url string) error { return err } } + +// RegexpFromURL loops through an array of patterns to see if it matches the url +func RegexpFromURL(url string, patterns []string) *regexp.Regexp { + for _, pattern := range patterns { + if re, err := regexp.Compile(pattern); err == nil { + if re.MatchString(url) { + return re + } + } + } + return nil +} diff --git a/service_soundcloud.go b/service_soundcloud.go index 4e47e8d..f332002 100644 --- a/service_soundcloud.go +++ b/service_soundcloud.go @@ -11,7 +11,7 @@ import ( ) // Regular expressions for soundcloud urls -var soundcloudSongPattern = `https?:\/\/(www\.)?soundcloud\.com\/([\w-]+)\/([\w-]+)` +var soundcloudSongPattern = `https?:\/\/(www\.)?soundcloud\.com\/([\w-]+)\/([\w-]+)(#t=\n\n?(:\n\n)*)?` var soundcloudPlaylistPattern = `https?:\/\/(www\.)?soundcloud\.com\/([\w-]+)\/sets\/([\w-]+)` // SoundCloud implements the Service interface @@ -21,17 +21,12 @@ type SoundCloud struct{} // SOUNDCLOUD SERVICE // ------------------ -// Name of the service -func (sc SoundCloud) ServiceName() string { - return "SoundCloud" -} - -// Checks to see if service will accept URL +// URLRegex checks to see if service will accept URL func (sc SoundCloud) URLRegex(url string) bool { return RegexpFromURL(url, []string{soundcloudSongPattern, soundcloudPlaylistPattern}) != nil } -// Creates the requested song/playlist and adds to the queue +// NewRequest creates the requested song/playlist and adds to the queue func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) { var apiResponse *jsonq.JsonQuery var err error @@ -44,13 +39,10 @@ func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) { if err == nil { // PLAYLIST if dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) { - // Check duration of playlist - //duration, _ := apiResponse.Int("duration") - // Create playlist title, _ := apiResponse.String("title") permalink, _ := apiResponse.String("permalink_url") - playlist := &YouTubeDLPlaylist{ + playlist := &YouTubePlaylist{ id: permalink, title: title, } @@ -62,7 +54,6 @@ func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) { if err == nil { return playlist.Title(), nil } else { - Verbose("soundcloud.NewRequest: " + err.Error()) return "", err } } else { @@ -74,53 +65,34 @@ func (sc SoundCloud) NewRequest(user *gumble.User, url string) (string, error) { } } -// Creates a track and adds to the queue +// NewSong creates a track and adds to the queue func (sc SoundCloud) NewSong(user *gumble.User, trackData *jsonq.JsonQuery, playlist Playlist) (string, error) { - title, err := trackData.String("title") - if err != nil { - return "", err - } - - id, err := trackData.Int("id") - if err != nil { - return "", err - } - - duration, err := trackData.Int("duration") - if err != nil { - return "", err - } + title, _ := trackData.String("title") + id, _ := trackData.Int("id") + duration, _ := trackData.Int("duration") + url, _ := trackData.String("permalink_url") thumbnail, err := trackData.String("artwork_url") if err != nil { // Song has no artwork, using profile avatar instead - userObj, err := trackData.Object("user") - if err != nil { - return "", err - } - - thumbnail, err = jsonq.NewQuery(userObj).String("avatar_url") - if err != nil { - return "", err + userObj, _ := trackData.Object("user") + thumbnail, _ = jsonq.NewQuery(userObj).String("avatar_url") + } + + if dj.conf.General.MaxSongDuration == 0 || (duration/1000) <= dj.conf.General.MaxSongDuration { + song := &YouTubeSong{ + id: strconv.Itoa(id), + title: title, + url: url, + thumbnail: thumbnail, + submitter: user, + duration: strconv.Itoa(duration), + format: "mp3", + playlist: playlist, + skippers: make([]string, 0), + dontSkip: false, } + dj.queue.AddSong(song) + return song.Title(), nil } - - url, err := trackData.String("permalink_url") - if err != nil { - return "", err - } - - song := &YouTubeDLSong{ - id: strconv.Itoa(id), - title: title, - url: url, - thumbnail: thumbnail, - submitter: user, - duration: strconv.Itoa(duration), - format: "mp3", - playlist: playlist, - skippers: make([]string, 0), - dontSkip: false, - } - dj.queue.AddSong(song) - return title, nil + return "", errors.New(VIDEO_TOO_LONG_MSG) } diff --git a/service_youtube.go b/service_youtube.go index 4bb8e8a..ef2b4c5 100644 --- a/service_youtube.go +++ b/service_youtube.go @@ -43,17 +43,12 @@ type YouTube struct{} // YOUTUBE SERVICE // --------------- -// Name of the service -func (yt YouTube) ServiceName() string { - return "Youtube" -} - -// Checks to see if service will accept URL +// URLRegex checks to see if service will accept URL func (yt YouTube) URLRegex(url string) bool { return RegexpFromURL(url, append(youtubeVideoPatterns, []string{youtubePlaylistPattern}...)) != nil } -// Creates the requested song/playlist and adds to the queue +// NewRequest creates the requested song/playlist and adds to the queue func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) { var shortURL, startOffset = "", "" if re, err := regexp.Compile(youtubePlaylistPattern); err == nil { @@ -76,7 +71,6 @@ func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) { if !isNil(song) { return song.Title(), err } else { - Verbose("youtube.NewRequest: " + err.Error()) return "", err } } @@ -85,8 +79,7 @@ func (yt YouTube) NewRequest(user *gumble.User, url string) (string, error) { } } -// NewSong gathers the metadata for a song extracted from a YouTube video, and returns -// the song. +// NewSong gathers the metadata for a song extracted from a YouTube video, and returns the song. func (yt YouTube) NewSong(user *gumble.User, id, offset string, playlist Playlist) (Song, error) { var apiResponse *jsonq.JsonQuery var err error @@ -161,7 +154,7 @@ func (yt YouTube) NewSong(user *gumble.User, id, offset string, playlist Playlis } if dj.conf.General.MaxSongDuration == 0 || totalSeconds <= dj.conf.General.MaxSongDuration { - song := &YouTubeDLSong{ + song := &YouTubeSong{ submitter: user, title: title, id: id, @@ -175,7 +168,6 @@ func (yt YouTube) NewSong(user *gumble.User, id, offset string, playlist Playlis dontSkip: false, } dj.queue.AddSong(song) - Verbose(song.Submitter() + " added track " + song.Title()) <<<<<<< HEAD return song, nil @@ -311,7 +303,7 @@ func (yt YouTube) NewPlaylist(user *gumble.User, id string) (Playlist, error) { } title, _ := apiResponse.String("items", "0", "snippet", "title") - playlist := &YouTubeDLPlaylist{ + playlist := &YouTubePlaylist{ id: id, title: title, } diff --git a/songqueue.go b/songqueue.go index d00f771..f1bdc1c 100644 --- a/songqueue.go +++ b/songqueue.go @@ -77,14 +77,6 @@ func (q *SongQueue) Traverse(visit func(i int, s Song)) { } } -// Gets the song at a specific point in the queue -func (q *SongQueue) Get(i int) (Song, error) { - if q.Len() > i+1 { - return q.queue[i], nil - } - return nil, errors.New("Out of Bounds") -} - // OnSongFinished event. Deletes Song that just finished playing, then queues the next Song (if exists). func (q *SongQueue) OnSongFinished() { resetOffset, _ := time.ParseDuration(fmt.Sprintf("%ds", 0)) diff --git a/youtube_dl.go b/youtube_dl.go index c1685ee..64afb97 100644 --- a/youtube_dl.go +++ b/youtube_dl.go @@ -11,8 +11,8 @@ import ( "github.com/layeh/gumble/gumble_ffmpeg" ) -// Extends a Song -type YouTubeDLSong struct { +// YouTubeSong implements the Song interface +type YouTubeSong struct { id string title string thumbnail string @@ -26,18 +26,19 @@ type YouTubeDLSong struct { dontSkip bool } -type YouTubeDLPlaylist struct { +// YouTubePlaylist implements the Playlist interface +type YouTubePlaylist struct { id string title string } -// ------------- -// YouTubeDLSong -// ------------- +// ------------ +// YOUTUBE SONG +// ------------ // Download downloads the song via youtube-dl if it does not already exist on disk. // All downloaded songs are stored in ~/.mumbledj/songs and should be automatically cleaned. -func (dl *YouTubeDLSong) Download() error { +func (dl *YouTubeSong) Download() error { // Checks to see if song is already downloaded if _, err := os.Stat(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename())); os.IsNotExist(err) { @@ -53,9 +54,7 @@ func (dl *YouTubeDLSong) Download() error { for s := range cmd.Args { args += cmd.Args[s] + " " } - Verbose(args) - Verbose(string(output)) - Verbose("youtube-dl: " + err.Error()) + fmt.Printf(args + "\n" + string(output) + "\n" + "youtube-dl: " + err.Error() + "\n") return errors.New("Song download failed.") } } @@ -64,7 +63,7 @@ func (dl *YouTubeDLSong) Download() error { // Play plays the song. Once the song is playing, a notification is displayed in a text message that features the song // thumbnail, URL, title, duration, and submitter. -func (dl *YouTubeDLSong) Play() { +func (dl *YouTubeSong) Play() { if dl.offset != 0 { offsetDuration, _ := time.ParseDuration(fmt.Sprintf("%ds", dl.offset)) dj.audioStream.Offset = offsetDuration @@ -79,7 +78,6 @@ func (dl *YouTubeDLSong) Play() { message = fmt.Sprintf(message+`From playlist "%s"`, dl.playlist.Title()) } dj.client.Self.Channel.Send(message+``, false) - Verbose("Now playing " + dl.title) go func() { dj.audioStream.Wait() @@ -89,7 +87,7 @@ func (dl *YouTubeDLSong) Play() { } // Delete deletes the song from ~/.mumbledj/songs if the cache is disabled. -func (dl *YouTubeDLSong) Delete() error { +func (dl *YouTubeSong) Delete() error { if dj.conf.Cache.Enabled == false { filePath := fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename()) if _, err := os.Stat(filePath); err == nil { @@ -105,7 +103,7 @@ func (dl *YouTubeDLSong) Delete() error { // AddSkip adds a skip to the skippers slice. If the user is already in the slice, AddSkip // returns an error and does not add a duplicate skip. -func (dl *YouTubeDLSong) AddSkip(username string) error { +func (dl *YouTubeSong) AddSkip(username string) error { for _, user := range dl.skippers { if username == user { return errors.New("This user has already skipped the current song.") @@ -117,7 +115,7 @@ func (dl *YouTubeDLSong) AddSkip(username string) error { // RemoveSkip removes a skip from the skippers slice. If username is not in slice, an error is // returned. -func (dl *YouTubeDLSong) RemoveSkip(username string) error { +func (dl *YouTubeSong) RemoveSkip(username string) error { for i, user := range dl.skippers { if username == user { dl.skippers = append(dl.skippers[:i], dl.skippers[i+1:]...) @@ -130,7 +128,7 @@ func (dl *YouTubeDLSong) RemoveSkip(username string) error { // SkipReached calculates the current skip ratio based on the number of users within MumbleDJ's // channel and the number of usernames in the skippers slice. If the value is greater than or equal // to the skip ratio defined in the config, the function returns true, and returns false otherwise. -func (dl *YouTubeDLSong) SkipReached(channelUsers int) bool { +func (dl *YouTubeSong) SkipReached(channelUsers int) bool { if float32(len(dl.skippers))/float32(channelUsers) >= dj.conf.General.SkipRatio { return true } @@ -138,47 +136,47 @@ func (dl *YouTubeDLSong) SkipReached(channelUsers int) bool { } // Submitter returns the name of the submitter of the Song. -func (dl *YouTubeDLSong) Submitter() string { +func (dl *YouTubeSong) Submitter() string { return dl.submitter.Name } // Title returns the title of the Song. -func (dl *YouTubeDLSong) Title() string { +func (dl *YouTubeSong) Title() string { return dl.title } // ID returns the id of the Song. -func (dl *YouTubeDLSong) ID() string { +func (dl *YouTubeSong) ID() string { return dl.id } // Filename returns the filename of the Song. -func (dl *YouTubeDLSong) Filename() string { +func (dl *YouTubeSong) Filename() string { return dl.id + dl.format } // Duration returns the duration of the Song. -func (dl *YouTubeDLSong) Duration() string { +func (dl *YouTubeSong) Duration() string { return dl.duration } // Thumbnail returns the thumbnail URL for the Song. -func (dl *YouTubeDLSong) Thumbnail() string { +func (dl *YouTubeSong) Thumbnail() string { return dl.thumbnail } // Playlist returns the playlist type for the Song (may be nil). -func (dl *YouTubeDLSong) Playlist() Playlist { +func (dl *YouTubeSong) Playlist() Playlist { return dl.playlist } // DontSkip returns the DontSkip boolean value for the Song. -func (dl *YouTubeDLSong) DontSkip() bool { +func (dl *YouTubeSong) DontSkip() bool { return dl.dontSkip } // SetDontSkip sets the DontSkip boolean value for the Song. -func (dl *YouTubeDLSong) SetDontSkip(value bool) { +func (dl *YouTubeSong) SetDontSkip(value bool) { dl.dontSkip = value } @@ -187,7 +185,7 @@ func (dl *YouTubeDLSong) SetDontSkip(value bool) { // ---------------- // AddSkip adds a skip to the playlist's skippers slice. -func (p *YouTubeDLPlaylist) AddSkip(username string) error { +func (p *YouTubePlaylist) AddSkip(username string) error { for _, user := range dj.playlistSkips[p.ID()] { if username == user { return errors.New("This user has already skipped the current song.") @@ -199,7 +197,7 @@ func (p *YouTubeDLPlaylist) AddSkip(username string) error { // RemoveSkip removes a skip from the playlist's skippers slice. If username is not in the slice // an error is returned. -func (p *YouTubeDLPlaylist) RemoveSkip(username string) error { +func (p *YouTubePlaylist) RemoveSkip(username string) error { for i, user := range dj.playlistSkips[p.ID()] { if username == user { dj.playlistSkips[p.ID()] = append(dj.playlistSkips[p.ID()][:i], dj.playlistSkips[p.ID()][i+1:]...) @@ -210,26 +208,26 @@ func (p *YouTubeDLPlaylist) RemoveSkip(username string) error { } // DeleteSkippers removes the skippers entry in dj.playlistSkips. -func (p *YouTubeDLPlaylist) DeleteSkippers() { +func (p *YouTubePlaylist) DeleteSkippers() { delete(dj.playlistSkips, p.ID()) } // SkipReached calculates the current skip ratio based on the number of users within MumbleDJ's // channel and the number of usernames in the skippers slice. If the value is greater than or equal // to the skip ratio defined in the config, the function returns true, and returns false otherwise. -func (p *YouTubeDLPlaylist) SkipReached(channelUsers int) bool { +func (p *YouTubePlaylist) SkipReached(channelUsers int) bool { if float32(len(dj.playlistSkips[p.ID()]))/float32(channelUsers) >= dj.conf.General.PlaylistSkipRatio { return true } return false } -// ID returns the id of the YouTubeDLPlaylist. -func (p *YouTubeDLPlaylist) ID() string { +// ID returns the id of the YouTubePlaylist. +func (p *YouTubePlaylist) ID() string { return p.id } -// Title returns the title of the YouTubeDLPlaylist. -func (p *YouTubeDLPlaylist) Title() string { +// Title returns the title of the YouTubePlaylist. +func (p *YouTubePlaylist) Title() string { return p.title }