diff --git a/config/defaults.js b/config/defaults.js index fb87e566c4..2a18ce94e4 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -36,6 +36,13 @@ const defaultConfig = { ffmpegArgs: [], // e.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s downloadFolder: undefined, // Custom download folder (absolute path) }, + "last-fm": { + enabled: false, + api_root: "http://ws.audioscrobbler.com/2.0/", + api_key: "04d76faaac8726e60988e14c105d421a", // api key registered by @semvis123 + secret: "a5d2a36fdf64819290f6982481eaffa2", + suffixesToRemove: [' - Topic', 'VEVO'] // removes suffixes of the artist name, for better recognition + }, discord: { enabled: false, activityTimoutEnabled: true, // if enabled, the discord rich presence gets cleared when music paused after the time specified below diff --git a/package.json b/package.json index ce144c385c..54a75d6656 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,9 @@ "electron-unhandled": "^3.0.2", "electron-updater": "^4.3.6", "filenamify": "^4.2.0", + "md5": "^2.3.0", "node-fetch": "^2.6.1", + "open": "^8.0.3", "ytdl-core": "^4.4.5", "ytpl": "^2.0.5" }, diff --git a/plugins/discord/back.js b/plugins/discord/back.js index 2fb8cc7969..bf9e0e2677 100644 --- a/plugins/discord/back.js +++ b/plugins/discord/back.js @@ -53,9 +53,6 @@ module.exports = (win, {activityTimoutEnabled, activityTimoutTime}) => { }); // Startup the rpc client - rpc.login({ - clientId, - }) - .catch(console.error); + rpc.login({ clientId }).catch(console.error); }); }; diff --git a/plugins/last-fm/back.js b/plugins/last-fm/back.js new file mode 100644 index 0000000000..a34a0a19f1 --- /dev/null +++ b/plugins/last-fm/back.js @@ -0,0 +1,175 @@ +const fetch = require('node-fetch'); +const md5 = require('md5'); +const open = require("open"); +const { setOptions } = require('../../config/plugins'); +const getSongInfo = require('../../providers/song-info'); +const defaultConfig = require('../../config/defaults'); + +const cleanupArtistName = (config, artist) => { + // removes the suffixes of the artist name for more recognition by last.fm + const { suffixesToRemove } = config; + if (suffixesToRemove === undefined) return artist; + + for (suffix of suffixesToRemove) { + artist = artist.replace(suffix, ''); + } + return artist; +} + +const createFormData = params => { + // creates the body for in the post request + const formData = new URLSearchParams(); + for (key in params) { + formData.append(key, params[key]); + } + return formData; +} +const createQueryString = (params, api_sig) => { + // creates a querystring + const queryData = []; + params.api_sig = api_sig; + for (key in params) { + queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`); + } + return '?'+queryData.join('&'); +} + +const createApiSig = (params, secret) => { + // this function creates the api signature, see: https://www.last.fm/api/authspec + const keys = []; + for (key in params) { + keys.push(key); + } + keys.sort(); + let sig = ''; + for (key of keys) { + if (String(key) === 'format') + continue + sig += `${key}${params[key]}`; + } + sig += secret; + sig = md5(sig); + return sig; +} + +const createToken = async ({ api_key, api_root, secret }) => { + // creates and stores the auth token + const data = { + method: 'auth.gettoken', + api_key: api_key, + format: 'json' + }; + const api_sig = createApiSig(data, secret); + let response = await fetch(`${api_root}${createQueryString(data, api_sig)}`); + response = await response.json(); + return response?.token; +} + +const authenticate = async config => { + // asks the user for authentication + config.token = await createToken(config); + setOptions('last-fm', config); + open(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`); + return config; +} + +const getAndSetSessionKey = async config => { + // get and store the session key + const data = { + api_key: config.api_key, + format: 'json', + method: 'auth.getsession', + token: config.token, + }; + const api_sig = createApiSig(data, config.secret); + let res = await fetch(`${config.api_root}${createQueryString(data, api_sig)}`); + res = await res.json(); + if (res.error) + await authenticate(config); + config.session_key = res?.session?.key; + setOptions('last-fm', config); + return config; +} + +const postSongDataToAPI = async (songInfo, config, data) => { + // this sends a post request to the api, and adds the common data + if (!config.session_key) + await getAndSetSessionKey(config); + + const postData = { + track: songInfo.title, + duration: songInfo.songDuration, + artist: songInfo.artist, + api_key: config.api_key, + sk: config.session_key, + format: 'json', + ...data, + }; + + postData.api_sig = createApiSig(postData, config.secret); + fetch('https://ws.audioscrobbler.com/2.0/', {method: 'POST', body: createFormData(postData)}) + .catch(res => { + if (res.response.data.error == 9) { + // session key is invalid, so remove it from the config and reauthenticate + config.session_key = undefined; + setOptions('last-fm', config); + authenticate(config); + } + }); +} + +const addScrobble = (songInfo, config) => { + // this adds one scrobbled song to last.fm + const data = { + method: 'track.scrobble', + timestamp: ~~((Date.now() - songInfo.elapsedSeconds) / 1000), + }; + postSongDataToAPI(songInfo, config, data); +} + +const setNowPlaying = (songInfo, config) => { + // this sets the now playing status in last.fm + const data = { + method: 'track.updateNowPlaying', + }; + postSongDataToAPI(songInfo, config, data); +} + + +// this will store the timeout that will trigger addScrobble +let scrobbleTimer = undefined; + +const lastfm = async (win, config) => { + const registerCallback = getSongInfo(win); + + if (!config.api_root || !config.suffixesToRemove) { + // settings are not present, creating them with the default values + config = defaultConfig.plugins['last-fm']; + config.enabled = true; + setOptions('last-fm', config); + } + + if (!config.session_key) { + // not authenticated + config = await getAndSetSessionKey(config); + } + + registerCallback( songInfo => { + // set remove the old scrobble timer + clearTimeout(scrobbleTimer); + // make the artist name a bit cleaner + songInfo.artist = cleanupArtistName(config, songInfo.artist); + if (!songInfo.isPaused) { + setNowPlaying(songInfo, config); + // scrobble when the song is half way through, or has passed the 4 minute mark + const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60); + if (scrobbleTime > songInfo.elapsedSeconds) { + // scrobble still needs to happen + const timeToWait = (scrobbleTime - songInfo.elapsedSeconds) * 1000; + scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config); + } + } + }); +} + +module.exports = lastfm; \ No newline at end of file diff --git a/providers/song-info.js b/providers/song-info.js index 131573c45b..9b4343616b 100644 --- a/providers/song-info.js +++ b/providers/song-info.js @@ -5,6 +5,7 @@ const fetch = require("node-fetch"); // This selects the progress bar, used for current progress const progressSelector = "#progress-bar"; + // Grab the progress using the selector const getProgress = async (win) => { // Get current value of the progressbar element diff --git a/yarn.lock b/yarn.lock index c969527eda..b3e533cb37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1708,6 +1708,13 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + babel-eslint@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232" @@ -2223,6 +2230,11 @@ char-regex@^1.0.2: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +charenc@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= + chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -2587,6 +2599,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypt@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= + crypto-browserify@^3.11.0: version "3.12.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" @@ -2761,6 +2778,11 @@ defer-to-connect@^2.0.0: resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.0.tgz#83d6b199db041593ac84d781b5222308ccf4c2c1" integrity sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg== +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + define-properties@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" @@ -3966,6 +3988,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== +follow-redirects@^1.10.0: + version "1.13.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" + integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -4691,7 +4718,7 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= -is-buffer@^1.1.5: +is-buffer@^1.1.5, is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -4752,7 +4779,7 @@ is-descriptor@^1.0.0, is-descriptor@^1.0.2: is-data-descriptor "^1.0.0" kind-of "^6.0.2" -is-docker@^2.0.0: +is-docker@^2.0.0, is-docker@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.1.1.tgz#4125a88e44e450d384e09047ede71adc2d144156" integrity sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw== @@ -6028,6 +6055,15 @@ md5.js@^1.3.4: inherits "^2.0.1" safe-buffer "^5.1.2" +md5@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" + integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g== + dependencies: + charenc "0.0.2" + crypt "0.0.2" + is-buffer "~1.1.6" + memory-fs@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" @@ -6524,6 +6560,15 @@ open@^7.3.0: is-docker "^2.0.0" is-wsl "^2.1.1" +open@^8.0.3: + version "8.0.5" + resolved "https://registry.yarnpkg.com/open/-/open-8.0.5.tgz#92ee3faafef4ddbe78006f7881572f3e81430b8f" + integrity sha512-hkPXCz7gijWp2GoWqsQ4O/5p7F6d5pIQ/+9NyeWG1nABJ4zvLi9kJRv1a44kVf5p13wK0WMoiRA+Xey68yOytA== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"