mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Scanner v4, audio file metadata used in setting book details, embedded cover art extracted and used
This commit is contained in:
		
							parent
							
								
									b74b12301c
								
							
						
					
					
						commit
						b26c1ba886
					
				| @ -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