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 @@
+
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