Add:Tasks widget in appbar for merging m4bs & remove old m4b merge routes

This commit is contained in:
advplyr 2022-10-02 14:16:17 -05:00
parent 441b8c5bb7
commit 39979ff8a3
16 changed files with 285 additions and 457 deletions

View File

@ -7,7 +7,7 @@
</nuxt-link>
<nuxt-link to="/">
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
</nuxt-link>
<ui-libraries-dropdown class="mr-2" />
@ -15,7 +15,7 @@
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-2xl md:text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
<widgets-notification-widget class="hidden md:block" />
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>

View File

@ -42,7 +42,7 @@
</div>
<div class="flex-grow" />
<div>
<ui-btn :to="`/audiobook/${libraryItemId}/manage`" class="flex items-center"
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
>Open Manager
<span class="material-icons text-lg ml-2">launch</span>
</ui-btn>

View File

@ -0,0 +1,25 @@
<template>
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative">
<div class="flex h-full items-center justify-center">
<widgets-loading-spinner />
</div>
</div>
</template>
<script>
export default {
data() {
return {}
},
computed: {
tasks() {
return this.$store.state.tasks.tasks
},
tasksRunning() {
return this.tasks.some((t) => !t.isFinished)
}
},
methods: {},
mounted() {}
}
</script>

View File

@ -270,6 +270,14 @@ export default {
this.$store.commit('scanners/addUpdate', data)
},
taskStarted(task) {
console.log('Task started', task)
this.$store.commit('tasks/addUpdateTask', task)
},
taskFinished(task) {
console.log('Task finished', task)
this.$store.commit('tasks/addUpdateTask', task)
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
@ -302,53 +310,6 @@ export default {
}
this.$store.commit('user/removeCollection', collection)
},
abmergeStarted(download) {
download.status = this.$constants.DownloadStatus.PENDING
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false })
this.$store.commit('downloads/addUpdateDownload', download)
},
abmergeReady(download) {
download.status = this.$constants.DownloadStatus.READY
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success' } }, true)
} else {
this.$toast.success(`Download "${download.filename}" is ready!`)
}
this.$store.commit('downloads/addUpdateDownload', download)
},
abmergeFailed(download) {
download.status = this.$constants.DownloadStatus.FAILED
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error' } }, true)
} else {
console.warn('Download failed no existing download', existingDownload)
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
}
this.$store.commit('downloads/addUpdateDownload', download)
},
abmergeKilled(download) {
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error' } }, true)
} else {
console.warn('Download killed no existing download found', existingDownload)
this.$toast.error(`Download "${download.filename}" was terminated`)
}
this.$store.commit('downloads/removeDownload', download)
},
abmergeExpired(download) {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
rssFeedOpen(data) {
this.$store.commit('feeds/addFeed', data)
},
@ -362,7 +323,7 @@ export default {
batchQuickMatchComplete(result) {
var success = result.success || false
var toast = 'Batch quick match complete!\n' + result.updates + ' Updated'
if (result.unmatched && (result.unmatched > 0)) {
if (result.unmatched && result.unmatched > 0) {
toast += '\n' + result.unmatched + ' with no matches'
}
if (success) {
@ -430,12 +391,9 @@ export default {
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
// Download Listeners
this.socket.on('abmerge_started', this.abmergeStarted)
this.socket.on('abmerge_ready', this.abmergeReady)
this.socket.on('abmerge_failed', this.abmergeFailed)
this.socket.on('abmerge_killed', this.abmergeKilled)
this.socket.on('abmerge_expired', this.abmergeExpired)
// Task Listeners
this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished)
// Feed Listeners
this.socket.on('rss_feed_open', this.rssFeedOpen)
@ -548,6 +506,19 @@ export default {
// Queue auto play
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
},
loadTasks() {
this.$axios
.$get('/api/tasks')
.then((payload) => {
console.log('Fetched tasks', payload)
if (payload.tasks) {
this.$store.commit('tasks/setTasks', payload.tasks)
}
})
.catch((error) => {
console.error('Failed to load tasks', error)
})
}
},
beforeMount() {
@ -565,6 +536,8 @@ export default {
this.checkVersionUpdate()
this.loadTasks()
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)

View File

@ -45,7 +45,7 @@
<div class="w-full max-h-72 overflow-auto">
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">No chapters</p>
<template v-for="(chapter, index) in metadataChapters">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''">
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary bg-opacity-25' : ''">
<div class="flex-grow font-semibold">{{ chapter.title }}</div>
<div class="w-24">
{{ $secondsToTimestamp(chapter.start) }}
@ -67,7 +67,8 @@
<p v-else class="text-success text-lg font-semibold">Embed Finished!</p>
</div>
<div v-else class="w-full flex justify-end items-center mb-4">
<ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">Start M4B Encode</ui-btn>
<ui-btn v-if="!isTaskFinished" color="primary" :loading="processing" @click.stop="encodeM4bClick">Start M4B Encode</ui-btn>
<p v-else-if="taskFailed" class="text-error text-lg font-semibold">M4B Failed! {{ taskError }}</p>
<p v-else class="text-success text-lg font-semibold">M4B Finished!</p>
</div>
@ -160,14 +161,23 @@ export default {
},
data() {
return {
processing: false,
audiofilesEncoding: {},
audiofilesFinished: {},
processing: false,
isFinished: false,
toneObject: null,
selectedTool: 'embed'
}
},
watch: {
task: {
handler(newVal) {
if (newVal) {
this.taskUpdated(newVal)
}
}
}
},
computed: {
libraryItemId() {
return this.libraryItem.id
@ -202,6 +212,21 @@ export default {
{ value: 'm4b', text: 'M4B Encoder' }
]
}
},
taskFailed() {
return this.isTaskFinished && this.task.isFailed
},
taskError() {
return this.taskFailed ? this.task.error || 'Unknown Error' : null
},
isTaskFinished() {
return this.task && this.task.isFinished
},
task() {
return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId)
},
taskRunning() {
return this.task && !this.task.isFinished
}
},
methods: {
@ -210,12 +235,12 @@ export default {
this.$axios
.$get(`/api/audiobook-merge/${this.libraryItemId}`)
.then(() => {
this.processing = false
console.log('Ab m4b merge started')
})
.catch((error) => {
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errorMsg)
this.processing = false
this.processing = true
})
},
embedClick() {
@ -246,7 +271,6 @@ export default {
console.log('audio metadata started', data)
if (data.libraryItemId !== this.libraryItemId) return
this.audiofilesFinished = {}
this.processing = true
},
audioMetadataFinished(data) {
console.log('audio metadata finished', data)
@ -278,6 +302,8 @@ export default {
this.selectedToolUpdated()
}
}
if (this.task) this.taskUpdated(this.task)
},
fetchToneObject() {
this.$axios
@ -289,6 +315,9 @@ export default {
.catch((error) => {
console.error('Failed to fetch tone object', error)
})
},
taskUpdated(task) {
this.processing = !task.isFinished
}
},
mounted() {

View File

@ -1,34 +0,0 @@
export const state = () => ({
downloads: []
})
export const getters = {
getDownloads: (state) => (libraryItemId) => {
return state.downloads.filter(d => d.libraryItemId === libraryItemId)
},
getDownload: (state) => (id) => {
return state.downloads.find(d => d.id === id)
}
}
export const actions = {
}
export const mutations = {
setDownloads(state, downloads) {
state.downloads = downloads
},
addUpdateDownload(state, download) {
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)
}
}

31
client/store/tasks.js Normal file
View File

@ -0,0 +1,31 @@
export const state = () => ({
tasks: []
})
export const getters = {
getTaskByLibraryItemId: (state) => (libraryItemId) => {
return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId)
}
}
export const actions = {
}
export const mutations = {
setTasks(state, tasks) {
state.tasks = tasks
},
addUpdateTask(state, task) {
var index = state.tasks.findIndex(d => d.id === task.id)
if (index >= 0) {
state.tasks.splice(index, 1, task)
} else {
state.tasks.push(task)
}
},
removeTask(state, task) {
state.tasks = state.tasks.filter(d => d.id !== task.id)
}
}

View File

@ -34,6 +34,7 @@ const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
const TaskManager = require('./managers/TaskManager')
class Server {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
@ -66,22 +67,23 @@ class Server {
this.auth = new Auth(this.db)
// Managers
this.taskManager = new TaskManager(this.emitter.bind(this))
this.notificationManager = new NotificationManager(this.db, this.emitter.bind(this))
this.backupManager = new BackupManager(this.db, this.emitter.bind(this))
this.logManager = new LogManager(this.db)
this.cacheManager = new CacheManager()
this.abMergeManager = new AbMergeManager(this.db, this.clientEmitter.bind(this))
this.abMergeManager = new AbMergeManager(this.db, this.taskManager, this.clientEmitter.bind(this))
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
this.coverManager = new CoverManager(this.db, this.cacheManager)
this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this), this.notificationManager)
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
// Routers
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.notificationManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.notificationManager, this.taskManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
this.staticRouter = new StaticRouter(this.db)
@ -127,7 +129,6 @@ class Server {
async init() {
Logger.info('[Server] Init v' + version)
await this.abMergeManager.removeOrphanDownloads()
await this.playbackSessionManager.removeOrphanStreams()
var previousVersion = await this.db.checkPreviousVersion() // Returns null if same server version

View File

@ -110,55 +110,13 @@ class MiscController {
res.sendStatus(200)
}
// GET: api/ab-manager-tasks/:id
async getAbManagerTask(req, res) {
if (!req.user.canDownload) {
Logger.error('User attempting to download without permission', req.user)
return res.sendStatus(403)
}
var taskId = req.params.id
Logger.info('Download Request', taskId)
var task = this.abMergeManager.getTask(taskId)
if (!task) {
Logger.error('Ab manager task request not found', taskId)
return res.sendStatus(404)
}
var options = {
headers: {
'Content-Type': task.mimeType
}
}
res.download(task.path, task.filename, options, (err) => {
if (err) {
Logger.error('Download Error', err)
}
// GET: api/tasks
getTasks(req, res) {
res.json({
tasks: this.taskManager.tasks.map(t => t.toJSON())
})
}
// DELETE: api/ab-manager-tasks/:id
async removeAbManagerTask(req, res) {
if (!req.user.canDownload || !req.user.canDelete) {
Logger.error('User attempting to remove ab manager task without permission', req.user.username)
return res.sendStatus(403)
}
this.abMergeManager.removeTaskById(req.params.id)
res.sendStatus(200)
}
// GET: api/ab-manager-tasks
async getAbManagerTasks(req, res) {
if (!req.user.canDownload) {
Logger.error('User attempting to get ab manager tasks without permission', req.user.username)
return res.sendStatus(403)
}
var taskData = {
tasks: this.abMergeManager.tasks,
pendingTasks: this.abMergeManager.pendingTasks
}
res.json(taskData)
}
// PATCH: api/settings (admin)
async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) {

View File

@ -4,23 +4,22 @@ const fs = require('../libs/fsExtra')
const workerThreads = require('worker_threads')
const Logger = require('../Logger')
const AbManagerTask = require('../objects/AbManagerTask')
const Task = require('../objects/Task')
const filePerms = require('../utils/filePerms')
const { getId } = require('../utils/index')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
const { getFileSize } = require('../utils/fileUtils')
class AbMergeManager {
constructor(db, clientEmitter) {
constructor(db, taskManager, clientEmitter) {
this.db = db
this.taskManager = taskManager
this.clientEmitter = clientEmitter
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
this.downloadDirPathExist = false
this.pendingTasks = []
this.tasks = []
}
async ensureDownloadDirPath() { // Creates download path if necessary and sets owner and permissions
@ -39,85 +38,47 @@ class AbMergeManager {
this.downloadDirPathExist = true
}
getTask(taskId) {
return this.tasks.find(d => d.id === taskId)
}
removeTaskById(taskId) {
var task = this.getTask(taskId)
if (task) {
this.removeTask(task)
}
}
async removeOrphanDownloads() {
try {
var dirs = await fs.readdir(this.downloadDirPath)
if (!dirs || !dirs.length) return true
dirs = dirs.filter(d => d.startsWith('abmerge'))
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
}
}
async startAudiobookMerge(user, libraryItem) {
var taskId = getId('abmerge')
var dlpath = Path.join(this.downloadDirPath, taskId)
Logger.info(`Start audiobook merge for ${libraryItem.id} - TaskId: ${taskId} - ${dlpath}`)
const task = new Task()
var audiobookDirname = Path.basename(libraryItem.path)
var filename = audiobookDirname + '.m4b'
var taskData = {
id: taskId,
const audiobookDirname = Path.basename(libraryItem.path)
const targetFilename = audiobookDirname + '.m4b'
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
const tempFilepath = Path.join(itemCachePath, targetFilename)
const taskData = {
libraryItemId: libraryItem.id,
type: 'abmerge',
dirpath: dlpath,
path: Path.join(dlpath, filename),
filename,
ext: '.m4b',
userId: user.id,
libraryItemPath: libraryItem.path,
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path)
userId: user.id,
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
tempFilepath,
targetFilename,
targetFilepath: Path.join(libraryItem.path, targetFilename),
itemCachePath,
toneMetadataObject: null
}
var abManagerTask = new AbManagerTask()
abManagerTask.setData(taskData)
abManagerTask.setTimeoutTimer(this.downloadTimedOut.bind(this))
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData)
this.taskManager.addTask(task)
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
try {
await fs.mkdir(abManagerTask.dirpath)
} catch (error) {
Logger.error(`[AbMergeManager] Failed to make directory ${abManagerTask.dirpath}`)
Logger.debug(`[AbMergeManager] Make directory error: ${error}`)
var taskJson = abManagerTask.toJSON()
this.clientEmitter(user.id, 'abmerge_failed', taskJson)
return
if (!await fs.pathExists(taskData.itemCachePath)) {
await fs.mkdir(taskData.itemCachePath)
}
this.clientEmitter(user.id, 'abmerge_started', abManagerTask.toJSON())
this.runAudiobookMerge(libraryItem, abManagerTask)
this.runAudiobookMerge(libraryItem, task)
}
async runAudiobookMerge(libraryItem, abManagerTask) {
async runAudiobookMerge(libraryItem, task) {
// If changing audio file type then encoding is needed
var audioTracks = libraryItem.media.tracks
var audioRequiresEncode = audioTracks[0].metadata.ext !== abManagerTask.ext
var shouldIncludeCover = libraryItem.media.coverPath
var audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
var firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
var isOneTrack = audioTracks.length === 1
const ffmpegInputs = []
if (!isOneTrack) {
var concatFilePath = Path.join(abManagerTask.dirpath, 'files.txt')
console.log('Write files.txt', concatFilePath)
var concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
await writeConcatFile(audioTracks, concatFilePath)
ffmpegInputs.push({
input: concatFilePath,
@ -132,7 +93,7 @@ class AbMergeManager {
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
var ffmpegOptions = [`-loglevel ${logLevel}`]
var ffmpegOutputOptions = []
var ffmpegOutputOptions = ['-f mp4']
if (audioRequiresEncode) {
ffmpegOptions = ffmpegOptions.concat([
@ -140,25 +101,20 @@ class AbMergeManager {
'-acodec aac',
'-ac 2',
'-b:a 64k'
// '-movflags use_metadata_tags'
])
} else {
ffmpegOptions.push('-max_muxing_queue_size 1000')
if (isOneTrack && firstTrackIsM4b && !shouldIncludeCover) {
if (isOneTrack && firstTrackIsM4b) {
ffmpegOptions.push('-c copy')
} else {
ffmpegOptions.push('-c:a copy')
}
}
if (abManagerTask.ext === '.m4b') {
ffmpegOutputOptions.push('-f mp4')
}
var chaptersFilePath = null
if (libraryItem.media.chapters.length) {
chaptersFilePath = Path.join(abManagerTask.dirpath, 'chapters.txt')
chaptersFilePath = Path.join(task.data.itemCachePath, 'chapters.txt')
try {
await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath)
} catch (error) {
@ -169,7 +125,7 @@ class AbMergeManager {
const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath)
toneMetadataObject.TrackNumber = 1
abManagerTask.toneMetadataObject = toneMetadataObject
task.data.toneMetadataObject = toneMetadataObject
Logger.debug(`[AbMergeManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject)
@ -177,7 +133,7 @@ class AbMergeManager {
inputs: ffmpegInputs,
options: ffmpegOptions,
outputOptions: ffmpegOutputOptions,
output: abManagerTask.path,
output: task.data.tempFilepath
}
var worker = null
@ -186,137 +142,83 @@ class AbMergeManager {
worker = new workerThreads.Worker(workerPath, { workerData })
} catch (error) {
Logger.error(`[AbMergeManager] Start worker thread failed`, error)
if (abManagerTask.userId) {
var taskJson = abManagerTask.toJSON()
this.clientEmitter(abManagerTask.userId, 'abmerge_failed', taskJson)
}
this.removeTask(abManagerTask)
task.setFailed('Failed to start worker thread')
this.removeTask(task, true)
return
}
worker.on('message', (message) => {
if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') {
if (!abManagerTask.isTimedOut) {
this.sendResult(abManagerTask, message)
}
this.sendResult(task, message)
} else if (message.type === 'FFMPEG') {
if (Logger[message.level]) {
Logger[message.level](message.log)
}
}
} else {
Logger.error('Invalid worker message', message)
}
})
this.pendingTasks.push({
id: abManagerTask.id,
abManagerTask,
id: task.id,
task,
worker
})
}
async sendResult(abManagerTask, result) {
abManagerTask.clearTimeoutTimer()
async sendResult(task, result) {
// Remove pending task
this.pendingTasks = this.pendingTasks.filter(d => d.id !== abManagerTask.id)
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
if (result.isKilled) {
if (abManagerTask.userId) {
this.clientEmitter(abManagerTask.userId, 'abmerge_killed', abManagerTask.toJSON())
}
task.setFailed('Ffmpeg task killed')
this.removeTask(task, true)
return
}
if (!result.success) {
if (abManagerTask.userId) {
this.clientEmitter(abManagerTask.userId, 'abmerge_failed', abManagerTask.toJSON())
}
this.removeTask(abManagerTask)
task.setFailed('Encoding failed')
this.removeTask(task, true)
return
}
// Write metadata to merged file
const success = await toneHelpers.tagAudioFile(abManagerTask.path, abManagerTask.toneMetadataObject)
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneMetadataObject)
if (!success) {
Logger.error(`[AbMergeManager] Failed to write metadata to file "${abManagerTask.path}"`)
if (abManagerTask.userId) {
this.clientEmitter(abManagerTask.userId, 'abmerge_failed', abManagerTask.toJSON())
}
this.removeTask(abManagerTask)
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
task.setFailed('Failed to write metadata to m4b file')
this.removeTask(task, true)
return
}
// Move library item tracks to cache
const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${abManagerTask.libraryItemId}`)
await fs.ensureDir(itemCacheDir)
for (const trackPath of abManagerTask.originalTrackPaths) {
for (const trackPath of task.data.originalTrackPaths) {
const trackFilename = Path.basename(trackPath)
const moveToPath = Path.join(itemCacheDir, trackFilename)
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
})
}
// Move m4b to target
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
// Set file permissions and ownership
await filePerms.setDefault(abManagerTask.path)
await filePerms.setDefault(itemCacheDir)
await filePerms.setDefault(task.data.targetFilepath)
await filePerms.setDefault(task.data.itemCachePath)
// Move merged file to library item
const moveToPath = Path.join(abManagerTask.libraryItemPath, abManagerTask.filename)
Logger.debug(`[AbMergeManager] Moving merged audiobook to library item at "${moveToPath}"`)
const moveSuccess = await fs.move(abManagerTask.path, moveToPath, { overwrite: true }).then(() => true).catch((err) => {
Logger.error(`[AbMergeManager] Failed to move merged audiobook from "${abManagerTask.path}" to "${moveToPath}"`, err)
return false
})
if (!moveSuccess) {
// TODO: Revert cached og files?
task.setFinished()
await this.removeTask(task, false)
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
}
var filesize = await getFileSize(abManagerTask.path)
abManagerTask.setComplete(filesize)
if (abManagerTask.userId) {
this.clientEmitter(abManagerTask.userId, 'abmerge_ready', abManagerTask.toJSON())
}
// abManagerTask.setExpirationTimer(this.downloadExpired.bind(this))
// this.tasks.push(abManagerTask)
await this.removeTask(abManagerTask)
Logger.info(`[AbMergeManager] Ab task finished ${abManagerTask.id}`)
}
// async downloadExpired(abManagerTask) {
// Logger.info(`[AbMergeManager] Download ${abManagerTask.id} expired`)
// if (abManagerTask.userId) {
// this.clientEmitter(abManagerTask.userId, 'abmerge_expired', abManagerTask.toJSON())
// }
// this.removeTask(abManagerTask)
// }
async downloadTimedOut(abManagerTask) {
Logger.info(`[AbMergeManager] Download ${abManagerTask.id} timed out (${abManagerTask.timeoutTimeMs}ms)`)
if (abManagerTask.userId) {
var taskJson = abManagerTask.toJSON()
taskJson.isTimedOut = true
this.clientEmitter(abManagerTask.userId, 'abmerge_failed', taskJson)
}
this.removeTask(abManagerTask)
}
async removeTask(abManagerTask) {
Logger.info('[AbMergeManager] Removing task ' + abManagerTask.id)
abManagerTask.clearTimeoutTimer()
// abManagerTask.clearExpirationTimer()
var pendingDl = this.pendingTasks.find(d => d.id === abManagerTask.id)
async removeTask(task, removeTempFilepath = false) {
Logger.info('[AbMergeManager] Removing task ' + task.id)
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
if (pendingDl) {
this.pendingTasks = this.pendingTasks.filter(d => d.id !== abManagerTask.id)
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
if (pendingDl.worker) {
try {
@ -327,12 +229,17 @@ class AbMergeManager {
}
}
await fs.remove(abManagerTask.dirpath).then(() => {
Logger.info('[AbMergeManager] Deleted download', abManagerTask.dirpath)
if (removeTempFilepath) { // On failed tasks remove the bad file if it exists
if (await fs.pathExists(task.data.tempFilepath)) {
await fs.remove(task.data.tempFilepath).then(() => {
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
}).catch((err) => {
Logger.error('[AbMergeManager] Failed to delete download', err)
Logger.error('[AbMergeManager] Failed to delete target file', err)
})
this.tasks = this.tasks.filter(d => d.id !== abManagerTask.id)
}
}
this.taskManager.taskFinished(task)
}
}
module.exports = AbMergeManager

View File

@ -8,8 +8,9 @@ const { writeMetadataFile } = require('../utils/ffmpegHelpers')
const toneHelpers = require('../utils/toneHelpers')
class AudioMetadataMangaer {
constructor(db, emitter, clientEmitter) {
constructor(db, taskManager, emitter, clientEmitter) {
this.db = db
this.taskManager = taskManager
this.emitter = emitter
this.clientEmitter = clientEmitter
}

View File

@ -25,22 +25,6 @@ class DownloadManager {
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)

View File

@ -0,0 +1,20 @@
class TaskManager {
constructor(emitter) {
this.emitter = emitter
this.tasks = []
}
addTask(task) {
this.tasks.push(task)
this.emitter('task_started', task.toJSON())
}
taskFinished(task) {
if (this.tasks.some(t => t.id === task.id)) {
this.tasks = this.tasks.filter(t => t !== task.id)
this.emitter('task_finished', task.toJSON())
}
}
}
module.exports = TaskManager

View File

@ -1,122 +0,0 @@
const { AudioMimeType } = require('../utils/constants')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes
class AbManagerTask {
constructor() {
this.id = null
this.libraryItemId = null
this.libraryItemPath = null
this.type = null
this.dirpath = null
this.path = null
this.ext = null
this.filename = null
this.size = 0
this.toneMetadataObject = null
this.originalTrackPaths = []
this.userId = null
this.isReady = false
this.isTimedOut = false
this.startedAt = null
this.finishedAt = null
this.expiresAt = null
this.expirationTimeMs = 0
this.timeoutTimeMs = 0
this.timeoutTimer = null
this.expirationTimer = null
}
get mimeType() {
return getAudioMimeTypeFromExtname(this.ext) || AudioMimeType.MP3
}
toJSON() {
return {
id: this.id,
libraryItemId: this.libraryItemId,
type: this.type,
dirpath: this.dirpath,
path: this.path,
ext: this.ext,
filename: this.filename,
size: this.size,
userId: this.userId,
isReady: this.isReady,
startedAt: this.startedAt,
finishedAt: this.finishedAt,
expirationSeconds: this.expirationSeconds,
toneMetadataObject: this.toneMetadataObject
}
}
setData(downloadData) {
downloadData.startedAt = Date.now()
downloadData.isProcessing = true
this.construct(downloadData)
}
construct(download) {
this.id = download.id
this.libraryItemId = download.libraryItemId
this.libraryItemPath = download.libraryItemPath
this.type = download.type
this.dirpath = download.dirpath
this.path = download.path
this.ext = download.ext
this.filename = download.filename
this.size = download.size || 0
this.originalTrackPaths = download.originalTrackPaths
this.userId = download.userId
this.isReady = !!download.isReady
this.startedAt = download.startedAt
this.finishedAt = download.finishedAt || null
this.expirationTimeMs = download.expirationTimeMs || DEFAULT_EXPIRATION
this.timeoutTimeMs = download.timeoutTimeMs || DEFAULT_TIMEOUT
this.expiresAt = download.expiresAt || null
}
setComplete(fileSize) {
this.finishedAt = Date.now()
this.size = fileSize
this.isReady = true
this.expiresAt = this.finishedAt + this.expirationTimeMs
}
setExpirationTimer(callback) {
this.expirationTimer = setTimeout(() => {
if (callback) {
callback(this)
}
}, this.expirationTimeMs)
}
setTimeoutTimer(callback) {
this.timeoutTimer = setTimeout(() => {
if (callback) {
this.isTimedOut = true
callback(this)
}
}, this.timeoutTimeMs)
}
clearTimeoutTimer() {
clearTimeout(this.timeoutTimer)
}
clearExpirationTimer() {
clearTimeout(this.expirationTimer)
}
}
module.exports = AbManagerTask

56
server/objects/Task.js Normal file
View File

@ -0,0 +1,56 @@
const { getId } = require('../utils/index')
class Task {
constructor() {
this.id = null
this.action = null // e.g. embed-metadata, encode-m4b, etc
this.data = null // additional info for the action like libraryItemId
this.title = null
this.description = null
this.error = null
this.isFailed = false
this.isFinished = false
this.startedAt = null
this.finishedAt = null
}
toJSON() {
return {
id: this.id,
action: this.action,
data: this.data ? { ...this.data } : {},
title: this.title,
description: this.description,
error: this.error,
isFailed: this.isFailed,
isFinished: this.isFinished,
startedAt: this.startedAt,
finishedAt: this.finishedAt
}
}
setData(action, title, description, data = {}) {
this.id = getId(action)
this.action = action
this.data = { ...data }
this.title = title
this.description = description
this.startedAt = Date.now()
}
setFailed(message) {
this.error = message
this.isFailed = true
this.failedAt = Date.now()
this.setFinished()
}
setFinished() {
this.isFinished = true
this.finishedAt = Date.now()
}
}
module.exports = Task

View File

@ -26,7 +26,7 @@ const Series = require('../objects/entities/Series')
const FileSystemController = require('../controllers/FileSystemController')
class ApiRouter {
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, cronManager, notificationManager, emitter, clientEmitter) {
constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, rssFeedManager, cronManager, notificationManager, taskManager, emitter, clientEmitter) {
this.db = db
this.auth = auth
this.scanner = scanner
@ -41,6 +41,7 @@ class ApiRouter {
this.rssFeedManager = rssFeedManager
this.cronManager = cronManager
this.notificationManager = notificationManager
this.taskManager = taskManager
this.emitter = emitter
this.clientEmitter = clientEmitter
@ -224,9 +225,7 @@ class ApiRouter {
//
this.router.post('/upload', MiscController.handleUpload.bind(this))
this.router.get('/audiobook-merge/:id', MiscController.mergeAudiobook.bind(this))
this.router.get('/ab-manager-tasks/:id', MiscController.getAbManagerTask.bind(this))
this.router.delete('/ab-manager-tasks/:id', MiscController.removeAbManagerTask.bind(this))
this.router.get('/ab-manager-tasks', MiscController.getAbManagerTasks.bind(this))
this.router.get('/tasks', MiscController.getTasks.bind(this))
this.router.patch('/settings', MiscController.updateServerSettings.bind(this)) // Root only
this.router.post('/purgecache', MiscController.purgeCache.bind(this)) // Root only
this.router.post('/authorize', MiscController.authorize.bind(this))