LastFM scrobbling
This commit is contained in:
parent
a04bd3e69b
commit
ddd8d4e3b2
12
migrations/003-lastfm-scrobble.sql
Normal file
12
migrations/003-lastfm-scrobble.sql
Normal 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;
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -93,6 +93,18 @@
|
||||
<label for="st-streamable">Include streamable tracks in searches (experimental)</label>
|
||||
<input type="checkbox" id="st-streamable" name="streamable">
|
||||
</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="option checkbox">
|
||||
<label for="st-trackids" title="Shows track number in album instead when unchecked">Show track IDs</label>
|
||||
|
@ -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 = '<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) {
|
||||
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'
|
||||
})
|
||||
|
@ -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}
|
||||
|
@ -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 }
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user