diff --git a/README.md b/README.md index b39679a..b475e8b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ ninbot retrieves the last week's worth of songs in `#non-nin-music` and then upd Your song will be added to the playlist if it is in one of the following formats: -- Spotify track (https://open.spotify.com/track/...) +- Spotify track (ninbot ignores album links for now) +- YouTube video (ninbot will try it's best to match) -The playlist updates every Monday at 00:00 UTC. \ No newline at end of file +The playlist updates every Monday at 00:00 UTC. diff --git a/package.json b/package.json index ec193b9..0f70036 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,11 @@ "discord.js": "^11.5.1", "dotenv": "^8.0.0", "express": "^4.17.1", + "fuse.js": "^3.4.5", + "get-youtube-id": "^1.0.1", "moment": "^2.24.0", + "node-fetch": "^2.6.0", + "query-string": "^6.8.3", "spotify-web-api-node": "^4.0.0", "typescript": "^3.5.2" }, diff --git a/src/lib/ninbot.ts b/src/lib/ninbot.ts index 7644c3a..9f515fe 100644 --- a/src/lib/ninbot.ts +++ b/src/lib/ninbot.ts @@ -7,7 +7,10 @@ import { Collection, } from 'discord.js'; import * as moment from 'moment'; +import * as Fuse from 'fuse.js'; + import Spotify from './spotify'; +import YouTube from './youtube'; require('dotenv').config(); @@ -106,36 +109,104 @@ export default class Ninbot { const messages = await this.fetchMessages(channel, fromDate, toDate); console.log( `${messages.size} message(s) were fetched in total`, - messages.map(message => message.content), + // messages.map(message => message.content), ); - // Filter the messages to those that contain Spotify links - let spotifyUrls: string[] = []; + let items: { + url: string; + service: 'spotify' | 'youtube'; + }[] = []; + + // Filter for messages to those that contain valid links messages.forEach(message => { - const match = message.content.match( + const spotifyMatch = message.content.match( /https:\/\/open.spotify.com\/track\/([^? ]+)/gi, ); - if (match) { - spotifyUrls = spotifyUrls.concat( - match.map( - url => - `spotify:track:${url.replace( - /https:\/\/open.spotify.com\/track\//gi, - '', - )}`, - ), + if (spotifyMatch) { + items = items.concat( + spotifyMatch.map(url => ({ + url, + service: 'spotify', + })), + ); + } + + const youtubeMatch = message.content.match( + /^(http(s)?:\/\/)?((w){3}.)?youtu(be|.be)?(\.com)?\/.+/gi, + ); + if (youtubeMatch) { + items = items.concat( + youtubeMatch.map(url => ({ + url, + service: 'youtube', + })), ); } }); + // Convert the submissions into Spotify URIs if possible + const tracks: string[] = []; + let spotifyTrackCount = 0; + let youtubeTrackCount = 0; + + for (let item of items) { + switch (item.service) { + case 'spotify': + spotifyTrackCount += 1; + tracks.push( + `spotify:track:${item.url.replace( + /https:\/\/open.spotify.com\/track\//gi, + '', + )}`, + ); + break; + + case 'youtube': + const video = await YouTube.getVideo(item.url); + if (!video) { + break; + } + + const formattedTitle = video.title + .replace(/ *\([^)]*\) */g, '') + .replace(/[^A-Za-z0-9 ]/g, '') + .replace(/\s{2,}/g, ' '); + + const fuse = new Fuse(await spotify.searchTracks(formattedTitle), { + threshold: 0.8, + keys: [ + { + name: 'title', + weight: 0.7, + }, + { + name: 'artists.name', + weight: 0.5, + }, + { + name: 'album', + weight: 0.1, + }, + ], + }); + + const fuzzyResults = fuse.search(video.title); + if (fuzzyResults.length) { + youtubeTrackCount += 1; + tracks.push(fuzzyResults[0].uri); + } + break; + } + } + console.log( - `${spotifyUrls.length} Spotify track(s) were detected`, - spotifyUrls, + `${tracks.length} track(s) found (Spotify: ${spotifyTrackCount}. YouTube: ${youtubeTrackCount}`, + tracks, ); // Update the playlist with the tracks - if (spotifyUrls.length) { - await spotify.addTracksToPlaylist(spotifyUrls.reverse()); + if (tracks.length) { + await spotify.addTracksToPlaylist(tracks.reverse()); } console.log( diff --git a/src/lib/spotify.ts b/src/lib/spotify.ts index 54c75ab..55bbf41 100644 --- a/src/lib/spotify.ts +++ b/src/lib/spotify.ts @@ -113,18 +113,17 @@ export default class Spotify { ); } - public async searchTracks( - query: string, - limit = 10, - ): Promise { - console.log(`Searching for tracks about "${query}"...`); + public async searchTracks(query: string, limit = 5): Promise { + console.log(`Searching tracks for "${query}"...`); const response = await this.client.searchTracks(query, { limit }); return response.body.tracks.items.map(item => ({ uri: item.uri, name: item.name, popularity: item.popularity, album: item.album.name, - artists: item.artists.map(artist => artist.name), + artists: item.artists.map(artist => ({ + name: artist.name, + })), })); } } diff --git a/src/lib/youtube.ts b/src/lib/youtube.ts new file mode 100644 index 0000000..3d0cb7a --- /dev/null +++ b/src/lib/youtube.ts @@ -0,0 +1,29 @@ +import fetch from 'node-fetch'; +import * as queryString from 'query-string'; +const getYouTubeID = require('get-youtube-id'); + +interface YouTubeVideo { + id: string; + title: string; +} + +export default class YouTube { + public static async getVideo(url: string): Promise { + const id = getYouTubeID(url); + if (!id) { + return; + } + + const response = await fetch( + `http://youtube.com/get_video_info?html5=1&video_id=${id}`, + ).then(res => res.text()); + + const query = queryString.parse(response); + const { videoDetails } = JSON.parse(query.player_response.toString()); + + return { + id: videoDetails.videoId, + title: videoDetails.title, + }; + } +} diff --git a/yarn.lock b/yarn.lock index 0fc64ac..8ae9b3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -720,6 +720,10 @@ fsevents@^1.2.7: nan "^2.12.1" node-pre-gyp "^0.12.0" +fuse.js@^3.4.5: + version "3.4.5" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.5.tgz#8954fb43f9729bd5dbcb8c08f251db552595a7a6" + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -741,6 +745,10 @@ get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" +get-youtube-id@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-youtube-id/-/get-youtube-id-1.0.1.tgz#adb6f475e292d98f98ed5bfb530887656193e157" + glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -1232,6 +1240,10 @@ negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" +node-fetch@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + node-pre-gyp@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149" @@ -1446,6 +1458,14 @@ qs@6.7.0, qs@^6.5.1: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" +query-string@^6.8.3: + version "6.8.3" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.8.3.tgz#fd9fb7ffb068b79062b43383685611ee47777d4b" + dependencies: + decode-uri-component "^0.2.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -1680,6 +1700,10 @@ source-map@^0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +split-on-first@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -1703,6 +1727,10 @@ static-extend@^0.1.1: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"