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')) const memexpire = 1800 let externalTracks = {} let downloadQueue = [] let downloading = false function createHash (data) { return crypto .createHash('sha1') .update(data.artist + data.name) .digest('hex') .substr(0, 8) } async function downloadLocally (id) { let info = await getTrackMetaReal(id) if (!info) throw new Error('No track with this ID in external list.') let file = await dl.fetchVideo(info) let filename = info.artist + ' - ' + info.title + '.mp3' info.file = path.join(values.directory, filename) await asn.promiseExec(`ffmpeg -i "${file.source}" -metadata artist="${info.artist}" -metadata title="${info.title}" -codec copy "${info.file}"`) await fs.unlink(file.source) let db = await dbPromise let ins = await asn.insertDB(db, info) if (!ins) { throw new Error('A track of this description already exists in the database.') } } async function getTrackMetaReal (id) { if (!id || !externalTracks[id]) return null let trdata = externalTracks[id] // Check for expiry if (trdata.file && trdata.expires > Date.now()) { return Object.assign({}, trdata) } let trsrch = 'ytsearch3:' + trdata.artist + ' - ' + trdata.title let dldata = await dl.getVideoInfo(trsrch) let bestMatch if (dldata.length === 1) bestMatch = dldata[0] let candidates = [] for (let i in dldata) { let obj = dldata[i] let title = obj.title.toLowerCase() // Skip any video with 'video' in it, but keep lyric videos if (title.indexOf('video') !== -1 && title.indexOf('lyric') === -1) continue // If the title has 'audio' in it, it might be the best match if (title.indexOf('audio') !== -1) { bestMatch = obj break } candidates.push(obj) } if (candidates.length && !bestMatch) { // Sort candidates by view count candidates = candidates.sort(function (a, b) { return b.view_count - a.view_count }) // Select the one with the most views bestMatch = candidates[0] } // If there were no suitable candidates, just take the first response if (!candidates.length && !bestMatch) bestMatch = dldata[0] externalTracks[id] = { id: trdata.id, title: trdata.title, artist: trdata.artist, file: bestMatch.url, duration: bestMatch.duration, expires: Date.now() + memexpire * 1000, external: true } return Object.assign({}, externalTracks[id]) } async function search (track, limit = 30) { if (!values.lastfm) return [] let data try { 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) { throw new Error('No results') } } catch (e) { return [] } let final = [] for (let i in data.results.trackmatches.track) { let res = data.results.trackmatches.track[i] let clean = { id: createHash(res), artist: res.artist, title: res.name, external: true, mbid: res.mbid } if (externalTracks[clean.id]) { // Copy object clean = Object.assign({}, externalTracks[clean.id]) } else { // Save in cache externalTracks[clean.id] = clean } final.push(clean) } return final } // Download thread let dltd = null function invokeDownload (add) { if (add) downloadQueue.push(add) if (dltd) return dltd = setTimeout(function (argument) { dltd = null if (downloading) return invokeDownload() if (!downloadQueue.length) return downloading = true downloadLocally(downloadQueue.shift()).then(() => { downloading = false if (downloadQueue.length) invokeDownload() }).catch((e) => { console.error(e) downloading = false if (downloadQueue.length) invokeDownload() }) }, 2 * 1000) } // 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 }