From ddd8d4e3b23b66d14b6bd48c8c66f102f6dbff59 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Wed, 18 Dec 2019 14:57:02 +0200 Subject: [PATCH] LastFM scrobbling --- migrations/003-lastfm-scrobble.sql | 12 +++++ package.json | 5 +- public/index.css | 3 ++ public/index.html | 12 +++++ public/index.js | 39 +++++++++++++++ src/common/async.js | 52 +------------------- src/lastfm.js | 79 +++++++++++++++++++++++++++++- src/server.js | 58 ++++++++++++++++++++-- 8 files changed, 203 insertions(+), 57 deletions(-) create mode 100644 migrations/003-lastfm-scrobble.sql diff --git a/migrations/003-lastfm-scrobble.sql b/migrations/003-lastfm-scrobble.sql new file mode 100644 index 0000000..655a8d6 --- /dev/null +++ b/migrations/003-lastfm-scrobble.sql @@ -0,0 +1,12 @@ +-- Up + +CREATE TABLE LastFM ( + id INTEGER PRIMARY KEY, + userId INTEGER, + name TEXT, + key TEXT, + created TEXT +); + +-- Down +DROP TABLE LastFM; diff --git a/package.json b/package.json index 48c154b..f6ef440 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,12 @@ "fs-extra": "^7.0.1", "oauth-libre": "^0.9.17", "redis": "^2.8.0", + "request": "^2.88.0", + "request-promise-native": "^1.0.8", "socket.io": "^2.3.0", "sqlite": "^3.0.3", - "sqlite3": "^4.1.1" + "sqlite3": "^4.1.1", + "xml2js": "^0.4.22" }, "devDependencies": { "@babel/cli": "^7.7.5", diff --git a/public/index.css b/public/index.css index dac9600..3c4ba9f 100644 --- a/public/index.css +++ b/public/index.css @@ -359,6 +359,9 @@ canvas#visualizer { .sidebar .option.checkbox input { margin: 4px 10px; } +.sidebar a { + color: #18b9c1; +} @media only screen and (max-width: 600px) { tr td:nth-child(1), th:nth-child(1) { display: none; diff --git a/public/index.html b/public/index.html index b2d0404..0130219 100644 --- a/public/index.html +++ b/public/index.html @@ -93,6 +93,18 @@ +
LastFM
+
+ Connect your LastFM account +
+
+ + Disconnect +
+
+ + +
Sorting
diff --git a/public/index.js b/public/index.js index 8453ecf..f4c0e0d 100644 --- a/public/index.js +++ b/public/index.js @@ -49,12 +49,14 @@ autoplay: true, trackids: true, streamable: true, + scrobble: false, sortby: 'id', sortdir: 'asc' } // User info var user = {} + var scrobbleTimeout window.mobilecheck = function() { var check = false; @@ -530,6 +532,19 @@ }) } + function scrobblePlaying (track) { + if (!user.lastfm || !user.lastfm.connected || !options.scrobble) return + if (scrobbleTimeout) clearTimeout(scrobbleTimeout) + // Scrobble the track after 10 seconds of playing + // Prevents scrobbling tracks that are not listened for long + scrobbleTimeout = setTimeout(function () { + if (audio.paused) return + httpPost('/api/lastfm/scrobble/' + track).catch(function (e) { + console.warn('Scrobbling failed:', e.message) + }) + }, 10000) + } + function play (id, q, fromNext) { if (id < 1) return playNext() httpGet('/api/track/' + id).then(function (data) { @@ -545,6 +560,7 @@ updateQT(q) + scrobblePlaying(id) nowPlaying = id if (!fromNext) { if (playlist != null && playlist > -1 && !q) { @@ -688,6 +704,24 @@ } } + function updateLastFMOptions () { + let lfmconnect = document.getElementById('lfm-connect') + let lfmconnected = document.getElementById('lfm-connected') + let lfmdisconnect = document.getElementById('lfm-disconnect') + let lfmscrobble = document.getElementById('lfm-scrobble') + if (user.lastfm && user.lastfm.connected) { + let lfm = user.lastfm.name + let url = '' + lfm + '' + lfmconnect.style.display = 'none' + lfmscrobble.style.display = 'block' + lfmconnected.innerHTML = 'Connected (' + url + ')' + } else { + lfmconnect.style.display = 'block' + lfmdisconnect.style.display = 'none' + lfmscrobble.style.display = 'none' + } + } + input.addEventListener('keyup', function (e) { e.which === 13 && showTracks(input.value.trim() === '' ? (pagePrev !== 0 ? pagePrev : 1) : 1) }, false) @@ -762,6 +796,11 @@ httpGet('/user/info').then(function (data) { user = data loggedin.innerHTML = 'Logged in as ' + data.username + // LastFM + httpGet('/api/lastfm').then(function (data) { + user.lastfm = data + updateLastFMOptions() + }) }, function (e) { window.location.href = '/user/login' }) diff --git a/src/common/async.js b/src/common/async.js index 8ea4e40..bf22f13 100644 --- a/src/common/async.js +++ b/src/common/async.js @@ -85,54 +85,4 @@ function copyAsync (fsrc, fdst) { }) } -function HTTP_GET (link, headers = {}) { - let parsed = url.parse(link) - let opts = { - host: parsed.hostname, - port: parsed.port, - path: parsed.path, - headers: { - 'Accept': '*/*', - 'Accept-Language': 'en-US' - } - } - - if (headers) { - opts.headers = Object.assign(opts.headers, headers) - } - - let reqTimeOut - - let httpModule = parsed.protocol === 'https:' ? require('https') : require('http') - return new Promise((resolve, reject) => { - let req = httpModule.get(opts, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - return reject(new Error('Invalid endpoint')) - } - - let data = '' - - reqTimeOut = setTimeout(() => { - req.abort() - data = null - reject(new Error('Request took too long!')) - }, 5000) - - res.on('data', (chunk) => { - data += chunk - }) - - res.on('end', () => { - clearTimeout(reqTimeOut) - - resolve(data) - }) - }).on('error', (e) => { - reject(new Error(e.message)) - }) - - req.setTimeout(10000) - }) -} - -export default {getFiles, promiseExec, askAsync, insertDB, copyAsync, GET: HTTP_GET} +export default {getFiles, promiseExec, askAsync, insertDB, copyAsync} diff --git a/src/lastfm.js b/src/lastfm.js index ad5f1d6..a2a54ee 100644 --- a/src/lastfm.js +++ b/src/lastfm.js @@ -1,8 +1,10 @@ import path from 'path' +import rp from 'request-promise-native' import asn from './common/async' import dl from './common/download' import crypto from 'crypto' import { dbPromise } from './database' +import { parseStringPromise } from 'xml2js' const fs = require('fs').promises const values = require(path.join(process.cwd(), 'values.json')) @@ -98,7 +100,7 @@ async function search (track, limit = 30) { let data try { - data = await asn.GET(`http://ws.audioscrobbler.com/2.0/?method=track.search&track=${track}&api_key=${values.lastfm}&format=json&limit=${limit}`) + data = await rp(`http://ws.audioscrobbler.com/2.0/?method=track.search&track=${track}&api_key=${values.lastfm.key}&format=json&limit=${limit}`) data = JSON.parse(data) if (!data.results || !data.results.trackmatches || !data.results.trackmatches.track) { @@ -154,4 +156,77 @@ function invokeDownload (add) { }, 2 * 1000) } -export default { search, getTrackMetaReal, invokeDownload } +// Authentication + +function getAPISig (params) { + let allStrings = [] + let qs = {} + params['api_key'] = values.lastfm.key + for (let key in params) { + let val = params[key] + if (val == null || val === '') continue + allStrings.push(key + val) + qs[key] = val + } + allStrings = allStrings.sort() + allStrings.push(values.lastfm.secret) + qs['api_sig'] = crypto.createHash('md5').update(allStrings.join('')).digest('hex') + return qs +} + +function getAuthURL () { + return 'http://www.last.fm/api/auth/?api_key=' + values.lastfm.key + '&cb=' + values.lastfm.redirectUri +} + +async function getSession (token) { + let sessSig = getAPISig({ token, method: 'auth.getSession' }) + let res = await rp('http://ws.audioscrobbler.com/2.0/', { qs: sessSig }) + let rep = await parseStringPromise(res) + let name = rep.lfm.session[0].name[0] + let key = rep.lfm.session[0].key[0] + return { name, key } +} + +async function storeSession (userId, session) { + if (!session.name || !session.key) throw new Error('Invalid session parameter.') + let db = await dbPromise + let existing = await db.get('SELECT * FROM LastFM WHERE userId = ?', userId) + if (existing) { + await db.run('UPDATE LastFM SET name = ?, key = ? WHERE userId = ?', session.name, session.key, userId) + } else { + await db.run('INSERT INTO LastFM (userId,name,key,created) VALUES (?,?,?,?)', userId, session.name, session.key, new Date()) + } + return true +} + +async function disregardSession (userId) { + let db = await dbPromise + return db.run('DELETE FROM LastFM WHERE userId = ?', userId) +} + +async function getSessionForUser (userId) { + let db = await dbPromise + return db.get('SELECT * FROM LastFM WHERE userId = ?', userId) +} + +async function scrobbleTrack (userId, trackData) { + let sess = await getSessionForUser(userId) + if (!sess) throw new Error('User does not have a LastFM session.') + let scrobbleSig = getAPISig({ + sk: sess.key, + method: 'track.scrobble', + artist: trackData.artist || 'Unknown', + title: trackData.title, + album: trackData.album, + duration: trackData.duration, + mbid: trackData.mbid, + timestamp: Math.floor(Date.now() / 1000) + }) + // let res = + await rp.post('http://ws.audioscrobbler.com/2.0/', { form: scrobbleSig }) + // let rep = await parseStringPromise(res) + return true +} + +export default { search, getTrackMetaReal, invokeDownload, getAPISig, getAuthURL, getSession, + getSessionForUser, storeSession, disregardSession, scrobbleTrack } diff --git a/src/server.js b/src/server.js index 3d62311..65a7980 100644 --- a/src/server.js +++ b/src/server.js @@ -252,9 +252,61 @@ router.get('/playlist/:id', userMiddleware, async (req, res, next) => { res.jsonp(await playlist.getPlaylist(req.params.id, sort, sortdir)) }) -// ----------- // -// FILE SERVER // -// ----------- // +// -------------- // +// LASTFM CONNECT // +// -------------- // + +async function scrobble (user, track) { + if (await lastfm.getSessionForUser(user)) { + try { + await lastfm.scrobbleTrack(user, track) + } catch (e) { + console.warn('Track scrobbling failed for', user) + console.error(e.stack) + } + } +} + +router.get('/lastfm', userMiddleware, async (req, res, next) => { + let sess = await lastfm.getSessionForUser(req.session.user) + if (sess) return res.jsonp({ connected: true, name: sess.name }) + res.jsonp({ connected: false }) +}) + +router.get('/lastfm/connect', userMiddleware, async (req, res, next) => { + if (await lastfm.getSessionForUser(req.session.user)) throw new Error('You already have a LFM session!') + res.redirect(lastfm.getAuthURL()) +}) + +router.get('/lastfm/disconnect', userMiddleware, async (req, res, next) => { + await lastfm.disregardSession(req.session.user) + res.redirect('/') +}) + +router.get('/lastfm/_redirect', userMiddleware, async (req, res, next) => { + let token = req.query.token + if (!token) throw new Error('Failed to get token from LastFM!') + let session = await lastfm.getSession(token) + await lastfm.storeSession(req.session.user, session) + res.redirect('/?success=lastfm') +}) + +router.post('/lastfm/scrobble/:track', userMiddleware, async (req, res, next) => { + let id = req.params.track + let user = req.session.user + let db = await dbPromise + let track = await db.get('SELECT title,artist,album,duration FROM Track WHERE id = ?', id) + if (!track) { + track = await lastfm.getTrackMetaReal(id) + if (!track) throw new Error('404 file not found') + } + await scrobble(user, track) + res.jsonp({ success: true }) +}) + +// ------------ // +// TRACK SERVER // +// ------------ // router.get('/serve/by-id/:id', userMiddleware, async (req, res, next) => { let id = req.params.id