Experimental streaming support
This commit is contained in:
parent
1e7414907f
commit
d3c7da844b
@ -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",
|
||||||
|
@ -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;
|
||||||
|
@ -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">
|
||||||
|
@ -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 || ' ')) + '</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>' + 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
|
||||||
|
if (!options.autoplay) return
|
||||||
playNext(e)
|
playNext(e)
|
||||||
}
|
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
clear.addEventListener('click', function () {
|
clear.addEventListener('click', function () {
|
||||||
|
@ -61,14 +61,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSeeker () {
|
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)
|
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 () {
|
||||||
volBar.style.width = (100 * audio.volume) + '%'
|
volBar.style.width = (100 * audio.volume) + '%'
|
||||||
|
@ -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}
|
||||||
|
@ -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`)
|
||||||
let ffmpeg = spawn('ffmpeg', ['-hide_banner', '-i', data.url, '-codec:a', 'libmp3lame', '-q:a', 2, '-joint_stereo', 1, '-y', tempName])
|
ffmpeg(data.url)
|
||||||
|
.audioCodec('libmp3lame')
|
||||||
ffmpeg.stdout.pipe(process.stderr)
|
.format('mp3')
|
||||||
ffmpeg.stderr.pipe(process.stderr)
|
.on('error', reject)
|
||||||
|
.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
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 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) {
|
||||||
|
track = await lastfm.getTrackMetaReal(id)
|
||||||
if (!track) return next(new Error('404 file not found'))
|
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) {
|
||||||
|
track = await lastfm.getTrackMetaReal(id)
|
||||||
if (!track) return next(new Error('404 file not found'))
|
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))
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user