Adding download tab and download manager, ffmpeg in worker thread

This commit is contained in:
Mark Cooper 2021-09-04 14:17:26 -05:00
parent a86bda59f6
commit e4dac5dd05
28 changed files with 757 additions and 60 deletions

View File

@ -6,10 +6,9 @@
</div>
</template>
<div class="absolute -top-10 left-0 w-full flex">
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'details' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'cover' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'match' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
<div class="w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'tracks' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive>
@ -26,7 +25,34 @@ export default {
selectedTab: 'details',
processing: false,
audiobook: null,
fetchOnShow: false
fetchOnShow: false,
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-edit-tabs-details'
},
{
id: 'cover',
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
},
{
id: 'tracks',
title: 'Tracks',
component: 'modals-edit-tabs-tracks'
},
{
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
}
]
}
},
watch: {
@ -54,11 +80,8 @@ export default {
}
},
tabName() {
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
return ''
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}

View File

@ -0,0 +1,154 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="w-full border border-black-200 p-4 my-4">
<p class="text-center text-lg mb-4 pb-8 border-b border-black-200">
<span class="text-error">Experimental Feature!</span> If your audiobook is made up of multiple audio files, this will concatenate them into a single file. The file type will be the same as the first track. Preparing downloads can take anywhere from a few seconds to several minutes and will be stored in
<span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 10 minutes then get deleted.
</p>
<div class="flex items-center">
<p class="text-lg">Single audio file</p>
<div class="flex-grow" />
<div>
<p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p>
<p v-if="singleAudioDownloadReady" class="text-success mb-2">Download Ready!</p>
<p v-if="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<ui-btn v-else @click="downloadWithProgress">Download</ui-btn>
</div>
</div>
</div>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
<div class="w-full h-full flex items-center justify-center">
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
<p class="w-24 font-mono pl-8 text-right">
{{ downloadAmount }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tempDisable: false,
isDownloading: false,
downloadPercent: '0',
downloadAmount: '0 KB'
}
},
watch: {
singleAudioDownloadPending(newVal) {
if (newVal) {
this.tempDisable = false
}
}
},
computed: {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
},
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
},
singleAudioDownloadPending() {
return this.singleAudioDownload && this.singleAudioDownload.isPending
},
singleAudioDownloadFailed() {
return this.singleAudioDownload && this.singleAudioDownload.isFailed
},
singleAudioDownloadReady() {
return this.singleAudioDownload && this.singleAudioDownload.isReady
},
singleAudioDownloadExpired() {
return this.singleAudioDownload && this.singleAudioDownload.isExpired
},
zipBundleDownload() {
return this.downloads.find((d) => d.type === 'zipBundle')
}
},
methods: {
startSingleAudioDownload() {
console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio'
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress() {
var downloadId = this.singleAudioDownload.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = this.singleAudioDownload.filename
this.isDownloading = true
var request = new XMLHttpRequest()
request.responseType = 'blob'
request.open('get', downloadUrl, true)
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
request.send()
request.onreadystatechange = () => {
if (request.readyState === 4) {
this.isDownloading = false
}
if (request.readyState == 4 && request.status == 200) {
const url = window.URL.createObjectURL(request.response)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
setTimeout(() => {
if (anchor) anchor.remove()
}, 1000)
}
}
request.onerror = (err) => {
console.error('Download error', err)
this.isDownloading = false
}
request.onprogress = (e) => {
const percent_complete = Math.floor((e.loaded / e.total) * 100)
this.downloadAmount = this.$bytesPretty(e.loaded)
this.downloadPercent = percent_complete
// const duration = (new Date().getTime() - startTime) / 1000
// const bps = e.loaded / duration
// const kbps = Math.floor(bps / 1024)
// const time = (e.total - e.loaded) / bps
// const seconds = Math.floor(time % 60)
// const minutes = Math.floor(time / 60)
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
}
}
},
mounted() {}
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="loading" :type="type" :class="classList" @click="click">
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
@ -23,7 +23,8 @@ export default {
},
paddingX: Number,
small: Boolean,
loading: Boolean
loading: Boolean,
disabled: Boolean
},
data() {
return {}

View File

@ -124,6 +124,41 @@ export default {
this.$store.commit('user/setSettings', user.settings)
}
},
downloadStarted(download) {
var filename = download.filename
this.$toast.success(`Preparing download for "${filename}"`)
download.isPending = true
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadReady(download) {
var filename = download.filename
this.$toast.success(`Download "${filename}" is ready!`)
download.isPending = false
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadFailed(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" is failed`)
download.isFailed = true
download.isReady = false
download.isPending = false
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadKilled(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" was terminated`)
this.$store.commit('downloads/removeDownload', download)
},
downloadExpired(download) {
download.isExpired = true
download.isReady = false
download.isPending = false
this.$store.commit('downloads/addUpdateDownload', download)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@ -164,6 +199,13 @@ export default {
this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
// Download Listeners
this.socket.on('download_started', this.downloadStarted)
this.socket.on('download_ready', this.downloadReady)
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
}
},
mounted() {

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "0.9.90-beta",
"version": "0.9.92-beta",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@ -23,9 +23,9 @@
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p>
<div class="flex items-center pt-4">
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream">
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
Play
<ui-btn :disabled="streaming" color="success" :padding-x="4" class="flex items-center" @click="startStream">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }}
</ui-btn>
<ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</ui-btn>

View File

@ -1,6 +1,5 @@
import Vue from "vue";
import Toast from "vue-toastification";
// Import the CSS or use your own!
import "vue-toastification/dist/index.css";
const options = {

36
client/store/downloads.js Normal file
View File

@ -0,0 +1,36 @@
export const state = () => ({
downloads: []
})
export const getters = {
getDownloads: (state) => (audiobookId) => {
return state.downloads.filter(d => d.audiobookId === audiobookId)
}
}
export const actions = {
}
export const mutations = {
addUpdateDownload(state, download) {
// Remove older downloads of matching type
state.downloads = state.downloads.filter(d => {
if (d.id !== download.id && d.type === download.type) {
return false
}
return true
})
var index = state.downloads.findIndex(d => d.id === download.id)
if (index >= 0) {
state.downloads.splice(index, 1, download)
} else {
state.downloads.push(download)
}
},
removeDownload(state, download) {
state.downloads = state.downloads.filter(d => d.id !== download.id)
}
}

View File

@ -11,6 +11,7 @@ if (isDev) {
process.env.CONFIG_PATH = devEnv.ConfigPath
process.env.METADATA_PATH = devEnv.MetadataPath
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath
}
const PORT = process.env.PORT || 80

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "0.9.90-beta",
"version": "0.9.92-beta",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {

View File

@ -1,15 +1,16 @@
const express = require('express')
const Logger = require('./Logger')
const User = require('./User')
const User = require('./objects/User')
const { isObject } = require('./utils/index')
class ApiController {
constructor(db, scanner, auth, streamManager, rssFeeds, emitter) {
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.emitter = emitter
this.router = express()
@ -40,13 +41,13 @@ class ApiController {
this.router.patch('/user/password', this.userChangePassword.bind(this))
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
this.router.post('/authorize', this.authorize.bind(this))
this.router.get('/genres', this.getGenres.bind(this))
this.router.post('/feed', this.openRssFeed.bind(this))
this.router.get('/download/:id', this.download.bind(this))
}
find(req, res) {
@ -307,6 +308,30 @@ class ApiController {
})
}
async download(req, res) {
var downloadId = req.params.id
Logger.info('Download Request', downloadId)
var download = this.downloadManager.getDownload(downloadId)
if (!download) {
Logger.error('Download request not found', downloadId)
return res.sendStatus(404)
}
var options = {
headers: {
// 'Content-Disposition': `attachment; filename=${download.filename}`,
'Content-Type': download.mimeType
// 'Content-Length': download.size
}
}
Logger.info('Starting Download', options, 'SIZE', download.size)
res.download(download.fullPath, download.filename, options, (err) => {
if (err) {
Logger.error('Download Error', err)
}
})
}
getGenres(req, res) {
res.json({
genres: this.db.getGenres()

View File

@ -1,10 +1,9 @@
const fs = require('fs-extra')
const Path = require('path')
const njodb = require("njodb")
const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
const Audiobook = require('./Audiobook')
const User = require('./User')
const Audiobook = require('./objects/Audiobook')
const User = require('./objects/User')
class Db {
constructor(CONFIG_PATH) {

212
server/DownloadManager.js Normal file
View File

@ -0,0 +1,212 @@
const Path = require('path')
const fs = require('fs-extra')
const workerThreads = require('worker_threads')
const Logger = require('./Logger')
const Download = require('./objects/Download')
const { writeConcatFile } = require('./utils/ffmpegHelpers')
const { getFileSize } = require('./utils/fileUtils')
class DownloadManager {
constructor(db, MetadataPath, emitter) {
this.db = db
this.MetadataPath = MetadataPath
this.emitter = emitter
this.downloadDirPath = Path.join(this.MetadataPath, 'downloads')
this.pendingDownloads = []
this.downloads = []
}
getDownload(downloadId) {
return this.downloads.find(d => d.id === downloadId)
}
async removeOrphanDownloads() {
try {
var dirs = await fs.readdir(this.downloadDirPath)
if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => {
var fullPath = Path.join(this.downloadDirPath, dirname)
Logger.info(`Removing Orphan Download ${dirname}`)
return fs.remove(fullPath)
}))
return true
} catch (error) {
return false
}
}
downloadSocketRequest(socket, payload) {
var client = socket.sheepClient
var audiobook = this.db.audiobooks.find(a => a.id === payload.audiobookId)
var options = {
...payload
}
delete options.audiobookId
this.prepareDownload(client, audiobook, options)
}
getBestFileType(tracks) {
if (!tracks || !tracks.length) {
return null
}
var firstTrack = tracks[0]
return firstTrack.ext.substr(1)
}
async prepareDownload(client, audiobook, options = {}) {
var downloadId = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
var dlpath = Path.join(this.downloadDirPath, downloadId)
Logger.info(`Start Download for ${audiobook.id} - DownloadId: ${downloadId} - ${dlpath}`)
await fs.ensureDir(dlpath)
var downloadType = options.type || 'singleAudio'
delete options.type
var filepath = null
var filename = null
var fileext = null
var audiobookDirname = Path.basename(audiobook.path)
if (downloadType === 'singleAudio') {
var audioFileType = options.audioFileType || this.getBestFileType(audiobook.tracks)
delete options.audioFileType
filename = audiobookDirname + '.' + audioFileType
fileext = '.' + audioFileType
filepath = Path.join(dlpath, filename)
}
var downloadData = {
id: downloadId,
audiobookId: audiobook.id,
type: downloadType,
options: options,
dirpath: dlpath,
fullPath: filepath,
filename,
ext: fileext,
userId: (client && client.user) ? client.user.id : null,
socket: (client && client.socket) ? client.socket : null
}
var download = new Download()
download.setData(downloadData)
if (downloadData.socket) {
downloadData.socket.emit('download_started', download.toJSON())
}
if (download.type === 'singleAudio') {
this.processSingleAudioDownload(audiobook, download)
}
}
async processSingleAudioDownload(audiobook, download) {
// var ffmpeg = Ffmpeg()
var concatFilePath = Path.join(download.dirpath, 'files.txt')
await writeConcatFile(audiobook.tracks, concatFilePath)
var workerData = {
input: concatFilePath,
inputFormat: 'concat',
inputOption: '-safe 0',
options: [
'-loglevel warning',
'-map 0:a',
'-c:a copy'
],
output: download.fullPath
}
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData })
worker.on('message', (message) => {
if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') {
this.sendResult(download, message)
}
} else {
Logger.error('Invalid worker message', message)
}
})
this.pendingDownloads.push({
id: download.id,
download,
worker
})
}
async downloadExpired(download) {
Logger.info(`[DownloadManager] Download ${download.id} expired`)
if (download.socket) {
download.socket.emit('download_expired', download.toJSON())
}
this.removeDownload(download)
}
async sendResult(download, result) {
// Remove pending download
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
if (result.isKilled) {
if (download.socket) {
download.socket.emit('download_killed', download.toJSON())
}
return
}
if (!result.success) {
if (download.socket) {
download.socket.emit('download_failed', download.toJSON())
}
this.removeDownload(download)
return
}
// Remove files.txt if it was used
if (download.type === 'singleAudio') {
var concatFilePath = Path.join(download.dirpath, 'files.txt')
try {
await fs.remove(concatFilePath)
} catch (error) {
Logger.error('[DownloadManager] Failed to remove files.txt')
}
}
result.size = await getFileSize(download.fullPath)
download.setComplete(result)
if (download.socket) {
download.socket.emit('download_ready', download.toJSON())
}
download.setExpirationTimer(this.downloadExpired.bind(this))
this.downloads.push(download)
Logger.info(`[DownloadManager] Download Ready ${download.id}`)
}
async removeDownload(download) {
Logger.info('[DownloadManager] Removing download ' + download.id)
var pendingDl = this.pendingDownloads.find(d => d.id === download.id)
if (pendingDl) {
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
Logger.warn(`[DownloadManager] Removing download in progress - stopping worker`)
try {
pendingDl.worker.postMessage('STOP')
} catch (error) {
Logger.error('[DownloadManager] Error posting stop message to worker', error)
}
}
await fs.remove(download.dirpath).then(() => {
Logger.info('[DownloadManager] Deleted download', download.dirpath)
}).catch((err) => {
Logger.error('[DownloadManager] Failed to delete download', err)
})
this.downloads = this.downloads.filter(d => d.id !== download.id)
}
}
module.exports = DownloadManager

View File

@ -1,6 +1,6 @@
const Logger = require('./Logger')
const BookFinder = require('./BookFinder')
const Audiobook = require('./Audiobook')
const Audiobook = require('./objects/Audiobook')
const audioFileScanner = require('./utils/audioFileScanner')
const { getAllAudiobookFiles } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index')

View File

@ -12,6 +12,7 @@ const ApiController = require('./ApiController')
const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager')
const Logger = require('./Logger')
class Server {
@ -32,10 +33,10 @@ class Server {
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.emitter.bind(this))
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.emitter.bind(this))
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
this.server = null
this.io = null
@ -90,6 +91,7 @@ class Server {
async init() {
Logger.info('[Server] Init')
await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads()
await this.db.init()
this.auth.init()
@ -186,6 +188,7 @@ class Server {
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('test', () => {
socket.emit('test_received', socket.id)
})

View File

@ -1,4 +1,4 @@
const Stream = require('./Stream')
const Stream = require('./objects/Stream')
const StreamTest = require('./test/StreamTest')
const Logger = require('./Logger')
const fs = require('fs-extra')

View File

@ -1,4 +1,4 @@
var { bytesPretty } = require('./utils/fileUtils')
var { bytesPretty } = require('../utils/fileUtils')
class AudioTrack {
constructor(audioTrack = null) {

View File

@ -1,7 +1,7 @@
const Path = require('path')
const { bytesPretty, elapsedPretty } = require('./utils/fileUtils')
const { comparePaths, getIno } = require('./utils/index')
const Logger = require('./Logger')
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
const { comparePaths, getIno } = require('../utils/index')
const Logger = require('../Logger')
const Book = require('./Book')
const AudioTrack = require('./AudioTrack')
const AudioFile = require('./AudioFile')

View File

@ -1,6 +1,7 @@
const Path = require('path')
const Logger = require('./Logger')
const parseAuthors = require('./utils/parseAuthors')
const Logger = require('../Logger')
const parseAuthors = require('../utils/parseAuthors')
class Book {
constructor(book = null) {
this.olid = null

107
server/objects/Download.js Normal file
View File

@ -0,0 +1,107 @@
const DEFAULT_EXPIRATION = 1000 * 60 * 10 // 10 minutes
class Download {
constructor(download) {
this.id = null
this.audiobookId = null
this.type = null
this.options = {}
this.dirpath = null
this.fullPath = null
this.ext = null
this.filename = null
this.size = 0
this.userId = null
this.socket = null // Socket to notify when complete
this.isReady = false
this.startedAt = null
this.finishedAt = null
this.expiresAt = null
this.expirationTimeMs = 0
if (download) {
this.construct(download)
}
}
get mimeType() {
if (this.ext === '.mp3' || this.ext === '.m4b' || this.ext === '.m4a') {
return 'audio/mpeg'
} else if (this.ext === '.mp4') {
return 'audio/mp4'
} else if (this.ext === '.ogg') {
return 'audio/ogg'
} else if (this.ext === '.aac' || this.ext === '.m4p') {
return 'audio/aac'
}
return 'audio/mpeg'
}
toJSON() {
return {
id: this.id,
audiobookId: this.audiobookId,
type: this.type,
options: this.options,
dirpath: this.dirpath,
fullPath: this.fullPath,
ext: this.ext,
filename: this.filename,
size: this.size,
userId: this.userId,
isReady: this.isReady,
startedAt: this.startedAt,
finishedAt: this.finishedAt,
expirationSeconds: this.expirationSeconds
}
}
construct(download) {
this.id = download.id
this.audiobookId = download.audiobookId
this.type = download.type
this.options = { ...download.options }
this.dirpath = download.dirpath
this.fullPath = download.fullPath
this.ext = download.ext
this.filename = download.filename
this.size = download.size || 0
this.userId = download.userId
this.socket = download.socket || null
this.isReady = !!download.isReady
this.startedAt = download.startedAt
this.finishedAt = download.finishedAt || null
this.expirationTimeMs = download.expirationTimeMs || DEFAULT_EXPIRATION
this.expiresAt = download.expiresAt || null
}
setData(downloadData) {
downloadData.startedAt = Date.now()
downloadData.isProcessing = true
this.construct(downloadData)
}
setComplete(fileSize) {
this.finishedAt = Date.now()
this.size = fileSize
this.isReady = true
this.expiresAt = this.finishedAt + this.expirationTimeMs
}
setExpirationTimer(callback) {
setTimeout(() => {
if (callback) {
callback(this)
}
}, this.expirationTimeMs)
}
}
module.exports = Download

View File

@ -2,9 +2,10 @@ const Ffmpeg = require('fluent-ffmpeg')
const EventEmitter = require('events')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const { secondsToTimestamp } = require('./utils/fileUtils')
const hlsPlaylistGenerator = require('./utils/hlsPlaylistGenerator')
const Logger = require('../Logger')
const { secondsToTimestamp } = require('../utils/fileUtils')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
class Stream extends EventEmitter {
constructor(streamPath, client, audiobook) {
@ -19,7 +20,7 @@ class Stream extends EventEmitter {
this.streamPath = Path.join(streamPath, this.id)
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
this.fakePlaylistPath = Path.join(this.streamPath, 'fake-output.m3u8')
this.finalPlaylistPath = Path.join(this.streamPath, 'final-output.m3u8')
this.startTime = 0
this.ffmpeg = null
@ -211,29 +212,12 @@ class Stream extends EventEmitter {
}, 2000)
}
escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
}
async start() {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
this.ffmpeg = Ffmpeg()
var currTrackEnd = 0
var startingTrack = this.tracks.find(t => {
currTrackEnd += t.duration
return this.startTime < currTrackEnd
})
var trackStartTime = currTrackEnd - startingTrack.duration
var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
var trackPaths = tracksToInclude.map(t => {
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
return line
})
var inputstr = trackPaths.join('\n\n')
await fs.writeFile(this.concatFilesPath, inputstr)
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, this.startTime)
this.ffmpeg.addInput(this.concatFilesPath)
this.ffmpeg.inputFormat('concat')
@ -266,7 +250,7 @@ class Stream extends EventEmitter {
])
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.fakePlaylistPath)
this.ffmpeg.output(this.finalPlaylistPath)
this.ffmpeg.on('start', (command) => {
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)

View File

@ -1,8 +1,6 @@
const Path = require('path')
const Logger = require('../Logger')
const prober = require('./prober')
const AudioFile = require('../AudioFile')
function getDefaultAudioStream(audioStreams) {
if (audioStreams.length === 1) return audioStreams[0]

View File

@ -0,0 +1,68 @@
const Ffmpeg = require('fluent-ffmpeg')
if (process.env.NODE_ENV !== 'production') {
Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH)
}
const { parentPort, workerData } = require("worker_threads")
const Logger = require('../Logger')
Logger.info('[DownloadWorker] Starting Worker...')
const ffmpegCommand = Ffmpeg()
const startTime = Date.now()
ffmpegCommand.input(workerData.input)
if (workerData.inputFormat) ffmpegCommand.inputFormat(workerData.inputFormat)
if (workerData.inputOption) ffmpegCommand.inputOption(workerData.inputOption)
if (workerData.options) ffmpegCommand.addOption(workerData.options)
ffmpegCommand.output(workerData.output)
var isKilled = false
async function runFfmpeg() {
var success = await new Promise((resolve) => {
ffmpegCommand.on('start', (command) => {
Logger.info('[DownloadWorker] FFMPEG concat started with command: ' + command)
})
ffmpegCommand.on('stderr', (stdErrline) => {
Logger.info(stdErrline)
})
ffmpegCommand.on('error', (err, stdout, stderr) => {
if (err.message && err.message.includes('SIGKILL')) {
// This is an intentional SIGKILL
Logger.info('[DownloadWorker] User Killed singleAudio')
} else {
Logger.error('[DownloadWorker] Ffmpeg Err', err.message)
}
resolve(false)
})
ffmpegCommand.on('end', (stdout, stderr) => {
Logger.info('[DownloadWorker] singleAudio ended')
resolve(true)
})
ffmpegCommand.run()
})
var resultMessage = {
type: 'RESULT',
isKilled,
elapsed: Date.now() - startTime,
success
}
parentPort.postMessage(resultMessage)
}
parentPort.on('message', (message) => {
if (message === 'STOP') {
Logger.info('[DownloadWorker] Requested a hard stop')
isKilled = true
ffmpegCommand.kill()
}
})
runFfmpeg()

View File

@ -0,0 +1,37 @@
const fs = require('fs-extra')
function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
}
// Returns first track start time
// startTime is for streams starting an encode part-way through an audiobook
async function writeConcatFile(tracks, outputPath, startTime = 0) {
var trackToStartWithIndex = 0
var firstTrackStartTime = 0
// Find first track greater than startTime
if (startTime > 0) {
var currTrackEnd = 0
var startingTrack = tracks.find(t => {
currTrackEnd += t.duration
return startTime < currTrackEnd
})
if (startingTrack) {
firstTrackStartTime = currTrackEnd - startingTrack.duration
trackToStartWithIndex = startingTrack.index
}
}
var tracksToInclude = tracks.filter(t => t.index >= trackToStartWithIndex)
var trackPaths = tracksToInclude.map(t => {
var line = 'file ' + escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
return line
})
var inputstr = trackPaths.join('\n\n')
await fs.writeFile(outputPath, inputstr)
return firstTrackStartTime
}
module.exports.writeConcatFile = writeConcatFile

View File

@ -17,6 +17,13 @@ async function getFileStat(path) {
}
module.exports.getFileStat = getFileStat
async function getFileSize(path) {
var stat = await getFileStat(path)
if (!stat) return 0
return stat.size || 0
}
module.exports.getFileSize = getFileSize
function bytesPretty(bytes, decimals = 0) {
if (bytes === 0) {
return '0 Bytes'