From 4e92ea3992ec8789e76750caaa7b03022b89ef6e Mon Sep 17 00:00:00 2001 From: Mark Cooper <mcoop320@gmail.com> Date: Fri, 10 Sep 2021 19:55:02 -0500 Subject: [PATCH] Update scanner v3, add isActive support for users --- client/components/app/BookShelf.vue | 2 +- client/package.json | 2 +- client/pages/config/index.vue | 2 +- client/store/audiobooks.js | 5 +- package.json | 2 +- server/Auth.js | 8 ++ server/Scanner.js | 80 +++++++++--- server/Server.js | 22 +--- server/Watcher.js | 74 +++++------ server/objects/User.js | 6 +- server/utils/audioFileScanner.js | 1 - server/utils/index.js | 2 +- server/utils/scandir.js | 184 +++++++++++++++++----------- 13 files changed, 230 insertions(+), 160 deletions(-) diff --git a/client/components/app/BookShelf.vue b/client/components/app/BookShelf.vue index 7337f040..6cd7fdad 100644 --- a/client/components/app/BookShelf.vue +++ b/client/components/app/BookShelf.vue @@ -13,7 +13,7 @@ <p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p> <ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn> </div> - <div class="w-full flex flex-col items-center"> + <div v-else class="w-full flex flex-col items-center"> <template v-for="(shelf, index) in groupedBooks"> <div :key="index" class="w-full bookshelfRow relative"> <div class="flex justify-center items-center"> diff --git a/client/package.json b/client/package.json index 33f800c7..7bec6973 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "1.1.2", + "version": "1.1.3", "description": "Audiobook manager and player", "main": "index.js", "scripts": { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 2e8c131b..30d2d658 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -17,7 +17,7 @@ <th style="width: 200px">Created At</th> <th style="width: 100px"></th> </tr> - <tr v-for="user in users" :key="user.id"> + <tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'"> <td> {{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span> </td> diff --git a/client/store/audiobooks.js b/client/store/audiobooks.js index 18c7fed7..df58166e 100644 --- a/client/store/audiobooks.js +++ b/client/store/audiobooks.js @@ -1,8 +1,6 @@ import { sort } from '@/assets/fastSort' import { decode } from '@/plugins/init.client' -// const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] - const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens', 'Comedy', 'Crime', 'Dystopian', 'Fantasy', 'Fiction', 'Health', 'History', 'Horror', 'Mystery', 'New Adult', 'Nonfiction', 'Philosophy', 'Politics', 'Religion', 'Romance', 'Sci-Fi', 'Self-Help', 'Short Story', 'Technology', 'Thriller', 'True Crime', 'Western', 'Young Adult'] export const state = () => ({ @@ -31,9 +29,10 @@ export const getters = { } if (state.keywordFilter) { const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator'] + const keyworkFilter = state.keywordFilter.toLowerCase() return filtered.filter(ab => { if (!ab.book) return false - return !!keywordFilterKeys.find(key => (ab.book[key] && ab.book[key].includes(state.keywordFilter))) + return !!keywordFilterKeys.find(key => (ab.book[key] && ab.book[key].toLowerCase().includes(keyworkFilter))) }) } return filtered diff --git a/package.json b/package.json index fc636e87..6457d602 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "1.1.2", + "version": "1.1.3", "description": "Self-hosted audiobook server for managing and playing audiobooks.", "main": "index.js", "scripts": { diff --git a/server/Auth.js b/server/Auth.js index b95ad6a7..fbd4de40 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -48,6 +48,10 @@ class Auth { var user = await this.verifyToken(token) if (!user) { Logger.error('Verify Token User Not Found', token) + return res.sendStatus(404) + } + if (!user.isActive) { + Logger.error('Verify Token User is disabled', token, user.username) return res.sendStatus(403) } req.user = user @@ -95,6 +99,10 @@ class Auth { return res.json({ error: 'User not found' }) } + if (!user.isActive) { + return res.json({ error: 'User unavailable' }) + } + // Check passwordless root user if (user.id === 'root' && (!user.pash || user.pash === '')) { if (password) { diff --git a/server/Scanner.js b/server/Scanner.js index 7d204be9..18e1f6b3 100644 --- a/server/Scanner.js +++ b/server/Scanner.js @@ -1,9 +1,10 @@ const fs = require('fs-extra') +const Path = require('path') const Logger = require('./Logger') const BookFinder = require('./BookFinder') const Audiobook = require('./objects/Audiobook') const audioFileScanner = require('./utils/audioFileScanner') -const { getAllAudiobookFileData, getAudiobookFileData } = require('./utils/scandir') +const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir') const { comparePaths, getIno } = require('./utils/index') const { secondsToTimestamp } = require('./utils/fileUtils') const { ScanResult } = require('./utils/constants') @@ -191,7 +192,7 @@ class Scanner { } const scanStart = Date.now() - var audiobookDataFound = await getAllAudiobookFileData(this.AudiobookPath, this.db.serverSettings) + var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings) // Set ino for each ab data as a string audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound) @@ -251,20 +252,7 @@ class Scanner { } async scanAudiobook(audiobookPath) { - var exists = await fs.pathExists(audiobookPath) - if (!exists) { - // Audiobook was deleted, TODO: Should confirm this better - var audiobook = this.db.audiobooks.find(ab => ab.fullPath === audiobookPath) - if (audiobook) { - var audiobookJSON = audiobook.toJSONMinified() - await this.db.removeEntity('audiobook', audiobook.id) - this.emitter('audiobook_removed', audiobookJSON) - return ScanResult.REMOVED - } - Logger.warn('Path was deleted but no audiobook found', audiobookPath) - return ScanResult.NOTHING - } - + Logger.debug('[Scanner] scanAudiobook', audiobookPath) var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings) if (!audiobookData) { return ScanResult.NOTHING @@ -273,6 +261,66 @@ class Scanner { return this.scanAudiobookData(audiobookData) } + // Files were modified in this directory, check it out + async checkDir(dir) { + var exists = await fs.pathExists(dir) + if (!exists) { + // Audiobook was deleted, TODO: Should confirm this better + var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir) + if (audiobook) { + var audiobookJSON = audiobook.toJSONMinified() + await this.db.removeEntity('audiobook', audiobook.id) + this.emitter('audiobook_removed', audiobookJSON) + return ScanResult.REMOVED + } + + // Path inside audiobook was deleted, scan audiobook + audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath)) + if (audiobook) { + Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`) + return this.scanAudiobook(audiobook.fullPath) + } + + Logger.warn('[Scanner] Path was deleted but no audiobook found', dir) + return ScanResult.NOTHING + } + + // Check if this is a subdirectory of an audiobook + var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath)) + if (audiobook) { + Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`) + return this.scanAudiobook(audiobook.fullPath) + } + + // Check if an audiobook is a subdirectory of this dir + audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir)) + if (audiobook) { + Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`) + return ScanResult.NOTHING + } + + // Must be a new audiobook + Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`) + return this.scanAudiobook(dir) + } + + // Array of files that may have been renamed, removed or added + async filesChanged(filepaths) { + if (!filepaths.length) return ScanResult.NOTHING + var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, '')) + var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths) + + var results = [] + for (const dir in fileGroupings) { + Logger.debug(`[Scanner] Check dir ${dir}`) + var fullPath = Path.join(this.AudiobookPath, dir) + var result = await this.checkDir(fullPath) + Logger.debug(`[Scanner] Check dir result ${result}`) + results.push(result) + } + return results + } + async fetchMetadata(id, trackIndex = 0) { var audiobook = this.audiobooks.find(a => a.id === id) if (!audiobook) { diff --git a/server/Server.js b/server/Server.js index 122d48e8..353f2a32 100644 --- a/server/Server.js +++ b/server/Server.js @@ -75,20 +75,10 @@ class Server { }) } - async newFilesAdded({ dir, files }) { - Logger.info(files.length, 'New Files Added in dir', dir) - var result = await this.scanner.scanAudiobook(dir) - Logger.info('New Files Added result', result) - } - async filesRemoved({ dir, files }) { - Logger.info(files.length, 'Files Removed in dir', dir) - var result = await this.scanner.scanAudiobook(dir) - Logger.info('Files Removed result', result) - } - async filesRenamed({ dir, files }) { - Logger.info(files.length, 'Files Renamed in dir', dir) - var result = await this.scanner.scanAudiobook(dir) - Logger.info('Files Renamed result', result) + async filesChanged(files) { + Logger.info('[Server]', files.length, 'Files Changed') + var result = await this.scanner.filesChanged(files) + Logger.info('[Server] Files changed result', result) } async scan() { @@ -125,9 +115,7 @@ class Server { this.auth.init() this.watcher.initWatcher() - this.watcher.on('new_files', this.newFilesAdded.bind(this)) - this.watcher.on('removed_files', this.filesRemoved.bind(this)) - this.watcher.on('renamed_files', this.filesRenamed.bind(this)) + this.watcher.on('files', this.filesChanged.bind(this)) } authMiddleware(req, res, next) { diff --git a/server/Watcher.js b/server/Watcher.js index 3ee7e223..c6b9bcc9 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -2,7 +2,6 @@ const Path = require('path') const EventEmitter = require('events') const Watcher = require('watcher') const Logger = require('./Logger') -const { getIno } = require('./utils/index') class FolderWatcher extends EventEmitter { constructor(audiobookPath) { @@ -11,10 +10,9 @@ class FolderWatcher extends EventEmitter { this.folderMap = {} this.watcher = null - this.pendingBatchDelay = 4000 - - // Audiobook paths with changes - this.pendingBatch = {} + this.pendingFiles = [] + this.pendingDelay = 4000 + this.pendingTimeout = null } initWatcher() { @@ -46,7 +44,6 @@ class FolderWatcher extends EventEmitter { } catch (error) { Logger.error('Chokidar watcher failed', error) } - } close() { @@ -55,43 +52,39 @@ class FolderWatcher extends EventEmitter { // After [pendingBatchDelay] seconds emit batch async onNewFile(path) { + if (this.pendingFiles.includes(path)) return + Logger.debug('FolderWatcher: New File', path) var dir = Path.dirname(path) - if (this.pendingBatch[dir]) { - this.pendingBatch[dir].files.push(path) - clearTimeout(this.pendingBatch[dir].timeout) - } else { - this.pendingBatch[dir] = { - dir, - files: [path] - } + if (dir === this.AudiobookPath) { + Logger.debug('New File added to root dir, ignoring it') + return } - this.pendingBatch[dir].timeout = setTimeout(() => { - this.emit('new_files', this.pendingBatch[dir]) - delete this.pendingBatch[dir] - }, this.pendingBatchDelay) + this.pendingFiles.push(path) + clearTimeout(this.pendingTimeout) + this.pendingTimeout = setTimeout(() => { + this.emit('files', this.pendingFiles.map(f => f)) + this.pendingFiles = [] + }, this.pendingDelay) } onFileRemoved(path) { Logger.debug('[FolderWatcher] File Removed', path) var dir = Path.dirname(path) - if (this.pendingBatch[dir]) { - this.pendingBatch[dir].files.push(path) - clearTimeout(this.pendingBatch[dir].timeout) - } else { - this.pendingBatch[dir] = { - dir, - files: [path] - } + if (dir === this.AudiobookPath) { + Logger.debug('New File added to root dir, ignoring it') + return } - this.pendingBatch[dir].timeout = setTimeout(() => { - this.emit('removed_files', this.pendingBatch[dir]) - delete this.pendingBatch[dir] - }, this.pendingBatchDelay) + this.pendingFiles.push(path) + clearTimeout(this.pendingTimeout) + this.pendingTimeout = setTimeout(() => { + this.emit('files', this.pendingFiles.map(f => f)) + this.pendingFiles = [] + }, this.pendingDelay) } onFileUpdated(path) { @@ -102,20 +95,17 @@ class FolderWatcher extends EventEmitter { Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`) var dir = Path.dirname(pathTo) - if (this.pendingBatch[dir]) { - this.pendingBatch[dir].files.push(pathTo) - clearTimeout(this.pendingBatch[dir].timeout) - } else { - this.pendingBatch[dir] = { - dir, - files: [pathTo] - } + if (dir === this.AudiobookPath) { + Logger.debug('New File added to root dir, ignoring it') + return } - this.pendingBatch[dir].timeout = setTimeout(() => { - this.emit('renamed_files', this.pendingBatch[dir]) - delete this.pendingBatch[dir] - }, this.pendingBatchDelay) + this.pendingFiles.push(pathTo) + clearTimeout(this.pendingTimeout) + this.pendingTimeout = setTimeout(() => { + this.emit('files', this.pendingFiles.map(f => f)) + this.pendingFiles = [] + }, this.pendingDelay) } } module.exports = FolderWatcher \ No newline at end of file diff --git a/server/objects/User.js b/server/objects/User.js index 790102e4..0486f5d8 100644 --- a/server/objects/User.js +++ b/server/objects/User.js @@ -24,13 +24,13 @@ class User { return this.type === 'root' } get canDelete() { - return !!this.permissions.delete + return !!this.permissions.delete && this.isActive } get canUpdate() { - return !!this.permissions.update + return !!this.permissions.update && this.isActive } get canDownload() { - return !!this.permissions.download + return !!this.permissions.download && this.isActive } getDefaultUserSettings() { diff --git a/server/utils/audioFileScanner.js b/server/utils/audioFileScanner.js index 20617ac7..eed77650 100644 --- a/server/utils/audioFileScanner.js +++ b/server/utils/audioFileScanner.js @@ -91,7 +91,6 @@ async function scanAudioFiles(audiobook, newAudioFiles) { var tracks = [] for (let i = 0; i < newAudioFiles.length; i++) { var audioFile = newAudioFiles[i] - var scanData = await scan(audioFile.fullPath) if (!scanData || scanData.error) { Logger.error('[AudioFileScanner] Scan failed for', audioFile.path) diff --git a/server/utils/index.js b/server/utils/index.js index 5ea10706..d5bfd088 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -59,7 +59,7 @@ module.exports.comparePaths = (path1, path2) => { module.exports.getIno = (path) => { return fs.promises.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => { - Logger.error('[Utils] Failed to get ino for path', path, error) + Logger.error('[Utils] Failed to get ino for path', path, err) return null }) } \ No newline at end of file diff --git a/server/utils/scandir.js b/server/utils/scandir.js index bae3aafb..7c7054b7 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -1,7 +1,6 @@ const Path = require('path') const dir = require('node-dir') const Logger = require('../Logger') -const { cleanString } = require('./index') const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a'] const INFO_FORMATS = ['nfo'] @@ -12,7 +11,7 @@ function getPaths(path) { return new Promise((resolve) => { dir.paths(path, function (err, res) { if (err) { - console.error(err) + Logger.error(err) resolve(false) } resolve(res) @@ -20,6 +19,54 @@ function getPaths(path) { }) } +function groupFilesIntoAudiobookPaths(paths) { + // Step 1: Normalize path, Remove leading "/", Filter out files in root dir + var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir) + + + // Step 2: Sort by least number of directories + pathsFiltered.sort((a, b) => { + var pathsA = Path.dirname(a).split(Path.sep).length + var pathsB = Path.dirname(b).split(Path.sep).length + return pathsA - pathsB + }) + + // Step 3: Group into audiobooks + var audiobookGroup = {} + pathsFiltered.forEach((path) => { + var dirparts = Path.dirname(path).split(Path.sep) + var numparts = dirparts.length + var _path = '' + for (let i = 0; i < numparts; i++) { + var dirpart = dirparts.shift() + _path = Path.join(_path, dirpart) + if (audiobookGroup[_path]) { + var relpath = Path.join(dirparts.join(Path.sep), Path.basename(path)) + audiobookGroup[_path].push(relpath) + return + } else if (!dirparts.length) { + audiobookGroup[_path] = [Path.basename(path)] + return + } + } + }) + return audiobookGroup +} +module.exports.groupFilesIntoAudiobookPaths = groupFilesIntoAudiobookPaths + +function cleanFileObjects(basepath, abrelpath, files) { + return files.map((file) => { + var ext = Path.extname(file) + return { + filetype: getFileType(ext), + filename: Path.basename(file), + path: Path.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3 + fullPath: Path.join(basepath, file), // /audiobooks/AUDIOBOOK/PATH/filename.mp3 + ext: ext + } + }) +} + function getFileType(ext) { var ext_cleaned = ext.toLowerCase() if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1) @@ -30,27 +77,53 @@ function getFileType(ext) { return 'unknown' } -// Input relative filepath, output all details that can be parsed -function getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle = false) { - var pathformat = Path.parse(relpath) - var path = pathformat.dir +// Primary scan: abRootPath is /audiobooks +async function scanRootDir(abRootPath, serverSettings = {}) { + var parseSubtitle = !!serverSettings.scannerParseSubtitle - if (!path) { - Logger.error('Ignoring file in root dir', relpath) - return null + var pathdata = await getPaths(abRootPath) + var filepaths = pathdata.files.map(filepath => { + return Path.normalize(filepath).replace(abRootPath, '') + }) + + var audiobookGrouping = groupFilesIntoAudiobookPaths(filepaths) + + if (!Object.keys(audiobookGrouping).length) { + Logger.error('Root path has no audiobooks') + return [] } - // If relative file directory has 3 folders, then the middle folder will be series - var splitDir = path.split(Path.sep) - var author = null - if (splitDir.length > 1) author = splitDir.shift() + var audiobooks = [] + for (const audiobookPath in audiobookGrouping) { + var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle) + + var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath]) + audiobooks.push({ + ...audiobookData, + audioFiles: fileObjs.filter(f => f.filetype === 'audio'), + otherFiles: fileObjs.filter(f => f.filetype !== 'audio') + }) + } + return audiobooks +} +module.exports.scanRootDir = scanRootDir + +// Input relative filepath, output all details that can be parsed +function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) { + var splitDir = dir.split(Path.sep) + + // Audio files will always be in the directory named for the title + var title = splitDir.pop() var series = null - if (splitDir.length > 1) series = splitDir.shift() - var title = splitDir.shift() + var author = null + // If there are at least 2 more directories, next furthest will be the series + if (splitDir.length > 1) series = splitDir.pop() + if (splitDir.length > 0) author = splitDir.pop() + + // There could be many more directories, but only the top 3 are used for naming /author/series/title/ + var publishYear = null - var subtitle = null - // If Title is of format 1999 - Title, then use 1999 as publish year var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/) if (publishYearMatch && publishYearMatch.length > 2) { @@ -60,6 +133,8 @@ function getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle = false } } + // Subtitle can be parsed from the title if user enabled + var subtitle = null if (parseSubtitle && title.includes(' - ')) { var splitOnSubtitle = title.split(' - ') title = splitOnSubtitle.shift() @@ -72,71 +147,34 @@ function getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle = false subtitle, series, publishYear, - path, // relative audiobook path i.e. /Author Name/Book Name/.. - fullPath: Path.join(abRootPath, path) // i.e. /audiobook/Author Name/Book Name/.. + path: dir, // relative audiobook path i.e. /Author Name/Book Name/.. + fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/.. } } -async function getAllAudiobookFileData(abRootPath, serverSettings = {}) { - var parseSubtitle = !!serverSettings.scannerParseSubtitle - - var paths = await getPaths(abRootPath) - var audiobooks = {} - - paths.files.forEach((filepath) => { - var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1) - var parsed = Path.parse(relpath) - var path = parsed.dir - - if (!audiobooks[path]) { - var audiobookData = getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle) - if (!audiobookData) return - - audiobooks[path] = { - ...audiobookData, - audioFiles: [], - otherFiles: [] - } - } - - var fileObj = { - filetype: getFileType(parsed.ext), - filename: parsed.base, - path: relpath, - fullPath: filepath, - ext: parsed.ext - } - if (fileObj.filetype === 'audio') { - audiobooks[path].audioFiles.push(fileObj) - } else { - audiobooks[path].otherFiles.push(fileObj) - } - }) - return Object.values(audiobooks) -} -module.exports.getAllAudiobookFileData = getAllAudiobookFileData - - async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings = {}) { var parseSubtitle = !!serverSettings.scannerParseSubtitle var paths = await getPaths(audiobookPath) - var audiobook = null + var filepaths = paths.files - paths.files.forEach((filepath) => { + // Sort by least number of directories + filepaths.sort((a, b) => { + var pathsA = Path.dirname(a).split(Path.sep).length + var pathsB = Path.dirname(b).split(Path.sep).length + return pathsA - pathsB + }) + + var audiobookDir = Path.normalize(audiobookPath).replace(abRootPath, '').slice(1) + var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookDir, parseSubtitle) + var audiobook = { + ...audiobookData, + audioFiles: [], + otherFiles: [] + } + + filepaths.forEach((filepath) => { var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1) - - if (!audiobook) { - var audiobookData = getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle) - if (!audiobookData) return - - audiobook = { - ...audiobookData, - audioFiles: [], - otherFiles: [] - } - } - var extname = Path.extname(filepath) var basename = Path.basename(filepath) var fileObj = {