Merged PR #87. Added Soundcloud support. Fixes #87.

pull/87/merge
Matthieu Grieger 2015-10-01 12:56:05 -07:00
parent 9928986bfe
commit 3b0b77858b
11 changed files with 710 additions and 540 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
mumbledj
Goopfile.lock
.vendor
.project

View File

@ -1,6 +1,6 @@
all: mumbledj
mumbledj: main.go commands.go parseconfig.go strings.go service.go service_youtube.go songqueue.go cache.go
mumbledj: main.go commands.go parseconfig.go strings.go service.go youtube_dl.go service_youtube.go service_soundcloud.go songqueue.go cache.go
go get github.com/nitrous-io/goop
rm -rf Goopfile.lock
goop install
@ -12,7 +12,7 @@ clean:
install:
mkdir -p ~/.mumbledj/config
mkdir -p ~/.mumbledj/songs
if [ -a ~/.mumbledj/config/mumbledj.gcfg ]; then mv ~/.mumbledj/config/mumbledj.gcfg ~/.mumbledj/config/mumbledj_backup.gcfg; fi;
if [ -f ~/.mumbledj/config/mumbledj.gcfg ]; then mv ~/.mumbledj/config/mumbledj.gcfg ~/.mumbledj/config/mumbledj_backup.gcfg; fi;
cp -u config.gcfg ~/.mumbledj/config/mumbledj.gcfg
if [ -d ~/bin ]; then cp -f mumbledj* ~/bin/mumbledj; else sudo cp -f mumbledj* /usr/local/bin/mumbledj; fi;

View File

@ -1,12 +1,13 @@
MumbleDJ
========
**A Mumble bot that plays music fetched from YouTube videos.**
**A Mumble bot that plays music fetched from YouTube videos and Soundcloud tracks.**
* [Usage](#usage)
* [Features](#features)
* [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)
@ -30,7 +31,8 @@ All commandline parameters are optional. Below are descriptions of all the avail
* `-accesstokens`: List of access tokens for the bot separated by spaces. Defaults to no access tokens.
## FEATURES
* Plays audio from both YouTube videos and YouTube playlists!
* Plays audio from YouTube and Soundcloud!
* Supports playlists and individual videos/tracks.
* Displays thumbnail, title, duration, submitter, and playlist title (if exists) when a new song is played.
* Incredible customization options. Nearly everything is able to be tweaked in `~/.mumbledj/mumbledj.gcfg`.
* A large array of [commands](#commands) that perform a wide variety of functions.
@ -42,7 +44,7 @@ These are all of the chat commands currently supported by MumbleDJ. All command
Command | Description | Arguments | Admin | Example
--------|-------------|-----------|-------|--------
**add** | Adds a YouTube video's audio to the song queue. If no songs are currently in the queue, the audio will begin playing immediately. YouTube playlists may also be added using this command. Please note, however, that if a YouTube playlist contains over 25 videos only the first 25 videos will be placed in the song queue. | youtube_video_url OR youtube_playlist_url | No | `!add https://www.youtube.com/watch?v=5xfEr2Oxdys`
**add** | Adds audio from a url to the song queue. If no songs are currently in the queue, the audio will begin playing immediately. Playlists may also be added using this command. Please note, however, that if a YouTube playlist contains over 25 videos only the first 25 videos will be placed in the song queue. | youtube_video_url OR youtube_playlist_url OR soundcloud_track_url OR soundcloud_playlist_url | No | `!add https://www.youtube.com/watch?v=5xfEr2Oxdys`
**skip**| Submits a vote to skip the current song. Once the skip ratio target (specified in `mumbledj.gcfg`) is met, the song will be skipped and the next will start playing. Each user may only submit one skip per song. | None | No | `!skip`
**skipplaylist** | Submits a vote to skip the current playlist. Once the skip ratio target (specified in mumbledj.gcfg) is met, the playlist will be skipped and the next song/playlist will start playing. Each user may only submit one skip per playlist. | None | No | `!skipplaylist`
**forceskip** | An admin command that forces a song skip. | None | Yes | `!forceskip`
@ -84,7 +86,20 @@ Effective April 20th, 2015, all requests to YouTube's API must use v3 of their A
**7)** Open up `~/.bashrc` with your favorite text editor (or `~/.zshrc` if you use `zsh`). Add the following line to the bottom: `export YOUTUBE_API_KEY="<your_key_here>"`. Replace \<your_key_here\> with your API key.
**8)** Close your current terminal window and open another one up. You should be able to use MumbleDJ now!
**8)** Close your current terminal window and open another one up. You should be able to use Youtube on 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="<your_key_here>"`. Replace \<your_key_here\> 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.

View File

@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"os"
"regexp"
"strconv"
"strings"
@ -37,35 +36,35 @@ func parseCommand(user *gumble.User, username, command string) {
// Add command
case dj.conf.Aliases.AddAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminAdd) {
add(user, username, argument)
add(user, argument)
} else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
}
// Skip command
case dj.conf.Aliases.SkipAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminSkip) {
skip(user, username, false, false)
skip(user, false, false)
} else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
}
// Skip playlist command
case dj.conf.Aliases.SkipPlaylistAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminAddPlaylists) {
skip(user, username, false, true)
skip(user, false, true)
} else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
}
// Forceskip command
case dj.conf.Aliases.AdminSkipAlias:
if dj.HasPermission(username, true) {
skip(user, username, true, false)
skip(user, true, false)
} else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
}
// Playlist forceskip command
case dj.conf.Aliases.AdminSkipPlaylistAlias:
if dj.HasPermission(username, true) {
skip(user, username, true, true)
skip(user, true, true)
} else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
}
@ -79,7 +78,7 @@ func parseCommand(user *gumble.User, username, command string) {
// Volume command
case dj.conf.Aliases.VolumeAlias:
if dj.HasPermission(username, dj.conf.Permissions.AdminVolume) {
volume(user, username, argument)
volume(user, argument)
} else {
dj.SendPrivateMessage(user, NO_PERMISSION_MSG)
}
@ -158,104 +157,36 @@ func parseCommand(user *gumble.User, username, command string) {
}
}
// add performs !add functionality. Checks input URL for YouTube format, and adds
// add performs !add functionality. Checks input URL for service, and adds
// the URL to the queue if the format matches.
func add(user *gumble.User, username, url string) {
func add(user *gumble.User, url string) error {
if url == "" {
dj.SendPrivateMessage(user, NO_ARGUMENT_MSG)
return errors.New("NO_ARGUMENT")
} else {
youtubePatterns := []string{
`https?:\/\/www\.youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`,
`https?:\/\/youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`,
`https?:\/\/youtu.be\/([\w-]+)(\?t=\d*m?\d*s?)?`,
`https?:\/\/youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`,
`https?:\/\/www.youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`,
}
matchFound := false
shortURL := ""
startOffset := ""
for _, pattern := range youtubePatterns {
if re, err := regexp.Compile(pattern); err == nil {
if re.MatchString(url) {
matchFound = true
matches := re.FindAllStringSubmatch(url, -1)
shortURL = matches[0][1]
if len(matches[0]) == 3 {
startOffset = matches[0][2]
}
break
}
}
}
if matchFound {
if newSong, err := NewYouTubeSong(username, shortURL, startOffset, nil); err == nil {
dj.client.Self.Channel.Send(fmt.Sprintf(SONG_ADDED_HTML, username, newSong.title), false)
if dj.queue.Len() == 1 && !dj.audioStream.IsPlaying() {
if err := dj.queue.CurrentSong().Download(); err == nil {
dj.queue.CurrentSong().Play()
} else {
dj.SendPrivateMessage(user, AUDIO_FAIL_MSG)
dj.queue.CurrentSong().Delete()
dj.queue.OnSongFinished()
}
}
} else if fmt.Sprint(err) == "Song exceeds the maximum allowed duration." {
dj.SendPrivateMessage(user, VIDEO_TOO_LONG_MSG)
} else if fmt.Sprint(err) == "Invalid API key supplied." {
dj.SendPrivateMessage(user, INVALID_API_KEY)
} else {
dj.SendPrivateMessage(user, INVALID_YOUTUBE_ID_MSG)
}
} else {
// Check to see if we have a playlist URL instead.
youtubePlaylistPattern := `https?:\/\/www\.youtube\.com\/playlist\?list=([\w-]+)`
if re, err := regexp.Compile(youtubePlaylistPattern); err == nil {
if re.MatchString(url) {
if dj.HasPermission(username, dj.conf.Permissions.AdminAddPlaylists) {
shortURL = re.FindStringSubmatch(url)[1]
oldLength := dj.queue.Len()
if newPlaylist, err := NewYouTubePlaylist(username, shortURL); err == nil {
dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_ADDED_HTML, username, newPlaylist.title), false)
if oldLength == 0 && dj.queue.Len() != 0 && !dj.audioStream.IsPlaying() {
if err := dj.queue.CurrentSong().Download(); err == nil {
dj.queue.CurrentSong().Play()
} else {
dj.SendPrivateMessage(user, AUDIO_FAIL_MSG)
dj.queue.CurrentSong().Delete()
dj.queue.OnSongFinished()
}
}
} else {
dj.SendPrivateMessage(user, INVALID_YOUTUBE_ID_MSG)
}
} else {
dj.SendPrivateMessage(user, NO_PLAYLIST_PERMISSION_MSG)
}
} else {
dj.SendPrivateMessage(user, INVALID_URL_MSG)
}
}
err := FindServiceAndAdd(user, url)
if err != nil {
dj.SendPrivateMessage(user, err.Error())
}
return err
}
}
// skip performs !skip functionality. Adds a skip to the skippers slice for the current song, and then
// evaluates if a skip should be performed. Both skip and forceskip are implemented here.
func skip(user *gumble.User, username string, admin, playlistSkip bool) {
func skip(user *gumble.User, admin, playlistSkip bool) {
if dj.audioStream.IsPlaying() {
if playlistSkip {
if dj.queue.CurrentSong().Playlist() != nil {
if err := dj.queue.CurrentSong().Playlist().AddSkip(username); err == nil {
if err := dj.queue.CurrentSong().Playlist().AddSkip(user.Name); err == nil {
submitterSkipped := false
if admin {
dj.client.Self.Channel.Send(ADMIN_PLAYLIST_SKIP_MSG, false)
} else if dj.queue.CurrentSong().Submitter() == username {
dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_SUBMITTER_SKIP_HTML, username), false)
} else if dj.queue.CurrentSong().Submitter() == user.Name {
dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_SUBMITTER_SKIP_HTML, user.Name), false)
submitterSkipped = true
} else {
dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_SKIP_ADDED_HTML, username), false)
dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_SKIP_ADDED_HTML, user.Name), false)
}
if submitterSkipped || dj.queue.CurrentSong().Playlist().SkipReached(len(dj.client.Self.Channel.Users)) || admin {
id := dj.queue.CurrentSong().Playlist().ID()
@ -284,15 +215,15 @@ func skip(user *gumble.User, username string, admin, playlistSkip bool) {
dj.SendPrivateMessage(user, NO_PLAYLIST_PLAYING_MSG)
}
} else {
if err := dj.queue.CurrentSong().AddSkip(username); err == nil {
if err := dj.queue.CurrentSong().AddSkip(user.Name); err == nil {
submitterSkipped := false
if admin {
dj.client.Self.Channel.Send(ADMIN_SONG_SKIP_MSG, false)
} else if dj.queue.CurrentSong().Submitter() == username {
dj.client.Self.Channel.Send(fmt.Sprintf(SUBMITTER_SKIP_HTML, username), false)
} else if dj.queue.CurrentSong().Submitter() == user.Name {
dj.client.Self.Channel.Send(fmt.Sprintf(SUBMITTER_SKIP_HTML, user.Name), false)
submitterSkipped = true
} else {
dj.client.Self.Channel.Send(fmt.Sprintf(SKIP_ADDED_HTML, username), false)
dj.client.Self.Channel.Send(fmt.Sprintf(SKIP_ADDED_HTML, user.Name), false)
}
if submitterSkipped || dj.queue.CurrentSong().SkipReached(len(dj.client.Self.Channel.Users)) || admin {
if !(submitterSkipped || admin) {
@ -317,7 +248,7 @@ func help(user *gumble.User) {
// volume performs !volume functionality. Checks input value against LowestVolume and HighestVolume from
// config to determine if the volume should be applied. If in the correct range, the new volume
// is applied and is immediately in effect.
func volume(user *gumble.User, username, value string) {
func volume(user *gumble.User, value string) {
if value == "" {
dj.client.Self.Channel.Send(fmt.Sprintf(CUR_VOLUME_HTML, dj.audioStream.Volume), false)
} else {
@ -325,7 +256,7 @@ func volume(user *gumble.User, username, value string) {
newVolume := float32(parsedVolume)
if newVolume >= dj.conf.Volume.LowestVolume && newVolume <= dj.conf.Volume.HighestVolume {
dj.audioStream.Volume = newVolume
dj.client.Self.Channel.Send(fmt.Sprintf(VOLUME_SUCCESS_HTML, username, dj.audioStream.Volume), false)
dj.client.Self.Channel.Send(fmt.Sprintf(VOLUME_SUCCESS_HTML, user.Name, dj.audioStream.Volume), false)
} else {
dj.SendPrivateMessage(user, fmt.Sprintf(NOT_IN_VOLUME_RANGE_MSG, dj.conf.Volume.LowestVolume, dj.conf.Volume.HighestVolume))
}

42
main.go
View File

@ -13,6 +13,7 @@ import (
"fmt"
"os"
"os/user"
"reflect"
"strings"
"time"
@ -100,7 +101,7 @@ func (dj *mumbledj) OnTextMessage(e *gumble.TextMessageEvent) {
func (dj *mumbledj) OnUserChange(e *gumble.UserChangeEvent) {
if e.Type.Has(gumble.UserChangeDisconnected) {
if dj.audioStream.IsPlaying() {
if dj.queue.CurrentSong().Playlist() != nil {
if !isNil(dj.queue.CurrentSong().Playlist()) {
dj.queue.CurrentSong().Playlist().RemoveSkip(e.User.Name)
}
dj.queue.CurrentSong().RemoveSkip(e.User.Name)
@ -130,15 +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 missing 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)
}
}
// isNil checks to see if an object is nil
func isNil(a interface{}) bool {
defer func() { recover() }()
return a == nil || reflect.ValueOf(a).IsNil()
}
// dj variable declaration. This is done outside of main() to allow global use.
var dj = mumbledj{
keepAlive: make(chan bool),
@ -151,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

View File

@ -7,6 +7,23 @@
package main
import (
"errors"
"fmt"
"regexp"
"time"
"github.com/layeh/gumble/gumble"
)
// Service interface. Each service will implement these functions
type Service interface {
ServiceName() string
TrackName() string
URLRegex(string) bool
NewRequest(*gumble.User, string) ([]Song, error)
}
// Song interface. Each service will implement these
// functions in their Song types.
type Song interface {
@ -20,7 +37,7 @@ type Song interface {
Title() string
ID() string
Filename() string
Duration() string
Duration() time.Duration
Thumbnail() string
Playlist() Playlist
DontSkip() bool
@ -37,3 +54,87 @@ type Playlist interface {
ID() string
Title() string
}
var services []Service
// FindServiceAndAdd tries the given url with each service
// and adds the song/playlist with the correct service
func FindServiceAndAdd(user *gumble.User, url string) error {
var urlService Service
// Checks all services to see if any can take the URL
for _, service := range services {
if service.URLRegex(url) {
urlService = service
}
}
if urlService == nil {
return errors.New(INVALID_URL_MSG)
} else {
var title string
var songsAdded = 0
var songArray []Song
var err error
// Get service to create songs
if songArray, err = urlService.NewRequest(user, url); err != nil {
return err
}
// Check Playlist Permission
if len(songArray) > 1 && !dj.HasPermission(user.Name, dj.conf.Permissions.AdminAddPlaylists) {
return errors.New(NO_PLAYLIST_PERMISSION_MSG)
}
// Loop through all songs and add to the queue
oldLength := dj.queue.Len()
for _, song := range songArray {
// Check song is not too long
if dj.conf.General.MaxSongDuration == 0 || int(song.Duration().Seconds()) <= dj.conf.General.MaxSongDuration {
if !isNil(song.Playlist()) {
title = song.Playlist().Title()
} else {
title = song.Title()
}
// Add song to queue
dj.queue.AddSong(song)
songsAdded++
}
}
// Alert channel of added song/playlist
if songsAdded == 0 {
return errors.New(fmt.Sprintf(TRACK_TOO_LONG_MSG, urlService.ServiceName()))
} else if songsAdded == 1 {
dj.client.Self.Channel.Send(fmt.Sprintf(SONG_ADDED_HTML, user.Name, title), false)
} else {
dj.client.Self.Channel.Send(fmt.Sprintf(PLAYLIST_ADDED_HTML, user.Name, title), false)
}
// Starts playing the new song if nothing else is playing
if oldLength == 0 && dj.queue.Len() != 0 && !dj.audioStream.IsPlaying() {
if err := dj.queue.CurrentSong().Download(); err == nil {
dj.queue.CurrentSong().Play()
} else {
dj.queue.CurrentSong().Delete()
dj.queue.OnSongFinished()
return errors.New(AUDIO_FAIL_MSG)
}
}
return nil
}
}
// 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
}

126
service_soundcloud.go Normal file
View File

@ -0,0 +1,126 @@
/*
* MumbleDJ
* By Matthieu Grieger
* service_soundcloud.go
* Copyright (c) 2014, 2015 Matthieu Grieger (MIT License)
*/
package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/jmoiron/jsonq"
"github.com/layeh/gumble/gumble"
)
// Regular expressions for soundcloud urls
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
type SoundCloud struct{}
// ------------------
// SOUNDCLOUD SERVICE
// ------------------
// ServiceName is the human readable version of the service name
func (sc SoundCloud) ServiceName() string {
return "Soundcloud"
}
// TrackName is the human readable version of the service name
func (sc SoundCloud) TrackName() string {
return "Song"
}
// URLRegex checks to see if service will accept URL
func (sc SoundCloud) URLRegex(url string) bool {
return RegexpFromURL(url, []string{soundcloudSongPattern, soundcloudPlaylistPattern}) != nil
}
// NewRequest creates the requested song/playlist and adds to the queue
func (sc SoundCloud) NewRequest(user *gumble.User, url string) ([]Song, error) {
var apiResponse *jsonq.JsonQuery
var songArray []Song
var err error
timesplit := strings.Split(url, "#t=")
url = fmt.Sprintf("http://api.soundcloud.com/resolve?url=%s&client_id=%s", timesplit[0], os.Getenv("SOUNDCLOUD_API_KEY"))
if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, errors.New(fmt.Sprintf(INVALID_API_KEY, sc.ServiceName()))
}
tracks, err := apiResponse.ArrayOfObjects("tracks")
if err == nil {
// PLAYLIST
// Create playlist
title, _ := apiResponse.String("title")
permalink, _ := apiResponse.String("permalink_url")
playlist := &AudioPlaylist{
id: permalink,
title: title,
}
// Add all tracks
for _, t := range tracks {
if song, err := sc.NewSong(user, jsonq.NewQuery(t), 0, playlist); err == nil {
songArray = append(songArray, song)
}
}
return songArray, nil
} else {
// SONG
// Calculate offset
offset := 0
if len(timesplit) == 2 {
timesplit = strings.Split(timesplit[1], ":")
multiplier := 1
for i := len(timesplit) - 1; i >= 0; i-- {
time, _ := strconv.Atoi(timesplit[i])
offset += time * multiplier
multiplier *= 60
}
}
// Add the track
if song, err := sc.NewSong(user, apiResponse, offset, nil); err == nil {
return append(songArray, song), err
}
return nil, err
}
}
// NewSong creates a track and adds to the queue
func (sc SoundCloud) NewSong(user *gumble.User, trackData *jsonq.JsonQuery, offset int, playlist Playlist) (Song, error) {
title, _ := trackData.String("title")
id, _ := trackData.Int("id")
durationMS, _ := 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, _ := trackData.Object("user")
thumbnail, _ = jsonq.NewQuery(userObj).String("avatar_url")
}
song := &AudioTrack{
id: strconv.Itoa(id),
title: title,
url: url,
thumbnail: thumbnail,
submitter: user,
duration: durationMS / 1000,
offset: offset,
format: "mp3",
playlist: playlist,
skippers: make([]string, 0),
dontSkip: false,
service: sc,
}
return song, nil
}

View File

@ -8,355 +8,107 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/jmoiron/jsonq"
"github.com/layeh/gumble/gumble_ffmpeg"
"github.com/layeh/gumble/gumble"
)
// ------------
// YOUTUBE SONG
// ------------
// YouTubeSong holds the metadata for a song extracted from a YouTube video.
type YouTubeSong struct {
submitter string
title string
id string
offset int
filename string
duration string
thumbnail string
skippers []string
playlist Playlist
dontSkip bool
// Regular expressions for youtube urls
var youtubePlaylistPattern = `https?:\/\/www\.youtube\.com\/playlist\?list=([\w-]+)`
var youtubeVideoPatterns = []string{
`https?:\/\/www\.youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`,
`https?:\/\/youtube\.com\/watch\?v=([\w-]+)(\&t=\d*m?\d*s?)?`,
`https?:\/\/youtu.be\/([\w-]+)(\?t=\d*m?\d*s?)?`,
`https?:\/\/youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`,
`https?:\/\/www.youtube.com\/v\/([\w-]+)(\?t=\d*m?\d*s?)?`,
}
// NewYouTubeSong gathers the metadata for a song extracted from a YouTube video, and returns
// the song.
func NewYouTubeSong(user, id, offset string, playlist *YouTubePlaylist) (*YouTubeSong, error) {
var apiResponse *jsonq.JsonQuery
var err error
url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=%s&key=%s",
id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, err
}
// YouTube implements the Service interface
type YouTube struct{}
var offsetDays, offsetHours, offsetMinutes, offsetSeconds int64
if offset != "" {
offsetExp := regexp.MustCompile(`t\=(?P<days>\d+d)?(?P<hours>\d+h)?(?P<minutes>\d+m)?(?P<seconds>\d+s)?`)
offsetMatch := offsetExp.FindStringSubmatch(offset)
offsetResult := make(map[string]string)
for i, name := range offsetExp.SubexpNames() {
if i < len(offsetMatch) {
offsetResult[name] = offsetMatch[i]
// ServiceName is the human readable version of the service name
func (yt YouTube) ServiceName() string {
return "YouTube"
}
// TrackName is the human readable version of the service name
func (yt YouTube) TrackName() string {
return "Video"
}
// URLRegex checks to see if service will accept URL
func (yt YouTube) URLRegex(url string) bool {
return RegexpFromURL(url, append(youtubeVideoPatterns, []string{youtubePlaylistPattern}...)) != nil
}
// NewRequest creates the requested song/playlist and adds to the queue
func (yt YouTube) NewRequest(user *gumble.User, url string) ([]Song, error) {
var songArray []Song
var shortURL, startOffset = "", ""
if re, err := regexp.Compile(youtubePlaylistPattern); err == nil {
if re.MatchString(url) {
shortURL = re.FindStringSubmatch(url)[1]
return yt.NewPlaylist(user, shortURL)
} else {
re = RegexpFromURL(url, youtubeVideoPatterns)
matches := re.FindAllStringSubmatch(url, -1)
shortURL = matches[0][1]
if len(matches[0]) == 3 {
startOffset = matches[0][2]
}
song, err := yt.NewSong(user, shortURL, startOffset, nil)
if !isNil(song) {
return append(songArray, song), nil
} else {
return nil, err
}
}
if offsetResult["days"] != "" {
offsetDays, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["days"], "d"), 10, 32)
}
if offsetResult["hours"] != "" {
offsetHours, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["hours"], "h"), 10, 32)
}
if offsetResult["minutes"] != "" {
offsetMinutes, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["minutes"], "m"), 10, 32)
}
if offsetResult["seconds"] != "" {
offsetSeconds, _ = strconv.ParseInt(strings.TrimSuffix(offsetResult["seconds"], "s"), 10, 32)
}
}
title, _ := apiResponse.String("items", "0", "snippet", "title")
thumbnail, _ := apiResponse.String("items", "0", "snippet", "thumbnails", "high", "url")
duration, _ := apiResponse.String("items", "0", "contentDetails", "duration")
var days, hours, minutes, seconds int64
timestampExp := regexp.MustCompile(`P(?P<days>\d+D)?T(?P<hours>\d+H)?(?P<minutes>\d+M)?(?P<seconds>\d+S)?`)
timestampMatch := timestampExp.FindStringSubmatch(duration)
timestampResult := make(map[string]string)
for i, name := range timestampExp.SubexpNames() {
if i < len(timestampMatch) {
timestampResult[name] = timestampMatch[i]
}
}
if timestampResult["days"] != "" {
days, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["days"], "D"), 10, 32)
}
if timestampResult["hours"] != "" {
hours, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["hours"], "H"), 10, 32)
}
if timestampResult["minutes"] != "" {
minutes, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["minutes"], "M"), 10, 32)
}
if timestampResult["seconds"] != "" {
seconds, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["seconds"], "S"), 10, 32)
}
totalSeconds := int((days * 86400) + (hours * 3600) + (minutes * 60) + seconds)
var durationString string
if hours != 0 {
if days != 0 {
durationString = fmt.Sprintf("%d:%02d:%02d:%02d", days, hours, minutes, seconds)
} else {
durationString = fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
}
} else {
durationString = fmt.Sprintf("%d:%02d", minutes, seconds)
return nil, err
}
}
if dj.conf.General.MaxSongDuration == 0 || totalSeconds <= dj.conf.General.MaxSongDuration {
song := &YouTubeSong{
// 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) {
url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=%s&key=%s", id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err := PerformGetRequest(url); err == nil {
title, _ := apiResponse.String("items", "0", "snippet", "title")
thumbnail, _ := apiResponse.String("items", "0", "snippet", "thumbnails", "high", "url")
duration, _ := apiResponse.String("items", "0", "contentDetails", "duration")
song := &AudioTrack{
submitter: user,
title: title,
id: id,
offset: int((offsetDays * 86400) + (offsetHours * 3600) + (offsetMinutes * 60) + offsetSeconds),
filename: id + ".m4a",
duration: durationString,
url: "https://youtu.be/" + id,
offset: int(yt.parseTime(offset, `\?T\=(?P<days>\d+D)?(?P<hours>\d+H)?(?P<minutes>\d+M)?(?P<seconds>\d+S)?`).Seconds()),
duration: int(yt.parseTime(duration, `P(?P<days>\d+D)?T(?P<hours>\d+H)?(?P<minutes>\d+M)?(?P<seconds>\d+S)?`).Seconds()),
thumbnail: thumbnail,
format: "m4a",
skippers: make([]string, 0),
playlist: nil,
playlist: playlist,
dontSkip: false,
service: yt,
}
dj.queue.AddSong(song)
return song, nil
}
return nil, errors.New("Song exceeds the maximum allowed duration.")
return nil, errors.New(fmt.Sprintf(INVALID_API_KEY, yt.ServiceName()))
}
// 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 (s *YouTubeSong) Download() error {
if _, err := os.Stat(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, s.Filename())); os.IsNotExist(err) {
cmd := exec.Command("youtube-dl", "--no-mtime", "--output", fmt.Sprintf(`~/.mumbledj/songs/%s`, s.Filename()), "--format", "m4a", "--", s.ID())
if err := cmd.Run(); err == nil {
if dj.conf.Cache.Enabled {
dj.cache.CheckMaximumDirectorySize()
}
return nil
}
return errors.New("Song download failed.")
}
return nil
}
// Play plays the song. Once the song is playing, a notification is displayed in a text message that features the video
// thumbnail, URL, title, duration, and submitter.
func (s *YouTubeSong) Play() {
if s.offset != 0 {
offsetDuration, _ := time.ParseDuration(fmt.Sprintf("%ds", s.offset))
dj.audioStream.Offset = offsetDuration
}
dj.audioStream.Source = gumble_ffmpeg.SourceFile(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, s.Filename()))
if err := dj.audioStream.Play(); err != nil {
panic(err)
} else {
if s.Playlist() == nil {
message := `
<table>
<tr>
<td align="center"><img src="%s" width=150 /></td>
</tr>
<tr>
<td align="center"><b><a href="http://youtu.be/%s">%s</a> (%s)</b></td>
</tr>
<tr>
<td align="center">Added by %s</td>
</tr>
</table>
`
dj.client.Self.Channel.Send(fmt.Sprintf(message, s.Thumbnail(), s.ID(), s.Title(),
s.Duration(), s.Submitter()), false)
} else {
message := `
<table>
<tr>
<td align="center"><img src="%s" width=150 /></td>
</tr>
<tr>
<td align="center"><b><a href="http://youtu.be/%s">%s</a> (%s)</b></td>
</tr>
<tr>
<td align="center">Added by %s</td>
</tr>
<tr>
<td align="center">From playlist "%s"</td>
</tr>
</table>
`
dj.client.Self.Channel.Send(fmt.Sprintf(message, s.Thumbnail(), s.ID(),
s.Title(), s.Duration(), s.Submitter(), s.Playlist().Title()), false)
}
go func() {
dj.audioStream.Wait()
dj.queue.OnSongFinished()
}()
}
}
// Delete deletes the song from ~/.mumbledj/songs if the cache is disabled.
func (s *YouTubeSong) Delete() error {
if dj.conf.Cache.Enabled == false {
filePath := fmt.Sprintf("%s/.mumbledj/songs/%s.m4a", dj.homeDir, s.ID())
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err == nil {
return nil
}
return errors.New("Error occurred while deleting audio file.")
}
return nil
}
return nil
}
// 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 (s *YouTubeSong) AddSkip(username string) error {
for _, user := range s.skippers {
if username == user {
return errors.New("This user has already skipped the current song.")
}
}
s.skippers = append(s.skippers, username)
return nil
}
// RemoveSkip removes a skip from the skippers slice. If username is not in slice, an error is
// returned.
func (s *YouTubeSong) RemoveSkip(username string) error {
for i, user := range s.skippers {
if username == user {
s.skippers = append(s.skippers[:i], s.skippers[i+1:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// 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 (s *YouTubeSong) SkipReached(channelUsers int) bool {
if float32(len(s.skippers))/float32(channelUsers) >= dj.conf.General.SkipRatio {
return true
}
return false
}
// Submitter returns the name of the submitter of the YouTubeSong.
func (s *YouTubeSong) Submitter() string {
return s.submitter
}
// Title returns the title of the YouTubeSong.
func (s *YouTubeSong) Title() string {
return s.title
}
// ID returns the id of the YouTubeSong.
func (s *YouTubeSong) ID() string {
return s.id
}
// Filename returns the filename of the YouTubeSong.
func (s *YouTubeSong) Filename() string {
return s.filename
}
// Duration returns the duration of the YouTubeSong.
func (s *YouTubeSong) Duration() string {
return s.duration
}
// Thumbnail returns the thumbnail URL for the YouTubeSong.
func (s *YouTubeSong) Thumbnail() string {
return s.thumbnail
}
// Playlist returns the playlist type for the YouTubeSong (may be nil).
func (s *YouTubeSong) Playlist() Playlist {
return s.playlist
}
// DontSkip returns the DontSkip boolean value for the YouTubeSong.
func (s *YouTubeSong) DontSkip() bool {
return s.dontSkip
}
// SetDontSkip sets the DontSkip boolean value for the YouTubeSong.
func (s *YouTubeSong) SetDontSkip(value bool) {
s.dontSkip = value
}
// ----------------
// YOUTUBE PLAYLIST
// ----------------
// YouTubePlaylist holds the metadata for a YouTube playlist.
type YouTubePlaylist struct {
id string
title string
}
// NewYouTubePlaylist gathers the metadata for a YouTube playlist and returns it.
func NewYouTubePlaylist(user, id string) (*YouTubePlaylist, error) {
var apiResponse *jsonq.JsonQuery
var err error
// Retrieve title of playlist
url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=%s&key=%s",
id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, err
}
title, _ := apiResponse.String("items", "0", "snippet", "title")
playlist := &YouTubePlaylist{
id: id,
title: title,
}
// Retrieve items in playlist
url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=25&playlistId=%s&key=%s",
id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, err
}
numVideos, _ := apiResponse.Int("pageInfo", "totalResults")
if numVideos > 25 {
numVideos = 25
}
for i := 0; i < numVideos; i++ {
index := strconv.Itoa(i)
videoTitle, err := apiResponse.String("items", index, "snippet", "title")
videoID, _ := apiResponse.String("items", index, "snippet", "resourceId", "videoId")
videoThumbnail, _ := apiResponse.String("items", index, "snippet", "thumbnails", "high", "url")
// A completely separate API call just to get the duration of a video in a
// playlist? WHY GOOGLE, WHY?!
var durationResponse *jsonq.JsonQuery
url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=%s&key=%s",
videoID, os.Getenv("YOUTUBE_API_KEY"))
if durationResponse, err = PerformGetRequest(url); err != nil {
return nil, err
}
videoDuration, _ := durationResponse.String("items", "0", "contentDetails", "duration")
var days, hours, minutes, seconds int64
timestampExp := regexp.MustCompile(`P(?P<days>\d+D)?T(?P<hours>\d+H)?(?P<minutes>\d+M)?(?P<seconds>\d+S)?`)
timestampMatch := timestampExp.FindStringSubmatch(videoDuration)
// parseTime converts from the string youtube returns to a time.Duration
func (yt YouTube) parseTime(duration, regex string) time.Duration {
var days, hours, minutes, seconds, totalSeconds int64
if duration != "" {
timestampExp := regexp.MustCompile(regex)
timestampMatch := timestampExp.FindStringSubmatch(strings.ToUpper(duration))
timestampResult := make(map[string]string)
for i, name := range timestampExp.SubexpNames() {
if i < len(timestampMatch) {
@ -377,112 +129,48 @@ func NewYouTubePlaylist(user, id string) (*YouTubePlaylist, error) {
seconds, _ = strconv.ParseInt(strings.TrimSuffix(timestampResult["seconds"], "S"), 10, 32)
}
totalSeconds := int((days * 86400) + (hours * 3600) + (minutes * 60) + seconds)
var durationString string
if hours != 0 {
if days != 0 {
durationString = fmt.Sprintf("%d:%02d:%02d:%02d", days, hours, minutes, seconds)
} else {
durationString = fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
}
} else {
durationString = fmt.Sprintf("%d:%02d", minutes, seconds)
}
if dj.conf.General.MaxSongDuration == 0 || totalSeconds <= dj.conf.General.MaxSongDuration {
playlistSong := &YouTubeSong{
submitter: user,
title: videoTitle,
id: videoID,
filename: videoID + ".m4a",
duration: durationString,
thumbnail: videoThumbnail,
skippers: make([]string, 0),
playlist: playlist,
dontSkip: false,
}
dj.queue.AddSong(playlistSong)
}
}
return playlist, nil
}
// AddSkip adds a skip to the playlist's skippers slice.
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.")
}
}
dj.playlistSkips[p.ID()] = append(dj.playlistSkips[p.ID()], username)
return nil
}
// RemoveSkip removes a skip from the playlist's skippers slice. If username is not in the slice
// an error is returned.
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:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// DeleteSkippers removes the skippers entry in dj.playlistSkips.
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 *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 YouTubePlaylist.
func (p *YouTubePlaylist) ID() string {
return p.id
}
// Title returns the title of the YouTubePlaylist.
func (p *YouTubePlaylist) Title() string {
return p.title
}
// -----------
// YOUTUBE API
// -----------
// PerformGetRequest does all the grunt work for a YouTube HTTPS GET request.
func PerformGetRequest(url string) (*jsonq.JsonQuery, error) {
jsonString := ""
if response, err := http.Get(url); err == nil {
defer response.Body.Close()
if response.StatusCode == 200 {
if body, err := ioutil.ReadAll(response.Body); err == nil {
jsonString = string(body)
}
} else {
if response.StatusCode == 403 {
return nil, errors.New("Invalid API key supplied.")
}
return nil, errors.New("Invalid YouTube ID supplied.")
}
totalSeconds = int64((days * 86400) + (hours * 3600) + (minutes * 60) + seconds)
} else {
return nil, errors.New("An error occurred while receiving HTTP GET response.")
totalSeconds = 0
}
output, _ := time.ParseDuration(strconv.Itoa(int(totalSeconds)) + "s")
return output
}
// NewPlaylist gathers the metadata for a YouTube playlist and returns it.
func (yt YouTube) NewPlaylist(user *gumble.User, id string) ([]Song, error) {
var apiResponse *jsonq.JsonQuery
var songArray []Song
var err error
// Retrieve title of playlist
url := fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlists?part=snippet&id=%s&key=%s", id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, err
}
title, _ := apiResponse.String("items", "0", "snippet", "title")
playlist := &AudioPlaylist{
id: id,
title: title,
}
jsonData := map[string]interface{}{}
decoder := json.NewDecoder(strings.NewReader(jsonString))
decoder.Decode(&jsonData)
jq := jsonq.NewQuery(jsonData)
// Retrieve items in playlist
url = fmt.Sprintf("https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=50&playlistId=%s&key=%s",
id, os.Getenv("YOUTUBE_API_KEY"))
if apiResponse, err = PerformGetRequest(url); err != nil {
return nil, err
}
numVideos, _ := apiResponse.Int("pageInfo", "totalResults")
if numVideos > 50 {
numVideos = 50
}
return jq, nil
for i := 0; i < numVideos; i++ {
index := strconv.Itoa(i)
videoID, _ := apiResponse.String("items", index, "snippet", "resourceId", "videoId")
if song, err := yt.NewSong(user, videoID, "", playlist); err == nil {
songArray = append(songArray, song)
}
}
return songArray, nil
}

View File

@ -42,10 +42,12 @@ func (q *SongQueue) CurrentSong() Song {
// NextSong moves to the next Song in SongQueue. NextSong() removes the first Song in the queue.
func (q *SongQueue) NextSong() {
if q.CurrentSong().Playlist() != nil {
if !isNil(q.CurrentSong().Playlist()) {
if s, err := q.PeekNext(); err == nil {
if s.Playlist() != nil && (q.CurrentSong().Playlist().ID() != s.Playlist().ID()) {
q.CurrentSong().Playlist().DeleteSkippers()
if !isNil(s.Playlist()) {
if q.CurrentSong().Playlist().ID() != s.Playlist().ID() {
q.CurrentSong().Playlist().DeleteSkippers()
}
}
} else {
q.CurrentSong().Playlist().DeleteSkippers()

View File

@ -7,8 +7,8 @@
package main
// Message shown to users when the bot has an invalid YouTube API key.
const INVALID_API_KEY = "MumbleDJ does not have a valid YouTube API key."
// Message shown to users when the bot has an invalid API key.
const INVALID_API_KEY = "MumbleDJ does not have a valid %s API key."
// Message shown to users when they do not have permission to execute a command.
const NO_PERMISSION_MSG = "You do not have permission to execute that command."
@ -26,7 +26,7 @@ const CHANNEL_DOES_NOT_EXIST_MSG = "The channel you specified does not exist."
const INVALID_URL_MSG = "The URL you submitted does not match the required format."
// Message shown to users when they attempt to add a video that's too long
const VIDEO_TOO_LONG_MSG = "The video you submitted exceeds the duration allowed by the server."
const TRACK_TOO_LONG_MSG = "The %s you submitted exceeds the duration allowed by the server."
// Message shown to users when they attempt to perform an action on a song when
// no song is playing.
@ -54,10 +54,10 @@ const ADMIN_SONG_SKIP_MSG = "An admin has decided to skip the current song."
const ADMIN_PLAYLIST_SKIP_MSG = "An admin has decided to skip the current playlist."
// Message shown to users when the audio for a video could not be downloaded.
const AUDIO_FAIL_MSG = "The audio download for this video failed. YouTube has likely not generated the audio files for this video yet. Skipping to the next song!"
const AUDIO_FAIL_MSG = "The audio download for this video failed. %s has likely not generated the audio files for this %s yet. Skipping to the next song!"
// Message shown to users when they supply a YouTube URL that does not contain a valid ID.
const INVALID_YOUTUBE_ID_MSG = "The YouTube URL you supplied did not contain a valid YouTube ID."
// Message shown to users when they supply an URL that does not contain a valid ID.
const INVALID_ID_MSG = "The %s URL you supplied did not contain a valid ID."
// Message shown to user when they successfully update the bot's comment.
const COMMENT_UPDATED_MSG = "The comment for the bot has successfully been updated."
@ -95,7 +95,7 @@ const PLAYLIST_SKIPPED_HTML = `
const HELP_HTML = `<br/>
<b>User Commands:</b>
<p><b>!help</b> - Displays this help.</p>
<p><b>!add</b> - Adds songs to queue.</p>
<p><b>!add</b> - Adds songs/playlists to queue.</p>
<p><b>!volume</b> - Either tells you the current volume or sets it to a new volume.</p>
<p><b>!skip</b> - Casts a vote to skip the current song</p>
<p> <b>!skipplaylist</b> - Casts a vote to skip over the current playlist.</p>
@ -168,5 +168,5 @@ const CURRENT_SONG_HTML = `
// Message shown to users when the currentsong command is issued when a song from a
// playlist is playing.
const CURRENT_SONG_PLAYLIST_HTML = `
The song currently playing is "%s", added <b>%s</b> from the playlist "%s".
The %s currently playing is "%s", added <b>%s</b> from the %s "%s".
`

276
youtube_dl.go Normal file
View File

@ -0,0 +1,276 @@
/*
* MumbleDJ
* By Matthieu Grieger
* youtube_dl.go
* Copyright (c) 2014, 2015 Matthieu Grieger (MIT License)
*/
package main
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/jmoiron/jsonq"
"github.com/layeh/gumble/gumble"
"github.com/layeh/gumble/gumble_ffmpeg"
)
// AudioTrack implements the Song interface
type AudioTrack struct {
id string
title string
thumbnail string
submitter *gumble.User
duration int
url string
offset int
format string
playlist Playlist
skippers []string
dontSkip bool
service Service
}
// AudioPlaylist implements the Playlist interface
type AudioPlaylist struct {
id string
title string
}
// ------------
// YOUTUBEDL 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 *AudioTrack) 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) {
cmd := exec.Command("youtube-dl", "--verbose", "--no-mtime", "--output", fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename()), "--format", dl.format, "--prefer-ffmpeg", dl.url)
output, err := cmd.CombinedOutput()
if err == nil {
if dj.conf.Cache.Enabled {
dj.cache.CheckMaximumDirectorySize()
}
return nil
} else {
args := ""
for s := range cmd.Args {
args += cmd.Args[s] + " "
}
fmt.Printf(args + "\n" + string(output) + "\n" + "youtube-dl: " + err.Error() + "\n")
return errors.New("Song download failed.")
}
}
return nil
}
// 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 *AudioTrack) Play() {
if dl.offset != 0 {
offsetDuration, _ := time.ParseDuration(fmt.Sprintf("%ds", dl.offset))
dj.audioStream.Offset = offsetDuration
}
dj.audioStream.Source = gumble_ffmpeg.SourceFile(fmt.Sprintf("%s/.mumbledj/songs/%s", dj.homeDir, dl.Filename()))
if err := dj.audioStream.Play(); err != nil {
panic(err)
} else {
message := `<table><tr><td align="center"><img src="%s" width=150 /></td></tr><tr><td align="center"><b><a href="%s">%s</a> (%s)</b></td></tr><tr><td align="center">Added by %s</td></tr>`
message = fmt.Sprintf(message, dl.thumbnail, dl.url, dl.title, dl.Duration().String(), dl.submitter.Name)
if !isNil(dl.playlist) {
message = fmt.Sprintf(message+`<tr><td align="center">From playlist "%s"</td></tr>`, dl.Playlist().Title())
}
dj.client.Self.Channel.Send(message+`</table>`, false)
go func() {
dj.audioStream.Wait()
dj.queue.OnSongFinished()
}()
}
}
// Delete deletes the song from ~/.mumbledj/songs if the cache is disabled.
func (dl *AudioTrack) 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 {
if err := os.Remove(filePath); err == nil {
return nil
}
return errors.New("Error occurred while deleting audio file.")
}
return nil
}
return nil
}
// 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 *AudioTrack) AddSkip(username string) error {
for _, user := range dl.skippers {
if username == user {
return errors.New("This user has already skipped the current song.")
}
}
dl.skippers = append(dl.skippers, username)
return nil
}
// RemoveSkip removes a skip from the skippers slice. If username is not in slice, an error is
// returned.
func (dl *AudioTrack) RemoveSkip(username string) error {
for i, user := range dl.skippers {
if username == user {
dl.skippers = append(dl.skippers[:i], dl.skippers[i+1:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// 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 *AudioTrack) SkipReached(channelUsers int) bool {
if float32(len(dl.skippers))/float32(channelUsers) >= dj.conf.General.SkipRatio {
return true
}
return false
}
// Submitter returns the name of the submitter of the Song.
func (dl *AudioTrack) Submitter() string {
return dl.submitter.Name
}
// Title returns the title of the Song.
func (dl *AudioTrack) Title() string {
return dl.title
}
// ID returns the id of the Song.
func (dl *AudioTrack) ID() string {
return dl.id
}
// Filename returns the filename of the Song.
func (dl *AudioTrack) Filename() string {
return dl.id + "." + dl.format
}
// Duration returns duration for the Song.
func (dl *AudioTrack) Duration() time.Duration {
timeDuration, _ := time.ParseDuration(strconv.Itoa(dl.duration) + "s")
return timeDuration
}
// Thumbnail returns the thumbnail URL for the Song.
func (dl *AudioTrack) Thumbnail() string {
return dl.thumbnail
}
// Playlist returns the playlist type for the Song (may be nil).
func (dl *AudioTrack) Playlist() Playlist {
return dl.playlist
}
// DontSkip returns the DontSkip boolean value for the Song.
func (dl *AudioTrack) DontSkip() bool {
return dl.dontSkip
}
// SetDontSkip sets the DontSkip boolean value for the Song.
func (dl *AudioTrack) SetDontSkip(value bool) {
dl.dontSkip = value
}
// ----------------
// YOUTUBEDL PLAYLIST
// ----------------
// AddSkip adds a skip to the playlist's skippers slice.
func (p *AudioPlaylist) 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.")
}
}
dj.playlistSkips[p.ID()] = append(dj.playlistSkips[p.ID()], username)
return nil
}
// RemoveSkip removes a skip from the playlist's skippers slice. If username is not in the slice
// an error is returned.
func (p *AudioPlaylist) 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:]...)
return nil
}
}
return errors.New("This user has not skipped the song.")
}
// DeleteSkippers removes the skippers entry in dj.playlistSkips.
func (p *AudioPlaylist) 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 *AudioPlaylist) 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 AudioPlaylist.
func (p *AudioPlaylist) ID() string {
return p.id
}
// Title returns the title of the AudioPlaylist.
func (p *AudioPlaylist) Title() string {
return p.title
}
// PerformGetRequest does all the grunt work for HTTPS GET request.
func PerformGetRequest(url string) (*jsonq.JsonQuery, error) {
jsonString := ""
if response, err := http.Get(url); err == nil {
defer response.Body.Close()
if response.StatusCode == 200 {
if body, err := ioutil.ReadAll(response.Body); err == nil {
jsonString = string(body)
}
} else {
if response.StatusCode == 403 {
return nil, errors.New("Invalid API key supplied.")
}
return nil, errors.New("Invalid ID supplied.")
}
} else {
return nil, errors.New("An error occurred while receiving HTTP GET response.")
}
jsonData := map[string]interface{}{}
decoder := json.NewDecoder(strings.NewReader(jsonString))
decoder.Decode(&jsonData)
jq := jsonq.NewQuery(jsonData)
return jq, nil
}