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",
"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",

View File

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

View File

@ -83,6 +83,10 @@
<label for="st-autoplay">Automatically play next track</label>
<input type="checkbox" id="st-autoplay" name="autoplay">
</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">
<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">

View File

@ -18,6 +18,7 @@
<th class="small">Duration</th> \
</tr>'
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 += '<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>' + (track.artist || '') + '</td>'
trTag.innerHTML += '<td>' + (track.album || '') + '</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
}
@ -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) {
if (externalStream) return
if (!options.autoplay) return
playNext(e)
}
}, false)
clear.addEventListener('click', function () {

View File

@ -61,14 +61,17 @@
}
function updateSeeker () {
if (isNaN(audio.duration) || audio.duration === 0) return
if (audio.duration === Infinity || isNaN(audio.duration)) {
ts.innerHTML = toHHMMSS(audio.currentTime)
seekBar.style.width = '0%'
} else {
ts.innerHTML = toHHMMSS(audio.currentTime) + ' / ' + toHHMMSS(audio.duration)
let progress = 100 * audio.currentTime / audio.duration
seekBar.style.width = progress + '%'
}
}
function updateVolume () {
volBar.style.width = (100 * audio.volume) + '%'

View File

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

View File

@ -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 () {
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()
})
}

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 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) {
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) {
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))
})