mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-04 10:14:36 +02:00
Adding chapters and downloading m4b file
This commit is contained in:
parent
07a2a0aefd
commit
299fc95c78
24 changed files with 311 additions and 69 deletions
|
@ -418,12 +418,9 @@ class ApiController {
|
|||
|
||||
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)
|
||||
|
|
|
@ -4,7 +4,7 @@ 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 { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
|
||||
const { getFileSize } = require('./utils/fileUtils')
|
||||
|
||||
class DownloadManager {
|
||||
|
@ -49,14 +49,6 @@ class DownloadManager {
|
|||
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)
|
||||
|
@ -73,7 +65,7 @@ class DownloadManager {
|
|||
var audiobookDirname = Path.basename(audiobook.path)
|
||||
|
||||
if (downloadType === 'singleAudio') {
|
||||
var audioFileType = options.audioFileType || this.getBestFileType(audiobook.tracks)
|
||||
var audioFileType = options.audioFileType || 'm4b'
|
||||
delete options.audioFileType
|
||||
filename = audiobookDirname + '.' + audioFileType
|
||||
fileext = '.' + audioFileType
|
||||
|
@ -105,21 +97,47 @@ class DownloadManager {
|
|||
}
|
||||
|
||||
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 metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
|
||||
await writeMetadataFile(audiobook, metadataFilePath)
|
||||
|
||||
const ffmpegInputs = [
|
||||
{
|
||||
input: concatFilePath,
|
||||
options: ['-safe 0', '-f concat']
|
||||
},
|
||||
{
|
||||
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 })
|
||||
worker.on('message', (message) => {
|
||||
if (message != null && typeof message === 'object') {
|
||||
|
@ -166,14 +184,14 @@ class DownloadManager {
|
|||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
|
|
|
@ -135,6 +135,8 @@ class Scanner {
|
|||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
existingAudiobook.setChapters()
|
||||
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
|
@ -161,6 +163,8 @@ class Scanner {
|
|||
}
|
||||
|
||||
audiobook.checkUpdateMissingParts()
|
||||
audiobook.setChapters()
|
||||
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||
await this.db.insertAudiobook(audiobook)
|
||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||
|
|
|
@ -14,7 +14,6 @@ const StreamManager = require('./StreamManager')
|
|||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const Logger = require('./Logger')
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
|
||||
class Server {
|
||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
|
|
|
@ -20,6 +20,7 @@ class AudioFile {
|
|||
this.timeBase = null
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
this.chapters = []
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
|
@ -60,6 +61,7 @@ class AudioFile {
|
|||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
chapters: this.chapters,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
|
@ -93,6 +95,7 @@ class AudioFile {
|
|||
this.timeBase = data.timeBase
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channelLayout
|
||||
this.chapters = data.chapters
|
||||
|
||||
this.tagAlbum = data.tagAlbum
|
||||
this.tagArtist = data.tagArtist
|
||||
|
@ -121,12 +124,13 @@ class AudioFile {
|
|||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
this.bitRate = data.bit_rate
|
||||
this.bitRate = data.bit_rate || null
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.timeBase = data.time_base
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channel_layout
|
||||
this.chapters = data.chapters || []
|
||||
|
||||
this.tagAlbum = data.file_tag_album || null
|
||||
this.tagArtist = data.file_tag_artist || null
|
||||
|
|
|
@ -97,12 +97,12 @@ class AudioTrack {
|
|||
this.format = probeData.format
|
||||
this.duration = probeData.duration
|
||||
this.size = probeData.size
|
||||
this.bitRate = probeData.bit_rate
|
||||
this.bitRate = probeData.bitRate
|
||||
this.language = probeData.language
|
||||
this.codec = probeData.codec
|
||||
this.timeBase = probeData.time_base
|
||||
this.timeBase = probeData.timeBase
|
||||
this.channels = probeData.channels
|
||||
this.channelLayout = probeData.channel_layout
|
||||
this.channelLayout = probeData.channelLayout
|
||||
|
||||
this.tagAlbum = probeData.file_tag_album || null
|
||||
this.tagArtist = probeData.file_tag_artist || null
|
||||
|
|
|
@ -26,6 +26,7 @@ class Audiobook {
|
|||
|
||||
this.tags = []
|
||||
this.book = null
|
||||
this.chapters = []
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
|
@ -51,6 +52,9 @@ class Audiobook {
|
|||
if (audiobook.book) {
|
||||
this.book = new Book(audiobook.book)
|
||||
}
|
||||
if (audiobook.chapters) {
|
||||
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
|
@ -122,7 +126,8 @@ class Audiobook {
|
|||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
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,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.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()),
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON()
|
||||
tracks: this.tracksToJSON(),
|
||||
chapters: this.chapters || []
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -403,5 +410,31 @@ class Audiobook {
|
|||
getAudioFileByIno(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
|
|
@ -1,4 +1,4 @@
|
|||
const DEFAULT_EXPIRATION = 1000 * 60 * 10 // 10 minutes
|
||||
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
||||
|
||||
class Download {
|
||||
constructor(download) {
|
||||
|
|
|
@ -191,7 +191,7 @@ class Stream extends EventEmitter {
|
|||
|
||||
this.socket.emit('stream_progress', {
|
||||
stream: this.id,
|
||||
percentCreated: perc,
|
||||
percent: perc,
|
||||
chunks,
|
||||
numSegments: this.numSegments
|
||||
})
|
||||
|
@ -201,7 +201,7 @@ class Stream extends EventEmitter {
|
|||
}
|
||||
|
||||
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(() => {
|
||||
if (!this.isTranscodeComplete) {
|
||||
this.checkFiles()
|
||||
|
@ -230,8 +230,9 @@ class Stream extends EventEmitter {
|
|||
this.ffmpeg.inputOption('-noaccurate_seek')
|
||||
}
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
this.ffmpeg.addOption([
|
||||
'-loglevel warning',
|
||||
`-loglevel ${logLevel}`,
|
||||
'-map 0:a',
|
||||
'-c:a copy'
|
||||
])
|
||||
|
|
|
@ -33,7 +33,8 @@ async function scan(path) {
|
|||
language: audioStream.language,
|
||||
channel_layout: audioStream.channel_layout,
|
||||
channels: audioStream.channels,
|
||||
sample_rate: audioStream.sample_rate
|
||||
sample_rate: audioStream.sample_rate,
|
||||
chapters: probeData.chapters || []
|
||||
}
|
||||
|
||||
for (const key in probeData) {
|
||||
|
|
|
@ -13,9 +13,11 @@ 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)
|
||||
workerData.inputs.forEach((inputData) => {
|
||||
ffmpegCommand.input(inputData.input)
|
||||
if (inputData.options) ffmpegCommand.inputOption(inputData.options)
|
||||
})
|
||||
|
||||
if (workerData.options) ffmpegCommand.addOption(workerData.options)
|
||||
ffmpegCommand.output(workerData.output)
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const fs = require('fs-extra')
|
||||
const package = require('../../package.json')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
|
@ -34,4 +35,33 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
|
|||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
var { format, streams } = data
|
||||
var { format, streams, chapters } = data
|
||||
var { format_long_name, duration, size, bit_rate } = format
|
||||
|
||||
var sizeBytes = !isNaN(size) ? Number(size) : null
|
||||
|
@ -146,6 +160,8 @@ function parseProbeData(data) {
|
|||
}
|
||||
}
|
||||
|
||||
cleanedData.chapters = parseChapters(chapters)
|
||||
|
||||
return cleanedData
|
||||
} catch (error) {
|
||||
console.error('Parse failed', error)
|
||||
|
@ -155,7 +171,7 @@ function parseProbeData(data) {
|
|||
|
||||
function probe(filepath) {
|
||||
return new Promise((resolve) => {
|
||||
Ffmpeg.ffprobe(filepath, (err, raw) => {
|
||||
Ffmpeg.ffprobe(filepath, ['-show_chapters'], (err, raw) => {
|
||||
if (err) {
|
||||
console.error(err)
|
||||
resolve(null)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue