LastFM scrobbling

This commit is contained in:
Evert Prants 2019-12-18 14:57:02 +02:00
parent a04bd3e69b
commit ddd8d4e3b2
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
8 changed files with 203 additions and 57 deletions

View File

@ -0,0 +1,12 @@
-- Up
CREATE TABLE LastFM (
id INTEGER PRIMARY KEY,
userId INTEGER,
name TEXT,
key TEXT,
created TEXT
);
-- Down
DROP TABLE LastFM;

View File

@ -23,9 +23,12 @@
"fs-extra": "^7.0.1", "fs-extra": "^7.0.1",
"oauth-libre": "^0.9.17", "oauth-libre": "^0.9.17",
"redis": "^2.8.0", "redis": "^2.8.0",
"request": "^2.88.0",
"request-promise-native": "^1.0.8",
"socket.io": "^2.3.0", "socket.io": "^2.3.0",
"sqlite": "^3.0.3", "sqlite": "^3.0.3",
"sqlite3": "^4.1.1" "sqlite3": "^4.1.1",
"xml2js": "^0.4.22"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.7.5", "@babel/cli": "^7.7.5",

View File

@ -359,6 +359,9 @@ canvas#visualizer {
.sidebar .option.checkbox input { .sidebar .option.checkbox input {
margin: 4px 10px; margin: 4px 10px;
} }
.sidebar a {
color: #18b9c1;
}
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
tr td:nth-child(1), th:nth-child(1) { tr td:nth-child(1), th:nth-child(1) {
display: none; display: none;

View File

@ -93,6 +93,18 @@
<label for="st-streamable">Include streamable tracks in searches (experimental)</label> <label for="st-streamable">Include streamable tracks in searches (experimental)</label>
<input type="checkbox" id="st-streamable" name="streamable"> <input type="checkbox" id="st-streamable" name="streamable">
</div> </div>
<div class="separator">LastFM</div>
<div class="option url" id="lfm-connect">
<a href="/api/lastfm/connect">Connect your LastFM account</a>
</div>
<div class="option url" id="lfm-disconnect">
<label id="lfm-connected"></label>
<a href="/api/lastfm/disconnect">Disconnect</a>
</div>
<div class="option checkbox" id="lfm-scrobble">
<label for="st-scrobble">Scrobble tracks</label>
<input type="checkbox" id="st-scrobble" name="scrobble">
</div>
<div class="separator">Sorting</div> <div class="separator">Sorting</div>
<div class="option checkbox"> <div class="option checkbox">
<label for="st-trackids" title="Shows track number in album instead when unchecked">Show track IDs</label> <label for="st-trackids" title="Shows track number in album instead when unchecked">Show track IDs</label>

View File

@ -49,12 +49,14 @@
autoplay: true, autoplay: true,
trackids: true, trackids: true,
streamable: true, streamable: true,
scrobble: false,
sortby: 'id', sortby: 'id',
sortdir: 'asc' sortdir: 'asc'
} }
// User info // User info
var user = {} var user = {}
var scrobbleTimeout
window.mobilecheck = function() { window.mobilecheck = function() {
var check = false; 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) { function play (id, q, fromNext) {
if (id < 1) return playNext() if (id < 1) return playNext()
httpGet('/api/track/' + id).then(function (data) { httpGet('/api/track/' + id).then(function (data) {
@ -545,6 +560,7 @@
updateQT(q) updateQT(q)
scrobblePlaying(id)
nowPlaying = id nowPlaying = id
if (!fromNext) { if (!fromNext) {
if (playlist != null && playlist > -1 && !q) { 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 = '<a href="https://www.last.fm/user/' + lfm + '" target="_blank">' + lfm + '</a>'
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) { input.addEventListener('keyup', function (e) {
e.which === 13 && showTracks(input.value.trim() === '' ? (pagePrev !== 0 ? pagePrev : 1) : 1) e.which === 13 && showTracks(input.value.trim() === '' ? (pagePrev !== 0 ? pagePrev : 1) : 1)
}, false) }, false)
@ -762,6 +796,11 @@
httpGet('/user/info').then(function (data) { httpGet('/user/info').then(function (data) {
user = data user = data
loggedin.innerHTML = 'Logged in as ' + data.username loggedin.innerHTML = 'Logged in as ' + data.username
// LastFM
httpGet('/api/lastfm').then(function (data) {
user.lastfm = data
updateLastFMOptions()
})
}, function (e) { }, function (e) {
window.location.href = '/user/login' window.location.href = '/user/login'
}) })

View File

@ -85,54 +85,4 @@ function copyAsync (fsrc, fdst) {
}) })
} }
function HTTP_GET (link, headers = {}) { export default {getFiles, promiseExec, askAsync, insertDB, copyAsync}
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}

View File

@ -1,8 +1,10 @@
import path from 'path' import path from 'path'
import rp from 'request-promise-native'
import asn from './common/async' import asn from './common/async'
import dl from './common/download' import dl from './common/download'
import crypto from 'crypto' import crypto from 'crypto'
import { dbPromise } from './database' import { dbPromise } from './database'
import { parseStringPromise } from 'xml2js'
const fs = require('fs').promises const fs = require('fs').promises
const values = require(path.join(process.cwd(), 'values.json')) const values = require(path.join(process.cwd(), 'values.json'))
@ -98,7 +100,7 @@ async function search (track, limit = 30) {
let data let data
try { 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) data = JSON.parse(data)
if (!data.results || !data.results.trackmatches || !data.results.trackmatches.track) { if (!data.results || !data.results.trackmatches || !data.results.trackmatches.track) {
@ -154,4 +156,77 @@ function invokeDownload (add) {
}, 2 * 1000) }, 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 }

View File

@ -252,9 +252,61 @@ router.get('/playlist/:id', userMiddleware, async (req, res, next) => {
res.jsonp(await playlist.getPlaylist(req.params.id, sort, sortdir)) 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) => { router.get('/serve/by-id/:id', userMiddleware, async (req, res, next) => {
let id = req.params.id let id = req.params.id