├── music ├── localLibrary.json ├── indexer.js ├── meezer.js └── libraryManager.js ├── Docker ├── authorize.js ├── docker-compose.yml └── deezerConfig.json ├── voice ├── models │ └── fake.ppn.example ├── correctVoiceConfig.js ├── rhino.js ├── porcupine.js ├── convert.js └── voiceProcessing.js ├── .gitattributes ├── commands ├── help.js ├── loop.js ├── pause.js ├── unpause.js ├── disconnect.js ├── skip.js ├── remove.js ├── join.js ├── queue.js ├── spotify.js └── play.js ├── config.json.example ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ └── build.yml ├── other ├── play-dltests.js ├── fusetest.js ├── fuzzyTest.js └── deezer.js ├── Dockerfile ├── diy_commando.js ├── package.json ├── .gitignore ├── mEmbeds.js ├── .eslintrc.json ├── registerCommands.js ├── index.js ├── README.md ├── general.js ├── presetEmbeds.js └── LICENSE /music/localLibrary.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } -------------------------------------------------------------------------------- /Docker/authorize.js: -------------------------------------------------------------------------------- 1 | require("play-dl").authorization(); -------------------------------------------------------------------------------- /voice/models/fake.ppn.example: -------------------------------------------------------------------------------- 1 | you must get this from the picovoice dashboard -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /commands/help.js: -------------------------------------------------------------------------------- 1 | const { helpGen } = require("../presetEmbeds"); 2 | 3 | function help(message) { 4 | message.reply({ embeds:[helpGen(message.author)] }) 5 | .then(msg => { 6 | setTimeout(() => { 7 | msg.delete() 8 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 9 | }, 30000); 10 | }); 11 | } 12 | module.exports = help; -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "token":"abcdefghfdfdgsgfsgsdgds", 3 | "PREFIX":">", 4 | "additionalMusicDirs":["H:\\WindowsFolder\\Music","/linux/folder"], 5 | "runIndexerOnStart": true, 6 | "voiceCommands": { 7 | "enabled": true 8 | "picoAccessKey": "abcdefghijklmnopqrstuvwxyz=", 9 | "rhinoFileName": "yourRhinoFileInsideModelsFolder.rhn", 10 | "porcupineFileName": "yourPorcupineFileInsideModelsFolder.ppn" 11 | } 12 | } -------------------------------------------------------------------------------- /voice/correctVoiceConfig.js: -------------------------------------------------------------------------------- 1 | const { voiceCommands } = require("../config.json"); 2 | 3 | /* 4 | -1 off 5 | 0 ok 6 | 1 incorrect 7 | */ 8 | 9 | function correctVoiceConfig() { 10 | if (voiceCommands && voiceCommands.enabled) { 11 | if (voiceCommands.picoAccessKey && voiceCommands.porcupineFileName && voiceCommands.rhinoFileName) { 12 | return 0; 13 | } 14 | else { 15 | return 1; 16 | } 17 | } 18 | else { 19 | return -1; 20 | } 21 | } 22 | 23 | module.exports = correctVoiceConfig; -------------------------------------------------------------------------------- /commands/loop.js: -------------------------------------------------------------------------------- 1 | const { guildsMeta, reactions } = require("../general.js"); 2 | 3 | function loop(message) { 4 | if (!guildsMeta[message.guildId]) { 5 | return false; 6 | } 7 | 8 | if (guildsMeta[message.guildId].looping) { 9 | guildsMeta[message.guildId].looping = false; 10 | message.react(reactions.next_track); 11 | } 12 | else { 13 | guildsMeta[message.guildId].looping = true; 14 | message.react(reactions.repeat); 15 | } 16 | 17 | return true; 18 | } 19 | 20 | module.exports = loop; -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /other/play-dltests.js: -------------------------------------------------------------------------------- 1 | const pdl = require("play-dl"); 2 | const url = "https://www.youtube.com/watch?v=_AcodjYU8iw&list=OLAK5uy_krKYX3C-XqMNGIkJbyC0ibL_xq5qWmbq4"; 3 | 4 | async function test() { 5 | const type = await pdl.validate(url); 6 | console.log("type: " + type); 7 | const ytplaylist = await pdl.playlist_info(url, { incomplete : true }); 8 | // const videos = await ytplaylist.all_videos(); 9 | /* for (const i in videos) { 10 | const video = videos[i]; 11 | console.log(video.title); 12 | }*/ 13 | console.log(ytplaylist.icons); 14 | 15 | } 16 | 17 | test(); -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM node:16-alpine 2 | 3 | LABEL maintainer="https://github.com/karyeet" 4 | 5 | COPY . /Mandarine/ 6 | 7 | RUN apk add python3\ 8 | && apk add --no-cache --virtual .gyp \ 9 | make \ 10 | g++ \ 11 | py3-pip\ 12 | && npm install --prefix Mandarine \ 13 | && python3 -m pip install deemix\ 14 | && apk del .gyp 15 | 16 | RUN mkdir ~/config \ 17 | && mv /Mandarine/Docker/deezerConfig.json ~/config/config.json \ 18 | && touch ~/config/.arl 19 | 20 | CMD echo $arl >> ~/config/.arl \ 21 | && npm start --prefix Mandarine -------------------------------------------------------------------------------- /Docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | #run authorize() for play-dl 2 | # sudo docker run -it --rm -v "./playdlData:/.data" karyeet/mandarine:latest /bin/ash -c "node /Mandarine/Docker/authorize.js" 3 | 4 | version: "3" 5 | 6 | services: 7 | mandarine: 8 | container_name: mandarine_music_bot 9 | image: karyeet/mandarine:latest 10 | volumes: 11 | - ./config/config.json:/Mandarine/config.json:ro 12 | - ./config/models/:/Mandarine/voice/models:ro 13 | - ./mandarineFiles:/root/mandarineFiles 14 | - ./playdlData:/Mandarine/.data 15 | environment: 16 | - "arl=5a2e" 17 | restart: unless-stopped 18 | 19 | -------------------------------------------------------------------------------- /other/fusetest.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | one off script to perform fuse.js configuration tests 4 | 5 | */ 6 | 7 | const Fuse = require("fuse.js"); 8 | const localLibrary = require("../music/localLibrary.json"); 9 | 10 | const query = "metro suphero"; 11 | 12 | const fuse = new Fuse(Object.values(localLibrary), 13 | { 14 | "keys": ["search", "title"], 15 | includeScore: true, 16 | threshold: 0.4, 17 | }); 18 | // perform search 19 | 20 | const fuseResult = fuse.search(query); 21 | // if valid match, return filename 22 | // console.log(fuseResult); 23 | if (fuseResult[0]) { 24 | console.log("fuse found"); 25 | console.log(fuseResult); 26 | } -------------------------------------------------------------------------------- /commands/pause.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Check if user is in voiceChannel 4 | Check if bot is in voiceChannel 5 | 6 | If user and bot are in same voice channel, pause 7 | 8 | If pause returns true, respond paused 9 | If pause returns false, respond An error occured while pausing 10 | 11 | */ 12 | 13 | const { audioPlayers, reactions } = require("../general.js"); 14 | 15 | function pause(message) { 16 | if (!(message.guild.me.voice.channelId) || !(message.member.voice.channelId == message.guild.me.voice.channelId)) { 17 | message.react(reactions.negative); 18 | return false; 19 | } 20 | message.react(reactions.positive); 21 | audioPlayers[message.guild.id].pause(); 22 | } 23 | 24 | module.exports = pause; -------------------------------------------------------------------------------- /commands/unpause.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Check if user is in voiceChannel 4 | Check if bot is in voiceChannel 5 | 6 | 7 | If user and bot are in same voice channel, pause 8 | 9 | If unpause returns true, respond paused 10 | If unpause returns false, respond An error occured while pausing 11 | 12 | */ 13 | 14 | const { audioPlayers, reactions } = require("../general.js"); 15 | 16 | function unpause(message) { 17 | if (!(message.guild.me.voice.channelId) || !(message.member.voice.channelId == message.guild.me.voice.channelId)) { 18 | message.react(reactions.negative); 19 | return false; 20 | } 21 | message.react(reactions.positive); 22 | audioPlayers[message.guild.id].unpause(); 23 | } 24 | 25 | module.exports = unpause; -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 10 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /diy_commando.js: -------------------------------------------------------------------------------- 1 | const DIY_COMMANDO = { 2 | 3 | "join": require("./commands/join.js"), 4 | 5 | "disconnect": require("./commands/disconnect.js"), 6 | "leave": require("./commands/disconnect.js"), 7 | 8 | "play": require("./commands/play.js"), 9 | "music": require("./commands/play.js"), 10 | 11 | "pause": require("./commands/pause.js"), 12 | "stop": require("./commands/pause.js"), 13 | 14 | "resume": require("./commands/unpause.js"), 15 | "unpause": require("./commands/unpause.js"), 16 | 17 | "skip": require("./commands/skip.js"), 18 | 19 | "queue": require("./commands/queue.js"), 20 | 21 | "remove": require("./commands/remove.js"), 22 | 23 | "loop": require("./commands/loop.js"), 24 | 25 | "spotify": require("./commands/spotify.js"), 26 | 27 | "help":require("./commands/help.js"), 28 | 29 | }; 30 | 31 | module.exports = DIY_COMMANDO; -------------------------------------------------------------------------------- /other/fuzzyTest.js: -------------------------------------------------------------------------------- 1 | const localLibrary = require("../music/localLibrary.json"); 2 | const FuzzySet = require('fuzzyset') 3 | 4 | function createFuzzySetArr(){ 5 | const fzpromise = new Promise((resolve, reject)=>{ 6 | const arr = []; 7 | for (const key in localLibrary){ 8 | const searchTerm = localLibrary[key].search; 9 | arr.push(searchTerm) 10 | } 11 | resolve(arr); 12 | }) 13 | return fzpromise; 14 | } 15 | 16 | async function test(){ 17 | const fuzzyArr = await createFuzzySetArr(); 18 | const fuzzySet = new FuzzySet(fuzzyArr); 19 | const fuzzyResult = fuzzySet.get("kanye west"); 20 | 21 | if (fuzzyResult && fuzzyResult[0]) { 22 | console.log("fuzzy found"); 23 | console.log(fuzzyResult); 24 | for (const index in fuzzyArr){ 25 | if(fuzzyArr[index] == fuzzyResult[0][1]){ 26 | const fuzzyfilepath = Object.keys(localLibrary)[index]; 27 | console.log(fuzzyfilepath); 28 | } 29 | } 30 | 31 | } 32 | 33 | } 34 | 35 | test() -------------------------------------------------------------------------------- /commands/disconnect.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Check if user is in voiceChannel 4 | Check if bot is in voiceChannel 5 | 6 | If user and bot are in same voice channel, disconnect 7 | 8 | */ 9 | const { getVoiceConnection } = require("@discordjs/voice"); 10 | 11 | const { queue, audioPlayers, reactions } = require("../general.js"); 12 | 13 | function disconnect(message) { 14 | 15 | console.log(message.guild.me.voice.channelId); 16 | console.log(message.member.voice.channelId == message.guild.me.voice.channelId); 17 | if (!message.guild.me.voice.channelId || !(message.member.voice.channelId == message.guild.me.voice.channelId)) { 18 | message.react(reactions.negative); 19 | // message.reply("We are not in the same voice channel"); 20 | return false; 21 | } 22 | 23 | queue[message.guildId] == []; 24 | audioPlayers[message.guildId] == null; 25 | message.react(reactions.positive); 26 | message.guild.me.setNickname(message.guild.me.user.username); 27 | return getVoiceConnection(message.guildId).destroy(); 28 | 29 | } 30 | 31 | module.exports = disconnect; -------------------------------------------------------------------------------- /commands/skip.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Check if user is in voiceChannel 4 | Check if bot is in voiceChannel 5 | 6 | If user and bot are in same voice channel 7 | 8 | Remove song from queue 9 | ---create AudioResource for next song in queue 10 | ---Tell AudioPlayer to start playing AudioResource 11 | If queue is empty do not call play 12 | 13 | */ 14 | 15 | const { audioPlayers, reactions } = require("../general.js"); 16 | 17 | // const play = require("./play.js"); 18 | 19 | /* 20 | Function checks if bot is in the voice channel AND if user who called upon skip is in the same channel. 21 | It stops what is in current queue, the listener in join.js skips, and then calls upon play function in play.js 22 | */ 23 | function skip(message) { 24 | if (!(message.guild.me.voice.channelId) || !(message.member.voice.channelId == message.guild.me.voice.channelId)) { 25 | message.react(reactions.negative); 26 | return false; 27 | } 28 | 29 | const audioPlayer = audioPlayers[message.guild.id]; 30 | // the listener in join.js will automatically shift when the audio is stopped 31 | message.react(reactions.positive); 32 | audioPlayer.stop(); 33 | // We dont need to call play because the listener will call it when the audio is stopped 34 | // play(message); 35 | 36 | } 37 | 38 | module.exports = skip; 39 | -------------------------------------------------------------------------------- /commands/remove.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | remove item from queue 4 | 5 | 6 | */ 7 | 8 | const { reactions, queue } = require("../general.js"); 9 | 10 | function remove(message, args) { 11 | if (!(message.guild.me.voice.channelId) || !(message.member.voice.channelId == message.guild.me.voice.channelId)) { 12 | message.react(reactions.negative); 13 | return false; 14 | } 15 | 16 | // if args cannot be a number or does not exist, dont do anything 17 | if (isNaN(Number(args)) || !args) { 18 | message.react(reactions.confused); 19 | return false; 20 | } 21 | 22 | // set index of item to remove to number(args) 23 | const indexToRemove = Number(args); 24 | 25 | // Don't allow removal of the 0th queue item, as this will remove the currently playing song which should be done using skip 26 | if (indexToRemove == 0) { 27 | message.react(reactions.negative); 28 | return false; 29 | } 30 | 31 | // if there is no queue or there is no item at the specified index, dont do anything 32 | if (!queue[message.guild.id] || !queue[message.guild.id][indexToRemove]) { 33 | message.react(reactions.negative); 34 | return false; 35 | } 36 | 37 | // remove item from array 38 | queue[message.guild.id].splice(indexToRemove, 1); 39 | message.react(reactions.positive); 40 | return true; 41 | 42 | 43 | } 44 | 45 | module.exports = remove; -------------------------------------------------------------------------------- /voice/rhino.js: -------------------------------------------------------------------------------- 1 | const { voiceCommands } = require("../config.json"); 2 | 3 | const { Rhino } = require("@picovoice/rhino-node"); 4 | 5 | const path = require("path"); 6 | 7 | const { picoAccessKey, rhinoFileName } = voiceCommands; 8 | 9 | const contextPath = path.join(path.join(__dirname, "models"), rhinoFileName); 10 | 11 | // const rhino = new Rhino(accessKey, contextPath); 12 | 13 | /* 14 | intentDetectors = {channelid : rhino} 15 | */ 16 | // const intentDetectors = {}; 17 | 18 | function processRhinoVoiceData(PCMprovider) { 19 | return new Promise((resolve) => { 20 | const rhino = new Rhino(picoAccessKey, contextPath, 0.5, 0.5, false); 21 | let isFinalized; 22 | PCMprovider.on("frame", function feedFrame(frame) { 23 | // if rhino already finalized dont run 24 | if (isFinalized) { 25 | return; 26 | } 27 | // feed frames until rhino makes it decision 28 | isFinalized = rhino.process(frame); 29 | // console.log("isfinalized " + isFinalized); 30 | if (isFinalized) { 31 | // rhino has decided, stop feeding 32 | PCMprovider.removeListener("frame", feedFrame); 33 | const inference = rhino.getInference(); 34 | if (inference.isUnderstood) { 35 | const intent = inference.intent; 36 | // console.log("internal: " + intent); 37 | rhino.release(); 38 | resolve(intent); 39 | } 40 | else { 41 | rhino.release(); 42 | resolve(false); 43 | } 44 | } 45 | }); 46 | }); 47 | } 48 | 49 | module.exports = { processRhinoVoiceData }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@discordjs/builders": "^1.5.0", 4 | "@discordjs/opus": "^0.9.0", 5 | "@discordjs/rest": "^1.6.0", 6 | "@discordjs/voice": "^0.15.0", 7 | "@picovoice/porcupine-node": "^2.1.7", 8 | "@picovoice/rhino-node": "^2.1.10", 9 | "bufferutil": "^4.0.7", 10 | "discord-api-types": "^0.37.36", 11 | "discord.js": "^13.16.0", 12 | "erlpack": "github:discord/erlpack", 13 | "ffmpeg-static": "^5.1.0", 14 | "fuzzyset": "^1.0.7", 15 | "music-metadata": "^8.1.3", 16 | "play-dl": "github:karyeet/play-dl#build-on-install", 17 | "prism-media": "github:karyeet/prism-media", 18 | "sodium-native": "^4.0.1", 19 | "utf-8-validate": "^5.0.10", 20 | "zlib-sync": "^0.1.7" 21 | }, 22 | "name": "mandarine", 23 | "version": "0.2.7", 24 | "engines": { 25 | "node": "16.x" 26 | }, 27 | "description": "A Node.js discord musicbot", 28 | "main": "index.js", 29 | "scripts": { 30 | "test": "echo \"Error: no test specified\" && exit 1", 31 | "start": "node ." 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/karyeet/Mandarine.git" 36 | }, 37 | "keywords": [ 38 | "Discord", 39 | "Musicbot", 40 | "Nodejs" 41 | ], 42 | "author": "Kareem Shehada", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/karyeet/Mandarine/issues" 46 | }, 47 | "homepage": "https://github.com/karyeet/Mandarine#readme", 48 | "devDependencies": { 49 | "eslint": "^8.11.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | #custom 79 | .data 80 | config.json 81 | 82 | #picovoice 83 | *.rhn 84 | *.ppn 85 | picopatch.js -------------------------------------------------------------------------------- /mEmbeds.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | let defaultEmbed = { embed:{} }; 3 | 4 | 5 | class mEmbeds { 6 | constructor(user) { 7 | this.embed = JSON.parse(JSON.stringify(defaultEmbed)); 8 | this.embed.embed.timestamp = new Date(); 9 | if (user) { 10 | this.embed.embed.footer = { "text":user.username + "#" + user.discriminator, "icon_url":user.avatarURL }; 11 | } 12 | else {this.embed.embed.footer = {};} 13 | this.embed.embed.author = []; 14 | this.embed.embed.fields = []; 15 | } 16 | setDefault() { 17 | defaultEmbed = this.embed; 18 | } 19 | 20 | setTitle(title, url) { 21 | this.embed.embed.title = title; 22 | this.embed.embed.url = url; 23 | } 24 | setDesc(desc) { 25 | this.embed.embed.description = desc; 26 | } 27 | setColor(color) { 28 | this.embed.embed.color = color; 29 | } 30 | setTimestamp(timestamp) { 31 | this.embed.embed.timestamp = timestamp; 32 | } 33 | setFooter(text, url) { 34 | this.embed.embed.footer.text = text; 35 | this.embed.embed.footer.icon_url = url; 36 | } 37 | setThumb(url) { 38 | this.embed.embed.thumbnail = { url:url }; 39 | } 40 | setImage(url) { 41 | this.embed.embed.image = { url:url }; 42 | } 43 | setAuthor(name, icon, url) { 44 | this.embed.embed.author = { name:name, icon_url:icon, url:url }; 45 | /* this.embed.embed.author.name = name 46 | this.embed.embed.author.icon_url = icon 47 | this.embed.embed.author.url = url*/ 48 | } 49 | addField(name, value, inline) { 50 | if (!inline) {inline = false;} 51 | this.embed.embed.fields.push({ name:name, value:value, inline:inline }); 52 | } 53 | } 54 | 55 | 56 | module.exports = mEmbeds; -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2021 9 | }, 10 | "rules": { 11 | "arrow-spacing": ["warn", { "before": true, "after": true }], 12 | "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], 13 | "comma-dangle": ["error", "always-multiline"], 14 | "comma-spacing": "error", 15 | "comma-style": "error", 16 | "curly": ["error", "multi-line", "consistent"], 17 | "dot-location": ["error", "property"], 18 | "handle-callback-err": "off", 19 | "indent": ["error", "tab"], 20 | "keyword-spacing": "error", 21 | "max-nested-callbacks": ["error", { "max": 4 }], 22 | "max-statements-per-line": ["error", { "max": 2 }], 23 | "no-console": "off", 24 | "no-empty-function": "error", 25 | "no-floating-decimal": "error", 26 | "no-inline-comments": "error", 27 | "no-lonely-if": "error", 28 | "no-multi-spaces": "error", 29 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], 30 | "no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }], 31 | "no-trailing-spaces": ["error"], 32 | "no-var": "error", 33 | "object-curly-spacing": ["error", "always"], 34 | "prefer-const": "error", 35 | "quotes": ["error", "double"], 36 | "semi": ["error", "always"], 37 | "space-before-blocks": "error", 38 | "space-before-function-paren": ["error", { 39 | "anonymous": "never", 40 | "named": "never", 41 | "asyncArrow": "always" 42 | }], 43 | "space-in-parens": "error", 44 | "space-infix-ops": "error", 45 | "space-unary-ops": "error", 46 | "spaced-comment": "error", 47 | "yoda": "error" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /voice/porcupine.js: -------------------------------------------------------------------------------- 1 | const { voiceCommands } = require("../config.json"); 2 | 3 | const { porcupineFileName, picoAccessKey } = voiceCommands; 4 | 5 | 6 | const path = require("path"); 7 | 8 | const { 9 | Porcupine, 10 | } = require("@picovoice/porcupine-node"); 11 | 12 | const userListeners = {}; 13 | const hotwordDetectors = {}; 14 | 15 | const keywordPath = path.join(path.join(__dirname, "models"), porcupineFileName); 16 | 17 | /* const porcupine = new Porcupine( 18 | accessKey, 19 | [BuiltinKeyword.GRASSHOPPER, BuiltinKeyword.BUMBLEBEE], 20 | [0.5, 0.65], 21 | ); 22 | */ 23 | 24 | function emitHotword(hotword, userid, voiceChannel, emitter) { 25 | emitter.emit("hotword", { 26 | "hotword": hotword, 27 | "userid": userid, 28 | "voiceChannel": voiceChannel, 29 | "PCMprovider": emitter, 30 | }); 31 | } 32 | 33 | // eventemitter providing frames to that should be provided to porcupine 34 | function listenToPCM(PCMprovider, userid, voiceChannel) { 35 | 36 | // create function within scope of userid for easy use with event emitter 37 | function processVoiceData(audioFrame) { 38 | if (!hotwordDetectors[userid]) { 39 | hotwordDetectors[userid] = new Porcupine( 40 | picoAccessKey, 41 | [keywordPath], 42 | [0.5], 43 | ); 44 | } 45 | const keywordIndex = hotwordDetectors[userid].process(audioFrame); 46 | if (keywordIndex === 0) { 47 | console.log("hotword"); 48 | // emit when found, along with context payload 49 | emitHotword("hotword", userid, voiceChannel, PCMprovider); 50 | return "hotword"; 51 | } 52 | return 0; 53 | } 54 | 55 | // if theres an old listener try to remove the event listener 56 | if (userListeners[userid]) { 57 | try { 58 | userListeners[userid].removeListener("frame", processVoiceData); 59 | } 60 | catch (err) { 61 | console.warn("[Error] while removing old listener for " + userid + "\n" + err); 62 | } 63 | } 64 | // set to new audio stream in table 65 | userListeners[userid] = PCMprovider; 66 | 67 | // on new data send to porcupine 68 | PCMprovider.on("frame", processVoiceData); 69 | 70 | // return PCMprovider 71 | return PCMprovider; 72 | } 73 | 74 | module.exports = { listenToPCM }; -------------------------------------------------------------------------------- /Docker/deezerConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "downloadLocation": "music", 3 | "tracknameTemplate": "%artist% - %title%", 4 | "albumTracknameTemplate": "%tracknumber% - %title%", 5 | "playlistTracknameTemplate": "%position% - %artist% - %title%", 6 | "createPlaylistFolder": true, 7 | "playlistNameTemplate": "%playlist%", 8 | "createArtistFolder": false, 9 | "artistNameTemplate": "%artist%", 10 | "createAlbumFolder": true, 11 | "albumNameTemplate": "%artist% - %album%", 12 | "createCDFolder": true, 13 | "createStructurePlaylist": false, 14 | "createSingleFolder": false, 15 | "padTracks": true, 16 | "paddingSize": "0", 17 | "illegalCharacterReplacer": "_", 18 | "queueConcurrency": 3, 19 | "maxBitrate": "128", 20 | "feelingLucky": false, 21 | "fallbackBitrate": true, 22 | "fallbackSearch": false, 23 | "fallbackISRC": false, 24 | "logErrors": false, 25 | "logSearched": false, 26 | "overwriteFile": "n", 27 | "createM3U8File": false, 28 | "playlistFilenameTemplate": "playlist", 29 | "syncedLyrics": false, 30 | "embeddedArtworkSize": 800, 31 | "embeddedArtworkPNG": false, 32 | "localArtworkSize": 1400, 33 | "localArtworkFormat": "jpg", 34 | "saveArtwork": false, 35 | "coverImageTemplate": "cover", 36 | "saveArtworkArtist": false, 37 | "artistImageTemplate": "folder", 38 | "jpegImageQuality": 90, 39 | "dateFormat": "Y-M-D", 40 | "albumVariousArtists": true, 41 | "removeAlbumVersion": false, 42 | "removeDuplicateArtists": true, 43 | "featuredToTitle": "0", 44 | "titleCasing": "nothing", 45 | "artistCasing": "nothing", 46 | "executeCommand": "", 47 | "tags": { 48 | "title": true, 49 | "artist": true, 50 | "artists": true, 51 | "album": true, 52 | "cover": true, 53 | "trackNumber": true, 54 | "trackTotal": false, 55 | "discNumber": true, 56 | "discTotal": false, 57 | "albumArtist": true, 58 | "genre": true, 59 | "year": true, 60 | "date": true, 61 | "explicit": true, 62 | "isrc": true, 63 | "length": true, 64 | "barcode": true, 65 | "bpm": true, 66 | "replayGain": false, 67 | "label": true, 68 | "lyrics": false, 69 | "syncedLyrics": false, 70 | "copyright": false, 71 | "composer": false, 72 | "involvedPeople": false, 73 | "source": false, 74 | "rating": false, 75 | "savePlaylistAsCompilation": false, 76 | "useNullSeparator": false, 77 | "saveID3v1": true, 78 | "multiArtistSeparator": "default", 79 | "singleAlbumArtist": false, 80 | "coverDescriptionUTF8": false 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | # Controls when the workflow will run 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - 'main' 9 | - 'Dockerfile' 10 | - 'file-ingest-dev' 11 | - 'change-prefix' 12 | release: 13 | types: 14 | - created 15 | 16 | 17 | jobs: 18 | build-matrix: 19 | strategy: 20 | matrix: 21 | os: [buildjet-2vcpu-ubuntu-2204-arm, ubuntu-latest] 22 | 23 | runs-on: ${{ matrix.os }} 24 | steps: 25 | # Get the repository's code 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | 29 | # https://github.com/docker/setup-buildx-action 30 | - name: Set up Docker Buildx 31 | id: buildx 32 | uses: docker/setup-buildx-action@v2 33 | 34 | - name: Login to Docker Hub 35 | if: github.event_name != 'pull_request' 36 | uses: docker/login-action@v2 37 | with: 38 | username: ${{ secrets.DOCKERHUB_USERNAME }} 39 | password: ${{ secrets.DOCKERHUB_TOKEN }} 40 | 41 | - name: Cache NPM 42 | uses: buildjet/setup-node@v3 43 | with: 44 | node-version: 16 45 | cache: npm 46 | 47 | - name: Determine Platform 48 | run: | 49 | if [[ "${{ runner.arch }}" == "X64" ]]; then 50 | echo "Runner is amd64" 51 | echo "PLATFORM=amd64" >> $GITHUB_ENV 52 | elif [[ "${{ runner.arch }}" == "ARM64" ]]; then 53 | echo "Runner is arm64" 54 | echo "PLATFORM=arm64" >> $GITHUB_ENV 55 | fi 56 | 57 | - name: Build and push 58 | uses: docker/build-push-action@v2 59 | with: 60 | context: . 61 | platforms: linux/${{ env.PLATFORM }} 62 | push: ${{ github.event_name != 'pull_request' }} 63 | tags: karyeet/mandarine:${{ env.PLATFORM }}-${{ github.ref_name }} 64 | cache-from: type=gha 65 | cache-to: type=gha,mode=max 66 | 67 | build-docker-multiarch: 68 | runs-on: ubuntu-latest 69 | needs: [build-matrix] 70 | steps: 71 | - name: Login to Docker Hub 72 | if: github.event_name != 'pull_request' 73 | uses: docker/login-action@v1 74 | with: 75 | username: ${{ secrets.DOCKERHUB_USERNAME }} 76 | password: ${{ secrets.DOCKERHUB_TOKEN }} 77 | - name: Create and push multiarch image 78 | run: | 79 | docker buildx imagetools create -t karyeet/mandarine:${GITHUB_REF_NAME} \ 80 | karyeet/mandarine:arm64-${GITHUB_REF_NAME} \ 81 | karyeet/mandarine:amd64-${GITHUB_REF_NAME} 82 | - name: Push to latest 83 | if: github.event_name == 'release' 84 | run: | 85 | echo "Pushing to latest tag" 86 | docker buildx imagetools create -t karyeet/mandarine:latest \ 87 | karyeet/mandarine:arm64-${GITHUB_REF_NAME} \ 88 | karyeet/mandarine:amd64-${GITHUB_REF_NAME} 89 | -------------------------------------------------------------------------------- /registerCommands.js: -------------------------------------------------------------------------------- 1 | // https://v13.discordjs.guide/interactions/slash-commands.html#registering-slash-commands 2 | 3 | 4 | const { token } = require('./config.json'); 5 | 6 | const { SlashCommandBuilder } = require('@discordjs/builders'); 7 | const { REST } = require('@discordjs/rest'); 8 | const { Routes } = require('discord-api-types/v9'); 9 | 10 | const rest = new REST({ version: '9' }).setToken(token); 11 | 12 | const SLASH_COMMANDS = { 13 | join: new SlashCommandBuilder() 14 | .setName('join') 15 | .setDescription('Join your voice channel!'), 16 | 17 | disconnect: new SlashCommandBuilder() 18 | .setName('disconnect') 19 | .setDescription('Leave your voice channel!'), 20 | 21 | pause: new SlashCommandBuilder() 22 | .setName('pause') 23 | .setDescription('Pause the current track'), 24 | 25 | queue: new SlashCommandBuilder() 26 | .setName('queue') 27 | .setDescription('Show the queue.'), 28 | 29 | remove: new SlashCommandBuilder() 30 | .setName('remove') 31 | .setDescription('Remove a track from the queue.') 32 | .addIntegerOption(option => 33 | option.setName('index') 34 | .setDescription('Index of the track, can be found using /queue') 35 | .setRequired(true) 36 | ), 37 | 38 | loop: new SlashCommandBuilder() 39 | .setName('loop') 40 | .setDescription('Toggle track looping.'), 41 | 42 | /* spotify: new SlashCommandBuilder() 43 | .setName('spotify') 44 | .setDescription('Control the bot with spotify! User must be sharing their currently listening to with discord.') 45 | .addUserOption(option => 46 | option.setName('user') 47 | .setDescription('User to listen along to.') 48 | .setRequired(true) 49 | ), 50 | */ 51 | 52 | skip: new SlashCommandBuilder() 53 | .setName('skip') 54 | .setDescription('Skip the current track'), 55 | 56 | resume: new SlashCommandBuilder() 57 | .setName('resume') 58 | .setDescription('Resume the current track'), 59 | 60 | play: new SlashCommandBuilder() 61 | .setName('play') 62 | .setDescription('Play audio from youtube or soundcloud') 63 | .addStringOption(option => 64 | option.setName('audio') 65 | .setDescription('Audio name or link from youtube or soundcloud.') 66 | .setRequired(true) 67 | ), 68 | music: new SlashCommandBuilder() 69 | .setName('music') 70 | .setDescription('Play music from a large library of mp3 files.') 71 | .addStringOption(option => 72 | option.setName('song') 73 | .setDescription('Song name.') 74 | .setRequired(true) 75 | ), 76 | 77 | 78 | } 79 | 80 | 81 | async function registerSlashCommands(clientId, commands){ 82 | const commandsJSON = []; 83 | for (const command of Object.keys(commands)) { 84 | commandsJSON.push(commands[command].toJSON()); 85 | } 86 | console.log(JSON.stringify(commandsJSON)) 87 | 88 | try { 89 | console.log('Started refreshing application (/) commands.'); 90 | 91 | await rest.put( 92 | Routes.applicationCommands(clientId), 93 | { body: commandsJSON }, 94 | ); 95 | 96 | console.log('Successfully reloaded application (/) commands.'); 97 | } catch (error) { 98 | console.warn(error); 99 | } 100 | 101 | }; 102 | 103 | 104 | registerSlashCommands("906647083572920381", SLASH_COMMANDS) 105 | 106 | module.exports = registerSlashCommands; -------------------------------------------------------------------------------- /music/indexer.js: -------------------------------------------------------------------------------- 1 | /* 2 | Only needs to be run seperatly when importing new music files 3 | Very basic 4 | 5 | */ 6 | 7 | 8 | const fs = require("fs/promises"); 9 | const localLibrary = {}; 10 | 11 | const homedir = require("os").homedir(); 12 | const path = require("path"); 13 | 14 | const pathToDLFiles = path.join(homedir, "mandarineFiles"); 15 | 16 | const config = require("../config.json"); 17 | 18 | let parseFile; 19 | let fileCount = 0; 20 | let dirCount = 0; 21 | 22 | // import('music-metadata').then((mmModule)=>{console.log("mm: " + mmModule); parseFile = mmModule.parseFile}); 23 | 24 | 25 | async function indexDirectory(directory) { 26 | const files = await fs.readdir(directory, { withFileTypes: true }); 27 | for (const file of files) { 28 | if (file.isDirectory()) { 29 | await indexDirectory(path.join(directory, file.name)); 30 | dirCount++; 31 | } 32 | else if (file.name.endsWith(".mp3")) { 33 | await addFile(path.join(directory, file.name)); 34 | fileCount++; 35 | } 36 | } 37 | return true; 38 | } 39 | 40 | async function addFile(filePath) { 41 | 42 | if (!localLibrary[filePath]) { 43 | const metadata = await parseFile(filePath); 44 | // console.log("metadata:" + JSON.stringify(metadata.common.title)); 45 | const title = metadata.common.title; 46 | const artist = metadata.common.artist; 47 | 48 | const search = [ 49 | (title).replace(/\.|'|-/g, ""), 50 | (artist + " " + title).replace(/\.|'|-/g, ""), 51 | (title + " " + artist).replace(/\.|'|-/g, ""), 52 | ]; 53 | 54 | const featSplitRegex = / feat\.? /; 55 | if (title.match(featSplitRegex)) { 56 | search.push(title.split(featSplitRegex)[0]); 57 | } 58 | 59 | if (metadata.common.isrc && metadata.common.isrc[0]) { 60 | search.push(metadata.common.isrc[0]); 61 | } 62 | 63 | if (artist.split(" ").length > 1) { 64 | // if the artist has spaces in their names, chances are people will only add one part of their name 65 | for (const artistNameSplit of artist.split(" ")) { 66 | search.push((title + " " + artistNameSplit).replace(/\.|'|-/g, "")); 67 | search.push((artistNameSplit + " " + title).replace(/\.|'|-/g, "")); 68 | } 69 | } 70 | 71 | if (search[2] != (title).replace(/\(.*\)|\.|'|-/g, "")) { 72 | search.push((title).replace(/\(.*\)|\.|'|-/g, "")); 73 | } 74 | 75 | localLibrary[filePath] = { 76 | "title": title, 77 | "artist": artist, 78 | "search": search, 79 | }; 80 | 81 | } 82 | } 83 | 84 | async function write() { 85 | // console.log(localLibrary); 86 | await fs.writeFile(path.join(__dirname, "localLibrary.json"), JSON.stringify(localLibrary)); 87 | return true; 88 | } 89 | 90 | async function index() { 91 | const musicmetadata = await import("music-metadata"); 92 | parseFile = musicmetadata.parseFile; 93 | 94 | if (config.additionalMusicDirs) { 95 | for (const dir of config.additionalMusicDirs) { 96 | console.log("Indexing " + dir + " and its subdirectories."); 97 | await indexDirectory(dir); 98 | } 99 | } 100 | console.log("Indexing " + pathToDLFiles + " and its subdirectories."); 101 | await indexDirectory(pathToDLFiles); 102 | console.log("write"); 103 | console.log("Indexed " + fileCount + " files in " + dirCount + " folders."); 104 | await write(); 105 | return 1; 106 | } 107 | 108 | 109 | if (require.main === module) { 110 | // called directly 111 | index(); 112 | } 113 | 114 | module.exports = { index }; -------------------------------------------------------------------------------- /voice/convert.js: -------------------------------------------------------------------------------- 1 | const prism = require("prism-media"); 2 | const { EventEmitter } = require("events"); 3 | 4 | const outputEvents = {}; 5 | 6 | // 16 bits of noise 7 | const PCMEndpointFrame = new Buffer.alloc(512, 0xFFFF); 8 | 9 | 10 | /* const fs = require("fs"); 11 | const path = require("path"); 12 | const writeStream = fs.createWriteStream(path.join(__dirname, "257438782654119937.pcm"), { flags:"a" }); 13 | */ 14 | // store orphaned frames (because need size of 512, but provided 640) 15 | const userFrameAccumulators = {}; 16 | 17 | 18 | function chunkArray(array, size) { 19 | return Array.from({ length: Math.ceil(array.length / size) }, (v, index) => 20 | array.slice(index * size, index * size + size), 21 | ); 22 | } 23 | 24 | function processFrames(data, userid, frameLength) { 25 | if (userid == "257438782654119937") { 26 | // writeStream.write(Buffer.from(data)); 27 | } 28 | // Two bytes per Int16 from the data buffer 29 | const newFrames16 = new Array(data.length / 2); 30 | for (let i = 0; i < data.length; i += 2) { 31 | newFrames16[i / 2] = data.readInt16LE(i); 32 | } 33 | 34 | // Split the incoming PCM integer data into arrays of size Porcupine.frameLength. If there's insufficient frames, or a remainder, 35 | // store it in 'frameAccumulator' for the next iteration, so that we don't miss any audio data 36 | userFrameAccumulators[userid] = userFrameAccumulators[userid].concat(newFrames16); 37 | const frames = chunkArray(userFrameAccumulators[userid], frameLength); 38 | 39 | if (frames[frames.length - 1].length !== frameLength) { 40 | // store remainder from divisions of frameLength 41 | userFrameAccumulators[userid] = frames.pop(); 42 | } 43 | else { 44 | userFrameAccumulators[userid] = []; 45 | } 46 | // provide frames through event 47 | for (const frame of frames) { 48 | outputEvents[userid].emit("frame", frame); 49 | } 50 | 51 | } 52 | 53 | 54 | function processOpusStream(userid, stream, outputFrameLength) { 55 | 56 | userFrameAccumulators[userid] = []; 57 | 58 | outputEvents[userid] = new EventEmitter(); 59 | 60 | const PCMStream = new prism.opus.Decoder({ frameSize: 640, channels: 1, rate: 16000 }); 61 | /* stream.once("data", (data)=>{ 62 | console.log("silent opus 620: " + JSON.stringify(data.toJSON())); 63 | }); 64 | PCMStream.once("data", (data) => { 65 | console.log("silent pcm 620: " + JSON.stringify(data.toJSON())); 66 | });*/ 67 | // pipe the opus stream to the decoder 68 | stream.pipe(PCMStream); 69 | 70 | const userVoiceFrames = []; 71 | PCMStream.on("data", (data) => { 72 | userVoiceFrames.push(data); 73 | }); 74 | 75 | // only send maximum of 1 second of silence after an audio frame 76 | let silenceTimer = 0; 77 | const timeToGive = 1; 78 | // decrease silencetimer by 0.25sec every 125ms because interval runs at double 16khz, so this interval must run at half 250ms 79 | setInterval(() => { 80 | silenceTimer -= 0.25; 81 | }, 125); 82 | 83 | // run interval at 16khz 84 | setInterval(() => { 85 | // console.log("userVoiceFrames.length: " + userVoiceFrames.length); 86 | if (userVoiceFrames.length > 0) { 87 | // process voice if it exists 88 | const data = userVoiceFrames.shift(); 89 | processFrames(data, userid, outputFrameLength); 90 | // set two seconds of silence 91 | silenceTimer = timeToGive; 92 | } 93 | else if (silenceTimer > 0) { 94 | // if silenceTimer, send silent frames 95 | const data = PCMEndpointFrame; 96 | processFrames(data, userid, outputFrameLength); 97 | } 98 | }, 1_000 / 32); 99 | 100 | return outputEvents[userid]; 101 | } 102 | 103 | module.exports = { processOpusStream }; 104 | 105 | -------------------------------------------------------------------------------- /commands/join.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Check if already in a discord voicechannel in members guild -- not doing 4 | voicechannel = voicestate 5 | 6 | Check if member is in a voice channel 7 | 8 | If so then join members current voice channel 9 | 10 | */ 11 | 12 | const { voiceCommands } = require("../config.json"); 13 | 14 | const { audioPlayers, queue, playNext, reactions, guildsMeta } = require("../general.js"); 15 | 16 | const { 17 | joinVoiceChannel, 18 | createAudioPlayer, 19 | NoSubscriberBehavior, 20 | AudioPlayerStatus } = require("@discordjs/voice"); 21 | 22 | // Checks if user who called upon the join command is in a voice channel. If not returns false otherwise joins channel 23 | function join(message) { 24 | const voice = message.member.voice; 25 | 26 | // create queue for guild 27 | queue[message.guildId] = []; 28 | 29 | // guildMeta for information about guilds 30 | // volume is simply a placeholder for now 31 | guildsMeta[message.guildId] = { 32 | "looping": false, 33 | "volume": 1, 34 | "spotify": false, 35 | }; 36 | 37 | // If user is not in channel if() returns false 38 | if (!voice.channelId) { 39 | message.reply("You are not in a voice chanel") 40 | .then((msg) => { 41 | setTimeout(() => { 42 | msg.delete() 43 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 44 | }, 10_000); 45 | }); 46 | message.react(reactions.negative); 47 | return false; 48 | } 49 | /* if (message.channel.guild.me.voice.channelId) { 50 | message.reply("I am already in a voice channel!"); 51 | }*/ 52 | 53 | // Otherwise join users channel 54 | // if voiceCommands are enabled, selfDeaf is disabled 55 | const connection = joinVoiceChannel({ 56 | channelId: voice.channelId, 57 | guildId: voice.channel.guildId, 58 | adapterCreator: voice.channel.guild.voiceAdapterCreator, 59 | selfDeaf: !voiceCommands.enabled, 60 | }); 61 | // and create audioPlayer 62 | const audioPlayer = createAudioPlayer({ 63 | behaviors: { 64 | noSubscriber: NoSubscriberBehavior.Pause, 65 | }, 66 | }); 67 | 68 | audioPlayer.on(AudioPlayerStatus.Idle, async () => { 69 | if (!guildsMeta[message.guildId].looping) { 70 | queue[message.guild.id].shift(); 71 | } 72 | 73 | if (queue[message.guild.id] && queue[message.guild.id][0]) { 74 | playNext(message); 75 | } 76 | else { 77 | message.guild.me.setNickname(message.guild.me.user.username); 78 | } 79 | }); 80 | 81 | // Add audio player to guild:audioplayer table 82 | audioPlayers[message.guildId] = audioPlayer; 83 | 84 | // voiceConnection will play from this audioPlayer 85 | connection.subscribe(audioPlayer); 86 | 87 | /* const audioStream = connection.receiver.subscribe("257438782654119937", 88 | { 89 | "autoDestroy":false, 90 | "objectMode": true, 91 | }); 92 | require("../voice/keywordProcessing").listenToOpus(audioStream); 93 | */ 94 | 95 | // initalize voice commands 96 | try { 97 | // dont start if not enabled 98 | const start = require("../voice/correctVoiceConfig")(); 99 | if (start == 0) { 100 | require("../voice/voiceProcessing").initializeVoiceCommands(message, connection); 101 | } 102 | else if (start == 1) { 103 | throw "voiceCommands.picoAccessKey, voiceCommands.porcupineFileName, or voiceCommands.rhinoFileName is not set correctly in config.json"; 104 | } 105 | } 106 | catch (error) { 107 | console.warn("Error while processing or initializing voice commands: " + error); 108 | message.reply("```join.js: " + error + "```"); 109 | } 110 | 111 | message.react(reactions.positive); 112 | return { connection, audioPlayer }; 113 | 114 | } 115 | 116 | module.exports = join; -------------------------------------------------------------------------------- /music/meezer.js: -------------------------------------------------------------------------------- 1 | const spawn = require("child_process").spawn; 2 | const https = require("https"); 3 | const homedir = require("os").homedir(); 4 | const { existsSync } = require("fs"); 5 | const path = require("path"); 6 | 7 | const pathToFiles = path.join(homedir, "/mandarineFiles/"); 8 | 9 | 10 | // python3 -m deemix --portable -p ./ https://www.deezer.com/track/2047662387 11 | 12 | function DownloadTrack(deezerTrack) { 13 | console.log("deemix download " + deezerTrack); 14 | const dlpromise = new Promise((resolve, reject) => { 15 | const deemix = spawn("python3", ["-m", "deemix", "--portable", "-p", pathToFiles, deezerTrack], { "cwd": homedir }); 16 | let filename; 17 | const killTimeout = setTimeout(() => { 18 | deemix.kill("SIGINT"); 19 | }, 30_000); 20 | deemix.stdout.on("data", (data) => { 21 | data = data.toString(); 22 | // console.log("data: "+ data); 23 | // eslint-disable-next-line no-useless-escape 24 | const findFileName = data.match(/(Completed download of (\\)?(\/)?)(.* - .*$)/m); 25 | // console.log("data " + data); 26 | console.log("findfilename: " + findFileName); 27 | if (findFileName) { 28 | filename = findFileName[4]; 29 | } 30 | }); 31 | deemix.on("close", (code) => { 32 | console.log("deemix finished download with code " + code); 33 | clearTimeout(killTimeout); 34 | if (code == 0) { 35 | console.log("filename: " + filename); 36 | resolve({ 37 | "path": pathToFiles + filename, 38 | "fileName": filename, 39 | }); 40 | } 41 | else { 42 | reject(code); 43 | } 44 | }); 45 | }); 46 | return dlpromise; 47 | } 48 | 49 | function searchTrack(query) { 50 | const trackpromise = new Promise((resolve, reject) => { 51 | // is query an ISRC? 52 | const isISRC = query.match(/^[A-Z]{2}-?\w{3}-?\d{2}-?\d{5}$/); 53 | console.log(isISRC); 54 | if (isISRC != null) { 55 | console.log("Searching Deezer for ISRC " + query); 56 | https.get("https://api.deezer.com/2.0/track/isrc:" + query, (res) => { 57 | if (res.statusCode != 200) { 58 | reject("DZ Search Code " + res.statusCode); 59 | } 60 | const chunks = []; 61 | res.on("data", (chunk) => { 62 | chunks.push(chunk); 63 | }); 64 | res.on("end", () => { 65 | let data; 66 | try { 67 | data = JSON.parse(Buffer.concat(chunks).toString()); 68 | } 69 | catch (err) { 70 | reject("Error while making parsing deezer response"); 71 | } 72 | console.log(data); 73 | // if there is a first result 74 | if (data && data.id) { 75 | // return it 76 | resolve(data); 77 | } 78 | else { 79 | reject("DZ Search No Results"); 80 | } 81 | }); 82 | 83 | }); 84 | } 85 | else { 86 | // we only get 1 result from the api 87 | console.log("Searching deezer for " + query); 88 | https.get("https://api.deezer.com/search?index=0&limit=1&q=" + encodeURIComponent(query), (res) => { 89 | if (res.statusCode != 200) { 90 | reject("DZ Search Code " + res.statusCode); 91 | } 92 | res.on("data", (data) => { 93 | // parse data 94 | data = JSON.parse(data.toString()); 95 | // if there is a first result 96 | if (data && data.data && data.data[0]) { 97 | // return it 98 | resolve(data.data[0]); 99 | } 100 | else { 101 | reject("DZ Search No Results"); 102 | } 103 | }); 104 | }); 105 | } 106 | 107 | }); 108 | return trackpromise; 109 | } 110 | function sanitizeFilename(text) { 111 | // eslint-disable-next-line no-useless-escape 112 | return text.replace(/[^. A-z0-9_-]/g, "_"); 113 | } 114 | 115 | function trackExists(artist, title) { 116 | // console.log(artist); 117 | // console.log(title); 118 | const filepath = path.join(pathToFiles, sanitizeFilename(artist + " - " + title) + ".mp3"); 119 | console.log("dzfile guess: " + filepath); 120 | if (existsSync(filepath)) { 121 | return filepath; 122 | } 123 | return false; 124 | } 125 | 126 | 127 | module.exports = { DownloadTrack, searchTrack, trackExists }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { Intents, Client } = require("discord.js"); 2 | 3 | // Intents: Guilds, VoiceStates, GuildMesssages 4 | const intents = [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_VOICE_STATES, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_MESSAGE_REACTIONS, Intents.FLAGS.GUILD_PRESENCES]; 5 | 6 | // Create Client 7 | const client = new Client({ intents }); 8 | 9 | let DIY_COMMANDO; 10 | 11 | const { reactions, guildsMeta } = require("./general.js"); 12 | 13 | const config = require("./config.json"); 14 | 15 | // Login 16 | console.log("runIndexer is set to " + config.runIndexerOnStart); 17 | if (config.runIndexerOnStart == true) { 18 | require("./music/indexer.js").index().then(() => { 19 | client.login(config.token); 20 | }); 21 | } 22 | else { 23 | client.login(config.token); 24 | } 25 | 26 | console.log("Prefix "+config.PREFIX) 27 | 28 | // table of command names and their corresponding files/functions 29 | 30 | client.once("ready", () => { 31 | console.log("Ready in " + client.guilds.cache.size + " guild(s)."); 32 | client.user.setActivity("Prefix: "+config.PREFIX, { type: "WATCHING" }); 33 | DIY_COMMANDO = require("./diy_commando.js"); 34 | }); 35 | 36 | 37 | // check if prefix is PREFIX, and if so return the command back 38 | function checkCommand(content) { 39 | if (!(content[0] == config.PREFIX)) { 40 | return false; 41 | } 42 | const splitContent = content.slice(1).split(" "); 43 | 44 | const command = splitContent[0].toLowerCase(); 45 | splitContent.shift(); 46 | const args = splitContent.join(" "); 47 | 48 | return { command, args }; 49 | } 50 | 51 | client.on("messageCreate", (message) => { 52 | // check if the message is a command and parse args 53 | const { command, args } = checkCommand(message.content); 54 | 55 | // Check the command against the command table, and then run it 56 | // We pass the command arg to the command function for different functionalities, ex. if ">music" is used instead of ">play" we use soundcloud exclusively 57 | 58 | processCommand(command, args, message); 59 | 60 | }); 61 | 62 | client.on("interactionCreate", async interaction => { 63 | if (!interaction.isCommand()) return; 64 | 65 | // console.log(interaction.commandName); 66 | // console.log(interaction.options.data); 67 | 68 | const command = interaction.commandName; 69 | const argsArray = []; 70 | 71 | for (const option of interaction.options.data) { 72 | argsArray.push(option.value); 73 | } 74 | // console.log(argsArray); 75 | const args = argsArray.join(" "); 76 | 77 | 78 | interaction.react = async (reaction) => { 79 | return interaction.editReply({ content: reaction, fetchReply: true, ephemeral: true }); 80 | }; 81 | await interaction.deferReply({ ephemeral: true, fetchReply: true }); 82 | interaction.author = interaction.member.user; 83 | // console.log(interaction.member.displayName) 84 | // console.log(interaction.author) 85 | 86 | // follow up for addtoqueue message 87 | interaction.reply = (replyArgs) => { 88 | replyArgs.ephemeral = false; 89 | replyArgs.fetchReply = true; 90 | return interaction.followUp(replyArgs); 91 | }; 92 | 93 | interaction.delete = interaction.deleteReply; 94 | 95 | processCommand(command, args, interaction); 96 | }); 97 | 98 | function processCommand(command, args, message) { 99 | if (DIY_COMMANDO[command]) { 100 | try { 101 | DIY_COMMANDO[command](message, args, command); 102 | // terminate spotify following if any commands are ran 103 | if ((command != "queue" && command != "spotify") && guildsMeta[message.guild.id] && guildsMeta[message.guild.id].spotify) { 104 | guildsMeta[message.guild.id].spotify = false; 105 | } 106 | } 107 | catch (error) { 108 | message.react(reactions.warning); 109 | message.reply("```index.js: " + error + "```"); 110 | console.log(error); 111 | } 112 | } 113 | } 114 | 115 | if (require("./voice/correctVoiceConfig")() == 0) { 116 | client.on("voiceStateUpdate", (oldState, newState) => { 117 | require("./voice/voiceProcessing").trackVCMembers(oldState, newState, client.id); 118 | }); 119 | } 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supersceded by https://github.com/karyeet/Tangerine 2 | 3 | 4 | # Mandarine 5 | A Node.js Discord music bot made using the latest versions of [Discord.js](https://github.com/discordjs/discord.js/) & [play-dl](https://github.com/play-dl/play-dl) 6 | 7 | Mandarine was created as a personal Music Bot in lieu of Rythm Bot (RIP). 8 | 9 | ## Features: 10 | - [x] Spotify Listen Along 11 | - [x] Soundcloud streaming 12 | - [x] Youtube streaming 13 | - [x] Spotify & Deezer link support 14 | - [x] play, pause, resume, skip, remove, queue 15 | - [x] Detailed queue embed with buttons to increment pages 16 | - [x] Other detailed embeds 17 | - [x] Deletes its own messages to avoid clutter 18 | - [x] Light weight & universal 19 | - [x] Support deezer as a source 20 | - [x] Local file support 21 | - [x] Youtube playlist support 22 | - [x] Spotify playlist support 23 | - [x] Partial support for voice commands 24 | - [x] Help command 25 | - [x] Docker image 26 | 27 | #### Planned Features: 28 | - [ ] Currently undergoing Typescript rewrite 29 | - [ ] Deezer Playlist support 30 | - [ ] Multi-threaded streaming 31 | - [ ] Prefix command & other customizations 32 | - [ ] Volume 33 | - [ ] Preload next song for seamless playing 34 | - [ ] Voice recognition for play command 35 | 36 | ## Docker Deployment (Preferred) 37 | [Create a discord bot](https://discord.com/developers/applications) 38 | then [add the bot to your server](https://help.pebblehost.com/en/article/how-to-invite-your-bot-to-a-discord-server-1asdlyg/). 39 | 40 | In the discord developer dashboard, toggle on: 41 | - PRESENCE INTENT 42 | - SERVER MEMBERS INTENT 43 | - MESSAGE CONTENT INTENT 44 | 45 | Example docker-compose.yml: 46 | 47 | ``` 48 | version: "3" 49 | 50 | services: 51 | mandarine: 52 | container_name: mandarine_music_bot 53 | image: karyeet/mandarine:latest 54 | volumes: 55 | - ./config/config.json:/Mandarine/config.json:ro 56 | - ./config/models/:/Mandarine/voice/models:ro 57 | - ./mandarineFiles:/root/mandarineFiles 58 | - ./playdlData:/Mandarine/.data 59 | environment: 60 | - "arl=5a2e" 61 | restart: unless-stopped 62 | ``` 63 | 64 | Create a folder and create your docker-compose.yml inside it. 65 | In the same folder create a folder called config, within this folder paste your config.json. An example config can be found in the repository. 66 | If you want to use the picovoice backed voice commands, paste your models within a folder called models within the config folder. 67 | 68 | To use >music, you must provide your deezer arl token to the environment. Replace "5a2e" with your full arl token in the docker-compose.yml file. 69 | 70 | To authorize play-dl with spotify or youtube, run this command while inside the same folder as your docker-compose.yml. Specify that you would like to save to file: 71 | 72 | ` 73 | sudo docker run -it --rm -v "./playdlData:/.data" karyeet/mandarine:latest /bin/ash -c "node /Mandarine/Docker/authorize.js" 74 | ` 75 | 76 | 77 | ## Normal Deployment 78 | Before anything, install [Node v16 LTS](https://nodejs.org/en/) 79 | and [create a discord bot](https://discord.com/developers/applications) 80 | then [add the bot to your server](https://help.pebblehost.com/en/article/how-to-invite-your-bot-to-a-discord-server-1asdlyg/). 81 | 82 | In the discord developer dashboard, toggle on: 83 | - PRESENCE INTENT 84 | - SERVER MEMBERS INTENT 85 | - MESSAGE CONTENT INTENT 86 | 87 | then 88 | 89 | 1. Download the Zip Archive 90 | 2. Unzip archive to a directory of your choosing 91 | 3. Create a file called config.json in the directory and copy the config example to the file, then configure as necessary. (token is required) 92 | 1. Get your bot token from the discord developer dashboard 93 | 4. Open command prompt or terminal in the chosen directory 94 | 5. Run `npm install` 95 | 5. To start the bot run `node .` or `npm start` 96 | 97 | To make use of the >music command, deemix must be installed via `python3 -m pip install deemix` 98 | 99 | To support spotify links, play-dl must have spotify authorization. Check the wiki. 100 | 101 | To automatically add local tagged mp3s to the localLibrary.json, add the files to the music folder (`$home$/mandarineFiles`) and run the indexer.js file found in `./music/indexer.js` using node. 102 | 103 | ### Check out the [wiki](https://github.com/karyeet/Mandarine/wiki) for commands and more help. 104 | -------------------------------------------------------------------------------- /voice/voiceProcessing.js: -------------------------------------------------------------------------------- 1 | const { processOpusStream } = require("./convert"); 2 | 3 | const { listenToPCM } = require("./porcupine"); 4 | 5 | const { processRhinoVoiceData } = require("./rhino.js"); 6 | 7 | const { guildsMeta, reactions } = require("../general.js"); 8 | 9 | const { getVoiceConnection } = require("@discordjs/voice"); 10 | 11 | const DIY_COMMANDO = require("../diy_commando.js"); 12 | 13 | // const { voiceCommands } = require("../config.json"); 14 | 15 | /* 16 | audioReceivers = { 17 | channelid:{ 18 | userid:receiver 19 | } 20 | } 21 | */ 22 | const audioReceivers = {}; 23 | 24 | /* 25 | hotwordDebounce:{channelid:true} 26 | */ 27 | const hotwordDebounce = {}; 28 | 29 | 30 | async function hotwordDetected(context) { 31 | const { userid, voiceChannel, PCMprovider } = context; 32 | // set debounce for channel/guild 33 | if (hotwordDebounce[voiceChannel.id]) { 34 | return; 35 | } 36 | hotwordDebounce[voiceChannel.id] = true; 37 | // stop sending audio frames to porcupine 38 | PCMprovider.removeAllListeners("frame"); 39 | // start sending to rhino & to an array in case rhino is inconclusive 40 | const voiceData = []; 41 | PCMprovider.on("frame", (frame) => { 42 | voiceData.push(frame); 43 | }); 44 | const intentResult = await processRhinoVoiceData(PCMprovider); 45 | if (!intentResult) { 46 | console.log("unknown intent"); 47 | } 48 | else { 49 | console.log(intentResult); 50 | DIY_COMMANDO[intentResult](guildsMeta[voiceChannel.guild.id].notifyRecording); 51 | } 52 | 53 | // resume listening for hotword 54 | console.log("resuming listening for hotword"); 55 | listenToPCM(PCMprovider, userid, voiceChannel); 56 | // unset debounce 57 | hotwordDebounce[voiceChannel.id] = false; 58 | } 59 | 60 | // pass along voice channel for context when hotword is found 61 | function listenToOpus(opusStream, userid, voiceChannel) { 62 | const picoPCMProvider = processOpusStream(userid, opusStream, 512); 63 | // listen for hotword event then start listening for hotwords, 64 | picoPCMProvider.on("hotword", hotwordDetected); 65 | return listenToPCM(picoPCMProvider, userid, voiceChannel); 66 | } 67 | 68 | function receiveMember(member, voiceConnection, voiceChannel) { 69 | // only if not bot 70 | if (!member.user.bot) { 71 | const audioreceiver = voiceConnection.receiver.subscribe(member.id, { 72 | "autoDestroy": true, 73 | "objectMode": true, 74 | }); 75 | audioReceivers[voiceChannel.id][member.id] = audioreceiver; 76 | return listenToOpus(audioreceiver, member.id, voiceChannel); 77 | } 78 | } 79 | 80 | async function initializeVoiceCommands(message, voiceConnection) { 81 | 82 | guildsMeta[message.guildId].notifyRecording = await message.channel.send(reactions.speaking + "Voice commands are enabled."); 83 | const voiceChannel = message.member.voice.channel; 84 | const members = voiceChannel.members; 85 | 86 | // initalize receiver table 87 | audioReceivers[voiceChannel.id] = {}; 88 | 89 | // create receiver for each member currently in vc 90 | members.forEach((member) => {receiveMember(member, voiceConnection, voiceChannel);}); 91 | 92 | } 93 | 94 | // should be executed by client eventemitter for voicestateupdate 95 | async function trackVCMembers(oldState, newState, clientid) { 96 | if (audioReceivers[oldState.channelId] && (oldState.id == clientid && !newState.channel || oldState.channelId != newState.channelId)) { 97 | // i left the voice channel, destroy all listeners for channelid 98 | // console.log("I was disconnected from VC"); 99 | for (const userid in audioReceivers[oldState.channelId]) { 100 | audioReceivers[oldState.channelId][userid].destroy(); 101 | } 102 | } 103 | else if (oldState.channelId != newState.channelId) { 104 | // someone left or joined a voice channel, otherwise dont care 105 | if (audioReceivers[oldState.channelId] && (!newState.channel || oldState.channelId != newState.channelId)) { 106 | // someone left my voice channel 107 | // destroy audioreceiver if it exists 108 | if (audioReceivers[oldState.channelId][oldState.id]) { 109 | audioReceivers[oldState.channelId][oldState.id].destroy(); 110 | } 111 | 112 | } 113 | else if (audioReceivers[newState.channelId] && oldState.channelId != newState.channelId) { 114 | // someone joined my voice channel 115 | if (!newState.member.user.bot) { 116 | // create new audioreceiver if they're not a bot and a voice connection exists 117 | const voiceConnection = await getVoiceConnection(newState.guild.id); 118 | if (voiceConnection) { 119 | receiveMember(newState.member, voiceConnection, newState.channel); 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | 127 | module.exports = { initializeVoiceCommands, listenToOpus, trackVCMembers }; -------------------------------------------------------------------------------- /commands/queue.js: -------------------------------------------------------------------------------- 1 | const { queueGen, CalcQueuePages } = require("../presetEmbeds"); 2 | const { queue, guildsMeta, reactions } = require("../general"); 3 | const { MessageActionRow, MessageButton } = require("discord.js"); 4 | 5 | function buttonGen(guildid, page) { 6 | const row = new MessageActionRow(); 7 | // create next button 8 | const nextButton = new MessageButton(); 9 | nextButton.setCustomId("next"); 10 | nextButton.setLabel("Next"); 11 | nextButton.setStyle("PRIMARY"); 12 | 13 | // create back button 14 | const backButton = new MessageButton(); 15 | backButton.setCustomId("back"); 16 | backButton.setLabel("Back"); 17 | backButton.setStyle("SECONDARY"); 18 | 19 | // if on the first page disable back button 20 | if (page == 1) { 21 | backButton.setDisabled(true); 22 | } 23 | else if (page == CalcQueuePages(queue[guildid].length)) { 24 | // if on the last page disable next button 25 | nextButton.setDisabled(true); 26 | } 27 | 28 | row.addComponents([backButton, nextButton]); 29 | return row; 30 | } 31 | 32 | function pageCheck(guildid, page) { 33 | // if the requested page is greater than the calculated max pages possible, set the page to the last page 34 | if (page > CalcQueuePages(queue[guildid].length)) { 35 | page = CalcQueuePages(queue[guildid].length); 36 | } 37 | // if the requested page is less than 1, set the page to 1 38 | if (page < 1) { 39 | page = 1; 40 | } 41 | return page; 42 | } 43 | 44 | async function queueFunc(message, args) { 45 | // if the queue doesnt exist or is empty, dont do anything 46 | if (!queue[message.guild.id] || queue[message.guild.id].length < 1) { 47 | message.react(reactions.negative); 48 | return false; 49 | } 50 | // if the page supplied by args is not a number, default page to 1 51 | 52 | let page = Number(args); 53 | if (isNaN(Number(page)) || !args) { 54 | page = 1; 55 | } 56 | 57 | page = pageCheck(message.guild.id, page); 58 | message.react(reactions.positive); 59 | 60 | // if there is more than one page create next and back button 61 | if (CalcQueuePages(queue[message.guild.id].length) > 1) { 62 | 63 | // message, auto delete after 60 seconds, and setup interaction responses 64 | message.reply(await replyOptions(true, queue[message.guild.id][0], message, page)) 65 | .then(msg => { 66 | // setTimeout(() => msg.delete(), 20000); not needed if we can delete message on interaction end 67 | const filter = (interaction) => interaction.customId == "next" || interaction.customId == "back"; 68 | const collector = msg.createMessageComponentCollector({ filter, time: 30_000 }); 69 | collector.on("collect", async (buttonInteraction) => { 70 | if (buttonInteraction.customId == "next") { 71 | // on next button press, increment page 72 | page += 1; 73 | // check page to make sure its not too high or low 74 | page = pageCheck(msg.guild.id, page); 75 | // edit the message with the new embed and buttons 76 | buttonInteraction.update({ components: [buttonGen(message.guild.id, page)], embeds:[queueGen(message.member.user, page, queue[message.guild.id], guildsMeta[message.guild.id])] }); 77 | } 78 | else if (buttonInteraction.customId == "back") { 79 | // on next button press, increment page 80 | page -= 1; 81 | // check page to make sure its not too high or low 82 | page = pageCheck(msg.guild.id, page); 83 | // edit the message with the new embed and buttons 84 | buttonInteraction.update({ components: [buttonGen(message.guild.id, page)], embeds:[queueGen(message.member.user, page, queue[message.guild.id], guildsMeta[message.guild.id])] }); 85 | } 86 | }); 87 | 88 | collector.on("end", () => { 89 | msg.delete() 90 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 91 | }); 92 | 93 | }); 94 | 95 | 96 | } 97 | else { 98 | // otherwise send the queue without buttons and delete after 60 seconds 99 | message.reply(await replyOptions(false, queue[message.guild.id][0], message, page)) 100 | .then(msg => { 101 | setTimeout(() => { 102 | msg.delete() 103 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 104 | }, 30000); 105 | }); 106 | } 107 | 108 | } 109 | 110 | async function replyOptions(buttons, firstEntry, message, page) { 111 | const options = { embeds:[queueGen(message.member.user, page, queue[message.guild.id], guildsMeta[message.guild.id])] }; 112 | 113 | // attachment://thumb.jpg 114 | if (buttons) { 115 | options.components = [buttonGen(message.guild.id, page)]; 116 | } 117 | 118 | if (firstEntry && firstEntry.thumbURL === "attachment://thumb.jpg" && firstEntry.thumbData) { 119 | options.files = [{ "name":"thumb.jpg", "attachment":firstEntry.thumbData }]; 120 | } 121 | 122 | return options; 123 | } 124 | 125 | module.exports = queueFunc; -------------------------------------------------------------------------------- /commands/spotify.js: -------------------------------------------------------------------------------- 1 | /* 2 | follow what someone is listening to in spotify 3 | this will override normal commands 4 | we will call play which will handle joining, this will simply add the songs to queue and keep the queue exclusive 5 | 6 | */ 7 | 8 | 9 | const { queue, guildsMeta, reactions } = require("../general.js"); 10 | const join = require("./join.js"); 11 | const play = require("./play.js"); 12 | const skip = require("./skip.js"); 13 | 14 | // const templateSpotifyURL = "https://open.spotify.com/track/"; 15 | // https://open.spotify.com/track/1AzqpMy3yLYNITSOUrnL8i 16 | 17 | function getTrack(activities) { 18 | const track = {}; 19 | for (let i = 0; i < activities.length;i++) { 20 | if (activities[i].syncId) { 21 | track.name = activities[i].details; 22 | track.artist = activities[i].state; 23 | track.id = activities[i].syncId; 24 | return track; 25 | } 26 | 27 | } 28 | // return false if we dont find an id 29 | return false; 30 | } 31 | 32 | async function spotify(message, args) { 33 | 34 | if (args == "stop" || args == "end" || args == "false") { 35 | guildsMeta[message.guild.id].spotify = false; 36 | message.react(reactions.positive); 37 | return true; 38 | } 39 | // if we are already following someone in spotify 40 | else if (guildsMeta[message.guild.id] && guildsMeta[message.guild.id].spotify) { 41 | console.log("Already listening along."); 42 | message.react(reactions.negative); 43 | return false; 44 | } 45 | 46 | // console.log(message.options); 47 | // console.log(message.options.getMember("user")); 48 | 49 | 50 | if ((message.options && message.options.getMember("user")) || (message.mentions && message.mentions.members && message.mentions.members.first())) { 51 | 52 | // look for spotify activity 53 | let targetMember; 54 | // interaction or message compatability 55 | if (message.options && message.options.getMember("user")) { 56 | targetMember = message.options.getMember("user"); 57 | } 58 | else { 59 | targetMember = message.mentions.members.first(); 60 | } 61 | 62 | console.log(targetMember.presence); 63 | let spotifyTrack = getTrack(targetMember.presence.activities); 64 | if (!spotifyTrack) { 65 | // they are not listening to spotify 66 | console.log("Member not listening on spotify."); 67 | message.react(reactions.negative); 68 | return false; 69 | } 70 | // else 71 | console.log("reply listening along"); 72 | const scapeGoatMessage = await message.reply("Listening along!"); 73 | // join in case we haven't 74 | await join(message); 75 | // reset the queue 76 | skip(scapeGoatMessage); 77 | console.log("listening along"); 78 | queue[message.guild.id] = []; 79 | const deezerPlay = await play(scapeGoatMessage, `${spotifyTrack.artist} - ${spotifyTrack.name}`, "music"); 80 | if (!deezerPlay) { 81 | // play youtube 82 | await play(scapeGoatMessage, `${spotifyTrack.artist} - ${spotifyTrack.name} lyrics`); 83 | } 84 | message.react(reactions.positive); 85 | guildsMeta[message.guild.id].spotify = targetMember.id; 86 | 87 | // if inactive spotify reaches 60 (seconds) checks, we will terminate 88 | let inactiveSpotify = 0; 89 | const checkSpotify = setInterval(async () => { 90 | if (guildsMeta[message.guild.id].spotify && inactiveSpotify < 60) { 91 | // refetch watched member 92 | const member = await message.guild.members.fetch(guildsMeta[message.guild.id].spotify); 93 | const newTrack = getTrack(member.presence.activities); 94 | // console.log(newTrack); 95 | // manage inactive spotify counter 96 | if (newTrack) { 97 | // if there is a song 98 | inactiveSpotify = 0; 99 | if (newTrack.id != spotifyTrack.id) { 100 | // if new song 101 | spotifyTrack = newTrack; 102 | // play 103 | console.log("playing new spotify song"); 104 | const deezerPlay = await play(scapeGoatMessage, `${spotifyTrack.artist} - ${spotifyTrack.name}`, "music"); 105 | if (!deezerPlay) { 106 | // play youtube 107 | await play(scapeGoatMessage, `${spotifyTrack.artist} - ${spotifyTrack.name} lyrics`); 108 | } 109 | skip(scapeGoatMessage); 110 | } 111 | } 112 | else { 113 | // if no song 114 | inactiveSpotify++; 115 | } 116 | } 117 | else { 118 | // stop checking if queue mode is no longer spotify 119 | console.log("no longer listening along"); 120 | guildsMeta[message.guild.id].spotify = false; 121 | clearInterval(checkSpotify); 122 | scapeGoatMessage.delete() 123 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 124 | } 125 | 126 | }, 1000); 127 | 128 | } 129 | else { 130 | // no member specified 131 | console.log("no member specified"); 132 | message.react(reactions.negative); 133 | return false; 134 | } 135 | 136 | 137 | return true; 138 | } 139 | 140 | module.exports = spotify; -------------------------------------------------------------------------------- /general.js: -------------------------------------------------------------------------------- 1 | // Contains functions & tables used by multiple scripts in the project 2 | 3 | const playdl = require("play-dl"); 4 | const { createAudioResource, StreamType } = require("@discordjs/voice"); 5 | const { createReadStream, existsSync } = require("fs"); 6 | const path = require("path"); 7 | 8 | const reactions = { 9 | "positive":"🍊", 10 | "negative":"👎", 11 | "confused":"⁉️", 12 | "warning":"⚠️", 13 | "repeat":"🔂", 14 | "next_track":"⏭️", 15 | "speaking":"🗣️", 16 | }; 17 | 18 | /* "guilid": [{ 19 | title: "Megolovania", 20 | url: "https://www.youtube.com/watch?v=WibFGyDMmYA" 21 | author: "Toby Fox", 22 | length: "5:13", 23 | thumbURL: "", 24 | type: "yt_track" 25 | stream_url: "https://www.youtube.com/watch?v=WibFGyDMmYA" or "/home/music/file.mp3" 26 | }]*/ 27 | const queue = {}; 28 | 29 | // guildMeta for information about guilds 30 | /* 31 | "looping": false, 32 | "volume": 1, 33 | "spotify": false, // when spotify is set, it will be the userid 34 | "summonMessage":message 35 | */ 36 | const guildsMeta = {}; 37 | 38 | // "guilid": audioPlayer 39 | const audioPlayers = {}; 40 | 41 | async function playNext(message) { 42 | try { 43 | 44 | // if the queue item doesnt exist dont bother playing 45 | if (!queue[message.guild.id] || !queue[message.guild.id][0] || !queue[message.guild.id][0].stream_url) { 46 | return false; 47 | } 48 | 49 | // set resource out of scope 50 | let audioResource; 51 | 52 | console.log("Stream url: " + queue[message.guild.id][0].stream_url); 53 | console.log("url: " + queue[message.guild.id][0].url); 54 | 55 | // get type of stream to see if we need to attach listeners 56 | const streamType = queue[message.guild.id][0].type; 57 | console.log("stream type " + streamType); 58 | if (streamType == "so_track") { 59 | // create playdl stream 60 | const audioStream = await playdl.stream(queue[message.guild.id][0].stream_url); 61 | // attach listeners to playdl for "proper functionality" 62 | playdl.attachListeners(audioPlayers[message.guildId], audioStream); 63 | console.log("attached listeners"); 64 | // create audio resource 65 | audioResource = createAudioResource(audioStream.stream, { inputType: audioStream.type }); 66 | } 67 | else if (streamType == "dz_track") { 68 | console.log("recognized dz_track"); 69 | const audioStream = createReadStream(queue[message.guild.id][0].stream_url); 70 | audioResource = createAudioResource(audioStream, { inputType: StreamType.Arbitrary }); 71 | } 72 | else if (streamType == "yt_video" || streamType == "yt_track") { 73 | /* const audioStream = ytdl(queue[message.guild.id][0].stream_url, { 74 | quality:"highestaudio", 75 | filter: "audioonly", 76 | highWaterMark: 1 << 25, 77 | });*/ 78 | 79 | const audioStream = await playdl.stream(queue[message.guild.id][0].stream_url, { 80 | "quality:": 2, 81 | }); 82 | // attach listeners to playdl for "proper functionality" 83 | playdl.attachListeners(audioPlayers[message.guildId], audioStream); 84 | console.log("attached listeners"); 85 | // create audio resource 86 | audioResource = createAudioResource(audioStream.stream, { inputType: audioStream.type }); 87 | 88 | } 89 | 90 | // play the audio resource for the current playdl stream // need to check docs for play-dl and input type 91 | console.log("created audioresource"); 92 | audioPlayers[message.guildId].play(audioResource); 93 | console.log("playing resource"); 94 | message.guild.me.setNickname(queue[message.guild.id][0].title.substring(0, 31)); 95 | } 96 | catch (error) { 97 | /* If an error happens while trying to play a song, 98 | we must skip the song here in order to allow the bot to resume 99 | this is because discordjs will not fire the idle event if it never starts streaming */ 100 | message.react(reactions.warning); 101 | message.reply("```general.js: " + error + "```"); 102 | console.log(error); 103 | console.log("Error while playing song, skipping song."); 104 | queue[message.guild.id].shift(); 105 | playNext(message); 106 | } 107 | } 108 | 109 | // set SC client id every start so it doesnt expire 110 | let SC_clientId; 111 | async function setScClientId() { 112 | try { 113 | console.log("Refreshing soundcloud token"); 114 | SC_clientId = await playdl.getFreeClientID(); 115 | 116 | playdl.setToken({ 117 | soundcloud : { 118 | client_id : SC_clientId, 119 | }, 120 | }); 121 | // console.log("SC ID: " + SC_clientId); 122 | } 123 | catch (err) { 124 | console.log("Error while refreshing Soundcloud ID!\n", err); 125 | } 126 | } 127 | 128 | async function refreshSpotifyToken() { 129 | const dataFolderPath = path.join(__dirname, ".data"); 130 | const spotifyFilePath = path.join(dataFolderPath, "spotify.data"); 131 | if (existsSync(dataFolderPath) && existsSync(spotifyFilePath)) { 132 | console.log("Refreshing spotify token"); 133 | if (playdl.is_expired()) { 134 | try { 135 | await playdl.refreshToken(); 136 | } 137 | catch (err) { 138 | console.log("Spotify authentication failed! Please reauthenticate.\n" + err); 139 | return false; 140 | } 141 | return true; 142 | } 143 | } 144 | else { 145 | console.log("Spotify not yet configured, skipping refresh."); 146 | return false; 147 | } 148 | } 149 | 150 | setScClientId(); 151 | 152 | 153 | refreshSpotifyToken(); 154 | 155 | // set sc id every 10 minutes 156 | setInterval(setScClientId, 10 * 60 * 1000); 157 | // refresh spotify token every 55 minutes 158 | setInterval(refreshSpotifyToken, 55 * 60 * 1000); 159 | 160 | module.exports = { queue, audioPlayers, playNext, reactions, guildsMeta, refreshSpotifyToken }; -------------------------------------------------------------------------------- /music/libraryManager.js: -------------------------------------------------------------------------------- 1 | /* 2 | library manager for finding files and faciliting deezer downloads 3 | 4 | */ 5 | 6 | 7 | const fs = require("fs"); 8 | const localLibrary = require("./localLibrary.json"); 9 | 10 | const FuzzySet = require("fuzzyset"); 11 | 12 | const homedir = require("os").homedir(); 13 | let parseFile; 14 | import("music-metadata").then((mmModule) => { 15 | parseFile = mmModule.parseFile; 16 | }); 17 | 18 | const meezer = require("./meezer.js"); 19 | const path = require("path"); 20 | 21 | const pathToDLFiles = path.join(homedir, "/mandarineFiles/"); 22 | 23 | /* 24 | 25 | { 26 | "filename.mp3":{ 27 | "artist":"artistName" 28 | "title:":"songTitle" 29 | "search":"filename" 30 | } 31 | } 32 | 33 | */ 34 | 35 | function createFuzzySetArr() { 36 | const fzpromise = new Promise((resolve) => { 37 | const arr = []; 38 | for (const key in localLibrary) { 39 | for (const index in localLibrary[key].search) { 40 | const searchTerm = localLibrary[key].search[index]; 41 | arr.push(searchTerm); 42 | // console.log("search term "+searchTerm) 43 | } 44 | } 45 | resolve(arr); 46 | }); 47 | return fzpromise; 48 | } 49 | 50 | // get array of files and create new fuzzy object, only use search key 51 | let fuzzySet; 52 | createFuzzySetArr().then((arr) => { 53 | fuzzySet = new FuzzySet(arr); 54 | }); 55 | 56 | 57 | async function requestTrack(query) { 58 | 59 | // perform search 60 | console.log("fuzzy search"); 61 | const fuzzyResult = await fuzzySet.get(query); 62 | console.log(fuzzyResult); 63 | 64 | 65 | let threshold = 0.77; 66 | const isISRC = query.match(/^[A-Z]{2}-?\w{3}-?\d{2}-?\d{5}$/); 67 | if (isISRC) { 68 | threshold = 1; 69 | } 70 | 71 | // if valid match, return filename 72 | if (fuzzyResult && fuzzyResult[0] && (console.log(fuzzyResult[0][0]) || true) && fuzzyResult[0][0] > threshold) { 73 | console.log("fuzzy found"); 74 | for (const key in localLibrary) { 75 | if (localLibrary[key].search.find(pattern => pattern == fuzzyResult[0][1])) { 76 | // const fuzzyfilepath = path.join(pathToFiles, key); 77 | const fuzzyfilepath = path.join(key); 78 | console.log(fuzzyfilepath); 79 | return { 80 | "path":fuzzyfilepath, 81 | "metadata": await parseFile(fuzzyfilepath), 82 | }; 83 | } 84 | } 85 | 86 | } 87 | // otherwise perform deezer track fetch (no explicit else) 88 | console.log("fuzzy fail"); 89 | let result = false; 90 | try { 91 | result = await meezer.searchTrack(query); 92 | } 93 | catch (err) { 94 | console.log(err); 95 | } 96 | // track not found then return false 97 | if (!result || !result.link) { 98 | console.log("Track not found"); 99 | return false; 100 | } 101 | // before download, see if track exists and fuzzy missed it 102 | const trackExists = meezer.trackExists(result.artist.name, result.title); 103 | if (trackExists) { 104 | console.log("track exist"); 105 | return { 106 | "metadata": await parseFile(trackExists), 107 | "path": trackExists, 108 | }; 109 | } 110 | // at this point track does not exist, start download! 111 | const trackDL = await meezer.DownloadTrack(result.link); 112 | 113 | if (trackDL) { 114 | // successfull download so add to library and return filename 115 | console.log("trackdl"); 116 | const metadata = await parseFile(trackDL.path); 117 | let isrc = false; 118 | if (metadata.common.isrc && metadata.common.isrc[0]) { 119 | isrc = metadata.common.isrc[0]; 120 | } 121 | addToLibrary(result.artist.name, result.title, isrc, trackDL.fileName); 122 | return { 123 | "metadata": metadata, 124 | "path": trackDL.path, 125 | }; 126 | } 127 | else { 128 | // trackdl returned a nontrue value 129 | console.log("track not found"); 130 | return false; 131 | } 132 | 133 | 134 | } 135 | 136 | /* function addDZdataToQueue(message, data) { 137 | const queueData = { 138 | "title": data.title, 139 | "url": data.link, 140 | "author": data.artist.name, 141 | "durationInSec":data.duration, 142 | "thumbURL": data.album.cover_medium, 143 | "type": "dz_track", 144 | "requester": message.author, 145 | "channel": message.channel, 146 | "stream_url": data.fileLocation, 147 | };*/ 148 | 149 | function addToLibrary(artist, title, isrc, fileName) { 150 | const search = [ 151 | (title).replace(/\.|'|-/g, ""), 152 | (artist + " " + title).replace(/\.|'|-/g, ""), 153 | (title + " " + artist).replace(/\.|'|-/g, ""), 154 | ]; 155 | 156 | 157 | const featSplitRegex = / feat\.? /; 158 | if (title.match(featSplitRegex)) { 159 | search.push(title.split(featSplitRegex)[0]); 160 | } 161 | 162 | if (isrc) { 163 | search.push(isrc); 164 | } 165 | 166 | if (artist.split(" ").length > 1) { 167 | // if the artist has spaces in their names, chances are people will only add one part of their name 168 | for (const artistNameSplit of artist.split(" ")) { 169 | search.push((title + " " + artistNameSplit).replace(/\.|'|-/g, "")); 170 | search.push((artistNameSplit + " " + title).replace(/\.|'|-/g, "")); 171 | } 172 | } 173 | 174 | if (search[2] != (title).replace(/\(.*\)|\.|'|-/g, "")) { 175 | search.push((title).replace(/\(.*\)|\.|'|-/g, "")); 176 | } 177 | 178 | localLibrary[path.join(pathToDLFiles, fileName)] = { 179 | "title": title, 180 | "artist": artist, 181 | "search": search, 182 | }; 183 | // add to fuzzy set 184 | for (const searchItem of search) { 185 | fuzzySet.add(searchItem); 186 | } 187 | 188 | fs.writeFileSync(path.join(__dirname, "localLibrary.json"), JSON.stringify(localLibrary)); 189 | } 190 | 191 | console.log(pathToDLFiles); 192 | /* async function test() { 193 | console.log(await requestTrack("metro boomin superhero")); 194 | }*/ 195 | // test(); 196 | // console.log(parseFile(path.join(pathToFiles, "Metro Boomin - Superhero (Heroes & Villains).mp3"))); 197 | /* setTimeout(()=>{ 198 | requestTrack("metro spider") 199 | }, 1_000) 200 | */ 201 | 202 | /* addToLibrary("foo", "bar", "foobar.mp3")*/ 203 | 204 | module.exports = { requestTrack }; -------------------------------------------------------------------------------- /presetEmbeds.js: -------------------------------------------------------------------------------- 1 | const mEmbeds = require("./mEmbeds.js"); 2 | 3 | // default 4 | const defaultEmbed = new mEmbeds(); 5 | 6 | defaultEmbed.setColor("#FFA500"); 7 | 8 | defaultEmbed.setDefault(); 9 | 10 | // song added to queue 11 | 12 | /* "guilid": [{ 13 | title: "Megolovania", 14 | url: "https://www.youtube.com/watch?v=WibFGyDMmYA" 15 | author: "Toby Fox", 16 | durationInSec: "5:13", 17 | thumbURL: "", 18 | type: "yt_track", 19 | requester, 20 | textchannel, 21 | }]*/ 22 | 23 | function secondsToTime(seconds) { 24 | if ((seconds % 60) < 10) { 25 | return Math.floor(seconds / 60) + ":0" + Math.floor(seconds % 60); 26 | } 27 | else { 28 | return Math.floor(seconds / 60) + ":" + Math.floor(seconds % 60); 29 | } 30 | } 31 | 32 | function songAdded(queueInfo, index) { 33 | const songAddedEmbed = new mEmbeds(queueInfo.requester); 34 | if (index) { 35 | songAddedEmbed.setTitle(`Added to Queue, #${index}`); 36 | } 37 | else { 38 | songAddedEmbed.setTitle("Added to Queue"); 39 | } 40 | 41 | if (queueInfo.url) { 42 | songAddedEmbed.setDesc("[" + queueInfo.title + "](" + queueInfo.url + ")"); 43 | } 44 | else { 45 | songAddedEmbed.setDesc(queueInfo.title); 46 | } 47 | 48 | songAddedEmbed.setThumb(queueInfo.thumbURL); 49 | songAddedEmbed.addField("Artist", queueInfo.author, true); 50 | songAddedEmbed.addField("Length", secondsToTime(queueInfo.durationInSec), true); 51 | return songAddedEmbed.embed.embed; 52 | } 53 | 54 | // now playing, not used because we change bot nickname 55 | 56 | function nowPlaying(queueInfo) { 57 | const nowPlayingEmbed = new mEmbeds(queueInfo.requester); 58 | nowPlayingEmbed.setTitle("Now Playing"); 59 | 60 | if (queueInfo.url) { 61 | nowPlayingEmbed.setDesc("[" + queueInfo.title + "](" + queueInfo.url + ")"); 62 | } 63 | else { 64 | nowPlayingEmbed.setDesc(queueInfo.title); 65 | } 66 | 67 | nowPlayingEmbed.setThumb(queueInfo.thumbURL); 68 | nowPlayingEmbed.addField("Artist", queueInfo.author, true); 69 | nowPlayingEmbed.addField("Length", secondsToTime(queueInfo.durationInSec), true); 70 | return nowPlayingEmbed.embed.embed; 71 | } 72 | 73 | const maxItemsPerPage = 4; 74 | function CalcQueuePages(queueLength) { 75 | let pages = Math.ceil((queueLength - 1) / maxItemsPerPage); 76 | if (pages === 0) { 77 | pages = 1; 78 | } 79 | return pages; 80 | } 81 | 82 | function queueGen(user, page, guildQueue, guildMeta) { 83 | const queueEmbed = new mEmbeds(user); 84 | 85 | let totalDurationInSec = 0; 86 | for (const track of guildQueue) { 87 | totalDurationInSec += track.durationInSec; 88 | } 89 | 90 | queueEmbed.setTitle("Queue | Duration: " + secondsToTime(totalDurationInSec) + " | Page: " + page + "/" + CalcQueuePages(guildQueue.length)); 91 | 92 | if (guildQueue[0].url) { 93 | queueEmbed.setDesc("**Currently Playing**: [" + guildQueue[0].title + "](" + guildQueue[0].url + ")"); 94 | } 95 | else { 96 | queueEmbed.setDesc("**Currently Playing**: " + guildQueue[0].title); 97 | } 98 | 99 | queueEmbed.setThumb(guildQueue[0].thumbURL); 100 | queueEmbed.addField("Artist", guildQueue[0].author, true); 101 | queueEmbed.addField("Length", secondsToTime(guildQueue[0].durationInSec), true); 102 | if (guildMeta.looping) { 103 | queueEmbed.addField("Looping", "True", true); 104 | } 105 | if (guildMeta.volume != 1) { 106 | queueEmbed.addField("Volume", guildMeta.volume, true); 107 | } 108 | if (guildMeta.spotify) { 109 | queueEmbed.addField("Spotify", `<@${guildMeta.spotify}>`, true); 110 | } 111 | // if maxItemsPerPage = 7 112 | // each page will be 9 fields, minimum page is 0, nowplaying fields are 0-1, queue fields are 2-9, for a total of 7 queue items 113 | // set i to ((page-1) * 7) + 1, while i is less than or equal to (page-1) * 7 + 7 and i is less than the guildqueue length, add a field 114 | // so this will loop through 1-7, 8-14 115 | for (let i = (page - 1) * maxItemsPerPage + 1; i <= ((page - 1) * maxItemsPerPage) + maxItemsPerPage && i < guildQueue.length; i++) { 116 | 117 | if (guildQueue[i].url) { 118 | queueEmbed.addField("#" + i, "[" + guildQueue[i].title + "](" + guildQueue[i].url + ") || " + secondsToTime(guildQueue[i].durationInSec), false); 119 | } 120 | else { 121 | queueEmbed.addField("#" + i, guildQueue[i].title + " || " + secondsToTime(guildQueue[i].durationInSec), false); 122 | } 123 | 124 | 125 | } 126 | 127 | return queueEmbed.embed.embed; 128 | } 129 | 130 | function helpGen(user, prefix = ">") { 131 | const helpEmbed = new mEmbeds(user); 132 | 133 | helpEmbed.setTitle("Commands"); 134 | helpEmbed.setDesc(`Replace brackets & stuff inside brackets with what they're describing. 135 | Ex: ">play [search]" would be ">play my favorite song"`); 136 | 137 | helpEmbed.addField(prefix + "join", "Make bot join VC.", false); 138 | helpEmbed.addField(prefix + "disconnect or >leave", "Make bot leave VC.", false); 139 | helpEmbed.addField(prefix + "play [link] or >play [search]", "Play audio using a link from spotify, soundcloud, or youtube OR search and play from youtube.", false); 140 | helpEmbed.addField(prefix + "music [search]", "Play a song from a library of high quality songs. Some songs may be fetched on request & may take a couple seconds to play.", false); 141 | helpEmbed.addField(prefix + "pause or " + prefix + "stop", "Pause audio playback.", false); 142 | helpEmbed.addField(prefix + "resume or " + prefix + "unpause", "Resume audio playback. >play will also work.", false); 143 | helpEmbed.addField(prefix + "skip", "Skip the currently playing track.", false); 144 | helpEmbed.addField(prefix + "queue", "Show tracks in the queue.", false); 145 | helpEmbed.addField(prefix + "remove [index]", "Remove a track from the queue using the index number shown in the queue command. This number may change everytime you remove a track.", false); 146 | helpEmbed.addField(prefix + "loop", "Loop or unloop the current track.", false); 147 | helpEmbed.addField(prefix + "spotify [mention]", "Listen along to a user whose spotify is linked and sharing to discord.", false); 148 | 149 | return helpEmbed.embed.embed; 150 | } 151 | 152 | module.exports = { helpGen, songAdded, nowPlaying, queueGen, CalcQueuePages }; -------------------------------------------------------------------------------- /other/deezer.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Communicate with a deemix-web server to download files 4 | 5 | This file is available as an example if anyone decides to complete the 6 | implementation or wants to use it in a personal project. 7 | 8 | */ 9 | 10 | 11 | // http://192.168.2.1:6595/api/search?term=metroboomin&type=track&start=0&nb=1 12 | 13 | // http://192.168.2.1:6595/api/addToQueue 14 | // payload 15 | // {"url":"https://www.deezer.com/track/2047662477","bitrate":null} 16 | 17 | // response all we care about 18 | // {"result": true/false} 19 | 20 | 21 | const { get, request } = require("http"); 22 | const { deemixHost, deemixPort } = require("../config.json"); 23 | 24 | function getSessionOptions(host, port) { 25 | return { 26 | "host": host, 27 | "port": port, 28 | "path": "/", 29 | "method": "HEAD", 30 | }; 31 | } 32 | 33 | function getSession() { 34 | return new Promise((resolve, reject) => { 35 | const options = getSessionOptions(deemixHost, deemixPort); 36 | const req = request(options, function(res) { 37 | // console.log(res.headers["set-cookie"]); 38 | if (!res.headers || !res.headers["set-cookie"]) { 39 | reject("Couldn't get sessionID"); 40 | } 41 | const sessionID = res.headers["set-cookie"][0].match(/(connect\.sid.*[A-Za-z0-9])(;)/)[1]; 42 | // console.log(sessionID); 43 | resolve(sessionID); 44 | }, 45 | ); 46 | req.end(); 47 | }); 48 | } 49 | 50 | function connectOptions(host, port, sessionID) { 51 | return { 52 | "host": host, 53 | "port": port, 54 | "path": "/api/connect", 55 | "headers":{ 56 | "Cookie": sessionID, 57 | }, 58 | }; 59 | } 60 | 61 | // to get arl 62 | function connect(sessionID) { 63 | return new Promise((resolve, reject) => { 64 | const options = connectOptions(deemixHost, deemixPort, sessionID); 65 | 66 | get(options, (res) => { 67 | if (res.statusCode !== 200) { 68 | reject("Deemix returned " + res.statusCode); 69 | } 70 | 71 | res.setEncoding("utf8"); 72 | 73 | let rawData = ""; 74 | 75 | res.on("data", (chunk) => { 76 | rawData += chunk; 77 | }); 78 | 79 | res.on("end", () => { 80 | const parsedData = JSON.parse(rawData); 81 | // console.log(parsedData); 82 | resolve(parsedData); 83 | }); 84 | 85 | }); 86 | }); 87 | } 88 | 89 | function loginARLOptions(host, port, contentLength, sessionID) { 90 | return { 91 | "host": host, 92 | "port": port, 93 | "path": "/api/loginARL", 94 | "method": "POST", 95 | "headers":{ 96 | "Content-Type": "application/json", 97 | "Content-Length": contentLength, 98 | "Cookie": sessionID, 99 | }, 100 | }; 101 | } 102 | 103 | 104 | function loginARL(arl, sessionID) { 105 | return new Promise((resolve, reject) => { 106 | const postData = JSON.stringify({ "arl":arl, "force":true, "child":0 }); 107 | // console.log(postData); 108 | // console.log(postData.length); 109 | const options = loginARLOptions(deemixHost, deemixPort, postData.length, sessionID); 110 | // console.log(options); 111 | const req = request(options, (res) => { 112 | if (res.statusCode !== 200) { 113 | reject("Deemix returned " + res.statusCode); 114 | } 115 | 116 | res.setEncoding("utf8"); 117 | 118 | let rawData = ""; 119 | 120 | res.on("data", (chunk) => { 121 | rawData += chunk; 122 | }); 123 | 124 | res.on("end", () => { 125 | const parsedData = JSON.parse(rawData); 126 | console.log(parsedData.status); 127 | resolve(parsedData.status); 128 | }); 129 | }); 130 | req.write(postData); 131 | req.end(); 132 | 133 | }); 134 | } 135 | 136 | function downloadTrackOptions(host, port, contentLength, sessionID) { 137 | // console.log(contentLength); 138 | return { 139 | "host": host, 140 | "port": port, 141 | "path": "/api/addToQueue", 142 | "method": "POST", 143 | "headers":{ 144 | "Content-Type": "application/json", 145 | "Content-Length": contentLength, 146 | "Cookie": sessionID, 147 | }, 148 | }; 149 | } 150 | 151 | function downloadTrack(id, sessionID) { 152 | return new Promise((resolve, reject) => { 153 | const postData = JSON.stringify({ "url":"https://www.deezer.com/track/" + id, "bitrate":null }); 154 | const options = downloadTrackOptions(deemixHost, deemixPort, postData.length, sessionID); 155 | 156 | const req = request(options, (res) => { 157 | if (res.statusCode !== 200) { 158 | reject("Deemix returned " + res.statusCode); 159 | } 160 | 161 | res.setEncoding("utf8"); 162 | 163 | let rawData = ""; 164 | 165 | res.on("data", (chunk) => { 166 | rawData += chunk; 167 | }); 168 | 169 | res.on("end", () => { 170 | const parsedData = JSON.parse(rawData); 171 | // console.log(parsedData); 172 | resolve(parsedData); 173 | }); 174 | }); 175 | req.write(postData); 176 | req.end(); 177 | 178 | }); 179 | } 180 | 181 | function searchOptions(term, host, port) { 182 | console.log(term); 183 | return { 184 | "host": host, 185 | "port": port, 186 | "path": "/api/search?term=" + encodeURIComponent(term) + "&type=track&start=0&nb=1", 187 | }; 188 | } 189 | 190 | 191 | function trackSearch(term) { 192 | return new Promise((resolve, reject) => { 193 | const options = searchOptions(term, deemixHost, deemixPort); 194 | console.log(options); 195 | get(options, (res) => { 196 | if (res.statusCode !== 200) { 197 | reject("Deemix returned " + res.statusCode); 198 | } 199 | 200 | res.setEncoding("utf8"); 201 | 202 | let rawData = ""; 203 | 204 | res.on("data", (chunk) => { 205 | rawData += chunk; 206 | }); 207 | 208 | res.on("end", () => { 209 | const parsedData = JSON.parse(rawData); 210 | // console.log(parsedData); 211 | resolve(parsedData.data); 212 | }); 213 | 214 | }); 215 | }); 216 | } 217 | 218 | async function loginAndValidateSession() { 219 | const sessionID = await getSession(); 220 | const connectData = await connect(sessionID); 221 | const arl = connectData.singleUser.arl; 222 | // console.log(arl); 223 | const status = await loginARL(arl, sessionID); 224 | if (!status) { 225 | throw "Unable to login to deemix"; 226 | } 227 | return sessionID; 228 | // status == 1 all good, status == false not good 229 | } 230 | 231 | exports = { trackSearch, downloadTrack, loginARL, connect, loginAndValidateSession, getSession }; 232 | 233 | 234 | async function test() { 235 | const sessionID = await loginAndValidateSession(); 236 | const track = await trackSearch("superhero metro"); 237 | if (track[0]) { 238 | downloadTrack(); 239 | } 240 | else { 241 | console.log("could not find track"); 242 | } 243 | } 244 | 245 | test(); -------------------------------------------------------------------------------- /commands/play.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Join -> voiceConnection 4 | voiceConnection.subscribe(AudioPlayer) 5 | audioPlayer plays AudioResource 6 | 7 | per stream we want audioresource 8 | create one per stream 9 | 10 | 11 | */ 12 | const { queue, audioPlayers, playNext, reactions, refreshSpotifyToken } = require("../general.js"); 13 | 14 | const { songAdded } = require("../presetEmbeds.js"); 15 | 16 | const { AudioPlayerStatus } = require("@discordjs/voice"); 17 | 18 | const playdl = require("play-dl"); 19 | const libmanger = require("../music/libraryManager.js"); 20 | 21 | const join = require("./join.js"); 22 | const unpause = require("./unpause.js"); 23 | // eslint-disable-next-line 24 | // const ytRegex = /http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?‌​[\w\?‌​=]*)?/; 25 | 26 | async function convertSPDataToYT(spotifyData) { 27 | if (spotifyData && spotifyData.type == "track") { 28 | // search youtube 29 | const data = await playdl.search(`${spotifyData.artists[0].name} - ${spotifyData.name} topic`, { "limit":1, "source":{ "youtube":"video" } }); 30 | // if result, return first result 31 | if (data && data[0]) { 32 | return data[0]; 33 | } 34 | else { 35 | return false; 36 | } 37 | } 38 | } 39 | 40 | async function convertSPDataToDeezer(spotifyData) { 41 | if (spotifyData && spotifyData.type == "track") { 42 | // search deezer 43 | let data = false; 44 | try { 45 | if (spotifyData.isrc.length > 11) { 46 | data = await libmanger.requestTrack(`${spotifyData.isrc}`); 47 | } 48 | } 49 | catch (err) { 50 | console.log(err); 51 | } 52 | if (!data) { 53 | try { 54 | data = await libmanger.requestTrack(`${spotifyData.artists[0].name} - ${spotifyData.name}`); 55 | } 56 | catch (err) { 57 | console.log(err); 58 | } 59 | } 60 | // if result 61 | if (data && data.path) { 62 | // return data 63 | return data; 64 | } 65 | else { 66 | return false; 67 | } 68 | } 69 | } 70 | 71 | 72 | function AddSCdataToQueue(message, data) { 73 | let artist = false; 74 | if (data.publisher && data.publisher.artist) { 75 | artist = data.publisher.artist; 76 | } 77 | const queueData = { 78 | "title": data.name, 79 | "url": data.permalink, 80 | "author": artist || data.user.name, 81 | "durationInSec":data.durationInSec, 82 | "thumbURL": data.thumbnail, 83 | "type": "so_track", 84 | "requester": message.author, 85 | "channel": message.channel, 86 | "stream_url": data.permalink, 87 | }; 88 | queue[message.guild.id].push(queueData); 89 | message.react(reactions.positive); 90 | // send embed and delete after 60 seconds 91 | message.reply({ embeds:[songAdded(queueData, queue[message.guild.id].length - 1)] }) 92 | .then(msg => { 93 | 94 | setTimeout(() => { 95 | msg.delete() 96 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 97 | }, 60000); 98 | 99 | }); 100 | } 101 | 102 | function addYTdataToQueue(message, data, isPlayList) { 103 | const queueData = { 104 | "title": data.title, 105 | "url": (data.video_url || data.url), 106 | "author": ((data.author && data.author.name) ? data.author.name : data.channel.name), 107 | "durationInSec": (data.lengthSeconds || data.durationInSec), 108 | "thumbURL": data.thumbnails[data.thumbnails.length - 1].url, 109 | "type": "yt_track", 110 | "requester": message.author, 111 | "channel": message.channel, 112 | "stream_url": (data.video_url || data.url), 113 | }; 114 | queue[message.guild.id].push(queueData); 115 | message.react(reactions.positive); 116 | // send embed and delete after 60 seconds 117 | // dont send if playlist so we dont spam and get hit by ratelimit 118 | if (!isPlayList) { 119 | message.reply({ embeds:[songAdded(queueData, queue[message.guild.id].length - 1)] }) 120 | .then(msg => { 121 | setTimeout(() => { 122 | msg.delete() 123 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 124 | }, 60000); 125 | }); 126 | } 127 | 128 | } 129 | 130 | 131 | function addDZdataToQueue(message, data, sendEmbed = true) { 132 | // console.log(data); 133 | const imageExists = data.metadata.common.picture && data.metadata.common.picture[0]; 134 | const queueData = { 135 | "title": data.metadata.common.title, 136 | "url": null, 137 | "author": data.metadata.common.artist, 138 | "durationInSec":(data.metadata.format.duration), 139 | "thumbURL": "attachment://thumb.jpg", 140 | "thumbData": imageExists ? data.metadata.common.picture[0].data : false, 141 | "type": "dz_track", 142 | "requester": message.author, 143 | "channel": message.channel, 144 | "stream_url": data.path, 145 | }; 146 | queue[message.guild.id].push(queueData); 147 | if (sendEmbed) { 148 | message.react(reactions.positive); 149 | // send embed and delete after 60 seconds 150 | if (imageExists) { 151 | message.reply( 152 | { embeds:[songAdded(queueData, queue[message.guild.id].length - 1)], 153 | files:[{ "name":"thumb.jpg", "attachment":data.metadata.common.picture[0].data }], 154 | }) 155 | .then(msg => { 156 | setTimeout(() => { 157 | msg.delete() 158 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 159 | }, 60000); 160 | }); 161 | } 162 | else { 163 | message.reply( 164 | { embeds:[songAdded(queueData, queue[message.guild.id].length - 1)], 165 | }) 166 | .then(msg => { 167 | setTimeout(() => { 168 | msg.delete() 169 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 170 | }, 60000); 171 | }); 172 | } 173 | } 174 | 175 | 176 | } 177 | 178 | async function play(message, args, command) { 179 | try { 180 | // If not already in a voice channel or queue does not exist, (re)join 181 | if (!message.guild.me.voice.channelId || !queue[message.guild.id]) { 182 | // const voiceConnection = 183 | // const { audioPlayer } = 184 | await join(message); 185 | // const voiceConnection = getVoiceConnection(message.channel.guild.id); 186 | 187 | } 188 | else if (!(message.member.voice.channelId == message.guild.me.voice.channelId)) { 189 | message.react(reactions.negative); 190 | return false; 191 | } 192 | 193 | // check for args 194 | console.log(args); 195 | if (args) { 196 | const validate = await playdl.validate(args); 197 | console.log(validate); 198 | if (validate == "search") { 199 | // console.log(command); 200 | if (command == "music") { 201 | // search library; args == query 202 | const data = await libmanger.requestTrack(args); 203 | // console.log(data); 204 | // if no result then return false 205 | if (data && data.path) { 206 | // set to existing track or download the track 207 | addDZdataToQueue(message, data, true); 208 | } 209 | else { 210 | message.react(reactions.warning); 211 | return false; 212 | } 213 | } 214 | else { 215 | // search youtube 216 | const data = await playdl.search(args, { "limit":1, "source":{ "youtube":"video" } }); 217 | // if result, add first result to queue 218 | if (data && data[0]) { 219 | addYTdataToQueue(message, data[0]); 220 | } 221 | else { 222 | message.react(reactions.warning); 223 | return false; 224 | } 225 | } 226 | } 227 | else if (validate == "so_track") { 228 | // get soundcloud track info 229 | const data = await playdl.soundcloud(args); 230 | // if result, add result to queue 231 | if (data) { 232 | AddSCdataToQueue(message, data); 233 | } 234 | else { 235 | message.react(reactions.warning); 236 | return false; 237 | } 238 | } 239 | else if (validate == "sp_track") { 240 | // get spotify song data so we can search on soundcloud 241 | await refreshSpotifyToken(); 242 | const track = await playdl.spotify(args); 243 | // try to add via deezer 244 | const DZData = await convertSPDataToDeezer(track); 245 | if (DZData) { 246 | addDZdataToQueue(message, DZData, true); 247 | } 248 | else { 249 | // deezer didnt work try youtube 250 | const YTData = await convertSPDataToYT(track); 251 | if (YTData) { 252 | addYTdataToQueue(message, YTData); 253 | } 254 | else { 255 | message.react(reactions.confused); 256 | return false; 257 | } 258 | } 259 | 260 | } 261 | else if (validate == "dz_track") { 262 | // get deezer song data so we can search on soundcloud 263 | const deezerData = await playdl.deezer(args); 264 | if (deezerData.type == "track") { 265 | // search soundcloud 266 | const data = await playdl.search(args, { "limit":1, "source":{ "soundcloud":"tracks" } }); 267 | // if data exists 268 | if (data && data[0]) { 269 | // add first result to queue 270 | AddSCdataToQueue(message, data[0]); 271 | } 272 | 273 | } 274 | else { 275 | message.react(reactions.confused); 276 | return false; 277 | } 278 | } 279 | else if (validate == "yt_video") { 280 | // get youtube video info 281 | const data = await playdl.video_info(args); 282 | // add result to queue if data 283 | if (data && data.video_details) { 284 | addYTdataToQueue(message, data.video_details); 285 | } 286 | else { 287 | message.react(reactions.warning); 288 | return false; 289 | } 290 | 291 | } 292 | else if (validate == "yt_playlist") { 293 | // get youtube playlist info 294 | const pldata = await playdl.playlist_info(args, { incomplete : true }); 295 | 296 | if (pldata && pldata.videoCount > 0) { 297 | const videos = await pldata.all_videos(); 298 | if (videos) { 299 | // add result to queue if data 300 | let length = 0; 301 | for (const i in videos) { 302 | const video = videos[i]; 303 | length += video.durationInSec; 304 | addYTdataToQueue(message, video, true); 305 | } 306 | // mock song data to create embed 307 | const queueData = { 308 | "title": pldata.title, 309 | "url": pldata.url || pldata.link, 310 | "author": pldata.channel.name, 311 | "durationInSec": length, 312 | "thumbURL": videos[0].thumbnails[videos[0].thumbnails.length - 1].url, 313 | "type": "yt_track", 314 | "requester": message.author, 315 | "channel": message.channel, 316 | "stream_url": pldata.url || pldata.link, 317 | }; 318 | message.reply({ embeds:[songAdded(queueData, queue[message.guild.id].length - 1)] }) 319 | .then(msg => { 320 | setTimeout(() => { 321 | msg.delete() 322 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 323 | }, 60000); 324 | }); 325 | // end of mock 326 | } 327 | } 328 | else { 329 | message.react(reactions.warning); 330 | return false; 331 | } 332 | 333 | } 334 | else if (validate == "sp_playlist" || validate == "sp_album") { 335 | // get spotify playlist info 336 | await refreshSpotifyToken(); 337 | const spotifyData = await playdl.spotify(args); 338 | 339 | if (spotifyData && spotifyData.tracksCount > 0) { 340 | const tracks = await spotifyData.all_tracks(); 341 | if (tracks) { 342 | // ask user to wait 343 | const replyMessage = await message.reply(`Adding ${validate.replace("sp_", "")} to queue, this might take a couple seconds!`); 344 | // add result to queue if data 345 | let length = 0; 346 | for (const i in tracks) { 347 | const track = tracks[i]; 348 | length += track.durationInSec; 349 | // try to add via deezer 350 | const DZData = await convertSPDataToDeezer(track); 351 | if (DZData) { 352 | addDZdataToQueue(message, DZData, false); 353 | } 354 | else { 355 | // deezer didnt work try spotify 356 | const YTData = await convertSPDataToYT(track); 357 | addYTdataToQueue(message, YTData, true); 358 | } 359 | } 360 | // mock song data to create embed 361 | let author = "Spotify"; 362 | if (validate == "sp_playlist") { 363 | author = spotifyData.owner.name; 364 | } 365 | else if (validate == "sp_album") { 366 | author = spotifyData.artists[0].name; 367 | for (let i = 1; i < spotifyData.artists.length; i++) { 368 | author = author + ", " + spotifyData.artists[i].name; 369 | } 370 | } 371 | const queueData = { 372 | "title": spotifyData.name, 373 | "url": spotifyData.url || spotifyData.link, 374 | "author": author, 375 | "durationInSec": length, 376 | "thumbURL": spotifyData.thumbnail.url, 377 | "type": "yt_track", 378 | "requester": message.author, 379 | "channel": message.channel, 380 | "stream_url": spotifyData.url || spotifyData.link, 381 | }; 382 | // remove original reply 383 | replyMessage.delete() 384 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 385 | // reply with added to queue message 386 | message.reply({ embeds:[songAdded(queueData, queue[message.guild.id].length - 1)] }) 387 | .then(msg => { 388 | setTimeout(() => { 389 | msg.delete() 390 | .catch((err) => {console.log("[NONFATAL] Failed to delete message", err);}); 391 | }, 60000); 392 | }); 393 | // end of mock 394 | } 395 | } 396 | else { 397 | message.react(reactions.warning); 398 | return false; 399 | } 400 | 401 | } 402 | else { 403 | message.react(reactions.confused); 404 | return false; 405 | } 406 | } 407 | 408 | // if paused unpause 409 | if (audioPlayers[message.guildId].state.status == AudioPlayerStatus.Paused) { 410 | unpause(message); 411 | } 412 | 413 | 414 | console.log(audioPlayers[message.guildId].state.status == AudioPlayerStatus.Idle); 415 | if (audioPlayers[message.guildId].state.status == AudioPlayerStatus.Idle && queue[message.guild.id][0]) { 416 | playNext(message); 417 | } 418 | 419 | return true; 420 | 421 | } 422 | catch (error) { 423 | message.react(reactions.warning); 424 | message.reply("```play.js: " + error + "```"); 425 | console.log(error); 426 | } 427 | } 428 | 429 | module.exports = play; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------