Experimental streaming support

This commit is contained in:
Evert Prants 2019-01-25 17:00:35 +02:00
parent 1e7414907f
commit d3c7da844b
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
9 changed files with 187 additions and 35 deletions

View File

@ -15,6 +15,7 @@
"bluebird": "^3.5.2", "bluebird": "^3.5.2",
"express": "^4.16.3", "express": "^4.16.3",
"express-async-errors": "^3.0.0", "express-async-errors": "^3.0.0",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^7.0.0", "fs-extra": "^7.0.0",
"socket.io": "^2.1.1", "socket.io": "^2.1.1",
"sqlite": "^3.0.0", "sqlite": "^3.0.0",

View File

@ -82,6 +82,9 @@ td,th {
tr:nth-child(even) { tr:nth-child(even) {
background-color: #0f1b23; background-color: #0f1b23;
} }
tr.external td {
background-color: rgba(0, 255, 233, 0.25);
}
.pages { .pages {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -83,6 +83,10 @@
<label for="st-autoplay">Automatically play next track</label> <label for="st-autoplay">Automatically play next track</label>
<input type="checkbox" id="st-autoplay" name="autoplay"> <input type="checkbox" id="st-autoplay" name="autoplay">
</div> </div>
<div class="option checkbox">
<label for="st-streamable">Include streamable tracks in searches (experimental)</label>
<input type="checkbox" id="st-streamable" name="streamable">
</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>
<input type="checkbox" id="st-trackids" name="trackids"> <input type="checkbox" id="st-trackids" name="trackids">

View File

@ -18,6 +18,7 @@
<th class="small">Duration</th> \ <th class="small">Duration</th> \
</tr>' </tr>'
var nowPlaying = 0 var nowPlaying = 0
var externalStream = false
var pageNum = 0 var pageNum = 0
var pages = 1 var pages = 1
@ -41,6 +42,7 @@
var options = { var options = {
autoplay: true, autoplay: true,
trackids: true, trackids: true,
streamable: true,
sortby: 'id', sortby: 'id',
sortdir: 'asc', sortdir: 'asc',
} }
@ -257,12 +259,13 @@
let trTag = document.createElement('tr') let trTag = document.createElement('tr')
trTag.className = 'track' trTag.className = 'track'
trTag.setAttribute('data-id', track.id) trTag.setAttribute('data-id', track.id)
trTag.innerHTML += '<td>' + (options.trackids ? track.id : (track.track || '&nbsp;')) + '</td>' if (track.external) trTag.className += ' external'
trTag.innerHTML += '<td>' + (track.external ? 'Stream' : (options.trackids ? track.id : (track.track || '&nbsp;'))) + '</td>'
trTag.innerHTML += '<td>' + title + '</td>' trTag.innerHTML += '<td>' + title + '</td>'
trTag.innerHTML += '<td>' + (track.artist || '') + '</td>' trTag.innerHTML += '<td>' + (track.artist || '') + '</td>'
trTag.innerHTML += '<td>' + (track.album || '') + '</td>' trTag.innerHTML += '<td>' + (track.album || '') + '</td>'
trTag.innerHTML += '<td>' + (track.year || '') + '</td>' trTag.innerHTML += '<td>' + (track.year || '') + '</td>'
trTag.innerHTML += '<td>' + toHHMMSS(track.duration) + '</td>' trTag.innerHTML += '<td>' + (track.duration ? toHHMMSS(track.duration) : '') + '</td>'
return trTag return trTag
} }
@ -383,7 +386,8 @@
clear.style.display = 'block' 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 pageNum = page
pages = data.pageCount pages = data.pageCount
constructList(data.tracks) constructList(data.tracks)
@ -400,7 +404,8 @@
window.resumeAudioContexts() 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() audio.play()
updateQT(q) updateQT(q)
@ -412,8 +417,9 @@
} }
function playNext () { function playNext () {
if (queue.length === 0) return play(nowPlaying + 1) if (queue.length !== 0) return play(queue.shift(), true)
play(queue.shift(), true) if (externalStream) return
play(nowPlaying + 1)
} }
function showPlaylist (pid) { function showPlaylist (pid) {
@ -510,9 +516,9 @@
}, false) }, false)
audio.addEventListener('ended', function (e) { audio.addEventListener('ended', function (e) {
if (options.autoplay) { if (externalStream) return
playNext(e) if (!options.autoplay) return
} playNext(e)
}, false) }, false)
clear.addEventListener('click', function () { clear.addEventListener('click', function () {

View File

@ -61,13 +61,16 @@
} }
function updateSeeker () { 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 () { function updateVolume () {

View File

@ -1,6 +1,8 @@
import {exec} from 'child_process' import {exec} from 'child_process'
import path from 'path' import path from 'path'
import fs from 'fs-extra' import fs from 'fs-extra'
import url from 'url'
import qs from 'querystring'
function filewalker (dir, done) { function filewalker (dir, done) {
let results = [] 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}

View File

@ -1,4 +1,6 @@
import {spawn} from 'child_process' import {spawn} from 'child_process'
import ffmpeg from 'fluent-ffmpeg'
import path from 'path' import path from 'path'
import asn from './async' import asn from './async'
@ -55,18 +57,12 @@ function getVideoInfo (arg) {
function fetchVideo (data) { function fetchVideo (data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (data.acodec !== 'mp3' || data.vcodec !== 'none') { let tempName = path.join(process.cwd(), `/tmp.yt.${data.id}.mp3`)
let tempName = path.join(process.cwd(), `/tmp.yt.${data.id}.mp3`) ffmpeg(data.url)
let ffmpeg = spawn('ffmpeg', ['-hide_banner', '-i', data.url, '-codec:a', 'libmp3lame', '-q:a', 2, '-joint_stereo', 1, '-y', tempName]) .audioCodec('libmp3lame')
.format('mp3')
ffmpeg.stdout.pipe(process.stderr) .on('error', reject)
ffmpeg.stderr.pipe(process.stderr) .on('end', function () {
ffmpeg.on('error', function (e) {
reject(e)
})
ffmpeg.on('close', function () {
resolve({ resolve({
title: data.title, title: data.title,
artist: data.uploader, artist: data.uploader,
@ -75,9 +71,8 @@ function fetchVideo (data) {
source: tempName source: tempName
}) })
}) })
} else { .output(tempName)
reject(new Error('Invalid format returned.')) .run()
}
}) })
} }

60
src/lastfm.js Normal file
View File

@ -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}

View File

@ -2,6 +2,10 @@ import path from 'path'
import sqlite from 'sqlite' import sqlite from 'sqlite'
import Promise from 'bluebird' import Promise from 'bluebird'
import express from 'express' import express from 'express'
import https from 'https'
import ffmpeg from 'fluent-ffmpeg'
import lastfm from './lastfm'
require('express-async-errors') require('express-async-errors')
@ -57,6 +61,7 @@ router.get('/tracks', async (req, res) => {
router.get('/tracks/search', async (req, res) => { router.get('/tracks/search', async (req, res) => {
let query = req.query.q let query = req.query.q
let streamable = (req.query.streamable === '1')
let qr = '' let qr = ''
let exact = false let exact = false
@ -82,6 +87,7 @@ router.get('/tracks/search', async (req, res) => {
exact = true exact = true
} }
let original = String(query)
if (!exact) query = `%${query}%` if (!exact) query = `%${query}%`
let sort = req.query.sort 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 ?`, let tracks = await db.all(`SELECT * FROM Track WHERE ${qr} LIKE ? ORDER BY ${sort} ${sortdir.toUpperCase()} LIMIT ? OFFSET ?`,
query, tracksPerPage, 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) { for (let i in tracks) {
delete tracks[i].file delete tracks[i].file
} }
@ -121,9 +138,13 @@ router.get('/tracks/search', async (req, res) => {
router.get('/track/:id', async (req, res, next) => { router.get('/track/:id', async (req, res, next) => {
let id = req.params.id let id = req.params.id
let db = await dbPromise let db = await dbPromise
let track = await db.get('SELECT * FROM Track WHERE id = ?', id) 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 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) => { router.get('/serve/by-id/:id', async (req, res, next) => {
let id = req.params.id let id = req.params.id
let dl = (req.query.dl === '1')
let db = await dbPromise let db = await dbPromise
let track = await db.get('SELECT file FROM Track WHERE id = ?', id) 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) let fpath = path.resolve(track.file)
res.set('Cache-Control', 'public, max-age=31557600') res.set('Cache-Control', 'public, max-age=31557600')
if (req.query.dl && parseInt(req.query.dl) === 1) { if (dl) return res.download(fpath)
return res.download(fpath)
}
res.redirect('/file/track' + fpath.substring(values.directory.length)) res.redirect('/file/track' + fpath.substring(values.directory.length))
}) })