From dc18eb408e9161b830e7e3b8830d7587c7999d17 Mon Sep 17 00:00:00 2001 From: Mark Cooper Date: Wed, 29 Sep 2021 20:43:36 -0500 Subject: [PATCH] Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used --- .../components/app/BookShelfCategorized.vue | 12 ++- client/package.json | 2 +- client/pages/config/index.vue | 2 +- package.json | 2 +- server/ApiController.js | 6 -- server/Scanner.js | 58 ++++++++------ server/objects/AudioFile.js | 43 +++++----- server/objects/AudioFileMetadata.js | 69 ++++++++++++++++ server/objects/AudioTrack.js | 41 +++++----- server/objects/Audiobook.js | 80 ++++++++++++++----- server/objects/Book.js | 42 ++++++++++ server/utils/audioFileScanner.js | 9 ++- server/utils/ffmpegHelpers.js | 30 ++++++- server/utils/fileUtils.js | 12 +++ server/utils/prober.js | 71 ++++++++++++++-- 15 files changed, 371 insertions(+), 108 deletions(-) create mode 100644 server/objects/AudioFileMetadata.js diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 01711cfc..38b19ff7 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -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' }) } diff --git a/client/package.json b/client/package.json index d739811f..58b36088 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "1.2.8", + "version": "1.2.9", "description": "Audiobook manager and player", "main": "index.js", "scripts": { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 9d2bbe30..5c29c21b 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -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') }) } }, diff --git a/package.json b/package.json index daeccf60..6f46131f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server/ApiController.js b/server/ApiController.js index 692e816a..354032e4 100644 --- a/server/ApiController.js +++ b/server/ApiController.js @@ -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') diff --git a/server/Scanner.js b/server/Scanner.js index 8e7fd411..beefa003 100644 --- a/server/Scanner.js +++ b/server/Scanner.js @@ -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 diff --git a/server/objects/AudioFile.js b/server/objects/AudioFile.js index 2fcd71fd..5faba3f0 100644 --- a/server/objects/AudioFile.js +++ b/server/objects/AudioFile.js @@ -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() { diff --git a/server/objects/AudioFileMetadata.js b/server/objects/AudioFileMetadata.js new file mode 100644 index 00000000..13d1c74e --- /dev/null +++ b/server/objects/AudioFileMetadata.js @@ -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 \ No newline at end of file diff --git a/server/objects/AudioTrack.js b/server/objects/AudioTrack.js index 90761f36..704212e7 100644 --- a/server/objects/AudioTrack.js +++ b/server/objects/AudioTrack.js @@ -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) { diff --git a/server/objects/Audiobook.js b/server/objects/Audiobook.js index e46a24d5..a2f29645 100644 --- a/server/objects/Audiobook.js +++ b/server/objects/Audiobook.js @@ -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 \ No newline at end of file diff --git a/server/objects/Book.js b/server/objects/Book.js index 649d4dfd..883994e7 100644 --- a/server/objects/Book.js +++ b/server/objects/Book.js @@ -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 \ No newline at end of file diff --git a/server/utils/audioFileScanner.js b/server/utils/audioFileScanner.js index 1c24d44a..1ad77ec3 100644 --- a/server/utils/audioFileScanner.js +++ b/server/utils/audioFileScanner.js @@ -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++ diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 011c274e..186ab5a2 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 3ba82257..5e8f41bc 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -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' diff --git a/server/utils/prober.js b/server/utils/prober.js index 3d338ad9..da30a337 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -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))