diff --git a/server/Server.js b/server/Server.js index 83e7ca3e7e..c9810cc8c3 100644 --- a/server/Server.js +++ b/server/Server.js @@ -16,11 +16,10 @@ const Logger = require('./Logger') const Auth = require('./Auth') const Watcher = require('./Watcher') const Scanner = require('./scanner/Scanner') +const LibraryScanner = require('./scanner/LibraryScanner') const Database = require('./Database') const SocketAuthority = require('./SocketAuthority') -const routes = require('./routes/index') - const ApiRouter = require('./routers/ApiRouter') const HlsRouter = require('./routers/HlsRouter') @@ -78,6 +77,7 @@ class Server { this.rssFeedManager = new RssFeedManager() this.scanner = new Scanner(this.coverManager, this.taskManager) + this.libraryScanner = new LibraryScanner(this.coverManager, this.taskManager) this.cronManager = new CronManager(this.scanner, this.podcastManager) // Routers diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index e64bc8d668..176b7f2d8e 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -977,6 +977,8 @@ class LibraryController { } res.sendStatus(200) await this.scanner.scan(req.library, options) + // TODO: New library scanner + // await this.libraryScanner.scan(req.library, options) await Database.resetLibraryIssuesFilterData(req.library.id) Logger.info('[LibraryController] Scan complete') } diff --git a/server/models/Book.js b/server/models/Book.js index d478587cdf..1c7d89a606 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -18,6 +18,31 @@ const Logger = require('../Logger') * @property {string} title */ +/** + * @typedef AudioFileObject + * @property {number} index + * @property {string} ino + * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata + * @property {number} addedAt + * @property {number} updatedAt + * @property {number} trackNumFromMeta + * @property {number} discNumFromMeta + * @property {number} trackNumFromFilename + * @property {number} discNumFromFilename + * @property {boolean} manuallyVerified + * @property {string} format + * @property {number} duration + * @property {number} bitRate + * @property {string} language + * @property {string} codec + * @property {string} timeBase + * @property {number} channels + * @property {string} channelLayout + * @property {ChapterObject[]} chapters + * @property {Object} metaTags + * @property {string} mimeType + */ + class Book extends Model { constructor(values, options) { super(values, options) @@ -52,7 +77,7 @@ class Book extends Model { this.duration /** @type {string[]} */ this.narrators - /** @type {Object} */ + /** @type {AudioFileObject[]} */ this.audioFiles /** @type {EBookFileObject} */ this.ebookFile diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index c9bded996e..fa288cdec4 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -3,6 +3,8 @@ const Logger = require('../Logger') const oldLibraryItem = require('../objects/LibraryItem') const libraryFilters = require('../utils/queries/libraryFilters') const { areEquivalent } = require('../utils/index') +const Book = require('./Book') +const Podcast = require('./Podcast') /** * @typedef LibraryFileObject @@ -791,6 +793,11 @@ class LibraryItem extends Model { return this.getOldLibraryItem(libraryItem) } + /** + * + * @param {import('sequelize').FindOptions} options + * @returns {Promise} + */ getMedia(options) { if (!this.mediaType) return Promise.resolve(null) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}` diff --git a/server/objects/files/AudioFile.js b/server/objects/files/AudioFile.js index be16cf9294..6e43e73b5b 100644 --- a/server/objects/files/AudioFile.js +++ b/server/objects/files/AudioFile.js @@ -64,7 +64,7 @@ class AudioFile { channelLayout: this.channelLayout, chapters: this.chapters, embeddedCoverArt: this.embeddedCoverArt, - metaTags: this.metaTags ? this.metaTags.toJSON() : {}, + metaTags: this.metaTags?.toJSON() || {}, mimeType: this.mimeType } } @@ -163,11 +163,16 @@ class AudioFile { return new AudioFile(this.toJSON()) } + /** + * + * @param {AudioFile} scannedAudioFile + * @returns {boolean} true if updates were made + */ updateFromScan(scannedAudioFile) { let hasUpdated = false const newjson = scannedAudioFile.toJSON() - const ignoreKeys = ['manuallyVerified', 'exclude', 'addedAt', 'updatedAt'] + const ignoreKeys = ['manuallyVerified', 'ctimeMs', 'addedAt', 'updatedAt'] for (const key in newjson) { if (key === 'metadata') { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 0dc49dffd9..5b61c8bfcb 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -40,6 +40,7 @@ class ApiRouter { constructor(Server) { this.auth = Server.auth this.scanner = Server.scanner + this.libraryScanner = Server.libraryScanner this.playbackSessionManager = Server.playbackSessionManager this.abMergeManager = Server.abMergeManager this.backupManager = Server.backupManager diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index cd596f8645..01251bfd9a 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -1,6 +1,197 @@ +const Path = require('path') +const Logger = require('../Logger') +const prober = require('../utils/prober') +const LibraryItem = require('../models/LibraryItem') +const AudioFile = require('../objects/files/AudioFile') + class AudioFileScanner { constructor() { } + /** + * Is array of numbers sequential, i.e. 1, 2, 3, 4 + * @param {number[]} nums + * @returns {boolean} + */ + isSequential(nums) { + if (!nums?.length) return false + if (nums.length === 1) return true + let prev = nums[0] + for (let i = 1; i < nums.length; i++) { + if (nums[i] - prev > 1) return false + prev = nums[i] + } + return true + } + + /** + * Remove + * @param {number[]} nums + * @returns {number[]} + */ + removeDupes(nums) { + if (!nums || !nums.length) return [] + if (nums.length === 1) return nums + + let nodupes = [nums[0]] + nums.forEach((num) => { + if (num > nodupes[nodupes.length - 1]) nodupes.push(num) + }) + return nodupes + } + + /** + * Order audio files by track/disc number + * @param {import('../models/Book')} book + * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @returns {import('../models/Book').AudioFileObject[]} + */ + runSmartTrackOrder(book, audioFiles) { + let discsFromFilename = [] + let tracksFromFilename = [] + let discsFromMeta = [] + let tracksFromMeta = [] + + audioFiles.forEach((af) => { + if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename) + if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta) + if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename) + if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta) + }) + discsFromFilename.sort((a, b) => a - b) + discsFromMeta.sort((a, b) => a - b) + tracksFromFilename.sort((a, b) => a - b) + tracksFromMeta.sort((a, b) => a - b) + + let discKey = null + if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) { + discKey = 'discNumFromMeta' + } else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) { + discKey = 'discNumFromFilename' + } + + let trackKey = null + tracksFromFilename = this.removeDupes(tracksFromFilename) + tracksFromMeta = this.removeDupes(tracksFromMeta) + if (tracksFromFilename.length > tracksFromMeta.length) { + trackKey = 'trackNumFromFilename' + } else { + trackKey = 'trackNumFromMeta' + } + + if (discKey !== null) { + Logger.debug(`[AudioFileScanner] Smart track order for "${book.title}" using disc key ${discKey} and track key ${trackKey}`) + audioFiles.sort((a, b) => { + let Dx = a[discKey] - b[discKey] + if (Dx === 0) Dx = a[trackKey] - b[trackKey] + return Dx + }) + } else { + Logger.debug(`[AudioFileScanner] Smart track order for "${book.title}" using track key ${trackKey}`) + audioFiles.sort((a, b) => a[trackKey] - b[trackKey]) + } + + for (let i = 0; i < audioFiles.length; i++) { + audioFiles[i].index = i + 1 + } + return audioFiles + } + + /** + * Get track and disc number from audio filename + * @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan + * @param {LibraryItem.LibraryFileObject} audioLibraryFile + * @returns {{trackNumber:number, discNumber:number}} + */ + getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) { + const { title, author, series, publishedYear } = mediaMetadataFromScan + const { filename, path } = audioLibraryFile.metadata + let partbasename = Path.basename(filename, Path.extname(filename)) + + // Remove title, author, series, and publishedYear from filename if there + if (title) partbasename = partbasename.replace(title, '') + if (author) partbasename = partbasename.replace(author, '') + if (series) partbasename = partbasename.replace(series, '') + if (publishedYear) partbasename = partbasename.replace(publishedYear) + + // Look for disc number + let discNumber = null + const discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i) + if (discMatch && discMatch.length > 2 && discMatch[2]) { + if (!isNaN(discMatch[2])) { + discNumber = Number(discMatch[2]) + } + + // Remove disc number from filename + partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '') + } + + // Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3 + const pathdir = Path.dirname(path).split('/').pop() + if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) { + const discFromFolder = Number(pathdir.replace(/cd/i, '')) + if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder + } + + const numbersinpath = partbasename.match(/\d{1,4}/g) + const trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null + return { + trackNumber, + discNumber + } + } + + /** + * + * @param {string} mediaType + * @param {LibraryItem.LibraryFileObject} libraryFile + * @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan + * @returns {Promise} + */ + async scan(mediaType, libraryFile, mediaMetadataFromScan) { + const probeData = await prober.probe(libraryFile.metadata.path) + + if (probeData.error) { + Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`) + return null + } + + if (!probeData.audioStream) { + Logger.error('[MediaFileScanner] Invalid audio file no audio stream') + return null + } + + const audioFile = new AudioFile() + audioFile.trackNumFromMeta = probeData.audioMetaTags.trackNumber + audioFile.discNumFromMeta = probeData.audioMetaTags.discNumber + if (mediaType === 'book') { + const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile) + audioFile.trackNumFromFilename = trackNumber + audioFile.discNumFromFilename = discNumber + } + audioFile.setDataFromProbe(libraryFile, probeData) + + return audioFile + } + + /** + * Scan LibraryFiles and return AudioFiles + * @param {string} mediaType + * @param {import('./LibraryItemScanData')} libraryItemScanData + * @param {LibraryItem.LibraryFileObject[]} audioLibraryFiles + * @returns {Promise} + */ + async executeMediaFileScans(mediaType, libraryItemScanData, audioLibraryFiles) { + const batchSize = 32 + const results = [] + for (let batch = 0; batch < audioLibraryFiles.length; batch += batchSize) { + const proms = [] + for (let i = batch; i < Math.min(batch + batchSize, audioLibraryFiles.length); i++) { + proms.push(this.scan(mediaType, audioLibraryFiles[i], libraryItemScanData.mediaMetadata)) + } + results.push(...await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))) + } + return results + } } module.exports = new AudioFileScanner() \ No newline at end of file diff --git a/server/scanner/LibraryItemScanData.js b/server/scanner/LibraryItemScanData.js index 8e8cb10ed5..1b40452437 100644 --- a/server/scanner/LibraryItemScanData.js +++ b/server/scanner/LibraryItemScanData.js @@ -1,6 +1,7 @@ const packageJson = require('../../package.json') const { LogLevel } = require('../utils/constants') const LibraryItem = require('../models/LibraryItem') +const globals = require('../utils/globals') class LibraryItemScanData { constructor(data) { @@ -33,11 +34,41 @@ class LibraryItemScanData { /** @type {boolean} */ this.hasPathChange /** @type {LibraryItem.LibraryFileObject[]} */ - this.libraryFilesRemoved + this.libraryFilesRemoved = [] /** @type {LibraryItem.LibraryFileObject[]} */ - this.libraryFilesAdded + this.libraryFilesAdded = [] /** @type {LibraryItem.LibraryFileObject[]} */ - this.libraryFilesModified + this.libraryFilesModified = [] + } + + /** @type {boolean} */ + get hasLibraryFileChanges() { + return this.libraryFilesRemoved.length + this.libraryFilesModified.length + this.libraryFilesAdded.length + } + + /** @type {boolean} */ + get hasAudioFileChanges() { + return this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified + } + + /** @type {LibraryItem.LibraryFileObject[]} */ + get audioLibraryFilesModified() { + return this.libraryFilesModified.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + } + + /** @type {LibraryItem.LibraryFileObject[]} */ + get audioLibraryFilesRemoved() { + return this.libraryFilesRemoved.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + } + + /** @type {LibraryItem.LibraryFileObject[]} */ + get audioLibraryFilesAdded() { + return this.libraryFilesAdded.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) + } + + /** @type {LibraryItem.LibraryFileObject[]} */ + get audioLibraryFiles() { + return this.libraryFiles.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || '')) } /** @@ -46,7 +77,7 @@ class LibraryItemScanData { * @param {import('./LibraryScan')} libraryScan */ async checkLibraryItemData(existingLibraryItem, libraryScan) { - const keysToCompare = ['libraryFolderId', 'ino', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'path', 'relPath', 'isFile'] + const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile'] this.hasChanges = false this.hasPathChange = false for (const key of keysToCompare) { @@ -61,6 +92,23 @@ class LibraryItemScanData { } } + // Check mtime, ctime and birthtime + if (existingLibraryItem.mtime.valueOf() !== this.mtimeMs) { + libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "mtime" changed from "${existingLibraryItem.mtime.valueOf()}" to "${this.mtimeMs}"`) + existingLibraryItem.mtime = this.mtimeMs + this.hasChanges = true + } + if (existingLibraryItem.birthtime.valueOf() !== this.birthtimeMs) { + libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "birthtime" changed from "${existingLibraryItem.birthtime.valueOf()}" to "${this.birthtimeMs}"`) + existingLibraryItem.birthtime = this.birthtimeMs + this.hasChanges = true + } + if (existingLibraryItem.ctime.valueOf() !== this.ctimeMs) { + libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "ctime" changed from "${existingLibraryItem.ctime.valueOf()}" to "${this.ctimeMs}"`) + existingLibraryItem.ctime = this.ctimeMs + this.hasChanges = true + } + this.libraryFilesRemoved = [] this.libraryFilesModified = [] let libraryFilesAdded = this.libraryFiles.map(lf => lf) @@ -98,15 +146,24 @@ class LibraryItemScanData { } } + this.libraryFilesAdded = libraryFilesAdded + if (this.hasChanges) { + existingLibraryItem.size = 0 + existingLibraryItem.libraryFiles.forEach((lf) => existingLibraryItem.size += lf.metadata.size) + existingLibraryItem.lastScan = Date.now() existingLibraryItem.lastScanVersion = packageJson.version + + libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.path}" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`) + + if (this.hasLibraryFileChanges) { + existingLibraryItem.changed('libraryFiles', true) + } await existingLibraryItem.save() } else { libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.path}" is up-to-date`) } - - this.libraryFilesAdded = libraryFilesAdded } /** @@ -126,6 +183,10 @@ class LibraryItemScanData { } for (const key in existingLibraryFile.metadata) { + if (existingLibraryFile.metadata.relPath === 'metadata.json' || existingLibraryFile.metadata.relPath === 'metadata.abs') { + if (key === 'mtimeMs' || key === 'size') continue + } + if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) { if (key !== 'path' && key !== 'relPath') { libraryScan.addLog(LogLevel.DEBUG, `Library file "${existingLibraryFile.metadata.path}" for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`) @@ -143,5 +204,20 @@ class LibraryItemScanData { return hasChanges } + + /** + * Check if existing audio file on Book was removed + * @param {import('../models/Book').AudioFileObject} existingAudioFile + * @returns {boolean} true if audio file was removed + */ + checkAudioFileRemoved(existingAudioFile) { + if (!this.audioLibraryFilesRemoved.length) return false + // First check exact path + if (this.audioLibraryFilesRemoved.some(af => af.metadata.path === existingAudioFile.metadata.path)) { + return true + } + // Fallback to check inode value + return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino) + } } module.exports = LibraryItemScanData \ No newline at end of file diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 4ca08e7551..5cccfaa44c 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -7,7 +7,12 @@ const fs = require('../libs/fsExtra') const fileUtils = require('../utils/fileUtils') const scanUtils = require('../utils/scandir') const { ScanResult, LogLevel } = require('../utils/constants') +const AudioFileScanner = require('./AudioFileScanner') +const ScanOptions = require('./ScanOptions') +const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') +const AudioFile = require('../objects/files/AudioFile') +const Book = require('../models/Book') class LibraryScanner { constructor(coverManager, taskManager) { @@ -102,7 +107,7 @@ class LibraryScanner { where: { libraryId: libraryScan.libraryId }, - attributes: ['id', 'mediaId', 'mediaType', 'path', 'relPath', 'ino', 'isMissing', 'mtime', 'ctime', 'birthtime', 'libraryFiles', 'libraryFolderId'] + attributes: ['id', 'mediaId', 'mediaType', 'path', 'relPath', 'ino', 'isMissing', 'isFile', 'mtime', 'ctime', 'birthtime', 'libraryFiles', 'libraryFolderId', 'size'] }) const libraryItemIdsMissing = [] @@ -129,8 +134,8 @@ class LibraryScanner { } } else { await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan) - if (libraryItemData.hasChanges) { - await this.rescanLibraryItem(existingLibraryItem, libraryItemData) + if (libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) { + await this.rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan) } } } @@ -222,9 +227,92 @@ class LibraryScanner { * * @param {import('../models/LibraryItem')} existingLibraryItem * @param {LibraryItemScanData} libraryItemData + * @param {LibraryScan} libraryScan */ - async rescanLibraryItem(existingLibraryItem, libraryItemData) { + async rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan) { + + if (existingLibraryItem.mediaType === 'book') { + /** @type {Book} */ + const media = await existingLibraryItem.getMedia({ + include: [ + { + model: Database.authorModel, + through: { + attributes: ['createdAt'] + } + }, + { + model: Database.seriesModel, + through: { + attributes: ['sequence', 'createdAt'] + } + } + ] + }) + + let hasMediaChanges = libraryItemData.hasAudioFileChanges + if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== media.audioFiles.length) { + // Filter out audio files that were removed + media.audioFiles = media.audioFiles.filter(af => libraryItemData.checkAudioFileRemoved(af)) + + // Update audio files that were modified + if (libraryItemData.audioLibraryFilesModified.length) { + let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified) + media.audioFiles = media.audioFiles.map((audioFileObj) => { + let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === audioFileObj.metadata.path) + if (!matchedScannedAudioFile) { + matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === audioFileObj.ino) + } + + if (matchedScannedAudioFile) { + scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile) + const audioFile = new AudioFile(audioFileObj) + audioFile.updateFromScan(matchedScannedAudioFile) + return audioFile.toJSON() + } + return audioFileObj + }) + // Modified audio files that were not found on the book + if (scannedAudioFiles.length) { + media.audioFiles.push(...scannedAudioFiles) + } + } + + // Add new audio files scanned in + if (libraryItemData.audioLibraryFilesAdded.length) { + const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded) + media.audioFiles.push(...scannedAudioFiles) + } + // Add audio library files that are not already set on the book (safety check) + let audioLibraryFilesToAdd = [] + for (const audioLibraryFile of libraryItemData.audioLibraryFiles) { + if (!media.audioFiles.some(af => af.ino === audioLibraryFile.ino)) { + libraryScan.addLog(LogLevel.DEBUG, `Existing audio library file "${audioLibraryFile.metadata.relPath}" was not set on book "${media.title}" so setting it now`) + audioLibraryFilesToAdd.push(audioLibraryFile) + } + } + if (audioLibraryFilesToAdd.length) { + const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, audioLibraryFilesToAdd) + media.audioFiles.push(...scannedAudioFiles) + } + + media.audioFiles = AudioFileScanner.runSmartTrackOrder(media, media.audioFiles) + + media.duration = 0 + media.audioFiles.forEach((af) => { + if (!isNaN(af.duration)) { + media.duration += af.duration + } + }) + + media.changed('audioFiles', true) + } + + if (hasMediaChanges) { + await media.save() + } + } } } module.exports = LibraryScanner \ No newline at end of file diff --git a/server/utils/prober.js b/server/utils/prober.js index 17aecfbd95..8e29305365 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -278,7 +278,12 @@ function parseProbeData(data, verbose = false) { } } -// Updated probe returns MediaProbeData object +/** + * Run ffprobe on audio filepath + * @param {string} filepath + * @param {boolean} [verbose=false] + * @returns {import('../scanner/MediaProbeData')|{error:string}} + */ function probe(filepath, verbose = false) { if (process.env.FFPROBE_PATH) { ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH