Experimental streaming support
This commit is contained in:
parent
1e7414907f
commit
d3c7da844b
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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">
|
||||
|
@ -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 || ' ')) + '</td>'
|
||||
if (track.external) trTag.className += ' external'
|
||||
trTag.innerHTML += '<td>' + (track.external ? 'Stream' : (options.trackids ? track.id : (track.track || ' '))) + '</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) {
|
||||
playNext(e)
|
||||
}
|
||||
if (externalStream) return
|
||||
if (!options.autoplay) return
|
||||
playNext(e)
|
||||
}, false)
|
||||
|
||||
clear.addEventListener('click', function () {
|
||||
|
@ -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 () {
|
||||
|
@ -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}
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
60
src/lastfm.js
Normal file
60
src/lastfm.js
Normal 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}
|
@ -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))
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user