mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-26 16:48:53 +01:00
Adding chapters and downloading m4b file
This commit is contained in:
parent
26d922d3dc
commit
315de87bfc
@ -8,7 +8,16 @@
|
|||||||
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
|
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute right-24 top-0 bottom-0">
|
<div class="absolute right-20 top-0 bottom-0">
|
||||||
|
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||||
|
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center justify-center text-gray-500">
|
||||||
|
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute right-32 top-0 bottom-0">
|
||||||
<controls-volume-control v-model="volume" @input="updateVolume" />
|
<controls-volume-control v-model="volume" @input="updateVolume" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex my-2">
|
<div class="flex my-2">
|
||||||
@ -58,6 +67,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
|
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
|
||||||
|
|
||||||
|
<modals-chapters-modal v-model="showChaptersModal" :chapters="chapters" @select="selectChapter" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -66,7 +77,11 @@ import Hls from 'hls.js'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
loading: Boolean
|
loading: Boolean,
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -84,7 +99,8 @@ export default {
|
|||||||
audioEl: null,
|
audioEl: null,
|
||||||
totalDuration: 0,
|
totalDuration: 0,
|
||||||
seekedTime: 0,
|
seekedTime: 0,
|
||||||
seekLoading: false
|
seekLoading: false,
|
||||||
|
showChaptersModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -96,6 +112,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
selectChapter(chapter) {
|
||||||
|
this.seek(chapter.start)
|
||||||
|
this.showChaptersModal = false
|
||||||
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
if (this.loading) {
|
if (this.loading) {
|
||||||
return
|
return
|
||||||
@ -110,7 +130,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.seekedTime = time
|
this.seekedTime = time
|
||||||
this.seekLoading = true
|
this.seekLoading = true
|
||||||
console.warn('SEEK TO', this.$secondsToTimestamp(time))
|
|
||||||
this.audioEl.currentTime = time
|
this.audioEl.currentTime = time
|
||||||
|
|
||||||
if (this.$refs.playedTrack) {
|
if (this.$refs.playedTrack) {
|
||||||
@ -361,7 +381,7 @@ export default {
|
|||||||
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||||
this.hlsInstance = new Hls(hlsOptions)
|
this.hlsInstance = new Hls(hlsOptions)
|
||||||
var audio = this.$refs.audio
|
var audio = this.$refs.audio
|
||||||
audio.volume = this.volume
|
audio.volume = this.volume
|
||||||
@ -386,10 +406,13 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||||
console.warn('[HLS] Destroying HLS Instance')
|
console.log('[HLS] Destroying HLS Instance')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
showChapters() {
|
||||||
|
this.showChaptersModal = true
|
||||||
|
},
|
||||||
play() {
|
play() {
|
||||||
if (!this.$refs.audio) {
|
if (!this.$refs.audio) {
|
||||||
console.error('No Audio ref')
|
console.error('No Audio ref')
|
||||||
@ -410,7 +433,6 @@ export default {
|
|||||||
this.staleHlsInstance = this.hlsInstance
|
this.staleHlsInstance = this.hlsInstance
|
||||||
this.staleHlsInstance.destroy()
|
this.staleHlsInstance.destroy()
|
||||||
this.hlsInstance = null
|
this.hlsInstance = null
|
||||||
console.log('Terminated HLS Instance', this.staleHlsInstance)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async resetStream(startTime) {
|
async resetStream(startTime) {
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio-player ref="audioPlayer" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -49,6 +49,9 @@ export default {
|
|||||||
book() {
|
book() {
|
||||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||||
},
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.book.title || 'No Title'
|
||||||
},
|
},
|
||||||
@ -94,7 +97,7 @@ export default {
|
|||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
var chunks = data.chunks
|
var chunks = data.chunks
|
||||||
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`)
|
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
} else {
|
} else {
|
||||||
@ -104,7 +107,7 @@ export default {
|
|||||||
streamOpen(stream) {
|
streamOpen(stream) {
|
||||||
this.stream = stream
|
this.stream = stream
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
console.log('[STREAM-CONTAINER] streamOpen', stream)
|
console.log('[StreamContainer] streamOpen', stream)
|
||||||
this.openStream()
|
this.openStream()
|
||||||
} else if (this.audioPlayerReady) {
|
} else if (this.audioPlayerReady) {
|
||||||
console.error('No Audio Ref')
|
console.error('No Audio Ref')
|
||||||
|
44
client/components/modals/ChaptersModal.vue
Normal file
44
client/components/modals/ChaptersModal.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" :width="500" :height="'unset'">
|
||||||
|
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px">
|
||||||
|
<template v-for="chap in chapters">
|
||||||
|
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg bg-opacity-20 rounded-lg relative" @click="clickChapter(chap)">
|
||||||
|
{{ chap.title }}
|
||||||
|
<span class="flex-grow" />
|
||||||
|
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickChapter(chap) {
|
||||||
|
this.$emit('select', chap)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -46,6 +46,11 @@ export default {
|
|||||||
title: 'Tracks',
|
title: 'Tracks',
|
||||||
component: 'modals-edit-tabs-tracks'
|
component: 'modals-edit-tabs-tracks'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'chapters',
|
||||||
|
title: 'Chapters',
|
||||||
|
component: 'modals-edit-tabs-chapters'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'download',
|
id: 'download',
|
||||||
title: 'Download',
|
title: 'Download',
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||||
|
59
client/components/modals/edit-tabs/Chapters.vue
Normal file
59
client/components/modals/edit-tabs/Chapters.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
|
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||||
|
<table v-else class="text-sm tracksTable">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||||
|
<th class="text-left">Title</th>
|
||||||
|
<th class="text-center">Start</th>
|
||||||
|
<th class="text-center">End</th>
|
||||||
|
</tr>
|
||||||
|
<template v-for="chapter in chapters">
|
||||||
|
<tr :key="chapter.id">
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ chapter.id }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ chapter.title }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
chapters: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
audiobook: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.chapters = this.audiobook.chapters || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -177,7 +177,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteAudiobook() {
|
deleteAudiobook() {
|
||||||
if (confirm(`Are you sure you want to remove this audiobook?`)) {
|
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$delete(`/api/audiobook/${this.audiobookId}`)
|
.$delete(`/api/audiobook/${this.audiobookId}`)
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||||
<div class="w-full border border-black-200 p-4 my-4">
|
<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">
|
<!-- <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="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.
|
<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> -->
|
||||||
|
<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 has multiple tracks, this will merge them into a single M4B audiobook file.<br />Preparing downloads can take 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 60 minutes, then be
|
||||||
|
deleted.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-lg">Single audio file</p>
|
<p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div>
|
<div>
|
||||||
<p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p>
|
<p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p>
|
||||||
|
@ -59,7 +59,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
button.btn::before {
|
.btn::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@ -70,7 +70,7 @@ button.btn::before {
|
|||||||
background-color: rgba(255, 255, 255, 0);
|
background-color: rgba(255, 255, 255, 0);
|
||||||
transition: all 0.1s ease-in-out;
|
transition: all 0.1s ease-in-out;
|
||||||
}
|
}
|
||||||
button.btn:hover:not(:disabled)::before {
|
.btn:hover:not(:disabled)::before {
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
button:disabled::before {
|
button:disabled::before {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -418,12 +418,9 @@ class ApiController {
|
|||||||
|
|
||||||
var options = {
|
var options = {
|
||||||
headers: {
|
headers: {
|
||||||
// 'Content-Disposition': `attachment; filename=${download.filename}`,
|
|
||||||
'Content-Type': download.mimeType
|
'Content-Type': download.mimeType
|
||||||
// 'Content-Length': download.size
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Logger.info('Starting Download', options, 'SIZE', download.size)
|
|
||||||
res.download(download.fullPath, download.filename, options, (err) => {
|
res.download(download.fullPath, download.filename, options, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
Logger.error('Download Error', err)
|
Logger.error('Download Error', err)
|
||||||
|
@ -4,7 +4,7 @@ const fs = require('fs-extra')
|
|||||||
const workerThreads = require('worker_threads')
|
const workerThreads = require('worker_threads')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const Download = require('./objects/Download')
|
const Download = require('./objects/Download')
|
||||||
const { writeConcatFile } = require('./utils/ffmpegHelpers')
|
const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
|
||||||
const { getFileSize } = require('./utils/fileUtils')
|
const { getFileSize } = require('./utils/fileUtils')
|
||||||
|
|
||||||
class DownloadManager {
|
class DownloadManager {
|
||||||
@ -49,14 +49,6 @@ class DownloadManager {
|
|||||||
this.prepareDownload(client, audiobook, options)
|
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 = {}) {
|
async prepareDownload(client, audiobook, options = {}) {
|
||||||
var downloadId = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
var downloadId = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||||
var dlpath = Path.join(this.downloadDirPath, downloadId)
|
var dlpath = Path.join(this.downloadDirPath, downloadId)
|
||||||
@ -73,7 +65,7 @@ class DownloadManager {
|
|||||||
var audiobookDirname = Path.basename(audiobook.path)
|
var audiobookDirname = Path.basename(audiobook.path)
|
||||||
|
|
||||||
if (downloadType === 'singleAudio') {
|
if (downloadType === 'singleAudio') {
|
||||||
var audioFileType = options.audioFileType || this.getBestFileType(audiobook.tracks)
|
var audioFileType = options.audioFileType || 'm4b'
|
||||||
delete options.audioFileType
|
delete options.audioFileType
|
||||||
filename = audiobookDirname + '.' + audioFileType
|
filename = audiobookDirname + '.' + audioFileType
|
||||||
fileext = '.' + audioFileType
|
fileext = '.' + audioFileType
|
||||||
@ -105,21 +97,47 @@ class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async processSingleAudioDownload(audiobook, download) {
|
async processSingleAudioDownload(audiobook, download) {
|
||||||
// var ffmpeg = Ffmpeg()
|
|
||||||
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
||||||
await writeConcatFile(audiobook.tracks, concatFilePath)
|
await writeConcatFile(audiobook.tracks, concatFilePath)
|
||||||
|
|
||||||
var workerData = {
|
var metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
|
||||||
input: concatFilePath,
|
await writeMetadataFile(audiobook, metadataFilePath)
|
||||||
inputFormat: 'concat',
|
|
||||||
inputOption: '-safe 0',
|
const ffmpegInputs = [
|
||||||
options: [
|
{
|
||||||
'-loglevel warning',
|
input: concatFilePath,
|
||||||
'-map 0:a',
|
options: ['-safe 0', '-f concat']
|
||||||
'-c:a copy'
|
},
|
||||||
],
|
{
|
||||||
output: download.fullPath
|
input: metadataFilePath
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||||
|
const ffmpegOptions = [
|
||||||
|
`-loglevel ${logLevel}`,
|
||||||
|
'-map 0:a',
|
||||||
|
'-map_metadata 1',
|
||||||
|
'-acodec aac',
|
||||||
|
'-ac 2',
|
||||||
|
'-b:a 64k',
|
||||||
|
'-id3v2_version 3']
|
||||||
|
|
||||||
|
if (audiobook.book.cover) {
|
||||||
|
ffmpegInputs.push({
|
||||||
|
input: audiobook.book.cover,
|
||||||
|
options: ['-f image2pipe']
|
||||||
|
})
|
||||||
|
ffmpegOptions.push('-vf [2:v]crop=trunc(iw/2)*2:trunc(ih/2)*2')
|
||||||
|
ffmpegOptions.push('-map 2:v')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var workerData = {
|
||||||
|
inputs: ffmpegInputs,
|
||||||
|
options: ffmpegOptions,
|
||||||
|
output: download.fullPath,
|
||||||
|
}
|
||||||
|
|
||||||
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData })
|
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData })
|
||||||
worker.on('message', (message) => {
|
worker.on('message', (message) => {
|
||||||
if (message != null && typeof message === 'object') {
|
if (message != null && typeof message === 'object') {
|
||||||
@ -166,14 +184,14 @@ class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove files.txt if it was used
|
// Remove files.txt if it was used
|
||||||
if (download.type === 'singleAudio') {
|
// if (download.type === 'singleAudio') {
|
||||||
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
// var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
||||||
try {
|
// try {
|
||||||
await fs.remove(concatFilePath)
|
// await fs.remove(concatFilePath)
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
Logger.error('[DownloadManager] Failed to remove files.txt')
|
// Logger.error('[DownloadManager] Failed to remove files.txt')
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
result.size = await getFileSize(download.fullPath)
|
result.size = await getFileSize(download.fullPath)
|
||||||
download.setComplete(result)
|
download.setComplete(result)
|
||||||
|
@ -135,6 +135,8 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
|
existingAudiobook.setChapters()
|
||||||
|
|
||||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||||
existingAudiobook.lastUpdate = Date.now()
|
existingAudiobook.lastUpdate = Date.now()
|
||||||
await this.db.updateAudiobook(existingAudiobook)
|
await this.db.updateAudiobook(existingAudiobook)
|
||||||
@ -161,6 +163,8 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
audiobook.checkUpdateMissingParts()
|
audiobook.checkUpdateMissingParts()
|
||||||
|
audiobook.setChapters()
|
||||||
|
|
||||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||||
await this.db.insertAudiobook(audiobook)
|
await this.db.insertAudiobook(audiobook)
|
||||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||||
|
@ -14,7 +14,6 @@ const StreamManager = require('./StreamManager')
|
|||||||
const RssFeeds = require('./RssFeeds')
|
const RssFeeds = require('./RssFeeds')
|
||||||
const DownloadManager = require('./DownloadManager')
|
const DownloadManager = require('./DownloadManager')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const { ScanResult } = require('./utils/constants')
|
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||||
|
@ -20,6 +20,7 @@ class AudioFile {
|
|||||||
this.timeBase = null
|
this.timeBase = null
|
||||||
this.channels = null
|
this.channels = null
|
||||||
this.channelLayout = null
|
this.channelLayout = null
|
||||||
|
this.chapters = []
|
||||||
|
|
||||||
this.tagAlbum = null
|
this.tagAlbum = null
|
||||||
this.tagArtist = null
|
this.tagArtist = null
|
||||||
@ -60,6 +61,7 @@ class AudioFile {
|
|||||||
timeBase: this.timeBase,
|
timeBase: this.timeBase,
|
||||||
channels: this.channels,
|
channels: this.channels,
|
||||||
channelLayout: this.channelLayout,
|
channelLayout: this.channelLayout,
|
||||||
|
chapters: this.chapters,
|
||||||
tagAlbum: this.tagAlbum,
|
tagAlbum: this.tagAlbum,
|
||||||
tagArtist: this.tagArtist,
|
tagArtist: this.tagArtist,
|
||||||
tagGenre: this.tagGenre,
|
tagGenre: this.tagGenre,
|
||||||
@ -93,6 +95,7 @@ class AudioFile {
|
|||||||
this.timeBase = data.timeBase
|
this.timeBase = data.timeBase
|
||||||
this.channels = data.channels
|
this.channels = data.channels
|
||||||
this.channelLayout = data.channelLayout
|
this.channelLayout = data.channelLayout
|
||||||
|
this.chapters = data.chapters
|
||||||
|
|
||||||
this.tagAlbum = data.tagAlbum
|
this.tagAlbum = data.tagAlbum
|
||||||
this.tagArtist = data.tagArtist
|
this.tagArtist = data.tagArtist
|
||||||
@ -121,12 +124,13 @@ class AudioFile {
|
|||||||
this.format = data.format
|
this.format = data.format
|
||||||
this.duration = data.duration
|
this.duration = data.duration
|
||||||
this.size = data.size
|
this.size = data.size
|
||||||
this.bitRate = data.bit_rate
|
this.bitRate = data.bit_rate || null
|
||||||
this.language = data.language
|
this.language = data.language
|
||||||
this.codec = data.codec
|
this.codec = data.codec
|
||||||
this.timeBase = data.time_base
|
this.timeBase = data.time_base
|
||||||
this.channels = data.channels
|
this.channels = data.channels
|
||||||
this.channelLayout = data.channel_layout
|
this.channelLayout = data.channel_layout
|
||||||
|
this.chapters = data.chapters || []
|
||||||
|
|
||||||
this.tagAlbum = data.file_tag_album || null
|
this.tagAlbum = data.file_tag_album || null
|
||||||
this.tagArtist = data.file_tag_artist || null
|
this.tagArtist = data.file_tag_artist || null
|
||||||
|
@ -97,12 +97,12 @@ class AudioTrack {
|
|||||||
this.format = probeData.format
|
this.format = probeData.format
|
||||||
this.duration = probeData.duration
|
this.duration = probeData.duration
|
||||||
this.size = probeData.size
|
this.size = probeData.size
|
||||||
this.bitRate = probeData.bit_rate
|
this.bitRate = probeData.bitRate
|
||||||
this.language = probeData.language
|
this.language = probeData.language
|
||||||
this.codec = probeData.codec
|
this.codec = probeData.codec
|
||||||
this.timeBase = probeData.time_base
|
this.timeBase = probeData.timeBase
|
||||||
this.channels = probeData.channels
|
this.channels = probeData.channels
|
||||||
this.channelLayout = probeData.channel_layout
|
this.channelLayout = probeData.channelLayout
|
||||||
|
|
||||||
this.tagAlbum = probeData.file_tag_album || null
|
this.tagAlbum = probeData.file_tag_album || null
|
||||||
this.tagArtist = probeData.file_tag_artist || null
|
this.tagArtist = probeData.file_tag_artist || null
|
||||||
|
@ -26,6 +26,7 @@ class Audiobook {
|
|||||||
|
|
||||||
this.tags = []
|
this.tags = []
|
||||||
this.book = null
|
this.book = null
|
||||||
|
this.chapters = []
|
||||||
|
|
||||||
if (audiobook) {
|
if (audiobook) {
|
||||||
this.construct(audiobook)
|
this.construct(audiobook)
|
||||||
@ -51,6 +52,9 @@ class Audiobook {
|
|||||||
if (audiobook.book) {
|
if (audiobook.book) {
|
||||||
this.book = new Book(audiobook.book)
|
this.book = new Book(audiobook.book)
|
||||||
}
|
}
|
||||||
|
if (audiobook.chapters) {
|
||||||
|
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get title() {
|
get title() {
|
||||||
@ -122,7 +126,8 @@ class Audiobook {
|
|||||||
book: this.bookToJSON(),
|
book: this.bookToJSON(),
|
||||||
tracks: this.tracksToJSON(),
|
tracks: this.tracksToJSON(),
|
||||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON())
|
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||||
|
chapters: this.chapters || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +146,8 @@ class Audiobook {
|
|||||||
hasBookMatch: !!this.book,
|
hasBookMatch: !!this.book,
|
||||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||||
numTracks: this.tracks.length
|
numTracks: this.tracks.length,
|
||||||
|
chapters: this.chapters || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +168,8 @@ class Audiobook {
|
|||||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
book: this.bookToJSON(),
|
book: this.bookToJSON(),
|
||||||
tracks: this.tracksToJSON()
|
tracks: this.tracksToJSON(),
|
||||||
|
chapters: this.chapters || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -403,5 +410,31 @@ class Audiobook {
|
|||||||
getAudioFileByIno(ino) {
|
getAudioFileByIno(ino) {
|
||||||
return this.audioFiles.find(af => af.ino === ino)
|
return this.audioFiles.find(af => af.ino === ino)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setChaptersFromAudioFile(audioFile) {
|
||||||
|
if (!audioFile.chapters) return []
|
||||||
|
return audioFile.chapters.map(c => ({ ...c }))
|
||||||
|
}
|
||||||
|
|
||||||
|
setChapters() {
|
||||||
|
if (this.audioFiles.length === 1) {
|
||||||
|
if (this.audioFiles[0].chapters) {
|
||||||
|
this.chapters = this.audioFiles[0].chapters.map(c => ({ ...c }))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.chapters = []
|
||||||
|
var currTrackId = 0
|
||||||
|
var currStartTime = 0
|
||||||
|
this.tracks.forEach((track) => {
|
||||||
|
this.chapters.push({
|
||||||
|
id: currTrackId++,
|
||||||
|
start: currStartTime,
|
||||||
|
end: currStartTime + track.duration,
|
||||||
|
title: `Chapter ${currTrackId}`
|
||||||
|
})
|
||||||
|
currStartTime += track.duration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = Audiobook
|
module.exports = Audiobook
|
@ -1,4 +1,4 @@
|
|||||||
const DEFAULT_EXPIRATION = 1000 * 60 * 10 // 10 minutes
|
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
||||||
|
|
||||||
class Download {
|
class Download {
|
||||||
constructor(download) {
|
constructor(download) {
|
||||||
|
@ -191,7 +191,7 @@ class Stream extends EventEmitter {
|
|||||||
|
|
||||||
this.socket.emit('stream_progress', {
|
this.socket.emit('stream_progress', {
|
||||||
stream: this.id,
|
stream: this.id,
|
||||||
percentCreated: perc,
|
percent: perc,
|
||||||
chunks,
|
chunks,
|
||||||
numSegments: this.numSegments
|
numSegments: this.numSegments
|
||||||
})
|
})
|
||||||
@ -201,7 +201,7 @@ class Stream extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startLoop() {
|
startLoop() {
|
||||||
this.socket.emit('stream_progress', { chunks: [], numSegments: 0 })
|
this.socket.emit('stream_progress', { stream: this.id, chunks: [], numSegments: 0, percent: '0%' })
|
||||||
this.loop = setInterval(() => {
|
this.loop = setInterval(() => {
|
||||||
if (!this.isTranscodeComplete) {
|
if (!this.isTranscodeComplete) {
|
||||||
this.checkFiles()
|
this.checkFiles()
|
||||||
@ -230,8 +230,9 @@ class Stream extends EventEmitter {
|
|||||||
this.ffmpeg.inputOption('-noaccurate_seek')
|
this.ffmpeg.inputOption('-noaccurate_seek')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||||
this.ffmpeg.addOption([
|
this.ffmpeg.addOption([
|
||||||
'-loglevel warning',
|
`-loglevel ${logLevel}`,
|
||||||
'-map 0:a',
|
'-map 0:a',
|
||||||
'-c:a copy'
|
'-c:a copy'
|
||||||
])
|
])
|
||||||
|
@ -33,7 +33,8 @@ async function scan(path) {
|
|||||||
language: audioStream.language,
|
language: audioStream.language,
|
||||||
channel_layout: audioStream.channel_layout,
|
channel_layout: audioStream.channel_layout,
|
||||||
channels: audioStream.channels,
|
channels: audioStream.channels,
|
||||||
sample_rate: audioStream.sample_rate
|
sample_rate: audioStream.sample_rate,
|
||||||
|
chapters: probeData.chapters || []
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in probeData) {
|
for (const key in probeData) {
|
||||||
|
@ -13,9 +13,11 @@ Logger.info('[DownloadWorker] Starting Worker...')
|
|||||||
const ffmpegCommand = Ffmpeg()
|
const ffmpegCommand = Ffmpeg()
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
ffmpegCommand.input(workerData.input)
|
workerData.inputs.forEach((inputData) => {
|
||||||
if (workerData.inputFormat) ffmpegCommand.inputFormat(workerData.inputFormat)
|
ffmpegCommand.input(inputData.input)
|
||||||
if (workerData.inputOption) ffmpegCommand.inputOption(workerData.inputOption)
|
if (inputData.options) ffmpegCommand.inputOption(inputData.options)
|
||||||
|
})
|
||||||
|
|
||||||
if (workerData.options) ffmpegCommand.addOption(workerData.options)
|
if (workerData.options) ffmpegCommand.addOption(workerData.options)
|
||||||
ffmpegCommand.output(workerData.output)
|
ffmpegCommand.output(workerData.output)
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
|
const package = require('../../package.json')
|
||||||
|
|
||||||
function escapeSingleQuotes(path) {
|
function escapeSingleQuotes(path) {
|
||||||
// return path.replace(/'/g, '\'\\\'\'')
|
// return path.replace(/'/g, '\'\\\'\'')
|
||||||
@ -35,3 +36,32 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
|
|||||||
return firstTrackStartTime
|
return firstTrackStartTime
|
||||||
}
|
}
|
||||||
module.exports.writeConcatFile = writeConcatFile
|
module.exports.writeConcatFile = writeConcatFile
|
||||||
|
|
||||||
|
|
||||||
|
async function writeMetadataFile(audiobook, outputPath) {
|
||||||
|
var inputstrs = [
|
||||||
|
';FFMETADATA1',
|
||||||
|
`title=${audiobook.title}`,
|
||||||
|
`artist=${audiobook.author}`,
|
||||||
|
`date=${audiobook.book.publishYear || ''}`,
|
||||||
|
`comment=AudioBookshelf v${package.version}`,
|
||||||
|
'genre=Audiobook'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (audiobook.chapters) {
|
||||||
|
audiobook.chapters.forEach((chap) => {
|
||||||
|
const chapterstrs = [
|
||||||
|
'[CHAPTER]',
|
||||||
|
'TIMEBASE=1/1000',
|
||||||
|
`START=${Math.round(chap.start * 1000)}`,
|
||||||
|
`END=${Math.round(chap.end * 1000)}`,
|
||||||
|
`title=${chap.title}`
|
||||||
|
]
|
||||||
|
inputstrs = inputstrs.concat(chapterstrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(outputPath, inputstrs.join('\n'))
|
||||||
|
return inputstrs
|
||||||
|
}
|
||||||
|
module.exports.writeMetadataFile = writeMetadataFile
|
@ -110,9 +110,23 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseChapters(chapters) {
|
||||||
|
if (!chapters) return []
|
||||||
|
return chapters.map(chap => {
|
||||||
|
var title = chap['TAG:title'] || chap.title
|
||||||
|
var timebase = chap.time_base && chap.time_base.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
|
||||||
|
return {
|
||||||
|
id: chap.id,
|
||||||
|
start: !isNaN(chap.start_time) ? chap.start_time : (chap.start / timebase),
|
||||||
|
end: chap.end_time || (chap.end / timebase),
|
||||||
|
title
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function parseProbeData(data) {
|
function parseProbeData(data) {
|
||||||
try {
|
try {
|
||||||
var { format, streams } = data
|
var { format, streams, chapters } = data
|
||||||
var { format_long_name, duration, size, bit_rate } = format
|
var { format_long_name, duration, size, bit_rate } = format
|
||||||
|
|
||||||
var sizeBytes = !isNaN(size) ? Number(size) : null
|
var sizeBytes = !isNaN(size) ? Number(size) : null
|
||||||
@ -146,6 +160,8 @@ function parseProbeData(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanedData.chapters = parseChapters(chapters)
|
||||||
|
|
||||||
return cleanedData
|
return cleanedData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Parse failed', error)
|
console.error('Parse failed', error)
|
||||||
@ -155,7 +171,7 @@ function parseProbeData(data) {
|
|||||||
|
|
||||||
function probe(filepath) {
|
function probe(filepath) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
Ffmpeg.ffprobe(filepath, (err, raw) => {
|
Ffmpeg.ffprobe(filepath, ['-show_chapters'], (err, raw) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
resolve(null)
|
resolve(null)
|
||||||
|
Loading…
Reference in New Issue
Block a user