mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update:Experimental metadata embed tool to use tone
This commit is contained in:
		
							parent
							
								
									b6e3559aba
								
							
						
					
					
						commit
						97da73baf3
					
				| @ -1,29 +1,31 @@ | ||||
| <template> | ||||
|   <div id="page-wrapper" class="bg-bg page p-8 overflow-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> | ||||
|     <div class="flex justify-center mb-2"> | ||||
|     <div class="flex justify-center mb-4"> | ||||
|       <div class="w-full max-w-2xl"> | ||||
|         <p class="text-xl">Metadata to embed</p> | ||||
|         <p class="text-xl mb-2">Metadata to embed</p> | ||||
|         <p class="mb-4 text-base text-gray-300">audiobookshelf uses <a href="https://github.com/sandreas/tone" target="_blank" class="hover:underline text-blue-400 hover:text-blue-300">tone</a> to write metadata.</p> | ||||
|       </div> | ||||
|       <div class="w-full max-w-2xl"></div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="flex justify-center flex-wrap"> | ||||
|       <div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2"> | ||||
|       <div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2"> | ||||
|         <div class="flex py-2 px-4"> | ||||
|           <div class="w-1/3 text-xs font-semibold uppercase text-gray-200">Meta Tag</div> | ||||
|           <div class="w-2/3 text-xs font-semibold uppercase text-gray-200">Value</div> | ||||
|         </div> | ||||
|         <div class="w-full max-h-72 overflow-auto"> | ||||
|           <template v-for="(keyValue, index) in metadataKeyValues"> | ||||
|             <div :key="keyValue.key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''"> | ||||
|               <div class="w-1/3 font-semibold">{{ keyValue.key }}</div> | ||||
|           <template v-for="(value, key, index) in toneObject"> | ||||
|             <div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''"> | ||||
|               <div class="w-1/3 font-semibold">{{ key }}</div> | ||||
|               <div class="w-2/3"> | ||||
|                 {{ keyValue.value }} | ||||
|                 {{ value }} | ||||
|               </div> | ||||
|             </div> | ||||
|           </template> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="w-full max-w-2xl border border-opacity-10 bg-bg mx-2"> | ||||
|       <div class="w-full max-w-2xl border border-white border-opacity-10 bg-bg mx-2"> | ||||
|         <div class="flex py-2 px-4"> | ||||
|           <div class="flex-grow text-xs font-semibold uppercase text-gray-200">Chapter Title</div> | ||||
|           <div class="w-24 text-xs font-semibold uppercase text-gray-200">Start</div> | ||||
| @ -34,10 +36,10 @@ | ||||
|             <div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary bg-opacity-25' : ''"> | ||||
|               <div class="flex-grow font-semibold">{{ chapter.title }}</div> | ||||
|               <div class="w-24"> | ||||
|                 {{ chapter.start.toFixed(2) }} | ||||
|                 {{ $secondsToTimestamp(chapter.start) }} | ||||
|               </div> | ||||
|               <div class="w-24"> | ||||
|                 {{ chapter.end.toFixed(2) }} | ||||
|                 {{ $secondsToTimestamp(chapter.end) }} | ||||
|               </div> | ||||
|             </div> | ||||
|           </template> | ||||
| @ -48,12 +50,17 @@ | ||||
|     <div class="w-full h-px bg-white bg-opacity-10 my-8" /> | ||||
| 
 | ||||
|     <div class="w-full max-w-4xl mx-auto"> | ||||
|       <div class="w-full flex justify-between items-center mb-4"> | ||||
|       <div class="w-full flex justify-between items-center mb-2"> | ||||
|         <p class="text-warning text-lg font-semibold">Warning: Modifies your audio files</p> | ||||
|         <ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click="updateAudioFileMetadata">Embed Metadata</ui-btn> | ||||
|         <ui-btn v-if="!embedFinished" color="primary" :loading="updatingMetadata" @click.stop="embedClick">Embed Metadata</ui-btn> | ||||
|         <p v-else class="text-success text-lg font-semibold">Embed Finished!</p> | ||||
|       </div> | ||||
|       <div class="w-full mx-auto border border-opacity-10 bg-bg"> | ||||
|       <div class="flex mb-4"> | ||||
|         <p class="text-gray-200"> | ||||
|           A backup of your audio files will be stored in <span class="rounded-md bg-neutral-600 text-sm text-white py-0.5 px-1 font-mono">/metadata/cache/items/{{ libraryItemId }}/</span> so you can restore the originals if necessary. | ||||
|         </p> | ||||
|       </div> | ||||
|       <div class="w-full mx-auto border border-white border-opacity-10 bg-bg"> | ||||
|         <div class="flex py-2 px-4"> | ||||
|           <div class="w-10 text-xs font-semibold text-gray-200">#</div> | ||||
|           <div class="flex-grow text-xs font-semibold uppercase text-gray-200">Filename</div> | ||||
| @ -118,7 +125,8 @@ export default { | ||||
|       audiofilesEncoding: {}, | ||||
|       audiofilesFinished: {}, | ||||
|       updatingMetadata: false, | ||||
|       embedFinished: false | ||||
|       embedFinished: false, | ||||
|       toneObject: null | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
| @ -137,98 +145,35 @@ export default { | ||||
|     streamLibraryItem() { | ||||
|       return this.$store.state.streamLibraryItem | ||||
|     }, | ||||
|     metadataKeyValues() { | ||||
|       const keyValues = [ | ||||
|         { | ||||
|           key: 'title', | ||||
|           value: this.mediaMetadata.title | ||||
|         }, | ||||
|         { | ||||
|           key: 'artist', | ||||
|           value: this.mediaMetadata.authorName | ||||
|         }, | ||||
|         { | ||||
|           key: 'album_artist', | ||||
|           value: this.mediaMetadata.authorName | ||||
|         }, | ||||
|         { | ||||
|           key: 'date', | ||||
|           value: this.mediaMetadata.publishedYear | ||||
|         }, | ||||
|         { | ||||
|           key: 'description', | ||||
|           value: this.mediaMetadata.description | ||||
|         }, | ||||
|         { | ||||
|           key: 'genre', | ||||
|           value: this.mediaMetadata.genres.join(';') | ||||
|         }, | ||||
|         { | ||||
|           key: 'performer', | ||||
|           value: this.mediaMetadata.narratorName | ||||
|         } | ||||
|       ] | ||||
| 
 | ||||
|       if (this.mediaMetadata.subtitle) { | ||||
|         keyValues.push({ | ||||
|           key: 'subtitle', | ||||
|           value: this.mediaMetadata.subtitle | ||||
|         }) | ||||
|       } | ||||
| 
 | ||||
|       if (this.mediaMetadata.asin) { | ||||
|         keyValues.push({ | ||||
|           key: 'asin', | ||||
|           value: this.mediaMetadata.asin | ||||
|         }) | ||||
|       } | ||||
|       if (this.mediaMetadata.isbn) { | ||||
|         keyValues.push({ | ||||
|           key: 'isbn', | ||||
|           value: this.mediaMetadata.isbn | ||||
|         }) | ||||
|       } | ||||
|       if (this.mediaMetadata.language) { | ||||
|         keyValues.push({ | ||||
|           key: 'language', | ||||
|           value: this.mediaMetadata.language | ||||
|         }) | ||||
|       } | ||||
|       if (this.mediaMetadata.series.length) { | ||||
|         var firstSeries = this.mediaMetadata.series[0] | ||||
|         keyValues.push({ | ||||
|           key: 'series', | ||||
|           value: firstSeries.name | ||||
|         }) | ||||
|         if (firstSeries.sequence) { | ||||
|           keyValues.push({ | ||||
|             key: 'series-part', | ||||
|             value: firstSeries.sequence | ||||
|           }) | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       return keyValues | ||||
|     }, | ||||
|     metadataChapters() { | ||||
|       var chapters = this.media.chapters || [] | ||||
|       return chapters.concat(chapters) | ||||
|     } | ||||
|   }, | ||||
|   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 | ||||
|           }) | ||||
|     embedClick() { | ||||
|       const payload = { | ||||
|         message: `Are you sure you want to embed metadata in ${this.audioFiles.length} audio files?`, | ||||
|         callback: (confirmed) => { | ||||
|           if (confirmed) { | ||||
|             this.updateAudioFileMetadata() | ||||
|           } | ||||
|         }, | ||||
|         type: 'yesNo' | ||||
|       } | ||||
|       this.$store.commit('globals/setConfirmPrompt', payload) | ||||
|     }, | ||||
|     updateAudioFileMetadata() { | ||||
|       this.updatingMetadata = true | ||||
|       this.$axios | ||||
|         .$get(`/api/items/${this.libraryItemId}/audio-metadata?tone=1`) | ||||
|         .then(() => { | ||||
|           console.log('Audio metadata encode started') | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Audio metadata encode failed', error) | ||||
|           this.updatingMetadata = false | ||||
|         }) | ||||
|     }, | ||||
|     audioMetadataStarted(data) { | ||||
|       console.log('audio metadata started', data) | ||||
| @ -252,9 +197,21 @@ export default { | ||||
|       if (data.libraryItemId !== this.libraryItemId) return | ||||
|       this.$set(this.audiofilesEncoding, data.ino, false) | ||||
|       this.$set(this.audiofilesFinished, data.ino, true) | ||||
|     }, | ||||
|     fetchToneObject() { | ||||
|       this.$axios | ||||
|         .$get(`/api/items/${this.libraryItemId}/tone-object`) | ||||
|         .then((toneObject) => { | ||||
|           delete toneObject.CoverFile | ||||
|           this.toneObject = toneObject | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed to fetch tone object', error) | ||||
|         }) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.fetchToneObject() | ||||
|     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) | ||||
|  | ||||
| @ -324,13 +324,13 @@ class LibraryItemController { | ||||
|     res.sendStatus(200) | ||||
| 
 | ||||
|     for (let i = 0; i < items.length; i++) { | ||||
|         var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i]) | ||||
|         var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options) | ||||
|         if (matchResult.updated) { | ||||
|             itemsUpdated++ | ||||
|         } else if (matchResult.warning) { | ||||
|             itemsUnmatched++ | ||||
|         } | ||||
|       var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i]) | ||||
|       var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options) | ||||
|       if (matchResult.updated) { | ||||
|         itemsUpdated++ | ||||
|       } else if (matchResult.warning) { | ||||
|         itemsUnmatched++ | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     var result = { | ||||
| @ -371,6 +371,20 @@ class LibraryItemController { | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   getToneMetadataObject(req, res) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
|       Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, 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) | ||||
|     } | ||||
| 
 | ||||
|     res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem)) | ||||
|   } | ||||
| 
 | ||||
|   // GET: api/items/:id/audio-metadata
 | ||||
|   async updateAudioFileMetadata(req, res) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
| @ -383,7 +397,8 @@ class LibraryItemController { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem) | ||||
|     const useTone = req.query.tone === '1' | ||||
|     this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -5,6 +5,7 @@ const Logger = require('../Logger') | ||||
| const filePerms = require('../utils/filePerms') | ||||
| const { secondsToTimestamp } = require('../utils/index') | ||||
| const { writeMetadataFile } = require('../utils/ffmpegHelpers') | ||||
| const toneHelpers = require('../utils/toneHelpers') | ||||
| 
 | ||||
| class AudioMetadataMangaer { | ||||
|   constructor(db, emitter, clientEmitter) { | ||||
| @ -13,7 +14,104 @@ class AudioMetadataMangaer { | ||||
|     this.clientEmitter = clientEmitter | ||||
|   } | ||||
| 
 | ||||
|   async updateAudioFileMetadataForItem(user, libraryItem) { | ||||
|   updateMetadataForItem(user, libraryItem, useTone = true) { | ||||
|     if (useTone) { | ||||
|       this.updateMetadataForItemWithTone(user, libraryItem) | ||||
|     } else { | ||||
|       this.updateMetadataForItemWithFfmpeg(user, libraryItem) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   //
 | ||||
|   // TONE
 | ||||
|   //
 | ||||
|   getToneMetadataObjectForApi(libraryItem) { | ||||
|     return toneHelpers.getToneMetadataObject(libraryItem) | ||||
|   } | ||||
| 
 | ||||
|   async updateMetadataForItemWithTone(user, libraryItem) { | ||||
|     var audioFiles = libraryItem.media.includedAudioFiles | ||||
| 
 | ||||
|     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) | ||||
| 
 | ||||
|     // Write chapters file
 | ||||
|     var chaptersFilePath = null | ||||
|     var cachePath = Path.join(global.MetadataPath, 'cache/items') | ||||
|     console.log('Items Cache Path', cachePath) | ||||
| 
 | ||||
|     var itemCacheDir = Path.join(cachePath, libraryItem.id) | ||||
|     await fs.ensureDir(itemCacheDir) | ||||
| 
 | ||||
|     if (libraryItem.media.chapters.length) { | ||||
|       chaptersFilePath = Path.join(itemCacheDir, 'chapters.txt') | ||||
|       try { | ||||
|         await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath) | ||||
|       } catch (error) { | ||||
|         Logger.error(`[AudioMetadataManager] Write chapters.txt failed`, error) | ||||
|         chaptersFilePath = null | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath) | ||||
|     Logger.debug(`[AudioMetadataManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject) | ||||
| 
 | ||||
|     const results = [] | ||||
|     for (const af of audioFiles) { | ||||
|       const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneMetadataObject, itemCacheDir) | ||||
|       results.push(result) | ||||
|     } | ||||
| 
 | ||||
|     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) | ||||
|   } | ||||
| 
 | ||||
|   async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneMetadataObject, itemCacheDir) { | ||||
|     const resultPayload = { | ||||
|       libraryItemId, | ||||
|       index: audioFile.index, | ||||
|       ino: audioFile.ino, | ||||
|       filename: audioFile.metadata.filename | ||||
|     } | ||||
|     this.emitter('audiofile_metadata_started', resultPayload) | ||||
| 
 | ||||
|     // Backup audio file
 | ||||
|     try { | ||||
|       const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename) | ||||
|       await fs.copy(audioFile.metadata.path, backupFilePath) | ||||
|       Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) | ||||
|     } catch (err) { | ||||
|       Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err) | ||||
|     } | ||||
| 
 | ||||
|     const _toneMetadataObject = { | ||||
|       ...toneMetadataObject, | ||||
|       'TrackNumber': audioFile.index | ||||
|     } | ||||
| 
 | ||||
|     resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject) | ||||
|     if (resultPayload.success) { | ||||
|       Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`) | ||||
|     } | ||||
| 
 | ||||
|     this.emitter('audiofile_metadata_finished', resultPayload) | ||||
|     return resultPayload | ||||
|   } | ||||
| 
 | ||||
|   //
 | ||||
|   // FFMPEG
 | ||||
|   //
 | ||||
|   async updateMetadataForItemWithFfmpeg(user, libraryItem) { | ||||
|     var audioFiles = libraryItem.media.audioFiles | ||||
| 
 | ||||
|     const itemAudioMetadataPayload = { | ||||
| @ -36,9 +134,8 @@ class AudioMetadataMangaer { | ||||
|       var coverPath = libraryItem.media.coverPath.replace(/\\/g, '/') | ||||
|     } | ||||
| 
 | ||||
|     // TODO: Split into batches
 | ||||
|     const proms = audioFiles.map(af => { | ||||
|       return this.updateAudioFileMetadata(libraryItem.id, af, outputDir, metadataFilePath, coverPath) | ||||
|       return this.updateAudioFileMetadataWithFfmpeg(libraryItem.id, af, outputDir, metadataFilePath, coverPath) | ||||
|     }) | ||||
| 
 | ||||
|     const results = await Promise.all(proms) | ||||
| @ -55,7 +152,7 @@ class AudioMetadataMangaer { | ||||
|     this.emitter('audio_metadata_finished', itemAudioMetadataPayload) | ||||
|   } | ||||
| 
 | ||||
|   updateAudioFileMetadata(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') { | ||||
|   updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') { | ||||
|     return new Promise((resolve) => { | ||||
|       const resultPayload = { | ||||
|         libraryItemId, | ||||
|  | ||||
| @ -10,6 +10,7 @@ class CacheManager { | ||||
|     this.CachePath = Path.join(global.MetadataPath, 'cache') | ||||
|     this.CoverCachePath = Path.join(this.CachePath, 'covers') | ||||
|     this.ImageCachePath = Path.join(this.CachePath, 'images') | ||||
|     this.ItemCachePath = Path.join(this.CachePath, 'items') | ||||
|   } | ||||
| 
 | ||||
|   async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
 | ||||
| @ -29,6 +30,11 @@ class CacheManager { | ||||
|       pathsCreated = true | ||||
|     } | ||||
| 
 | ||||
|     if (!(await fs.pathExists(this.ItemCachePath))) { | ||||
|       await fs.mkdir(this.ItemCachePath) | ||||
|       pathsCreated = true | ||||
|     } | ||||
| 
 | ||||
|     if (pathsCreated) { | ||||
|       await filePerms.setDefault(this.CachePath) | ||||
|     } | ||||
|  | ||||
| @ -3,7 +3,7 @@ const Logger = require('../../Logger') | ||||
| const BookMetadata = require('../metadata/BookMetadata') | ||||
| const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index') | ||||
| const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata') | ||||
| const { overdriveMediaMarkersExist, parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers') | ||||
| const { parseOverdriveMediaMarkersAsChapters } = require('../../utils/parsers/parseOverdriveMediaMarkers') | ||||
| const abmetadataGenerator = require('../../utils/abmetadataGenerator') | ||||
| const { readTextFile } = require('../../utils/fileUtils') | ||||
| const AudioFile = require('../files/AudioFile') | ||||
| @ -111,12 +111,15 @@ class Book { | ||||
|   get invalidAudioFiles() { | ||||
|     return this.audioFiles.filter(af => af.invalid) | ||||
|   } | ||||
|   get includedAudioFiles() { | ||||
|     return this.audioFiles.filter(af => !af.exclude && !af.invalid) | ||||
|   } | ||||
|   get hasIssues() { | ||||
|     return this.missingParts.length || this.invalidAudioFiles.length | ||||
|   } | ||||
|   get tracks() { | ||||
|     var startOffset = 0 | ||||
|     return this.audioFiles.filter(af => !af.exclude && !af.invalid).map((af) => { | ||||
|     return this.includedAudioFiles.map((af) => { | ||||
|       var audioTrack = new AudioTrack() | ||||
|       audioTrack.setData(this.libraryItemId, af, startOffset) | ||||
|       startOffset += audioTrack.duration | ||||
|  | ||||
| @ -133,6 +133,14 @@ class BookMetadata { | ||||
|       return `${getTitleIgnorePrefix(se.name)} #${se.sequence}` | ||||
|     }).join(', ') | ||||
|   } | ||||
|   get firstSeriesName() { | ||||
|     if (!this.series.length) return '' | ||||
|     return this.series[0].name | ||||
|   } | ||||
|   get firstSeriesSequence() { | ||||
|     if (!this.series.length) return '' | ||||
|     return this.series[0].sequence | ||||
|   } | ||||
|   get narratorName() { | ||||
|     return this.narrators.join(', ') | ||||
|   } | ||||
|  | ||||
| @ -95,6 +95,7 @@ class ApiRouter { | ||||
|     this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this)) | ||||
|     this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this)) | ||||
|     this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) | ||||
|     this.router.get('/items/:id/tone-object', LibraryItemController.middleware.bind(this), LibraryItemController.getToneMetadataObject.bind(this)) | ||||
|     this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) | ||||
|     this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this)) | ||||
|     this.router.post('/items/:id/open-feed', LibraryItemController.middleware.bind(this), LibraryItemController.openRSSFeed.bind(this)) | ||||
|  | ||||
| @ -80,7 +80,7 @@ function elapsedPretty(seconds) { | ||||
| } | ||||
| module.exports.elapsedPretty = elapsedPretty | ||||
| 
 | ||||
| function secondsToTimestamp(seconds, includeMs = false) { | ||||
| function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = false) { | ||||
|   var _seconds = seconds | ||||
|   var _minutes = Math.floor(seconds / 60) | ||||
|   _seconds -= _minutes * 60 | ||||
| @ -91,6 +91,9 @@ function secondsToTimestamp(seconds, includeMs = false) { | ||||
|   _seconds = Math.floor(_seconds) | ||||
| 
 | ||||
|   var msString = '.' + (includeMs ? ms.toFixed(3) : '0.0').split('.')[1] | ||||
|   if (alwaysIncludeHours) { | ||||
|     return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}` | ||||
|   } | ||||
|   if (!_hours) { | ||||
|     return `${_minutes}:${_seconds.toString().padStart(2, '0')}${msString}` | ||||
|   } | ||||
|  | ||||
| @ -177,8 +177,8 @@ function parseTags(format, verbose) { | ||||
|     file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'), | ||||
|     file_tag_description: tryGrabTags(format, 'description', 'desc'), | ||||
|     file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'), | ||||
|     file_tag_series: tryGrabTags(format, 'series', 'show'), | ||||
|     file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id'), | ||||
|     file_tag_series: tryGrabTags(format, 'series', 'show', 'mvin'), | ||||
|     file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvnm'), | ||||
|     file_tag_isbn: tryGrabTags(format, 'isbn'), | ||||
|     file_tag_language: tryGrabTags(format, 'language', 'lang'), | ||||
|     file_tag_asin: tryGrabTags(format, 'asin'), | ||||
|  | ||||
							
								
								
									
										87
									
								
								server/utils/toneHelpers.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								server/utils/toneHelpers.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,87 @@ | ||||
| const tone = require('node-tone') | ||||
| const fs = require('../libs/fsExtra') | ||||
| const Logger = require('../Logger') | ||||
| const { secondsToTimestamp } = require('./index') | ||||
| 
 | ||||
| module.exports.writeToneChaptersFile = (chapters, filePath) => { | ||||
|   var chaptersTxt = '' | ||||
|   for (const chapter of chapters) { | ||||
|     chaptersTxt += `${secondsToTimestamp(chapter.start, true, true)} ${chapter.title}\n` | ||||
|   } | ||||
|   return fs.writeFile(filePath, chaptersTxt) | ||||
| } | ||||
| 
 | ||||
| module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => { | ||||
|   const coverPath = libraryItem.media.coverPath | ||||
|   const bookMetadata = libraryItem.media.metadata | ||||
| 
 | ||||
|   const metadataObject = { | ||||
|     'Title': bookMetadata.title || '', | ||||
|     'Album': bookMetadata.title || '', | ||||
|     'TrackTotal': libraryItem.media.tracks.length | ||||
|   } | ||||
|   const additionalFields = [] | ||||
| 
 | ||||
|   if (bookMetadata.subtitle) { | ||||
|     metadataObject['Subtitle'] = bookMetadata.subtitle | ||||
|   } | ||||
|   if (bookMetadata.authorName) { | ||||
|     metadataObject['Artist'] = bookMetadata.authorName | ||||
|     metadataObject['AlbumArtist'] = bookMetadata.authorName | ||||
|   } | ||||
|   if (bookMetadata.description) { | ||||
|     metadataObject['Comment'] = bookMetadata.description | ||||
|     metadataObject['Description'] = bookMetadata.description | ||||
|   } | ||||
|   if (bookMetadata.narratorName) { | ||||
|     metadataObject['Narrator'] = bookMetadata.narratorName | ||||
|     metadataObject['Composer'] = bookMetadata.narratorName | ||||
|   } | ||||
|   if (bookMetadata.firstSeriesName) { | ||||
|     metadataObject['MovementName'] = bookMetadata.firstSeriesName | ||||
|   } | ||||
|   if (bookMetadata.firstSeriesSequence) { | ||||
|     metadataObject['Movement'] = bookMetadata.firstSeriesSequence | ||||
|   } | ||||
|   if (bookMetadata.genres.length) { | ||||
|     metadataObject['Genre'] = bookMetadata.genres.join('/') | ||||
|   } | ||||
|   if (bookMetadata.publisher) { | ||||
|     metadataObject['Publisher'] = bookMetadata.publisher | ||||
|   } | ||||
|   if (bookMetadata.asin) { | ||||
|     additionalFields.push(`ASIN=${bookMetadata.asin}`) | ||||
|   } | ||||
|   if (bookMetadata.isbn) { | ||||
|     additionalFields.push(`ISBN=${bookMetadata.isbn}`) | ||||
|   } | ||||
|   if (coverPath) { | ||||
|     metadataObject['CoverFile'] = coverPath | ||||
|   } | ||||
|   if (parsePublishedYear(bookMetadata.publishedYear)) { | ||||
|     metadataObject['PublishingDate'] = parsePublishedYear(bookMetadata.publishedYear) | ||||
|   } | ||||
|   if (chaptersFile) { | ||||
|     metadataObject['ChaptersFile'] = chaptersFile | ||||
|   } | ||||
| 
 | ||||
|   if (additionalFields.length) { | ||||
|     metadataObject['AdditionalFields'] = additionalFields | ||||
|   } | ||||
| 
 | ||||
|   return metadataObject | ||||
| } | ||||
| 
 | ||||
| module.exports.tagAudioFile = (filePath, payload) => { | ||||
|   return tone.tag(filePath, payload).then((data) => { | ||||
|     return true | ||||
|   }).catch((error) => { | ||||
|     Logger.error(`[toneHelpers] tagAudioFile: Failed for "${filePath}"`, error) | ||||
|     return false | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| function parsePublishedYear(publishedYear) { | ||||
|   if (isNaN(publishedYear) || !publishedYear || Number(publishedYear) <= 0) return null | ||||
|   return `01/01/${publishedYear}` | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user