diff --git a/package.json b/package.json index 1d1f57c..0b7f9f6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "bluebird": "^3.5.2", "express": "^4.16.3", "express-async-errors": "^3.0.0", + "fluent-ffmpeg": "^2.1.2", "fs-extra": "^7.0.0", "socket.io": "^2.1.1", "sqlite": "^3.0.0", diff --git a/public/index.css b/public/index.css index 18b4bde..ba293e8 100644 --- a/public/index.css +++ b/public/index.css @@ -82,6 +82,9 @@ td,th { tr:nth-child(even) { background-color: #0f1b23; } +tr.external td { + background-color: rgba(0, 255, 233, 0.25); +} .pages { display: flex; flex-direction: row; diff --git a/public/index.html b/public/index.html index 5a053b3..a740724 100644 --- a/public/index.html +++ b/public/index.html @@ -83,6 +83,10 @@ +
+ + +
diff --git a/public/index.js b/public/index.js index 3685078..241602c 100644 --- a/public/index.js +++ b/public/index.js @@ -18,6 +18,7 @@ Duration \ ' var nowPlaying = 0 + var externalStream = false var pageNum = 0 var pages = 1 @@ -41,6 +42,7 @@ var options = { autoplay: true, trackids: true, + streamable: true, sortby: 'id', sortdir: 'asc', } @@ -257,12 +259,13 @@ let trTag = document.createElement('tr') trTag.className = 'track' trTag.setAttribute('data-id', track.id) - trTag.innerHTML += '' + (options.trackids ? track.id : (track.track || ' ')) + '' + if (track.external) trTag.className += ' external' + trTag.innerHTML += '' + (track.external ? 'Stream' : (options.trackids ? track.id : (track.track || ' '))) + '' trTag.innerHTML += '' + title + '' trTag.innerHTML += '' + (track.artist || '') + '' trTag.innerHTML += '' + (track.album || '') + '' trTag.innerHTML += '' + (track.year || '') + '' - trTag.innerHTML += '' + toHHMMSS(track.duration) + '' + trTag.innerHTML += '' + (track.duration ? toHHMMSS(track.duration) : '') + '' return trTag } @@ -383,7 +386,8 @@ clear.style.display = 'block' } - httpGet('/api/tracks/search?q=' + query + '&page=' + page + '&sort=' + options.sortby + '&sortdir=' + options.sortdir).then(function (data) { + httpGet('/api/tracks/search?q=' + query + '&page=' + page + '&sort=' + options.sortby + '&sortdir=' + + options.sortdir + '&streamable=' + (+options.streamable)).then(function (data) { pageNum = page pages = data.pageCount constructList(data.tracks) @@ -400,7 +404,8 @@ window.resumeAudioContexts() - audio.src = '/api/serve/by-id/' + id + externalStream = (data.external === true) + audio.src = data.play_url || ('/api/serve/by-id/' + id) audio.play() updateQT(q) @@ -412,8 +417,9 @@ } function playNext () { - if (queue.length === 0) return play(nowPlaying + 1) - play(queue.shift(), true) + if (queue.length !== 0) return play(queue.shift(), true) + if (externalStream) return + play(nowPlaying + 1) } function showPlaylist (pid) { @@ -510,9 +516,9 @@ }, false) audio.addEventListener('ended', function (e) { - if (options.autoplay) { - playNext(e) - } + if (externalStream) return + if (!options.autoplay) return + playNext(e) }, false) clear.addEventListener('click', function () { diff --git a/public/player.js b/public/player.js index 9382e31..ede6f19 100644 --- a/public/player.js +++ b/public/player.js @@ -61,13 +61,16 @@ } function updateSeeker () { - if (isNaN(audio.duration) || audio.duration === 0) return + if (audio.duration === Infinity || isNaN(audio.duration)) { + ts.innerHTML = toHHMMSS(audio.currentTime) - ts.innerHTML = toHHMMSS(audio.currentTime) + ' / ' + toHHMMSS(audio.duration) + seekBar.style.width = '0%' + } else { + ts.innerHTML = toHHMMSS(audio.currentTime) + ' / ' + toHHMMSS(audio.duration) + let progress = 100 * audio.currentTime / audio.duration - let progress = 100 * audio.currentTime / audio.duration - - seekBar.style.width = progress + '%' + seekBar.style.width = progress + '%' + } } function updateVolume () { diff --git a/src/common/async.js b/src/common/async.js index eea02e7..93fe5b0 100644 --- a/src/common/async.js +++ b/src/common/async.js @@ -1,6 +1,8 @@ import {exec} from 'child_process' import path from 'path' import fs from 'fs-extra' +import url from 'url' +import qs from 'querystring' function filewalker (dir, done) { let results = [] @@ -83,4 +85,54 @@ function copyAsync (fsrc, fdst) { }) } -export default {getFiles, promiseExec, askAsync, insertDB, copyAsync} +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} diff --git a/src/common/download.js b/src/common/download.js index b0d0ace..cefd1d7 100644 --- a/src/common/download.js +++ b/src/common/download.js @@ -1,4 +1,6 @@ import {spawn} from 'child_process' +import ffmpeg from 'fluent-ffmpeg' + import path from 'path' import asn from './async' @@ -55,18 +57,12 @@ function getVideoInfo (arg) { function fetchVideo (data) { return new Promise((resolve, reject) => { - if (data.acodec !== 'mp3' || data.vcodec !== 'none') { - let tempName = path.join(process.cwd(), `/tmp.yt.${data.id}.mp3`) - let ffmpeg = spawn('ffmpeg', ['-hide_banner', '-i', data.url, '-codec:a', 'libmp3lame', '-q:a', 2, '-joint_stereo', 1, '-y', tempName]) - - ffmpeg.stdout.pipe(process.stderr) - ffmpeg.stderr.pipe(process.stderr) - - ffmpeg.on('error', function (e) { - reject(e) - }) - - ffmpeg.on('close', function () { + let tempName = path.join(process.cwd(), `/tmp.yt.${data.id}.mp3`) + ffmpeg(data.url) + .audioCodec('libmp3lame') + .format('mp3') + .on('error', reject) + .on('end', function () { resolve({ title: data.title, artist: data.uploader, @@ -75,9 +71,8 @@ function fetchVideo (data) { source: tempName }) }) - } else { - reject(new Error('Invalid format returned.')) - } + .output(tempName) + .run() }) } diff --git a/src/lastfm.js b/src/lastfm.js new file mode 100644 index 0000000..2d936e5 --- /dev/null +++ b/src/lastfm.js @@ -0,0 +1,60 @@ +import path from 'path' +import as from './common/async' +import dl from './common/download' +import crypto from 'crypto' + +const values = require(path.join(process.cwd(), 'values.json')) + +let externalTracks = {} + +function createHash (data) { + return crypto.createHash('sha1').update(data.artist + data.name).digest('hex') + .substr(0, 8) +} + +async function getTrackMetaReal (id) { + if (!id || !externalTracks[id]) return null + let trdata = externalTracks[id] + if (trdata.file) return trdata + + let trsrch = 'ytsearch1:' + trdata.artist + ' - ' + trdata.title + let dldata = await dl.getVideoInfo(trsrch) + + externalTracks[id] = { + id: trdata.id, + title: trdata.title, + artist: trdata.artist, + file: dldata.url, + duration: dldata.duration, + external: true + } + + return externalTracks[id] +} + +async function search (track, limit = 30) { + if (!values.lastfm) return [] + let data + try { + data = await as.GET(`http://ws.audioscrobbler.com/2.0/?method=track.search&track=${track}&api_key=${values.lastfm}&format=json&limit=${limit}`) + data = JSON.parse(data) + } catch (e) { + console.warn(e.stack) + return [] + } + if (!data.results || !data.results.trackmatches || !data.results.trackmatches.track) return + let final = [] + for (let i in data.results.trackmatches.track) { + let res = data.results.trackmatches.track[i] + try { + let tr = { artist: res.artist, title: res.name, external: true, id: createHash(res) } + externalTracks[tr.id] = tr + final.push(tr) + } catch (e) { + continue + } + } + return final +} + +export default {search, getTrackMetaReal} diff --git a/src/server.js b/src/server.js index 314083e..a8d2ef6 100644 --- a/src/server.js +++ b/src/server.js @@ -2,6 +2,10 @@ import path from 'path' import sqlite from 'sqlite' import Promise from 'bluebird' import express from 'express' +import https from 'https' +import ffmpeg from 'fluent-ffmpeg' + +import lastfm from './lastfm' require('express-async-errors') @@ -57,6 +61,7 @@ router.get('/tracks', async (req, res) => { router.get('/tracks/search', async (req, res) => { let query = req.query.q + let streamable = (req.query.streamable === '1') let qr = '' let exact = false @@ -82,6 +87,7 @@ router.get('/tracks/search', async (req, res) => { exact = true } + let original = String(query) if (!exact) query = `%${query}%` let sort = req.query.sort @@ -110,6 +116,17 @@ router.get('/tracks/search', async (req, res) => { let tracks = await db.all(`SELECT * FROM Track WHERE ${qr} LIKE ? ORDER BY ${sort} ${sortdir.toUpperCase()} LIMIT ? OFFSET ?`, query, tracksPerPage, offset) + let llimit = tracksPerPage - count + if (streamable && page === pageCount && llimit > 1) { + if (llimit < 10) llimit = 10 + try { + let lfm = await lastfm.search(original, llimit) + if (lfm && lfm.length) { + tracks = tracks.concat(lfm) + } + } catch (e) {} + } + for (let i in tracks) { delete tracks[i].file } @@ -121,9 +138,13 @@ router.get('/tracks/search', async (req, res) => { router.get('/track/:id', async (req, res, next) => { let id = req.params.id + let db = await dbPromise let track = await db.get('SELECT * FROM Track WHERE id = ?', id) - if (!track) return next(new Error('404 file not found')) + if (!track) { + track = await lastfm.getTrackMetaReal(id) + if (!track) return next(new Error('404 file not found')) + } delete track.file @@ -152,16 +173,23 @@ router.get('/playlist/:id', async (req, res, next) => { router.get('/serve/by-id/:id', async (req, res, next) => { let id = req.params.id + let dl = (req.query.dl === '1') let db = await dbPromise let track = await db.get('SELECT file FROM Track WHERE id = ?', id) - if (!track) return next(new Error('404 file not found')) + if (!track) { + track = await lastfm.getTrackMetaReal(id) + if (!track) return next(new Error('404 file not found')) + return ffmpeg(track.file) + .audioCodec('libmp3lame') + .format('mp3') + .on('error', (e) => console.error(e)) + .pipe(res, {end: true}) + } let fpath = path.resolve(track.file) res.set('Cache-Control', 'public, max-age=31557600') - if (req.query.dl && parseInt(req.query.dl) === 1) { - return res.download(fpath) - } + if (dl) return res.download(fpath) res.redirect('/file/track' + fpath.substring(values.directory.length)) })