diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index bc215dfe..b0b2d2c9 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -194,6 +194,13 @@ export default { console.error('No Audio Ref') } }, + streamError(streamId) { + if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) { + this.terminateStream() + this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id) + this.stream = null + } + }, sendStreamSync(syncData) { var diff = syncData.currentTime - this.lastServerUpdateSentSeconds if (Math.abs(diff) < 1 && !syncData.timeListened) { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 4fb65785..25d6b23e 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -134,6 +134,10 @@ export default { streamReset(payload) { if (this.$refs.streamContainer) this.$refs.streamContainer.streamReset(payload) }, + streamError({ id, errorMessage }) { + this.$toast.error(`Stream Failed: ${errorMessage}`) + if (this.$refs.streamContainer) this.$refs.streamContainer.streamError(id) + }, audiobookAdded(audiobook) { this.$store.commit('audiobooks/addUpdate', audiobook) }, @@ -327,6 +331,7 @@ export default { this.socket.on('stream_progress', this.streamProgress) this.socket.on('stream_ready', this.streamReady) this.socket.on('stream_reset', this.streamReset) + this.socket.on('stream_error', this.streamError) // Audiobook Listeners this.socket.on('audiobook_updated', this.audiobookUpdated) diff --git a/client/package.json b/client/package.json index 693a63cb..e27991bc 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "1.6.16", + "version": "1.6.17", "description": "Audiobook manager and player", "main": "index.js", "scripts": { diff --git a/package.json b/package.json index cf3b49b4..439c7e09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "1.6.16", + "version": "1.6.17", "description": "Self-hosted audiobook server for managing and playing audiobooks", "main": "index.js", "scripts": { diff --git a/server/StreamManager.js b/server/StreamManager.js index 554cfdee..29537fbe 100644 --- a/server/StreamManager.js +++ b/server/StreamManager.js @@ -28,8 +28,12 @@ class StreamManager { this.streams = this.streams.filter(s => s.id !== stream.id) } - async openStream(client, audiobook) { - var stream = new Stream(this.StreamsPath, client, audiobook) + async openStream(client, audiobook, transcodeOptions = {}) { + if (!client || !client.user) { + Logger.error('[StreamManager] Cannot open stream invalid client', client) + return + } + var stream = new Stream(this.StreamsPath, client, audiobook, transcodeOptions) stream.on('closed', () => { this.removeStream(stream) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index be2f4a30..b4a71583 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -10,13 +10,15 @@ const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator') const UserListeningSession = require('./UserListeningSession') class Stream extends EventEmitter { - constructor(streamPath, client, audiobook) { + constructor(streamPath, client, audiobook, transcodeOptions = {}) { super() this.id = (Date.now() + Math.trunc(Math.random() * 1000)).toString(36) this.client = client this.audiobook = audiobook + this.transcodeOptions = transcodeOptions + this.segmentLength = 6 this.maxSeekBackTime = 30 this.streamPath = Path.join(streamPath, this.id) @@ -110,6 +112,14 @@ class Stream extends EventEmitter { return Number(prog.toFixed(3)) } + get isAACEncodable() { + return ['mp4', 'm4a', 'm4b'].includes(this.tracksAudioFileType) + } + + get transcodeForceAAC() { + return !!this.transcodeOptions.forceAAC + } + toJSON() { return { id: this.id, @@ -314,8 +324,8 @@ class Stream extends EventEmitter { this.ffmpeg.inputOption('-noaccurate_seek') } - const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'error' - const audioCodec = (this.hlsSegmentType === 'fmp4' || this.tracksAudioFileType === 'opus') ? 'aac' : 'copy' + const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning' + const audioCodec = (this.hlsSegmentType === 'fmp4' || this.tracksAudioFileType === 'opus' || this.transcodeForceAAC) ? 'aac' : 'copy' this.ffmpeg.addOption([ `-loglevel ${logLevel}`, '-map 0:a', @@ -349,11 +359,13 @@ class Stream extends EventEmitter { Logger.info('[INFO] FFMPEG transcoding started with command: ' + command) Logger.info('') if (this.isResetting) { + // AAC encode is much slower + const clearIsResettingTime = this.transcodeForceAAC ? 3000 : 500 setTimeout(() => { Logger.info('[STREAM] Clearing isResetting') this.isResetting = false this.startLoop() - }, 500) + }, clearIsResettingTime) } else { this.startLoop() } @@ -368,10 +380,21 @@ class Stream extends EventEmitter { // This is an intentional SIGKILL Logger.info('[FFMPEG] Transcode Killed') this.ffmpeg = null + clearInterval(this.loop) } else { - Logger.error('Ffmpeg Err', err.message) + Logger.error('Ffmpeg Err', '"' + err.message + '"') + + // Temporary workaround for https://github.com/advplyr/audiobookshelf/issues/172 + const aacErrorMsg = 'ffmpeg exited with code 1: Could not write header for output file #0 (incorrect codec parameters ?)' + if (audioCodec === 'copy' && this.isAACEncodable && err.message && err.message.startsWith(aacErrorMsg)) { + Logger.info(`[Stream] Re-attempting stream with AAC encode`) + this.transcodeOptions.forceAAC = true + this.reset(this.startTime) + } else { + // Close stream show error + this.close(err.message) + } } - clearInterval(this.loop) }) this.ffmpeg.on('end', (stdout, stderr) => { @@ -392,7 +415,7 @@ class Stream extends EventEmitter { this.ffmpeg.run() } - async close() { + async close(errorMessage = null) { clearInterval(this.loop) Logger.info('Closing Stream', this.id) @@ -407,7 +430,8 @@ class Stream extends EventEmitter { }) if (this.socket) { - this.socket.emit('stream_closed', this.id) + if (errorMessage) this.socket.emit('stream_error', { id: this.id, error: (errorMessage || '').trim() }) + else this.socket.emit('stream_closed', this.id) } this.emit('closed')