mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-07 16:44:16 +01:00
Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used
This commit is contained in:
parent
6f891208d0
commit
dc18eb408e
@ -94,13 +94,17 @@ export default {
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
shelves() {
|
||||
var shelves = [
|
||||
{ books: this.mostRecentPlayed, label: 'Continue Reading' },
|
||||
{ books: this.mostRecentAdded, label: 'Recently Added' }
|
||||
]
|
||||
var shelves = []
|
||||
if (this.mostRecentPlayed.length) {
|
||||
shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' })
|
||||
}
|
||||
|
||||
shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' })
|
||||
|
||||
if (this.recentlyUpdatedSeries) {
|
||||
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
|
||||
}
|
||||
|
||||
if (this.booksRecentlyRead.length) {
|
||||
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -179,7 +179,7 @@ export default {
|
||||
.catch((error) => {
|
||||
console.error('failed to reset audiobooks', error)
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
|
||||
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -37,7 +37,6 @@ class ApiController {
|
||||
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
|
||||
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
||||
|
||||
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
|
||||
this.router.patch('/match/:id', this.match.bind(this))
|
||||
|
||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||
@ -70,11 +69,6 @@ class ApiController {
|
||||
this.scanner.findCovers(req, res)
|
||||
}
|
||||
|
||||
async getMetadata(req, res) {
|
||||
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
|
||||
res.json(metadata)
|
||||
}
|
||||
|
||||
authorize(req, res) {
|
||||
if (!req.user) {
|
||||
Logger.error('Invalid user in authorize')
|
||||
|
@ -7,7 +7,7 @@ const audioFileScanner = require('./utils/audioFileScanner')
|
||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
|
||||
const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
const { ScanResult, CoverDestination } = require('./utils/constants')
|
||||
|
||||
class Scanner {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
||||
@ -27,6 +27,20 @@ class Scanner {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
|
||||
getCoverDirectory(audiobook) {
|
||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: Path.join('/local', audiobook.path)
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
|
||||
relPath: Path.join('/metadata', 'books', audiobook.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
|
||||
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
|
||||
var abdFile = audiobookDataAudioFiles[i]
|
||||
@ -48,7 +62,7 @@ class Scanner {
|
||||
|
||||
async scanAudiobookData(audiobookData) {
|
||||
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
|
||||
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
|
||||
// Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
|
||||
|
||||
if (existingAudiobook) {
|
||||
// REMOVE: No valid audio files
|
||||
@ -64,8 +78,6 @@ class Scanner {
|
||||
|
||||
// ino is now set for every file in scandir
|
||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||
// audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
|
||||
|
||||
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
@ -124,7 +136,8 @@ class Scanner {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
|
||||
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles)
|
||||
if (otherFilesUpdated) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
@ -167,6 +180,19 @@ class Scanner {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
if (audiobook.hasDescriptionTextFile) {
|
||||
await audiobook.saveDescriptionFromTextFile()
|
||||
}
|
||||
|
||||
if (audiobook.hasEmbeddedCoverArt) {
|
||||
var outputCoverDirs = this.getCoverDirectory(audiobook)
|
||||
var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||
if (relativeDir) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||
}
|
||||
}
|
||||
|
||||
audiobook.setDetailsFromFileMetadata()
|
||||
audiobook.checkUpdateMissingParts()
|
||||
audiobook.setChapters()
|
||||
|
||||
@ -177,14 +203,11 @@ class Scanner {
|
||||
}
|
||||
|
||||
async scan() {
|
||||
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
|
||||
// TEMP - fix relative file paths
|
||||
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
|
||||
// TEMP - update ino for each audiobook
|
||||
if (this.audiobooks.length) {
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var ab = this.audiobooks[i]
|
||||
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
||||
|
||||
// Update ino if inos are not set
|
||||
var shouldUpdateIno = ab.hasMissingIno
|
||||
if (shouldUpdateIno) {
|
||||
@ -319,10 +342,6 @@ class Scanner {
|
||||
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
|
||||
|
||||
|
||||
Logger.debug(`[Scanner] fileGroupings `, filepaths, fileGroupings)
|
||||
|
||||
|
||||
var results = []
|
||||
for (const dir in fileGroupings) {
|
||||
Logger.debug(`[Scanner] Check dir ${dir}`)
|
||||
@ -334,19 +353,6 @@ class Scanner {
|
||||
return results
|
||||
}
|
||||
|
||||
async fetchMetadata(id, trackIndex = 0) {
|
||||
var audiobook = this.audiobooks.find(a => a.id === id)
|
||||
if (!audiobook) {
|
||||
return false
|
||||
}
|
||||
var tracks = audiobook.tracks
|
||||
var index = isNaN(trackIndex) ? 0 : Number(trackIndex)
|
||||
var firstTrack = tracks[index]
|
||||
var firstTrackFullPath = firstTrack.fullPath
|
||||
var scanResult = await audioFileScanner.scan(firstTrackFullPath)
|
||||
return scanResult
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
|
||||
var found = 0
|
||||
|
@ -1,3 +1,5 @@
|
||||
const AudioFileMetadata = require('./AudioFileMetadata')
|
||||
|
||||
class AudioFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
@ -21,12 +23,10 @@ class AudioFile {
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
this.chapters = []
|
||||
this.embeddedCoverArt = null
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
// Tags scraped from the audio file
|
||||
this.metadata = null
|
||||
|
||||
this.manuallyVerified = false
|
||||
this.invalid = false
|
||||
@ -62,11 +62,8 @@ class AudioFile {
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
chapters: this.chapters,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
embeddedCoverArt: this.embeddedCoverArt,
|
||||
metadata: this.metadata ? this.metadata.toJSON() : {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,12 +93,20 @@ class AudioFile {
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channelLayout
|
||||
this.chapters = data.chapters
|
||||
this.embeddedCoverArt = data.embeddedCoverArt || null
|
||||
|
||||
this.tagAlbum = data.tagAlbum
|
||||
this.tagArtist = data.tagArtist
|
||||
this.tagGenre = data.tagGenre
|
||||
this.tagTitle = data.tagTitle
|
||||
this.tagTrack = data.tagTrack
|
||||
// Old version of AudioFile used `tagAlbum` etc.
|
||||
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
|
||||
if (isOldVersion) {
|
||||
this.metadata = new AudioFileMetadata(data)
|
||||
} else {
|
||||
this.metadata = new AudioFileMetadata(data.metadata || {})
|
||||
}
|
||||
// this.tagAlbum = data.tagAlbum
|
||||
// this.tagArtist = data.tagArtist
|
||||
// this.tagGenre = data.tagGenre
|
||||
// this.tagTitle = data.tagTitle
|
||||
// this.tagTrack = data.tagTrack
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
@ -131,12 +136,10 @@ class AudioFile {
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channel_layout
|
||||
this.chapters = data.chapters || []
|
||||
this.embeddedCoverArt = data.embedded_cover_art || null
|
||||
|
||||
this.tagAlbum = data.file_tag_album || null
|
||||
this.tagArtist = data.file_tag_artist || null
|
||||
this.tagGenre = data.file_tag_genre || null
|
||||
this.tagTitle = data.file_tag_title || null
|
||||
this.tagTrack = data.file_tag_track || null
|
||||
this.metadata = new AudioFileMetadata()
|
||||
this.metadata.setData(data)
|
||||
}
|
||||
|
||||
clone() {
|
||||
|
69
server/objects/AudioFileMetadata.js
Normal file
69
server/objects/AudioFileMetadata.js
Normal file
@ -0,0 +1,69 @@
|
||||
class AudioFileMetadata {
|
||||
constructor(metadata) {
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
this.tagSubtitle = null
|
||||
this.tagAlbumArtist = null
|
||||
this.tagDate = null
|
||||
this.tagComposer = null
|
||||
this.tagPublisher = null
|
||||
this.tagComment = null
|
||||
this.tagDescription = null
|
||||
this.tagEncoder = null
|
||||
this.tagEncodedBy = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
// Only return the tags that are actually set
|
||||
var json = {}
|
||||
for (const key in this) {
|
||||
if (key.startsWith('tag') && this[key]) {
|
||||
json[key] = this[key]
|
||||
}
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
construct(metadata) {
|
||||
this.tagAlbum = metadata.tagAlbum || null
|
||||
this.tagArtist = metadata.tagArtist || null
|
||||
this.tagGenre = metadata.tagGenre || null
|
||||
this.tagTitle = metadata.tagTitle || null
|
||||
this.tagTrack = metadata.tagTrack || null
|
||||
this.tagSubtitle = metadata.tagSubtitle || null
|
||||
this.tagAlbumArtist = metadata.tagAlbumArtist || null
|
||||
this.tagDate = metadata.tagDate || null
|
||||
this.tagComposer = metadata.tagComposer || null
|
||||
this.tagPublisher = metadata.tagPublisher || null
|
||||
this.tagComment = metadata.tagComment || null
|
||||
this.tagDescription = metadata.tagDescription || null
|
||||
this.tagEncoder = metadata.tagEncoder || null
|
||||
this.tagEncodedBy = metadata.tagEncodedBy || null
|
||||
}
|
||||
|
||||
// Data parsed in prober.js
|
||||
setData(payload) {
|
||||
this.tagAlbum = payload.file_tag_album || null
|
||||
this.tagArtist = payload.file_tag_artist || null
|
||||
this.tagGenre = payload.file_tag_genre || null
|
||||
this.tagTitle = payload.file_tag_title || null
|
||||
this.tagTrack = payload.file_tag_track || null
|
||||
this.tagSubtitle = payload.file_tag_subtitle || null
|
||||
this.tagAlbumArtist = payload.file_tag_albumartist || null
|
||||
this.tagDate = payload.file_tag_date || null
|
||||
this.tagComposer = payload.file_tag_composer || null
|
||||
this.tagPublisher = payload.file_tag_publisher || null
|
||||
this.tagComment = payload.file_tag_comment || null
|
||||
this.tagDescription = payload.file_tag_description || null
|
||||
this.tagEncoder = payload.file_tag_encoder || null
|
||||
this.tagEncodedBy = payload.file_tag_encodedby || null
|
||||
}
|
||||
}
|
||||
module.exports = AudioFileMetadata
|
@ -20,11 +20,12 @@ class AudioTrack {
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
// Storing tags in audio track is unnecessary, tags are stored on audio file
|
||||
// this.tagAlbum = null
|
||||
// this.tagArtist = null
|
||||
// this.tagGenre = null
|
||||
// this.tagTitle = null
|
||||
// this.tagTrack = null
|
||||
|
||||
if (audioTrack) {
|
||||
this.construct(audioTrack)
|
||||
@ -50,11 +51,11 @@ class AudioTrack {
|
||||
this.channels = audioTrack.channels
|
||||
this.channelLayout = audioTrack.channelLayout
|
||||
|
||||
this.tagAlbum = audioTrack.tagAlbum
|
||||
this.tagArtist = audioTrack.tagArtist
|
||||
this.tagGenre = audioTrack.tagGenre
|
||||
this.tagTitle = audioTrack.tagTitle
|
||||
this.tagTrack = audioTrack.tagTrack
|
||||
// this.tagAlbum = audioTrack.tagAlbum
|
||||
// this.tagArtist = audioTrack.tagArtist
|
||||
// this.tagGenre = audioTrack.tagGenre
|
||||
// this.tagTitle = audioTrack.tagTitle
|
||||
// this.tagTrack = audioTrack.tagTrack
|
||||
}
|
||||
|
||||
get name() {
|
||||
@ -77,11 +78,11 @@ class AudioTrack {
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
// tagAlbum: this.tagAlbum,
|
||||
// tagArtist: this.tagArtist,
|
||||
// tagGenre: this.tagGenre,
|
||||
// tagTitle: this.tagTitle,
|
||||
// tagTrack: this.tagTrack
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,11 +105,11 @@ class AudioTrack {
|
||||
this.channels = probeData.channels
|
||||
this.channelLayout = probeData.channelLayout
|
||||
|
||||
this.tagAlbum = probeData.file_tag_album || null
|
||||
this.tagArtist = probeData.file_tag_artist || null
|
||||
this.tagGenre = probeData.file_tag_genre || null
|
||||
this.tagTitle = probeData.file_tag_title || null
|
||||
this.tagTrack = probeData.file_tag_track || null
|
||||
// this.tagAlbum = probeData.file_tag_album || null
|
||||
// this.tagArtist = probeData.file_tag_artist || null
|
||||
// this.tagGenre = probeData.file_tag_genre || null
|
||||
// this.tagTitle = probeData.file_tag_title || null
|
||||
// this.tagTrack = probeData.file_tag_track || null
|
||||
}
|
||||
|
||||
syncFile(newFile) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
const Path = require('path')
|
||||
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
|
||||
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno } = require('../utils/index')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
const nfoGenerator = require('../utils/nfoGenerator')
|
||||
const Logger = require('../Logger')
|
||||
const Book = require('./Book')
|
||||
@ -115,6 +116,14 @@ class Audiobook {
|
||||
return !this.ino || (this.audioFiles || []).find(abf => !abf.ino) || (this.otherFiles || []).find(f => !f.ino) || (this.tracks || []).find(t => !t.ino)
|
||||
}
|
||||
|
||||
get hasEmbeddedCoverArt() {
|
||||
return !!(this.audioFiles || []).find(af => af.embeddedCoverArt)
|
||||
}
|
||||
|
||||
get hasDescriptionTextFile() {
|
||||
return !!(this.otherFiles || []).find(of => of.filename === 'desc.txt')
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
return this.book ? this.book.toJSON() : null
|
||||
}
|
||||
@ -192,20 +201,6 @@ class Audiobook {
|
||||
}
|
||||
}
|
||||
|
||||
// Scanner had a bug that was saving a file path as the audiobook path.
|
||||
// audiobook path should be a directory.
|
||||
// fixing this before a scan prevents audiobooks being removed and re-added
|
||||
fixRelativePath(abRootPath) {
|
||||
var pathExt = Path.extname(this.path)
|
||||
if (pathExt) {
|
||||
this.path = Path.dirname(this.path)
|
||||
this.fullPath = Path.join(abRootPath, this.path)
|
||||
Logger.warn('Audiobook path has extname', pathExt, 'fixed path:', this.path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Originally files did not store the inode value
|
||||
// this function checks all files and sets the inode
|
||||
async checkUpdateInos() {
|
||||
@ -414,23 +409,37 @@ class Audiobook {
|
||||
}
|
||||
|
||||
// On scan check other files found with other files saved
|
||||
syncOtherFiles(newOtherFiles) {
|
||||
async syncOtherFiles(newOtherFiles) {
|
||||
var hasUpdates = false
|
||||
|
||||
var currOtherFileNum = this.otherFiles.length
|
||||
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
||||
// Some files are not there anymore and filtered out
|
||||
if (currOtherFileNum !== this.otherFiles.length) hasUpdates = true
|
||||
|
||||
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (descriptionTxt) {
|
||||
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
||||
if (newDescription) {
|
||||
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
||||
this.update({ book: { description: newDescription } })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Should use inode
|
||||
newOtherFiles.forEach((file) => {
|
||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
||||
if (!existingOtherFile) {
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename}/${file.filetype} | "${this.title}"`)
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
|
||||
this.addOtherFile(file)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
|
||||
var hasUpdates = currOtherFileNum !== this.otherFiles.length
|
||||
|
||||
// Check if cover was a local image and that it still exists
|
||||
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
||||
@ -535,5 +544,38 @@ class Audiobook {
|
||||
writeNfoFile(nfoFilename = 'metadata.nfo') {
|
||||
return nfoGenerator(this, nfoFilename)
|
||||
}
|
||||
|
||||
// Return cover filename
|
||||
async saveEmbeddedCoverArt(coverDirFullPath, coverDirRelPath) {
|
||||
var audioFileWithCover = this.audioFiles.find(af => af.embeddedCoverArt)
|
||||
if (!audioFileWithCover) return false
|
||||
|
||||
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||
var coverFilePath = Path.join(coverDirFullPath, coverFilename)
|
||||
|
||||
var success = await extractCoverArt(audioFileWithCover.fullPath, coverFilePath)
|
||||
if (success) {
|
||||
var coverRelPath = Path.join(coverDirRelPath, coverFilename)
|
||||
this.update({ book: { cover: coverRelPath } })
|
||||
return coverRelPath
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// If desc.txt exists then use it as description
|
||||
async saveDescriptionFromTextFile() {
|
||||
var descriptionTextFile = this.otherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (!descriptionTextFile) return false
|
||||
var newDescription = await readTextFile(descriptionTextFile.fullPath)
|
||||
if (!newDescription) return false
|
||||
return this.update({ book: { description: newDescription } })
|
||||
}
|
||||
|
||||
// Audio file metadata tags map to EMPTY book details
|
||||
setDetailsFromFileMetadata() {
|
||||
if (!this.audioFiles.length) return false
|
||||
var audioFile = this.audioFiles[0]
|
||||
return this.book.setDetailsFromFileMetadata(audioFile.metadata)
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
@ -183,5 +183,47 @@ class Book {
|
||||
isSearchMatch(search) {
|
||||
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
|
||||
}
|
||||
|
||||
setDetailsFromFileMetadata(audioFileMetadata) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagComposer',
|
||||
key: 'narrarator'
|
||||
},
|
||||
{
|
||||
tag: 'tagDescription',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
tag: 'tagPublisher',
|
||||
key: 'publisher'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'publishYear'
|
||||
},
|
||||
{
|
||||
tag: 'tagSubtitle',
|
||||
key: 'subtitle'
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
key: 'author'
|
||||
}
|
||||
]
|
||||
|
||||
var updatePayload = {}
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
if (!this[mapping.key] && audioFileMetadata[mapping.tag]) {
|
||||
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
|
||||
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
return this.update(updatePayload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
@ -2,6 +2,8 @@ const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const prober = require('./prober')
|
||||
|
||||
const ImageCodecs = ['mjpeg', 'jpeg', 'png']
|
||||
|
||||
function getDefaultAudioStream(audioStreams) {
|
||||
if (audioStreams.length === 1) return audioStreams[0]
|
||||
var defaultStream = audioStreams.find(a => a.is_default)
|
||||
@ -37,6 +39,11 @@ async function scan(path) {
|
||||
chapters: probeData.chapters || []
|
||||
}
|
||||
|
||||
var hasCoverArt = probeData.video_stream ? ImageCodecs.includes(probeData.video_stream.codec) : false
|
||||
if (hasCoverArt) {
|
||||
finalData.embedded_cover_art = probeData.video_stream.codec
|
||||
}
|
||||
|
||||
for (const key in probeData) {
|
||||
if (probeData[key] && key.startsWith('file_tag')) {
|
||||
finalData[key] = probeData[key]
|
||||
@ -129,7 +136,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
}
|
||||
|
||||
if (tracks.find(t => t.index === trackNumber)) {
|
||||
Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
// Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
audioFile.invalid = true
|
||||
audioFile.error = 'Duplicate track number'
|
||||
numDuplicateTracks++
|
||||
|
@ -1,5 +1,8 @@
|
||||
const Ffmpeg = require('fluent-ffmpeg')
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
@ -64,4 +67,29 @@ async function writeMetadataFile(audiobook, outputPath) {
|
||||
await fs.writeFile(outputPath, inputstrs.join('\n'))
|
||||
return inputstrs
|
||||
}
|
||||
module.exports.writeMetadataFile = writeMetadataFile
|
||||
module.exports.writeMetadataFile = writeMetadataFile
|
||||
|
||||
async function extractCoverArt(filepath, outputpath) {
|
||||
var dirname = Path.dirname(outputpath)
|
||||
await fs.ensureDir(dirname)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
var ffmpeg = Ffmpeg(filepath)
|
||||
ffmpeg.addOption(['-map 0:v'])
|
||||
ffmpeg.output(outputpath)
|
||||
|
||||
ffmpeg.on('start', (cmd) => {
|
||||
Logger.debug(`[FfmpegHelpers] Extract Cover Cmd: ${cmd}`)
|
||||
})
|
||||
ffmpeg.on('error', (err, stdout, stderr) => {
|
||||
Logger.error(`[FfmpegHelpers] Extract Cover Error ${err}`)
|
||||
resolve(false)
|
||||
})
|
||||
ffmpeg.on('end', () => {
|
||||
Logger.debug(`[FfmpegHelpers] Cover Art Extracted Successfully`)
|
||||
resolve(outputpath)
|
||||
})
|
||||
ffmpeg.run()
|
||||
})
|
||||
}
|
||||
module.exports.extractCoverArt = extractCoverArt
|
@ -1,4 +1,5 @@
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
async function getFileStat(path) {
|
||||
try {
|
||||
@ -24,6 +25,17 @@ async function getFileSize(path) {
|
||||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
async function readTextFile(path) {
|
||||
try {
|
||||
var data = await fs.readFile(path)
|
||||
return String(data)
|
||||
} catch (error) {
|
||||
Logger.error(`[FileUtils] ReadTextFile error ${error}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
module.exports.readTextFile = readTextFile
|
||||
|
||||
function bytesPretty(bytes, decimals = 0) {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
|
@ -1,4 +1,6 @@
|
||||
var Ffmpeg = require('fluent-ffmpeg')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
function tryGrabBitRate(stream, all_streams, total_bit_rate) {
|
||||
if (!isNaN(stream.bit_rate) && stream.bit_rate) {
|
||||
@ -72,6 +74,15 @@ function tryGrabTag(stream, tag) {
|
||||
return stream.tags[tag] || stream.tags[tag.toUpperCase()] || null
|
||||
}
|
||||
|
||||
function tryGrabTags(stream, ...tags) {
|
||||
if (!stream.tags) return null
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
var value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
|
||||
if (value) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
||||
var info = {
|
||||
index: stream.index,
|
||||
@ -124,6 +135,54 @@ function parseChapters(chapters) {
|
||||
})
|
||||
}
|
||||
|
||||
function parseTags(format) {
|
||||
if (!format.tags) {
|
||||
Logger.debug('No Tags')
|
||||
return {}
|
||||
}
|
||||
// Logger.debug('Tags', format.tags)
|
||||
const tags = {
|
||||
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
|
||||
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
|
||||
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
|
||||
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
|
||||
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
|
||||
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
|
||||
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
|
||||
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'tpe2'),
|
||||
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
|
||||
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
|
||||
file_tag_publisher: tryGrabTags(format, 'publisher', 'tpub', 'tpb'),
|
||||
file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'),
|
||||
file_tag_description: tryGrabTags(format, 'description', 'desc'),
|
||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||
|
||||
// Not sure if these are actually used yet or not
|
||||
file_tag_creation_time: tryGrabTag(format, 'creation_time'),
|
||||
file_tag_wwwaudiofile: tryGrabTags(format, 'wwwaudiofile', 'woaf', 'waf'),
|
||||
file_tag_contentgroup: tryGrabTags(format, 'contentgroup', 'tit1', 'tt1'),
|
||||
file_tag_releasetime: tryGrabTags(format, 'releasetime', 'tdrl'),
|
||||
file_tag_movementname: tryGrabTags(format, 'movementname', 'mvnm'),
|
||||
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
|
||||
file_tag_series: tryGrabTag(format, 'series'),
|
||||
file_tag_seriespart: tryGrabTag(format, 'series-part'),
|
||||
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
|
||||
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2')
|
||||
}
|
||||
for (const key in tags) {
|
||||
if (!tags[key]) {
|
||||
delete tags[key]
|
||||
}
|
||||
}
|
||||
|
||||
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime']
|
||||
var success = keysToLookOutFor.find(key => !!tags[key])
|
||||
if (success) {
|
||||
Logger.debug('Notable!', success)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
function parseProbeData(data) {
|
||||
try {
|
||||
var { format, streams, chapters } = data
|
||||
@ -131,20 +190,16 @@ function parseProbeData(data) {
|
||||
|
||||
var sizeBytes = !isNaN(size) ? Number(size) : null
|
||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
||||
|
||||
// Logger.debug('Parsing Data for', Path.basename(format.filename))
|
||||
var tags = parseTags(format)
|
||||
var cleanedData = {
|
||||
format: format_long_name,
|
||||
duration: !isNaN(duration) ? Number(duration) : null,
|
||||
size: sizeBytes,
|
||||
sizeMb,
|
||||
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
|
||||
file_tag_encoder: tryGrabTag(format, 'encoder') || tryGrabTag(format, 'encoded_by'),
|
||||
file_tag_title: tryGrabTag(format, 'title'),
|
||||
file_tag_track: tryGrabTag(format, 'track') || tryGrabTag(format, 'trk'),
|
||||
file_tag_album: tryGrabTag(format, 'album') || tryGrabTag(format, 'tal'),
|
||||
file_tag_artist: tryGrabTag(format, 'artist') || tryGrabTag(format, 'tp1'),
|
||||
file_tag_date: tryGrabTag(format, 'date') || tryGrabTag(format, 'tye'),
|
||||
file_tag_genre: tryGrabTag(format, 'genre'),
|
||||
file_tag_creation_time: tryGrabTag(format, 'creation_time')
|
||||
...tags
|
||||
}
|
||||
|
||||
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
|
||||
|
Loading…
Reference in New Issue
Block a user