mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Option to disable backup of audio files in embed metadata tool #1370
This commit is contained in:
		
							parent
							
								
									7ccf36a896
								
							
						
					
					
						commit
						5a26704c32
					
				| @ -63,6 +63,10 @@ | ||||
| 
 | ||||
|     <div class="w-full max-w-4xl mx-auto"> | ||||
|       <div v-if="isEmbedTool" class="w-full flex justify-end items-center mb-4"> | ||||
|         <ui-checkbox v-if="!isFinished" v-model="shouldBackupAudioFiles" label="Backup audio files" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" @input="toggleBackupAudioFiles" /> | ||||
| 
 | ||||
|         <div class="flex-grow" /> | ||||
| 
 | ||||
|         <ui-btn v-if="!isFinished" color="primary" :loading="processing" @click.stop="embedClick">{{ $strings.ButtonStartMetadataEmbed }}</ui-btn> | ||||
|         <p v-else class="text-success text-lg font-semibold">{{ $strings.MessageEmbedFinished }}</p> | ||||
|       </div> | ||||
| @ -104,7 +108,7 @@ | ||||
|           </p> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="flex items-start mb-2"> | ||||
|         <div v-if="shouldBackupAudioFiles || isM4BTool" class="flex items-start mb-2"> | ||||
|           <span class="material-icons text-base text-warning pt-1">star</span> | ||||
|           <p class="text-gray-200 ml-2"> | ||||
|             A backup of your original 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>. Make sure to periodically purge items cache. | ||||
| @ -171,7 +175,7 @@ export default { | ||||
|     if (!store.getters['user/getIsAdminOrUp']) { | ||||
|       return redirect('/?error=unauthorized') | ||||
|     } | ||||
|     var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { | ||||
|     const libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { | ||||
|       console.error('Failed', error) | ||||
|       return false | ||||
|     }) | ||||
| @ -201,6 +205,7 @@ export default { | ||||
|       selectedTool: 'embed', | ||||
|       isCancelingEncode: false, | ||||
|       showEncodeOptions: false, | ||||
|       shouldBackupAudioFiles: true, | ||||
|       encodingOptions: { | ||||
|         bitrate: '64k', | ||||
|         channels: '2', | ||||
| @ -275,6 +280,9 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     toggleBackupAudioFiles(val) { | ||||
|       localStorage.setItem('embedMetadataShouldBackup', val ? 1 : 0) | ||||
|     }, | ||||
|     cancelEncodeClick() { | ||||
|       this.isCancelingEncode = true | ||||
|       this.$axios | ||||
| @ -332,7 +340,7 @@ export default { | ||||
|     updateAudioFileMetadata() { | ||||
|       this.processing = true | ||||
|       this.$axios | ||||
|         .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?tone=1`) | ||||
|         .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata?backup=${this.shouldBackupAudioFiles ? 1 : 0}`) | ||||
|         .then(() => { | ||||
|           console.log('Audio metadata encode started') | ||||
|         }) | ||||
| @ -350,9 +358,14 @@ export default { | ||||
|       console.log('audio metadata finished', data) | ||||
|       if (data.libraryItemId !== this.libraryItemId) return | ||||
|       this.processing = false | ||||
|       this.isFinished = true | ||||
|       this.audiofilesEncoding = {} | ||||
|       this.$toast.success('Audio file metadata updated') | ||||
| 
 | ||||
|       if (data.failed) { | ||||
|         this.$toast.error(data.error) | ||||
|       } else { | ||||
|         this.isFinished = true | ||||
|         this.$toast.success('Audio file metadata updated') | ||||
|       } | ||||
|     }, | ||||
|     audiofileMetadataStarted(data) { | ||||
|       if (data.libraryItemId !== this.libraryItemId) return | ||||
| @ -378,6 +391,9 @@ export default { | ||||
|       } | ||||
| 
 | ||||
|       if (this.task) this.taskUpdated(this.task) | ||||
| 
 | ||||
|       const shouldBackupAudioFiles = localStorage.getItem('embedMetadataShouldBackup') | ||||
|       this.shouldBackupAudioFiles = shouldBackupAudioFiles != 0 | ||||
|     }, | ||||
|     fetchToneObject() { | ||||
|       this.$axios | ||||
|  | ||||
| @ -47,7 +47,6 @@ class ToolsController { | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   // POST: api/tools/item/:id/embed-metadata
 | ||||
|   async embedAudioFileMetadata(req, res) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
| @ -60,9 +59,11 @@ class ToolsController { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     const useTone = req.query.tone === '1' | ||||
|     const forceEmbedChapters = req.query.forceEmbedChapters === '1' | ||||
|     this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters) | ||||
|     const options = { | ||||
|       forceEmbedChapters: req.query.forceEmbedChapters === '1', | ||||
|       backup: req.query.backup === '1' | ||||
|     } | ||||
|     this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, options) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -1,16 +1,13 @@ | ||||
| const Path = require('path') | ||||
| const workerThreads = require('worker_threads') | ||||
| 
 | ||||
| const SocketAuthority = require('../SocketAuthority') | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| const fs = require('../libs/fsExtra') | ||||
| 
 | ||||
| const filePerms = require('../utils/filePerms') | ||||
| const { secondsToTimestamp } = require('../utils/index') | ||||
| const { filePathToPOSIX } = require('../utils/fileUtils') | ||||
| const { writeMetadataFile } = require('../utils/ffmpegHelpers') | ||||
| const toneHelpers = require('../utils/toneHelpers') | ||||
| const filePerms = require('../utils/filePerms') | ||||
| 
 | ||||
| class AudioMetadataMangaer { | ||||
|   constructor(db, taskManager) { | ||||
| @ -18,23 +15,15 @@ class AudioMetadataMangaer { | ||||
|     this.taskManager = taskManager | ||||
|   } | ||||
| 
 | ||||
|   updateMetadataForItem(user, libraryItem, useTone, forceEmbedChapters) { | ||||
|     if (useTone) { | ||||
|       this.updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) | ||||
|     } else { | ||||
|       this.updateMetadataForItemWithFfmpeg(user, libraryItem) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   //
 | ||||
|   // TONE
 | ||||
|   //
 | ||||
|   getToneMetadataObjectForApi(libraryItem) { | ||||
|     return toneHelpers.getToneMetadataObject(libraryItem) | ||||
|   } | ||||
| 
 | ||||
|   async updateMetadataForItemWithTone(user, libraryItem, forceEmbedChapters) { | ||||
|     var audioFiles = libraryItem.media.includedAudioFiles | ||||
|   async updateMetadataForItem(user, libraryItem, options = {}) { | ||||
|     const forceEmbedChapters = !!options.forceEmbedChapters | ||||
|     const backupFiles = !!options.backup | ||||
| 
 | ||||
|     const audioFiles = libraryItem.media.includedAudioFiles | ||||
| 
 | ||||
|     const itemAudioMetadataPayload = { | ||||
|       userId: user.id, | ||||
| @ -45,35 +34,55 @@ class AudioMetadataMangaer { | ||||
| 
 | ||||
|     SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload) | ||||
| 
 | ||||
|     // Write chapters file
 | ||||
|     var toneJsonPath = null | ||||
|     // Ensure folder for backup files
 | ||||
|     const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`) | ||||
|     await fs.ensureDir(itemCacheDir) | ||||
|     let cacheDirCreated = false | ||||
|     if (!await fs.pathExists(itemCacheDir)) { | ||||
|       await fs.mkdir(itemCacheDir) | ||||
|       await filePerms.setDefault(itemCacheDir, true) | ||||
|       cacheDirCreated = true | ||||
|     } | ||||
| 
 | ||||
|     // Write chapters file
 | ||||
|     const toneJsonPath = Path.join(itemCacheDir, 'metadata.json') | ||||
| 
 | ||||
|     try { | ||||
|       toneJsonPath = Path.join(itemCacheDir, 'metadata.json') | ||||
|       const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null | ||||
|       await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length) | ||||
|     } catch (error) { | ||||
|       Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error) | ||||
|       toneJsonPath = null | ||||
| 
 | ||||
|       itemAudioMetadataPayload.failed = true | ||||
|       itemAudioMetadataPayload.error = 'Failed to write metadata.json' | ||||
|       SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const results = [] | ||||
|     for (const af of audioFiles) { | ||||
|       const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir) | ||||
|       const result = await this.updateAudioFileMetadataWithTone(libraryItem.id, af, toneJsonPath, itemCacheDir, backupFiles) | ||||
|       results.push(result) | ||||
|     } | ||||
| 
 | ||||
|     // Remove temp cache file/folder if not backing up
 | ||||
|     if (!backupFiles) { | ||||
|       // If cache dir was created from this then remove it
 | ||||
|       if (cacheDirCreated) { | ||||
|         await fs.remove(itemCacheDir) | ||||
|       } else { | ||||
|         await fs.remove(toneJsonPath) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const elapsed = Date.now() - itemAudioMetadataPayload.startedAt | ||||
|     Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed)}`) | ||||
|     Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed / 1000, true)}`) | ||||
|     itemAudioMetadataPayload.results = results | ||||
|     itemAudioMetadataPayload.elapsed = elapsed | ||||
|     itemAudioMetadataPayload.finishedAt = Date.now() | ||||
|     SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) | ||||
|   } | ||||
| 
 | ||||
|   async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir) { | ||||
|   async updateAudioFileMetadataWithTone(libraryItemId, audioFile, toneJsonPath, itemCacheDir, backupFiles) { | ||||
|     const resultPayload = { | ||||
|       libraryItemId, | ||||
|       index: audioFile.index, | ||||
| @ -83,12 +92,14 @@ class AudioMetadataMangaer { | ||||
|     SocketAuthority.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) | ||||
|     if (backupFiles) { | ||||
|       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 = { | ||||
| @ -104,161 +115,5 @@ class AudioMetadataMangaer { | ||||
|     SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) | ||||
|     return resultPayload | ||||
|   } | ||||
| 
 | ||||
|   //
 | ||||
|   // FFMPEG
 | ||||
|   //
 | ||||
|   async updateMetadataForItemWithFfmpeg(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 })) | ||||
|     } | ||||
| 
 | ||||
|     SocketAuthority.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) | ||||
| 
 | ||||
|     if (libraryItem.media.coverPath != null) { | ||||
|       var coverPath = filePathToPOSIX(libraryItem.media.coverPath) | ||||
|     } | ||||
| 
 | ||||
|     const proms = audioFiles.map(af => { | ||||
|       return this.updateAudioFileMetadataWithFfmpeg(libraryItem.id, af, outputDir, metadataFilePath, coverPath) | ||||
|     }) | ||||
| 
 | ||||
|     const results = await Promise.all(proms) | ||||
| 
 | ||||
|     Logger.debug(`[AudioMetadataManager] Finished`) | ||||
| 
 | ||||
|     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() | ||||
|     SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) | ||||
|   } | ||||
| 
 | ||||
|   updateAudioFileMetadataWithFfmpeg(libraryItemId, audioFile, outputDir, metadataFilePath, coverPath = '') { | ||||
|     return new Promise((resolve) => { | ||||
|       const resultPayload = { | ||||
|         libraryItemId, | ||||
|         index: audioFile.index, | ||||
|         ino: audioFile.ino, | ||||
|         filename: audioFile.metadata.filename | ||||
|       } | ||||
|       SocketAuthority.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_chapters 1', '-map_metadata 1', `-metadata track=${audioFile.index}`, '-write_id3v2 1', '-movflags use_metadata_tags'] | ||||
| 
 | ||||
|       if (coverPath != '') { | ||||
|         var ffmpegCoverPathInput = { | ||||
|           input: coverPath, | ||||
|           options: ['-f image2pipe'] | ||||
|         } | ||||
|         var ffmpegCoverPathOptions = [ | ||||
|           '-c:v copy', | ||||
|           '-map 2:v', | ||||
|           '-map 0:a' | ||||
|         ] | ||||
| 
 | ||||
|         ffmpegInputs.push(ffmpegCoverPathInput) | ||||
|         Logger.debug(`[AudioFileMetaDataManager] Cover found for "${audioFile.metadata.filename}". Cover will be merged to metadata`) | ||||
|       } else { | ||||
|         // remove the video stream to account for the user getting rid an existing cover in abs
 | ||||
|         var ffmpegCoverPathOptions = [ | ||||
|           '-map 0', | ||||
|           '-map -0:v' | ||||
|         ] | ||||
| 
 | ||||
|         Logger.debug(`[AudioFileMetaDataManager] No cover found for "${audioFile.metadata.filename}". Cover will be skipped or removed from metadata`) | ||||
|       } | ||||
| 
 | ||||
|       ffmpegOptions.push(...ffmpegCoverPathOptions) | ||||
| 
 | ||||
|       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 | ||||
|                 SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) | ||||
|                 resolve(resultPayload) | ||||
|               }).catch((error) => { | ||||
|                 Logger.error(`[AudioFileMetadataManager] Audio file failed to move "${inputPath}"`, error) | ||||
|                 resultPayload.success = false | ||||
|                 SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) | ||||
|                 resolve(resultPayload) | ||||
|               }) | ||||
|             } else { | ||||
|               Logger.debug(`[AudioFileMetadataManager] Metadata encode FAILED for "${audioFile.metadata.filename}"`) | ||||
| 
 | ||||
|               resultPayload.success = false | ||||
|               SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) | ||||
|               resolve(resultPayload) | ||||
|             } | ||||
|           } else if (message.type === 'FFMPEG') { | ||||
|             if (message.level === 'debug' && process.env.NODE_ENV === 'production') { | ||||
|               // stderr is not necessary in production
 | ||||
|             } else if (Logger[message.level]) { | ||||
|               Logger[message.level](message.log) | ||||
|             } | ||||
|           } | ||||
|         } else { | ||||
|           Logger.error('Invalid worker message', message) | ||||
|         } | ||||
|       }) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| module.exports = AudioMetadataMangaer | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| const Ffmpeg = require('../libs/fluentFfmpeg') | ||||
| const fs = require('../libs/fsExtra') | ||||
| const Path = require('path') | ||||
| const package = require('../../package.json') | ||||
| const Logger = require('../Logger') | ||||
| const { filePathToPOSIX } = require('./fileUtils') | ||||
| 
 | ||||
| @ -41,59 +40,6 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) { | ||||
| } | ||||
| module.exports.writeConcatFile = writeConcatFile | ||||
| 
 | ||||
| 
 | ||||
| async function writeMetadataFile(libraryItem, outputPath) { | ||||
|   var inputstrs = [ | ||||
|     ';FFMETADATA1', | ||||
|     `title=${libraryItem.media.metadata.title}`, | ||||
|     `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(';')}`, | ||||
|     `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 = [ | ||||
|         '[CHAPTER]', | ||||
|         'TIMEBASE=1/1000', | ||||
|         `START=${Math.round(chap.start * 1000)}`, | ||||
|         `END=${Math.round(chap.end * 1000)}`, | ||||
|         `title=${chap.title}` | ||||
|       ] | ||||
|       inputstrs = inputstrs.concat(chapterstrs) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   await fs.writeFile(outputPath, inputstrs.join('\n')) | ||||
|   return inputstrs | ||||
| } | ||||
| module.exports.writeMetadataFile = writeMetadataFile | ||||
| 
 | ||||
| async function extractCoverArt(filepath, outputpath) { | ||||
|   var dirname = Path.dirname(outputpath) | ||||
|   await fs.ensureDir(dirname) | ||||
|  | ||||
| @ -100,8 +100,6 @@ function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = fal | ||||
| } | ||||
| module.exports.secondsToTimestamp = secondsToTimestamp | ||||
| 
 | ||||
| module.exports.msToTimestamp = (ms, includeMs) => secondsToTimestamp(ms / 1000, includeMs) | ||||
| 
 | ||||
| module.exports.reqSupportsWebp = (req) => { | ||||
|   if (!req || !req.headers || !req.headers.accept) return false | ||||
|   return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*' | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user