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",
|
"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",
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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'
|
||||||
})
|
})
|
||||||
|
@ -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}
|
|
||||||
|
@ -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 }
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user