Browse Source

Bring In Standard Music Features (#2)

* added a basic set of configurations and commands based on an example online

* in theory, added the ability to let the bot join a voice channel

* bot is now able to leave and join channels (with no whitespace in their names though)

* add and remove made more efficient, starting investigation into music streaming

* testing out a new function(s) for playing a song from the beet db on the host machine

* missed a bracket

* adding a var at global scope for the voice connection

* buggy play function, getting undefined error

* moved play file logic to the else case only

* newlines at the end of stdout suck

* going to bed, figured out that the connection variable is not becoming a voiceconnection object, which it should be

* music plays now :)

* making the voice connection a gloval var to access it over the lifespan of the bot

* adding support for multi word channels

* making play command able to take artist, album, etc (untested)

* this is NOT working right now. no music plays. this was
not coded well for async, event based programming
i need to find a way to, on play, build a current playlist
then, at the end of each song (when dispatcher.time is
equal to the song length) trigger a play of the next song in the list
then we need 2 more commands:
one should add to the playlist
one should pause the song
one should skip the song
one should go back a song
one should kill all audio (stop)
okay so more than 2

but the main idea is we gotta get rid of the loop and crap, that's
not how nodejs is supposed to operate

* adding pause/skip/next/playlist functionality. let us see how much i broke in the process :D

* couple syntax fixes. still cannot start the bot, says cannot read property  of undefined for dispatcher upon startup. not sure how to address this yet, as we need to be able to detect when a song ends

* moved dispatcher.on detection to where it is created. need to fix string parsing/escaping before passing to shell

* changed logic to replace special characters for shell commands

* apparently you need this references in classes, so added those. right now the addmusic command seems to be finding too many results.

* created array of valid music types, and now check for valid input. the previous commit's claim of poor searching was a result of bad input

* & in filepath seems to be an issue still, also adding an album seems to get the entire collection, adding a song added only that song, but i got playback exhausted immediately

* seems between the filtering out of special characters and the entering of the paths into the queue path[i] becomes undefined... probably a scope issue but not sure

* fixed scope issue with exec callback, now need to figure out why the songs don't play and we just exhaust the queue without music playing

* changed some packages around, still debugging instant skipping to next track

* fixed issue where entire album/artist would map to 1 filepath. dispatcher ending instantly, however, still a thing

* hardcoding the filepath works, but using a var doesn't. which is odd since i got the filepath from the console.log...

* wasted more memory, but got this to work. basically, the playFile command needs the raw filepath, without escapes. so i pass the escaped version we need for the lookups and such, and the raw for playing. not sure that the former needs to be passed though, may only need it in the loop during addition to the queue. will address in next commit

* okay, things now work. saved memory as promised by not passing both filepaths. stop, pause, resume, next all work. do NOT use skip. pls. save your earholes

* Got promise working to make skip work properly. Now that basic music features are in, next might be repeat/shuffle, maybe also testing out some ways to clean up the gigantic if chain.
Tareef D 7 years ago
parent
commit
00faef6e39
7 changed files with 243 additions and 133 deletions
  1. 66 0
      Queue.js
  2. 0 12
      node_modules/opusscript/README.md
  3. 0 79
      node_modules/opusscript/index.d.ts
  4. 10 10
      node_modules/opusscript/package.json
  5. 3 3
      package-lock.json
  6. 1 1
      package.json
  7. 163 28
      viki.js

+ 66 - 0
Queue.js

@@ -0,0 +1,66 @@
+class Queue
+{
+  // initializes the queue
+  constructor()
+  {
+    this.queue = [];
+    this.offset = 0;
+  }
+
+  // Returns the length of the queue.
+  getLength()
+  {
+    return (this.queue.length - this.offset);
+  }
+
+  // Returns true if the queue is empty, and false otherwise.
+  isEmpty()
+  {
+    return (this.queue.length == 0);
+  }
+
+  // Enqueues x in the queue (to the end)
+  enqueue(x)
+  {
+    this.queue.push(x);
+  }
+
+  // Dequeues an item and returns it. If the queue is empty, throws an error
+  dequeue()
+  {
+    // if the queue is empty, throw
+    if (this.queue.length == 0)
+    {
+      throw "Queue already empty!";
+    }
+
+    // store the item at the front of the queue
+    var item = this.queue[this.offset];
+
+    // increment the offset and refactor if necessary
+    if (++ this.offset * 2 >= this.queue.length)
+    {
+      this.queue = this.queue.slice(this.offset);
+      this.offset = 0;
+    }
+
+    // return the dequeued item
+    return item;
+  }
+
+  // Returns the item at the front of the queue (without dequeuing it). If the
+  // queue is empty then undefined is returned.
+  peek()
+  {
+    return (this.queue.length > 0 ? this.queue[this.offset] : undefined);
+  }
+  
+  // Deletes all the data, resets to as on construction
+  reset()
+  {
+    this.queue = [];
+    this.offset = 0;
+  }
+}
+
+module.exports = Queue;

+ 0 - 12
node_modules/opusscript/README.md

@@ -25,15 +25,3 @@ var decodedPacket = encoder.decode(encodedPacket);
 // Delete the encoder when finished with it (Emscripten does not automatically call C++ object destructors)
 encoder.delete();
 ```
-
-#### TypeScript
-
-Since this module wasn't written for TypeScript, you need to use `import = require` syntax.
-
-```ts
-// Import using:
-import OpusScript = require('opusscript');
-
-// and NOT:
-import OpusScript from 'opusscript';
-```

+ 0 - 79
node_modules/opusscript/index.d.ts

@@ -1,79 +0,0 @@
-declare module 'opusscript' {
-    /**
-     * Opus application type
-     */
-    enum OpusApplication {
-        /**
-         * Voice Over IP
-         */
-        VOIP = 2048,
-        /**
-         * Audio
-         */
-        AUDIO = 2049,
-        /**
-         * Restricted Low-Delay
-         */
-        RESTRICTED_LOWDELAY = 2051
-    }
-    enum OpusError {
-        "OK" = 0,
-        "Bad argument" = -1,
-        "Buffer too small" = -2,
-        "Internal error" = -3,
-        "Invalid packet" = -4,
-        "Unimplemented" = -5,
-        "Invalid state" = -6,
-        "Memory allocation fail" = -7
-    }
-    /**
-     * Valid audio sampling rates
-     */
-    type VALID_SAMPLING_RATES = 8000 | 12000 | 16000 | 24000 | 48000;
-    /**
-     * Maximum bytes in a frame
-     */
-    type MAX_FRAME_SIZE = 2880;
-    /**
-     * Maximum bytes in a packet
-     */
-    type MAX_PACKET_SIZE = 3828;
-    class OpusScript {
-        /**
-         * Different Opus application types
-         */
-        static Application: typeof OpusApplication;
-        /**
-         * Opus Error codes
-         */
-        static Error: typeof OpusError;
-        /**
-         * Array of sampling rates that Opus can use
-         */
-        static VALID_SAMPLING_RATES: [8000, 12000, 16000, 24000, 48000];
-        /**
-         * The maximum size (in bytes) to send in a packet
-         */
-        static MAX_PACKET_SIZE: MAX_PACKET_SIZE;
-
-        /**
-         * Create a new Opus en/decoder
-         */
-        constructor(samplingRate: VALID_SAMPLING_RATES, channels?: number, application?: OpusApplication);
-        /**
-         * Encode a buffer into Opus
-         */
-        encode(buffer: Buffer, frameSize: number): Buffer;
-        /**
-         * Decode an opus buffer
-         */
-        decode(buffer: Buffer): Buffer;
-        encoderCTL(ctl: number, arg: number): void;
-        decoderCTL(ctl: number, arg: number): void;
-        /**
-         * Delete the opus object
-         */
-        delete(): void;
-    }
-    export = OpusScript;
-}

+ 10 - 10
node_modules/opusscript/package.json

@@ -1,27 +1,27 @@
 {
-  "_from": "opusscript@0.0.6",
-  "_id": "opusscript@0.0.6",
+  "_from": "opusscript@^0.0.4",
+  "_id": "opusscript@0.0.4",
   "_inBundle": false,
-  "_integrity": "sha512-F7nx1SWZCD5Rq2W+5Fx39HlkRkz/5Zqt0LglEB9uHexk8HjedDEiM+u/Y2rBfDFcS/0uQIWu2lJhw+Gjsta+cA==",
+  "_integrity": "sha512-bEPZFE2lhUJYQD5yfTFO4RhbRZ937x6hRwBC1YoGacT35bwDVwKFP1+amU8NYfZL/v4EU7ZTU3INTqzYAnuP7Q==",
   "_location": "/opusscript",
   "_phantomChildren": {},
   "_requested": {
     "type": "version",
     "registry": true,
-    "raw": "opusscript@0.0.6",
+    "raw": "opusscript@^0.0.4",
     "name": "opusscript",
     "escapedName": "opusscript",
-    "rawSpec": "0.0.6",
+    "rawSpec": "^0.0.4",
     "saveSpec": null,
-    "fetchSpec": "0.0.6"
+    "fetchSpec": "^0.0.4"
   },
   "_requiredBy": [
     "#USER",
     "/"
   ],
-  "_resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.6.tgz",
-  "_shasum": "cf492fc5fb2c819af296ae02eaa3cf210433c9ba",
-  "_spec": "opusscript@0.0.6",
+  "_resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.4.tgz",
+  "_shasum": "c718edcfdcd2a1f55fadb266dd07268d4a46afde",
+  "_spec": "opusscript@^0.0.4",
   "_where": "/home/tdedhar/Documents/viki",
   "author": {
     "name": "abalabahaha"
@@ -45,5 +45,5 @@
     "type": "git",
     "url": "git+https://github.com/abalabahaha/opusscript.git"
   },
-  "version": "0.0.6"
+  "version": "0.0.4"
 }

+ 3 - 3
package-lock.json

@@ -313,9 +313,9 @@
       }
     },
     "opusscript": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.6.tgz",
-      "integrity": "sha512-F7nx1SWZCD5Rq2W+5Fx39HlkRkz/5Zqt0LglEB9uHexk8HjedDEiM+u/Y2rBfDFcS/0uQIWu2lJhw+Gjsta+cA=="
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.4.tgz",
+      "integrity": "sha512-bEPZFE2lhUJYQD5yfTFO4RhbRZ937x6hRwBC1YoGacT35bwDVwKFP1+amU8NYfZL/v4EU7ZTU3INTqzYAnuP7Q=="
     },
     "os-homedir": {
       "version": "1.0.2",

+ 1 - 1
package.json

@@ -11,7 +11,7 @@
     "ffmpeg": "0.0.4",
     "libsodium-wrappers": "^0.7.3",
     "node-opus": "^0.2.7",
-    "opusscript": "0.0.6",
+    "opusscript": "0.0.4",
     "prism-media": "^0.2.1",
     "sodium": "^2.0.3",
     "uws": "^9.14.0"

+ 163 - 28
viki.js

@@ -4,6 +4,9 @@ const Discord = require("discord.js");
 // Load up the shell command library
 var exec = require('child_process').exec;
 
+// Load up the queue library
+const Queue = require('./Queue.js');
+
 // Define a function to execute a command
 
 function execute(command, callback)
@@ -14,16 +17,27 @@ function execute(command, callback)
   });
 };
 
-// This is your client. Some people call it `bot`, some people call it `self`, 
-// some might call it `cootchie`. Either way, when you see `client.something`, or `bot.something`,
-// this is what we're refering to. Your client.
-const client = new Discord.Client();
+// Define a new function to search + replace a char in a string
+String.prototype.replaceAll = function(remove, replace)
+{
+    var target = this;
+    return target.split(remove).join(replace);
+};
 
+// Initialize the bot.
+const client = new Discord.Client();
 
 // this allows us to define a voice connection with global scope
 var connection;
 var dispatcher;
 
+// this is the playlist queue for music
+var playlist = new Queue();
+
+// Array of music classes users can call (artist, track, etc)
+const musicTypes = ['track', 'title', 'song', 'artist', 'album'];
+const musicTypesString = "track, title, song, artist, album";
+
 // Here we load the config.json file that contains our token and our prefix values. 
 const config = require("./config.json");
 // config.token contains the bot's token
@@ -53,16 +67,38 @@ client.on("guildDelete", guild => {
 });
 
 
+// This plays the next song in the queue, and logs that in the channel where it was requested.
+function play()
+{
+  let nextSong = playlist.dequeue();
+  dispatcher = connection.playFile(nextSong[0]);
+  console.log(`Playing ${nextSong[2]}.`);
+  nextSong[1].reply(`Playing ${nextSong[2]}.`);
+  dispatcher.setVolume(0.2);
+  dispatcher.setBitrate(96);
+
+  dispatcher.on("end", reason => 
+  {
+    if (!(playlist.isEmpty()))
+    {
+      play();
+      console.log(reason);
+    }
+    else
+    {
+      console.log("Playlist exhausted, music playback stopped.");
+    }
+  });
+}
+
 client.on("message", async message =>
 {
   // This event will run on every single message received, from any channel or DM.
   
-  // It's good practice to ignore other bots. This also makes your bot ignore itself
-  // and not get into a spam loop (we call that "botception").
+  // Ignores bot msgs
   if (message.author.bot) return;
   
-  // Also good practice to ignore any message that does not start with our prefix, 
-  // which is set in the configuration file.
+  // ignores if message isn't prefixed
   if (message.content.indexOf(config.prefix) !== 0) return;
   
   // Here we separate our "command" name, and our "arguments" for the command. 
@@ -74,15 +110,17 @@ client.on("message", async message =>
   
   // Let's go with a few common example commands! Feel free to delete or change those.
   
-  if (command === "ping") {
+  if (command === "ping")
+  {
     // Calculates ping between sending a message and editing it, giving a nice round-trip latency.
     // The second ping is an average latency between the bot and the websocket server (one-way, not round-trip)
     const m = await message.channel.send("Ping?");
     m.edit(`Pong! Latency is ${m.createdTimestamp - message.createdTimestamp}ms. API Latency is ${Math.round(client.ping)}ms`);
   }
   
-  if (command === "say") {
-    // makes the bot say something and delete the message. As an example, it's open to anyone to use. 
+  if (command === "say")
+  {
+    // makes the bot say something and delete the original message. As an example, it's open to anyone to use. 
     // To get the "message" itself we join the `args` back into a string with spaces: 
     const sayMessage = args.join(" ");
     // Then we delete the command message (sneaky, right?). The catch just ignores the error with a cute smiley thing.
@@ -171,43 +209,140 @@ client.on("message", async message =>
     channelVar.leave();
   }
   
-  if (command === "play")
+  if (command === "addmusic") // adds songs to queue, starts playback if none already
   {
-	  // this command plays the song if one song matches the query given in beets
-	  
-    const song = args.join(' '); // creates a single string of all args (the song)
-    var path; // this will hold the filepath of our song
-    exec(`beet ls -p title:${song} | wc -l`, function (error, stdout, stderr)
+    var type = args[0];
+    if (!(musicTypes.includes(type)))
+    {
+      return message.reply("Sorry, that is not a valid command. Please enter something from: " + musicTypesString);
+    }
+    if (type == 'song' || type == 'track') type = 'title'; // account for poor beets API
+    args.splice(0, 1);
+    const query = args.join(' '); // creates a single string of all args (the query)
+    var path; // this will hold the filepaths from our query
+    exec(`beet ls -p ${type}:${query} | wc -l`, function (error, stdout, stderr)
     {
       if (error)
       {
-        return message.reply(`Sorry, I encountered an issue looking for that song: ${error}`);
+        return message.reply(`Sorry, I encountered an issue looking for that: ${error}`);
       }
-      else if (stdout !== '1\n')
+      else if (stdout === '\n' || stdout === '' || stdout === undefined)
       {
-        return message.reply(`There were ${stdout} songs that matched your search. Please give the full song name, or wait until I become a less lazy dev.`);
+        return message.reply(`There were no results that matched your search. Please give the type and name of your query (e.g. song songname, album albumname...)`);
       }
       else
-      {
-        exec(`beet ls -p title:${song}`, function (error, stdout, stderr)
+      {  
+        exec(`beet ls -p ${type}:${query}`, function (error, stdout, stderr)
         {
           if (error)
           {
-            return message.reply(`Sorry, I encountered an issue looking for that song: ${error}`);
+            return message.reply(`Sorry, I encountered an issue looking for that: ${error}`);
           }
           else
           {
             path = stdout.trim();
-            console.log(song);
-            console.log(path);
-            dispatcher = connection.playFile(path);
-            dispatcher.setVolume(0.75);
-            dispatcher.setBitrate(2048);
+            path = path.split("\n"); // now an array of paths (with spaces)
+            
+            // for each song, get the path and readable info, send to queue
+            for (var i = 0; i < path.length; i++)
+            {
+              let filepathRaw = path[i];
+              path[i] = path[i].replaceAll(" ", "\\ ");
+              path[i] = path[i].replaceAll("'", "\\'");
+              path[i] = path[i].replaceAll("&", "\\\46");
+              path[i] = path[i].replaceAll("(", "\\(");
+              path[i] = path[i].replaceAll(")", "\\)");
+              let filepath = path[i]; // path[i] descoped in callback
+
+              exec(`beet ls ${path[i]}`, function (error, stdouts, stderr)
+              {
+                if (error)
+                {
+                  return message.reply(`Sorry, I encountered an issue looking for song ${i}: ${error}`);
+                }
+                else
+                {
+                  stdouts = stdouts.trim();
+                  playlist.enqueue([filepathRaw, message, stdouts]);
+                  
+                  // check if music is playing, if not start it
+                  if ((dispatcher === undefined || dispatcher.destroyed == true) && !(playlist.isEmpty()))
+                  {
+                    play();
+                  }
+                }
+              });
+            }
           }
         });
       }
+
+      let amt = stdout.trim();
+      message.reply(`${amt} songs added!`);
     });
   }
+  
+  if (command === 'stop') // clears playlist, stops music
+  {
+    playlist.reset();
+    dispatcher.end();
+    console.log("Playback stopped, playlist cleared.")
+  }
+  
+  if (command === 'next') // returns next song in playlist, or informs that there is none
+  {
+    if (playlist.isEmpty())
+    {
+      message.reply("The playlist is empty.");
+    }
+    else
+    {
+      const next = playlist.peek();
+      message.reply(`Next song is: ${next[2]}.`);
+    }
+  }
+  
+  if (command === 'pause') // pauses the dispatcher if playing, or does nothing
+  {
+    dispatcher.pause();
+    message.reply("Playback paused.");
+  }
+  
+  if (command === 'resume') // resumes the dispatcher, or does nothing
+  {
+    dispatcher.resume();
+    message.reply("Playback resumed.");
+  }
+  
+  if (command === 'skip') // starts playing the next song in the queue if it exists
+  {
+    if (playlist.isEmpty())
+    {
+      message.reply("Sorry, the playlist is empty.");
+    }
+    else
+    {
+      function resolveEnd()
+      {
+	return new Promise((success, fail) =>
+        {
+          dispatcher.end();
+
+          dispatcher.on("end", () =>
+          {
+            success('Track skipped!');
+          });
+
+          dispatcher.on("error", () =>
+          {
+            fail('Couldn\'t skip :(');
+          });
+        });
+      }
+
+      resolveEnd();
+    }
+  }
 });
 
 client.login(config.token);