mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Experimental embed metadata in audio files #141
This commit is contained in:
		
							parent
							
								
									5f0f8b92d1
								
							
						
					
					
						commit
						84c12a6e7e
					
				| @ -38,7 +38,9 @@ | ||||
|       </div> | ||||
|       <draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate"> | ||||
|         <transition-group type="transition" :name="!drag ? 'flip-list' : null"> | ||||
|           <li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center"> | ||||
|           <li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative"> | ||||
|             <div v-if="audiofilesEncoding[audio.ino]" class="absolute top-0 left-0 w-full h-full bg-success bg-opacity-25" /> | ||||
| 
 | ||||
|             <div class="font-book text-center px-4 py-1 w-12"> | ||||
|               {{ audio.include ? index - numExcluded + 1 : -1 }} | ||||
|             </div> | ||||
| @ -71,12 +73,18 @@ | ||||
|             <div class="font-sans text-xs font-normal w-56"> | ||||
|               {{ audio.error }} | ||||
|             </div> | ||||
|             <div class="font-sans text-xs font-normal w-40 flex justify-center"> | ||||
|               <ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" /> | ||||
|             <div class="font-sans text-xs font-normal w-40 flex items-center justify-center"> | ||||
|               <widgets-loading-spinner v-if="audiofilesEncoding[audio.ino]" /> | ||||
|               <p v-if="audiofilesEncoding[audio.ino]" class="text-warning pl-4 text-base">Encoding</p> | ||||
|               <ui-toggle-switch v-else v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" /> | ||||
|             </div> | ||||
|           </li> | ||||
|         </transition-group> | ||||
|       </draggable> | ||||
| 
 | ||||
|       <div v-if="showExperimentalFeatures && isRootUser" class="w-full flex justify-end items-center py-6"> | ||||
|         <ui-btn color="primary" small :loading="updatingMetadata" @click="updateAudioFileMetadata">Encode metadata in audio files <span class="text-warning font-bold text-xs">(experimental)</span></ui-btn> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @ -125,10 +133,18 @@ export default { | ||||
|         ghostClass: 'ghost' | ||||
|       }, | ||||
|       saving: false, | ||||
|       currentSort: 'current' | ||||
|       currentSort: 'current', | ||||
|       updatingMetadata: false, | ||||
|       audiofilesEncoding: {} | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     showExperimentalFeatures() { | ||||
|       return this.$store.state.showExperimentalFeatures | ||||
|     }, | ||||
|     isRootUser() { | ||||
|       return this.$store.getters['user/getIsRoot'] | ||||
|     }, | ||||
|     media() { | ||||
|       return this.libraryItem.media || {} | ||||
|     }, | ||||
| @ -162,12 +178,23 @@ export default { | ||||
|     }, | ||||
|     streamLibraryItem() { | ||||
|       return this.$store.state.streamLibraryItem | ||||
|     }, | ||||
|     showExperimentalFeatures() { | ||||
|       return this.$store.state.showExperimentalFeatures | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     updateAudioFileMetadata() { | ||||
|       if (confirm(`Warning!\n\nThis will modify the audio files for this audiobook.\nMake sure your audio files are backed up before using this feature.`)) { | ||||
|         this.updatingMetadata = true | ||||
|         this.$axios | ||||
|           .$get(`/api/items/${this.libraryItemId}/audio-metadata`) | ||||
|           .then(() => { | ||||
|             console.log('Audio metadata encode started') | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|             console.error('Audio metadata encode failed', error) | ||||
|             this.updatingMetadata = false | ||||
|           }) | ||||
|       } | ||||
|     }, | ||||
|     draggableUpdate(e) { | ||||
|       this.currentSort = '' | ||||
|     }, | ||||
| @ -242,7 +269,33 @@ export default { | ||||
|       } else { | ||||
|         return 'check_circle' | ||||
|       } | ||||
|     }, | ||||
|     audioMetadataStarted(data) { | ||||
|       console.log('audio metadata started', data) | ||||
|       if (data.libraryItemId !== this.libraryItemId) return | ||||
|       this.updatingMetadata = true | ||||
|     }, | ||||
|     audioMetadataFinished(data) { | ||||
|       console.log('audio metadata finished', data) | ||||
|       if (data.libraryItemId !== this.libraryItemId) return | ||||
|       this.updatingMetadata = false | ||||
|       this.audiofilesEncoding = {} | ||||
|       this.$toast.success('Audio file metadata updated') | ||||
|     }, | ||||
|     audiofileMetadataStarted(data) { | ||||
|       if (data.libraryItemId !== this.libraryItemId) return | ||||
|       this.$set(this.audiofilesEncoding, data.ino, true) | ||||
|     }, | ||||
|     audiofileMetadataFinished(data) { | ||||
|       if (data.libraryItemId !== this.libraryItemId) return | ||||
|       this.$set(this.audiofilesEncoding, data.ino, false) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted) | ||||
|     this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished) | ||||
|     this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted) | ||||
|     this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| @ -30,6 +30,7 @@ const LogManager = require('./managers/LogManager') | ||||
| const BackupManager = require('./managers/BackupManager') | ||||
| const PlaybackSessionManager = require('./managers/PlaybackSessionManager') | ||||
| const PodcastManager = require('./managers/PodcastManager') | ||||
| const AudioMetadataMangaer = require('./managers/AudioMetadataManager') | ||||
| 
 | ||||
| class Server { | ||||
|   constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { | ||||
| @ -72,11 +73,12 @@ class Server { | ||||
|     this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) | ||||
|     this.coverManager = new CoverManager(this.db, this.cacheManager) | ||||
|     this.podcastManager = new PodcastManager(this.db, this.watcher, this.emitter.bind(this)) | ||||
|     this.audioMetadataManager = new AudioMetadataMangaer(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) | ||||
| 
 | ||||
|     this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this)) | ||||
| 
 | ||||
|     // Routers
 | ||||
|     this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.emitter.bind(this), this.clientEmitter.bind(this)) | ||||
|     this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.emitter.bind(this), this.clientEmitter.bind(this)) | ||||
|     this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this)) | ||||
|     this.staticRouter = new StaticRouter(this.db) | ||||
| 
 | ||||
|  | ||||
| @ -354,6 +354,22 @@ class LibraryItemController { | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   // POST: api/items/:id/audio-metadata
 | ||||
|   async updateAudioFileMetadata(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|       Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { | ||||
|       Logger.error(`[LibraryItemController] Invalid library item`) | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   middleware(req, res, next) { | ||||
|     var item = this.db.libraryItems.find(li => li.id === req.params.id) | ||||
|     if (!item || !item.media) return res.sendStatus(404) | ||||
|  | ||||
| @ -121,7 +121,7 @@ class AbMergeManager { | ||||
|         '-acodec aac', | ||||
|         '-ac 2', | ||||
|         '-b:a 64k', | ||||
|         '-id3v2_version 3' | ||||
|         '-movflags use_metadata_tags' | ||||
|       ]) | ||||
|     } else { | ||||
|       ffmpegOptions.push('-max_muxing_queue_size 1000') | ||||
|  | ||||
							
								
								
									
										137
									
								
								server/managers/AudioMetadataManager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								server/managers/AudioMetadataManager.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,137 @@ | ||||
| const Path = require('path') | ||||
| const fs = require('fs-extra') | ||||
| const workerThreads = require('worker_threads') | ||||
| const Logger = require('../Logger') | ||||
| const filePerms = require('../utils/filePerms') | ||||
| const { secondsToTimestamp } = require('../utils/index') | ||||
| const { writeMetadataFile } = require('../utils/ffmpegHelpers') | ||||
| 
 | ||||
| class AudioMetadataMangaer { | ||||
|   constructor(db, emitter, clientEmitter) { | ||||
|     this.db = db | ||||
|     this.emitter = emitter | ||||
|     this.clientEmitter = clientEmitter | ||||
|   } | ||||
| 
 | ||||
|   async updateAudioFileMetadataForItem(user, libraryItem) { | ||||
|     var audioFiles = libraryItem.media.audioFiles | ||||
| 
 | ||||
|     const itemAudioMetadataPayload = { | ||||
|       userId: user.id, | ||||
|       libraryItemId: libraryItem.id, | ||||
|       startedAt: Date.now(), | ||||
|       audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename })) | ||||
|     } | ||||
| 
 | ||||
|     this.emitter('audio_metadata_started', itemAudioMetadataPayload) | ||||
| 
 | ||||
|     var downloadsPath = Path.join(global.MetadataPath, 'downloads') | ||||
|     var outputDir = Path.join(downloadsPath, libraryItem.id) | ||||
|     await fs.ensureDir(outputDir) | ||||
| 
 | ||||
|     var metadataFilePath = Path.join(outputDir, 'metadata.txt') | ||||
|     await writeMetadataFile(libraryItem, metadataFilePath) | ||||
| 
 | ||||
|     const proms = audioFiles.map(af => { | ||||
|       return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath) | ||||
|     }) | ||||
| 
 | ||||
|     const results = await Promise.all(proms) | ||||
| 
 | ||||
|     Logger.debug(`[AudioMetadataManager] Finished`, results) | ||||
| 
 | ||||
|     await fs.remove(outputDir) | ||||
| 
 | ||||
|     const elapsed = Date.now() - itemAudioMetadataPayload.startedAt | ||||
|     Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`) | ||||
|     itemAudioMetadataPayload.results = results | ||||
|     itemAudioMetadataPayload.elapsed = elapsed | ||||
|     itemAudioMetadataPayload.finishedAt = Date.now() | ||||
|     this.emitter('audio_metadata_finished', itemAudioMetadataPayload) | ||||
|   } | ||||
| 
 | ||||
|   updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath) { | ||||
|     return new Promise((resolve) => { | ||||
|       const resultPayload = { | ||||
|         libraryItemId, | ||||
|         index: audioFile.index, | ||||
|         ino: audioFile.ino, | ||||
|         filename: audioFile.metadata.filename | ||||
|       } | ||||
|       this.emitter('audiofile_metadata_started', resultPayload) | ||||
| 
 | ||||
|       Logger.debug(`[AudioFileMetadataManager] Starting audio file metadata encode for "${audioFile.metadata.filename}"`) | ||||
| 
 | ||||
|       var outputPath = Path.join(outputDir, audioFile.metadata.filename) | ||||
|       var inputPath = audioFile.metadata.path | ||||
|       const isM4b = audioFile.metadata.format === 'm4b' | ||||
|       const ffmpegInputs = [ | ||||
|         { | ||||
|           input: inputPath, | ||||
|           options: isM4b ? ['-f mp4'] : [] | ||||
|         }, | ||||
|         { | ||||
|           input: metadataFilePath | ||||
|         } | ||||
|       ] | ||||
| 
 | ||||
|       /* | ||||
|         Mp4 doesnt support writing custom tags by default. Supported tags are itunes tags: https://git.videolan.org/?p=ffmpeg.git;a=blob;f=libavformat/movenc.c;h=b6821d447c92183101086cb67099b2f4804293de;hb=HEAD#l2905
 | ||||
| 
 | ||||
|         Workaround -movflags use_metadata_tags found here: https://superuser.com/a/1208277      
 | ||||
|          | ||||
|         Ffmpeg premapped id3 tags: https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
 | ||||
|       */ | ||||
| 
 | ||||
|       const ffmpegOptions = ['-c copy', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags'] | ||||
|       var workerData = { | ||||
|         inputs: ffmpegInputs, | ||||
|         options: ffmpegOptions, | ||||
|         outputOptions: isM4b ? ['-f mp4'] : [], | ||||
|         output: outputPath, | ||||
|       } | ||||
|       var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js') | ||||
|       var worker = new workerThreads.Worker(workerPath, { workerData }) | ||||
| 
 | ||||
|       worker.on('message', async (message) => { | ||||
|         if (message != null && typeof message === 'object') { | ||||
|           if (message.type === 'RESULT') { | ||||
|             Logger.debug(message) | ||||
| 
 | ||||
|             if (message.success) { | ||||
|               Logger.debug(`[AudioFileMetadataManager] Metadata encode SUCCESS for "${audioFile.metadata.filename}"`) | ||||
| 
 | ||||
|               await filePerms.setDefault(outputPath, true) | ||||
| 
 | ||||
|               fs.move(outputPath, inputPath, { overwrite: true }).then(() => { | ||||
|                 Logger.debug(`[AudioFileMetadataManager] Audio file replaced successfully "${inputPath}"`) | ||||
| 
 | ||||
|                 resultPayload.success = true | ||||
|                 this.emitter('audiofile_metadata_finished', resultPayload) | ||||
|                 resolve(resultPayload) | ||||
|               }).catch((error) => { | ||||
|                 Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error) | ||||
|                 resultPayload.success = false | ||||
|                 this.emitter('audiofile_metadata_finished', resultPayload) | ||||
|                 resolve(resultPayload) | ||||
|               }) | ||||
|             } else { | ||||
|               Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`) | ||||
| 
 | ||||
|               resultPayload.success = false | ||||
|               this.emitter('audiofile_metadata_finished', resultPayload) | ||||
|               resolve(resultPayload) | ||||
|             } | ||||
|           } else if (message.type === 'FFMPEG') { | ||||
|             if (Logger[message.level]) { | ||||
|               Logger[message.level](message.log) | ||||
|             } | ||||
|           } | ||||
|         } else { | ||||
|           Logger.error('Invalid worker message', message) | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| module.exports = AudioMetadataMangaer | ||||
| @ -25,7 +25,7 @@ const Series = require('../objects/entities/Series') | ||||
| const FileSystemController = require('../controllers/FileSystemController') | ||||
| 
 | ||||
| class ApiRouter { | ||||
|   constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, emitter, clientEmitter) { | ||||
|   constructor(db, auth, scanner, playbackSessionManager, abMergeManager, coverManager, backupManager, watcher, cacheManager, podcastManager, audioMetadataManager, emitter, clientEmitter) { | ||||
|     this.db = db | ||||
|     this.auth = auth | ||||
|     this.scanner = scanner | ||||
| @ -36,6 +36,7 @@ class ApiRouter { | ||||
|     this.watcher = watcher | ||||
|     this.cacheManager = cacheManager | ||||
|     this.podcastManager = podcastManager | ||||
|     this.audioMetadataManager = audioMetadataManager | ||||
|     this.emitter = emitter | ||||
|     this.clientEmitter = clientEmitter | ||||
| 
 | ||||
| @ -91,6 +92,7 @@ class ApiRouter { | ||||
|     this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this)) | ||||
|     this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this)) | ||||
|     this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only
 | ||||
|     this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
 | ||||
| 
 | ||||
|     this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this)) | ||||
|     this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this)) | ||||
|  | ||||
| @ -39,7 +39,7 @@ async function runFfmpeg() { | ||||
|     ffmpegCommand.on('stderr', (stdErrline) => { | ||||
|       parentPort.postMessage({ | ||||
|         type: 'FFMPEG', | ||||
|         level: 'error', | ||||
|         level: 'debug', | ||||
|         log: '[DownloadWorker] Ffmpeg Stderr: ' + stdErrline | ||||
|       }) | ||||
|     }) | ||||
|  | ||||
| @ -48,10 +48,33 @@ async function writeMetadataFile(libraryItem, outputPath) { | ||||
|     `artist=${libraryItem.media.metadata.authorName}`, | ||||
|     `album_artist=${libraryItem.media.metadata.authorName}`, | ||||
|     `date=${libraryItem.media.metadata.publishedYear || ''}`, | ||||
|     `description=${libraryItem.media.metadata.description}`, | ||||
|     `genre=${libraryItem.media.metadata.genres.join(';')}` | ||||
|     `description=${libraryItem.media.metadata.description || ''}`, | ||||
|     `genre=${libraryItem.media.metadata.genres.join(';')}`, | ||||
|     `performer=${libraryItem.media.metadata.narratorName || ''}`, | ||||
|     `encoded_by=audiobookshelf:${package.version}` | ||||
|   ] | ||||
| 
 | ||||
|   if (libraryItem.media.metadata.asin) { | ||||
|     inputstrs.push(`ASIN=${libraryItem.media.metadata.asin}`) | ||||
|   } | ||||
|   if (libraryItem.media.metadata.isbn) { | ||||
|     inputstrs.push(`ISBN=${libraryItem.media.metadata.isbn}`) | ||||
|   } | ||||
|   if (libraryItem.media.metadata.language) { | ||||
|     inputstrs.push(`language=${libraryItem.media.metadata.language}`) | ||||
|   } | ||||
|   if (libraryItem.media.metadata.series.length) { | ||||
|     // Only uses first series
 | ||||
|     var firstSeries = libraryItem.media.metadata.series[0] | ||||
|     inputstrs.push(`series=${firstSeries.name}`) | ||||
|     if (firstSeries.sequence) { | ||||
|       inputstrs.push(`series-part=${firstSeries.sequence}`) | ||||
|     } | ||||
|   } | ||||
|   if (libraryItem.media.metadata.subtitle) { | ||||
|     inputstrs.push(`subtitle=${libraryItem.media.metadata.subtitle}`) | ||||
|   } | ||||
| 
 | ||||
|   if (libraryItem.media.chapters) { | ||||
|     libraryItem.media.chapters.forEach((chap) => { | ||||
|       const chapterstrs = [ | ||||
|  | ||||
| @ -204,12 +204,12 @@ function parseTags(format, verbose) { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   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', 'file_tag_isbn'] | ||||
|   keysToLookOutFor.forEach((key) => { | ||||
|     if (tags[key]) { | ||||
|       Logger.debug(`Notable! ${key} => ${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', 'file_tag_isbn']
 | ||||
|   // keysToLookOutFor.forEach((key) => {
 | ||||
|   //   if (tags[key]) {
 | ||||
|   //     Logger.debug(`Notable! ${key} => ${tags[key]}`)
 | ||||
|   //   }
 | ||||
|   // })
 | ||||
|   return tags | ||||
| } | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user