mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-25 21:29:37 +00:00
Support for libraries and folder mapping, updating static cover path, detect reader.txt
This commit is contained in:
parent
a590e795e3
commit
577f3bead9
43 changed files with 2548 additions and 768 deletions
|
|
@ -4,9 +4,10 @@ const fs = require('fs-extra')
|
|||
const Logger = require('./Logger')
|
||||
const User = require('./objects/User')
|
||||
const { isObject } = require('./utils/index')
|
||||
const Library = require('./objects/Library')
|
||||
|
||||
class ApiController {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, emitter, clientEmitter) {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, watcher, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
|
|
@ -14,6 +15,7 @@ class ApiController {
|
|||
this.rssFeeds = rssFeeds
|
||||
this.downloadManager = downloadManager
|
||||
this.coverController = coverController
|
||||
this.watcher = watcher
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
this.MetadataPath = MetadataPath
|
||||
|
|
@ -26,7 +28,14 @@ class ApiController {
|
|||
this.router.get('/find/covers', this.findCovers.bind(this))
|
||||
this.router.get('/find/:method', this.find.bind(this))
|
||||
|
||||
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
|
||||
this.router.get('/libraries', this.getLibraries.bind(this))
|
||||
this.router.get('/library/:id', this.getLibrary.bind(this))
|
||||
this.router.delete('/library/:id', this.deleteLibrary.bind(this))
|
||||
this.router.patch('/library/:id', this.updateLibrary.bind(this))
|
||||
this.router.get('/library/:id/audiobooks', this.getLibraryAudiobooks.bind(this))
|
||||
this.router.post('/library', this.createNewLibrary.bind(this))
|
||||
|
||||
this.router.get('/audiobooks', this.getAudiobooks.bind(this)) // Old route should pass library id
|
||||
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
|
||||
|
|
@ -59,6 +68,8 @@ class ApiController {
|
|||
this.router.post('/feed', this.openRssFeed.bind(this))
|
||||
|
||||
this.router.get('/download/:id', this.download.bind(this))
|
||||
|
||||
this.router.get('/filesystem', this.getFileSystemPaths.bind(this))
|
||||
}
|
||||
|
||||
find(req, res) {
|
||||
|
|
@ -77,6 +88,102 @@ class ApiController {
|
|||
res.json({ user: req.user })
|
||||
}
|
||||
|
||||
getLibraries(req, res) {
|
||||
var libraries = this.db.libraries.map(lib => lib.toJSON())
|
||||
res.json(libraries)
|
||||
}
|
||||
|
||||
getLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
return res.json(library.toJSON())
|
||||
}
|
||||
|
||||
async deleteLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
|
||||
// Remove library watcher
|
||||
this.watcher.removeLibrary(library)
|
||||
|
||||
// Remove audiobooks in this library
|
||||
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
||||
Logger.info(`[Server] deleting library "${library.name}" with ${audiobooks.length} audiobooks"`)
|
||||
for (let i = 0; i < audiobooks.length; i++) {
|
||||
await this.handleDeleteAudiobook(audiobooks[i])
|
||||
}
|
||||
|
||||
var libraryJson = library.toJSON()
|
||||
await this.db.removeEntity('library', library.id)
|
||||
this.emitter('library_removed', libraryJson)
|
||||
return res.json(libraryJson)
|
||||
}
|
||||
|
||||
async updateLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
var hasUpdates = library.update(req.body)
|
||||
if (hasUpdates) {
|
||||
// Update watcher
|
||||
this.watcher.updateLibrary(library)
|
||||
|
||||
// Remove audiobooks no longer in library
|
||||
var audiobooksToRemove = this.db.audiobooks.filter(ab => !library.checkFullPathInLibrary(ab.fullPath))
|
||||
if (audiobooksToRemove.length) {
|
||||
Logger.info(`[Scanner] Updating library, removing ${audiobooksToRemove.length} audiobooks`)
|
||||
for (let i = 0; i < audiobooksToRemove.length; i++) {
|
||||
await this.handleDeleteAudiobook(audiobooksToRemove[i])
|
||||
}
|
||||
}
|
||||
await this.db.updateEntity('library', library)
|
||||
this.emitter('library_updated', library.toJSON())
|
||||
}
|
||||
return res.json(library.toJSON())
|
||||
}
|
||||
|
||||
getLibraryAudiobooks(req, res) {
|
||||
var libraryId = req.params.id
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
return res.status(400).send('Library does not exist')
|
||||
}
|
||||
|
||||
var audiobooks = []
|
||||
if (req.query.q) {
|
||||
audiobooks = this.db.audiobooks.filter(ab => {
|
||||
return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q)
|
||||
}).map(ab => ab.toJSONMinified())
|
||||
} else {
|
||||
audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified())
|
||||
}
|
||||
res.json(audiobooks)
|
||||
}
|
||||
|
||||
async createNewLibrary(req, res) {
|
||||
var newLibraryPayload = {
|
||||
...req.body
|
||||
}
|
||||
if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) {
|
||||
return res.status(500).send('Invalid request')
|
||||
}
|
||||
|
||||
var library = new Library()
|
||||
library.setData(newLibraryPayload)
|
||||
await this.db.insertEntity('library', library)
|
||||
this.emitter('library_added', library.toJSON())
|
||||
|
||||
// Add library watcher
|
||||
this.watcher.addLibrary(library)
|
||||
|
||||
res.json(library)
|
||||
}
|
||||
|
||||
getAudiobooks(req, res) {
|
||||
var audiobooks = []
|
||||
if (req.query.q) {
|
||||
|
|
@ -370,7 +477,7 @@ class ApiController {
|
|||
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
||||
account.createdAt = Date.now()
|
||||
var newUser = new User(account)
|
||||
var success = await this.db.insertUser(newUser)
|
||||
var success = await this.db.insertEntity('user', newUser)
|
||||
if (success) {
|
||||
this.clientEmitter(req.user.id, 'user_added', newUser)
|
||||
res.json({
|
||||
|
|
@ -492,5 +599,49 @@ class ApiController {
|
|||
genres: this.db.getGenres()
|
||||
})
|
||||
}
|
||||
|
||||
async getDirectories(dir, relpath, excludedDirs, level = 0) {
|
||||
try {
|
||||
var paths = await fs.readdir(dir)
|
||||
|
||||
var dirs = await Promise.all(paths.map(async dirname => {
|
||||
var fullPath = Path.join(dir, dirname)
|
||||
var path = Path.join(relpath, dirname)
|
||||
|
||||
var isDir = (await fs.lstat(fullPath)).isDirectory()
|
||||
if (isDir && !excludedDirs.includes(dirname)) {
|
||||
return {
|
||||
path,
|
||||
dirname,
|
||||
fullPath,
|
||||
level,
|
||||
dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : []
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}))
|
||||
dirs = dirs.filter(d => d)
|
||||
return dirs
|
||||
} catch (error) {
|
||||
Logger.error('Failed to readdir', dir, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getFileSystemPaths(req, res) {
|
||||
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc']
|
||||
|
||||
// Do not include existing mapped library paths in response
|
||||
this.db.libraries.forEach(lib => {
|
||||
lib.folders.forEach((folder) => {
|
||||
excludedDirs.push(Path.basename(folder.fullPath))
|
||||
})
|
||||
})
|
||||
|
||||
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
|
||||
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
|
||||
res.json(dirs)
|
||||
}
|
||||
}
|
||||
module.exports = ApiController
|
||||
|
|
@ -20,7 +20,7 @@ class CoverController {
|
|||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: Path.join('/local', audiobook.path)
|
||||
relPath: '/s/book/' + audiobook.id
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
|
|
|
|||
132
server/Db.js
132
server/Db.js
|
|
@ -4,20 +4,25 @@ const jwt = require('jsonwebtoken')
|
|||
const Logger = require('./Logger')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const User = require('./objects/User')
|
||||
const Library = require('./objects/Library')
|
||||
const ServerSettings = require('./objects/ServerSettings')
|
||||
|
||||
class Db {
|
||||
constructor(CONFIG_PATH) {
|
||||
this.ConfigPath = CONFIG_PATH
|
||||
this.AudiobooksPath = Path.join(CONFIG_PATH, 'audiobooks')
|
||||
this.UsersPath = Path.join(CONFIG_PATH, 'users')
|
||||
this.SettingsPath = Path.join(CONFIG_PATH, 'settings')
|
||||
constructor(ConfigPath, AudiobookPath) {
|
||||
this.ConfigPath = ConfigPath
|
||||
this.AudiobookPath = AudiobookPath
|
||||
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
|
||||
this.UsersPath = Path.join(ConfigPath, 'users')
|
||||
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
|
||||
this.SettingsPath = Path.join(ConfigPath, 'settings')
|
||||
|
||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||
this.usersDb = new njodb.Database(this.UsersPath)
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
||||
|
||||
this.users = []
|
||||
this.libraries = []
|
||||
this.audiobooks = []
|
||||
this.settings = []
|
||||
|
||||
|
|
@ -27,18 +32,14 @@ class Db {
|
|||
getEntityDb(entityName) {
|
||||
if (entityName === 'user') return this.usersDb
|
||||
else if (entityName === 'audiobook') return this.audiobooksDb
|
||||
else if (entityName === 'library') return this.librariesDb
|
||||
return this.settingsDb
|
||||
}
|
||||
|
||||
getEntityDbKey(entityName) {
|
||||
if (entityName === 'user') return 'usersDb'
|
||||
else if (entityName === 'audiobook') return 'audiobooksDb'
|
||||
return 'settingsDb'
|
||||
}
|
||||
|
||||
getEntityArrayKey(entityName) {
|
||||
if (entityName === 'user') return 'users'
|
||||
else if (entityName === 'audiobook') return 'audiobooks'
|
||||
else if (entityName === 'library') return 'libraries'
|
||||
return 'settings'
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +47,6 @@ class Db {
|
|||
return new User({
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
|
||||
username: 'root',
|
||||
pash: '',
|
||||
stream: null,
|
||||
|
|
@ -56,6 +56,20 @@ class Db {
|
|||
})
|
||||
}
|
||||
|
||||
getDefaultLibrary() {
|
||||
var defaultLibrary = new Library()
|
||||
defaultLibrary.setData({
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
folder: { // Generates default folder
|
||||
id: 'audiobooks',
|
||||
fullPath: this.AudiobookPath,
|
||||
libraryId: 'main'
|
||||
}
|
||||
})
|
||||
return defaultLibrary
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.load()
|
||||
|
||||
|
|
@ -63,25 +77,33 @@ class Db {
|
|||
if (!this.users.find(u => u.type === 'root')) {
|
||||
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||
Logger.debug('Generated default token', token)
|
||||
await this.insertUser(this.getDefaultUser(token))
|
||||
await this.insertEntity('user', this.getDefaultUser(token))
|
||||
}
|
||||
|
||||
if (!this.libraries.length) {
|
||||
await this.insertEntity('library', this.getDefaultLibrary())
|
||||
}
|
||||
|
||||
if (!this.serverSettings) {
|
||||
this.serverSettings = new ServerSettings()
|
||||
await this.insertSettings(this.serverSettings)
|
||||
await this.insertEntity('settings', this.serverSettings)
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
var p1 = this.audiobooksDb.select(() => true).then((results) => {
|
||||
this.audiobooks = results.data.map(a => new Audiobook(a))
|
||||
Logger.info(`[DB] Audiobooks Loaded ${this.audiobooks.length}`)
|
||||
Logger.info(`[DB] ${this.audiobooks.length} Audiobooks Loaded`)
|
||||
})
|
||||
var p2 = this.usersDb.select(() => true).then((results) => {
|
||||
this.users = results.data.map(u => new User(u))
|
||||
Logger.info(`[DB] Users Loaded ${this.users.length}`)
|
||||
Logger.info(`[DB] ${this.users.length} Users Loaded`)
|
||||
})
|
||||
var p3 = this.settingsDb.select(() => true).then((results) => {
|
||||
var p3 = this.librariesDb.select(() => true).then((results) => {
|
||||
this.libraries = results.data.map(l => new Library(l))
|
||||
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
|
||||
})
|
||||
var p4 = this.settingsDb.select(() => true).then((results) => {
|
||||
if (results.data && results.data.length) {
|
||||
this.settings = results.data
|
||||
var serverSettings = this.settings.find(s => s.id === 'server-settings')
|
||||
|
|
@ -90,30 +112,21 @@ class Db {
|
|||
}
|
||||
}
|
||||
})
|
||||
await Promise.all([p1, p2, p3])
|
||||
await Promise.all([p1, p2, p3, p4])
|
||||
}
|
||||
|
||||
insertSettings(settings) {
|
||||
return this.settingsDb.insert([settings]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted ${results.inserted} settings`)
|
||||
this.settings = this.settings.concat(settings)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert settings Failed ${error}`)
|
||||
})
|
||||
}
|
||||
// insertAudiobook(audiobook) {
|
||||
// return this.insertAudiobooks([audiobook])
|
||||
// }
|
||||
|
||||
insertAudiobook(audiobook) {
|
||||
return this.insertAudiobooks([audiobook])
|
||||
}
|
||||
|
||||
insertAudiobooks(audiobooks) {
|
||||
return this.audiobooksDb.insert(audiobooks).then((results) => {
|
||||
Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
|
||||
this.audiobooks = this.audiobooks.concat(audiobooks)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert audiobooks Failed ${error}`)
|
||||
})
|
||||
}
|
||||
// insertAudiobooks(audiobooks) {
|
||||
// return this.audiobooksDb.insert(audiobooks).then((results) => {
|
||||
// Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
|
||||
// this.audiobooks = this.audiobooks.concat(audiobooks)
|
||||
// }).catch((error) => {
|
||||
// Logger.error(`[DB] Insert audiobooks Failed ${error}`)
|
||||
// })
|
||||
// }
|
||||
|
||||
updateAudiobook(audiobook) {
|
||||
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||
|
|
@ -125,16 +138,25 @@ class Db {
|
|||
})
|
||||
}
|
||||
|
||||
insertUser(user) {
|
||||
return this.usersDb.insert([user]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
||||
this.users.push(user)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert user Failed ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
// insertUser(user) {
|
||||
// return this.usersDb.insert([user]).then((results) => {
|
||||
// Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
||||
// this.users.push(user)
|
||||
// return true
|
||||
// }).catch((error) => {
|
||||
// Logger.error(`[DB] Insert user Failed ${error}`)
|
||||
// return false
|
||||
// })
|
||||
// }
|
||||
|
||||
// insertSettings(settings) {
|
||||
// return this.settingsDb.insert([settings]).then((results) => {
|
||||
// Logger.debug(`[DB] Inserted ${results.inserted} settings`)
|
||||
// this.settings = this.settings.concat(settings)
|
||||
// }).catch((error) => {
|
||||
// Logger.error(`[DB] Insert settings Failed ${error}`)
|
||||
// })
|
||||
// }
|
||||
|
||||
updateUserStream(userId, streamId) {
|
||||
return this.usersDb.update((record) => record.id === userId, (user) => {
|
||||
|
|
@ -153,6 +175,20 @@ class Db {
|
|||
})
|
||||
}
|
||||
|
||||
insertEntity(entityName, entity) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.insert([entity]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
|
||||
|
||||
var arrayKey = this.getEntityArrayKey(entityName)
|
||||
this[arrayKey].push(entity)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Failed to insert ${entityName}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
updateEntity(entityName, entity) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
|
||||
// Utils
|
||||
const Logger = require('./Logger')
|
||||
const BookFinder = require('./BookFinder')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const { version } = require('../package.json')
|
||||
const audioFileScanner = require('./utils/audioFileScanner')
|
||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
|
||||
const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const { ScanResult, CoverDestination } = require('./utils/constants')
|
||||
|
||||
// Classes
|
||||
const BookFinder = require('./BookFinder')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
|
||||
class Scanner {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
|
|
@ -20,6 +25,8 @@ class Scanner {
|
|||
this.emitter = emitter
|
||||
|
||||
this.cancelScan = false
|
||||
this.cancelLibraryScan = {}
|
||||
this.librariesScanning = []
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
}
|
||||
|
|
@ -32,7 +39,7 @@ class Scanner {
|
|||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: Path.join('/local', audiobook.path)
|
||||
relPath: '/s/book/' + audiobook.id
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
|
|
@ -97,164 +104,151 @@ class Scanner {
|
|||
return filesUpdated
|
||||
}
|
||||
|
||||
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
|
||||
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
|
||||
|
||||
// inode value may change when using shared drives, update inode if matching path is found
|
||||
// Note: inode will not change on rename
|
||||
var hasUpdatedIno = false
|
||||
if (!existingAudiobook) {
|
||||
// check an audiobook exists with matching path, then update inodes
|
||||
existingAudiobook = this.audiobooks.find(a => a.path === audiobookData.path)
|
||||
if (existingAudiobook) {
|
||||
existingAudiobook.ino = audiobookData.ino
|
||||
hasUpdatedIno = true
|
||||
}
|
||||
async scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, forceAudioFileScan) {
|
||||
// Always sync files and inode values
|
||||
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
|
||||
if (hasUpdatedIno || filesInodeUpdated > 0) {
|
||||
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
|
||||
hasUpdatedIno = true
|
||||
}
|
||||
|
||||
if (existingAudiobook) {
|
||||
// Always sync files and inode values
|
||||
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
|
||||
if (hasUpdatedIno || filesInodeUpdated > 0) {
|
||||
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
|
||||
hasUpdatedIno = true
|
||||
}
|
||||
// TEMP: Check if is older audiobook and needs force rescan
|
||||
if (!forceAudioFileScan && (!existingAudiobook.scanVersion || existingAudiobook.checkHasOldCoverPath())) {
|
||||
Logger.info(`[Scanner] Force rescan for "${existingAudiobook.title}" | Last scan v${existingAudiobook.scanVersion} | Old Cover Path ${!!existingAudiobook.checkHasOldCoverPath()}`)
|
||||
forceAudioFileScan = true
|
||||
}
|
||||
|
||||
// ino is now set for every file in scandir
|
||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||
|
||||
// TEMP: Check if is older audiobook and needs force rescan
|
||||
if (!forceAudioFileScan && existingAudiobook.checkNeedsAudioFileRescan()) {
|
||||
Logger.info(`[Scanner] Re-Scanning all audio files for "${existingAudiobook.title}" (last scan <= 1.3.0)`)
|
||||
forceAudioFileScan = true
|
||||
}
|
||||
// REMOVE: No valid audio files
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
|
||||
// REMOVE: No valid audio files
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
||||
if (removedAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||
}
|
||||
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
// Check for mismatched audio tracks - tracks with no matching audio file
|
||||
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
||||
if (removedAudioTracks.length) {
|
||||
Logger.error(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
||||
}
|
||||
|
||||
// ino is now set for every file in scandir
|
||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
||||
if (removedAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||
}
|
||||
|
||||
// Check for mismatched audio tracks - tracks with no matching audio file
|
||||
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
||||
if (removedAudioTracks.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
||||
}
|
||||
|
||||
// Check for new audio files and sync existing audio files
|
||||
var newAudioFiles = []
|
||||
var hasUpdatedAudioFiles = false
|
||||
audiobookData.audioFiles.forEach((file) => {
|
||||
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
||||
if (existingAudioFile) { // Audio file exists, sync paths
|
||||
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
||||
hasUpdatedAudioFiles = true
|
||||
}
|
||||
} else {
|
||||
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
||||
if (audioFileWithMatchingPath) {
|
||||
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
||||
} else {
|
||||
newAudioFiles.push(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Rescan audio file metadata
|
||||
if (forceAudioFileScan) {
|
||||
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
|
||||
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
|
||||
if (numAudioFilesUpdated > 0) {
|
||||
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
|
||||
// Check for new audio files and sync existing audio files
|
||||
var newAudioFiles = []
|
||||
var hasUpdatedAudioFiles = false
|
||||
audiobookData.audioFiles.forEach((file) => {
|
||||
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
||||
if (existingAudioFile) { // Audio file exists, sync path (path may have been renamed)
|
||||
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
||||
hasUpdatedAudioFiles = true
|
||||
|
||||
// Use embedded cover art if audiobook has no cover
|
||||
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
|
||||
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
|
||||
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||
if (relativeDir) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New audio file, triple check for matching file path
|
||||
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
||||
if (audioFileWithMatchingPath) {
|
||||
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
||||
} else {
|
||||
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
|
||||
newAudioFiles.push(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Scan and add new audio files found and set tracks
|
||||
if (newAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
// Rescan audio file metadata
|
||||
if (forceAudioFileScan) {
|
||||
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
|
||||
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
|
||||
if (numAudioFilesUpdated > 0) {
|
||||
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
|
||||
hasUpdatedAudioFiles = true
|
||||
|
||||
// Use embedded cover art if audiobook has no cover
|
||||
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
|
||||
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
|
||||
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||
if (relativeDir) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
|
||||
}
|
||||
|
||||
// If after a scan no valid audio tracks remain
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
var hasUpdates = hasUpdatedIno || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
|
||||
// Check that audio tracks are in sequential order with no gaps
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Sync other files (all files that are not audio files)
|
||||
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, forceAudioFileScan)
|
||||
if (otherFilesUpdated) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Syncs path and fullPath
|
||||
if (existingAudiobook.syncPaths(audiobookData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If audiobook was missing before, it is now found
|
||||
if (existingAudiobook.isMissing) {
|
||||
existingAudiobook.isMissing = false
|
||||
hasUpdates = true
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||
}
|
||||
|
||||
// Save changes and notify users
|
||||
if (hasUpdates) {
|
||||
existingAudiobook.setChapters()
|
||||
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
|
||||
return ScanResult.UPTODATE
|
||||
}
|
||||
|
||||
// NEW: Check new audiobook
|
||||
// Scan and add new audio files found and set tracks
|
||||
if (newAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
}
|
||||
|
||||
// If after a scan no valid audio tracks remain
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
var hasUpdates = hasUpdatedIno || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
|
||||
// Check that audio tracks are in sequential order with no gaps
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Sync other files (all files that are not audio files) - Updates cover path
|
||||
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, this.MetadataPath, forceAudioFileScan)
|
||||
if (otherFilesUpdated) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Syncs path and fullPath
|
||||
if (existingAudiobook.syncPaths(audiobookData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If audiobook was missing before, it is now found
|
||||
if (existingAudiobook.isMissing) {
|
||||
existingAudiobook.isMissing = false
|
||||
hasUpdates = true
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||
}
|
||||
|
||||
// Save changes and notify users
|
||||
if (hasUpdates || !existingAudiobook.scanVersion) {
|
||||
if (!existingAudiobook.scanVersion) {
|
||||
Logger.debug(`[Scanner] No scan version "${existingAudiobook.title}" - updating`)
|
||||
}
|
||||
existingAudiobook.setChapters()
|
||||
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||
existingAudiobook.setLastScan(version)
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
|
||||
return ScanResult.UPTODATE
|
||||
}
|
||||
|
||||
async scanNewAudiobook(audiobookData) {
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
|
||||
return ScanResult.NOTHING
|
||||
|
|
@ -262,15 +256,16 @@ class Scanner {
|
|||
|
||||
var audiobook = new Audiobook()
|
||||
audiobook.setData(audiobookData)
|
||||
|
||||
// Scan audio files and set tracks, pulls metadata
|
||||
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
||||
if (!audiobook.tracks.length) {
|
||||
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
if (audiobook.hasDescriptionTextFile) {
|
||||
await audiobook.saveDescriptionFromTextFile()
|
||||
}
|
||||
// Look for desc.txt and reader.txt and update
|
||||
await audiobook.saveDataFromTextFiles()
|
||||
|
||||
if (audiobook.hasEmbeddedCoverArt) {
|
||||
var outputCoverDirs = this.getCoverDirectory(audiobook)
|
||||
|
|
@ -280,22 +275,79 @@ class Scanner {
|
|||
}
|
||||
}
|
||||
|
||||
// Set book details from metadata pulled from audio files
|
||||
audiobook.setDetailsFromFileMetadata()
|
||||
|
||||
// Check for gaps in track numbers
|
||||
audiobook.checkUpdateMissingParts()
|
||||
|
||||
// Set chapters from audio files
|
||||
audiobook.setChapters()
|
||||
|
||||
audiobook.setLastScan(version)
|
||||
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||
await this.db.insertAudiobook(audiobook)
|
||||
await this.db.insertEntity('audiobook', audiobook)
|
||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||
return ScanResult.ADDED
|
||||
}
|
||||
|
||||
async scan(forceAudioFileScan = false) {
|
||||
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
|
||||
var scannerFindCovers = this.db.serverSettings.scannerFindCovers
|
||||
var libraryId = audiobookData.libraryId
|
||||
var audiobooksInLibrary = this.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||
var existingAudiobook = audiobooksInLibrary.find(a => a.ino === audiobookData.ino)
|
||||
|
||||
// inode value may change when using shared drives, update inode if matching path is found
|
||||
// Note: inode will not change on rename
|
||||
var hasUpdatedIno = false
|
||||
if (!existingAudiobook) {
|
||||
// check an audiobook exists with matching path, then update inodes
|
||||
existingAudiobook = audiobooksInLibrary.find(a => a.path === audiobookData.path)
|
||||
if (existingAudiobook) {
|
||||
existingAudiobook.ino = audiobookData.ino
|
||||
hasUpdatedIno = true
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAudiobook) {
|
||||
return this.scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, forceAudioFileScan)
|
||||
}
|
||||
return this.scanNewAudiobook(audiobookData)
|
||||
}
|
||||
|
||||
async scan(libraryId, forceAudioFileScan = false) {
|
||||
if (this.librariesScanning.includes(libraryId)) {
|
||||
Logger.error(`[Scanner] Already scanning ${libraryId}`)
|
||||
return
|
||||
}
|
||||
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Library not found for scan ${libraryId}`)
|
||||
return
|
||||
} else if (!library.folders.length) {
|
||||
Logger.warn(`[Scanner] Library has no folders to scan "${library.name}"`)
|
||||
return
|
||||
}
|
||||
|
||||
this.emitter('scan_start', {
|
||||
id: libraryId,
|
||||
name: library.name,
|
||||
scanType: 'library',
|
||||
folders: library.folders.length
|
||||
})
|
||||
Logger.info(`[Scanner] Starting scan of library "${library.name}" with ${library.folders.length} folders`)
|
||||
|
||||
this.librariesScanning.push(libraryId)
|
||||
|
||||
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||
|
||||
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
|
||||
// TEMP - update ino for each audiobook
|
||||
if (this.audiobooks.length) {
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var ab = this.audiobooks[i]
|
||||
if (audiobooksInLibrary.length) {
|
||||
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||
var ab = audiobooksInLibrary[i]
|
||||
// Update ino if inos are not set
|
||||
var shouldUpdateIno = ab.hasMissingIno
|
||||
if (shouldUpdateIno) {
|
||||
|
|
@ -309,13 +361,23 @@ class Scanner {
|
|||
}
|
||||
|
||||
const scanStart = Date.now()
|
||||
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
||||
var audiobookDataFound = []
|
||||
for (let i = 0; i < library.folders.length; i++) {
|
||||
var folder = library.folders[i]
|
||||
var abDataFoundInFolder = await scanRootDir(folder, this.db.serverSettings)
|
||||
Logger.debug(`[Scanner] ${abDataFoundInFolder.length} ab data found in folder "${folder.fullPath}"`)
|
||||
audiobookDataFound = audiobookDataFound.concat(abDataFoundInFolder)
|
||||
}
|
||||
|
||||
// Remove audiobooks with no inode
|
||||
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
if (this.cancelLibraryScan[libraryId]) {
|
||||
console.log('2', this.cancelLibraryScan)
|
||||
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||
delete this.cancelLibraryScan[libraryId]
|
||||
this.librariesScanning = this.librariesScanning.filter(l => l !== libraryId)
|
||||
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: null })
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -327,8 +389,8 @@ class Scanner {
|
|||
}
|
||||
|
||||
// Check for removed audiobooks
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var audiobook = this.audiobooks[i]
|
||||
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||
var audiobook = audiobooksInLibrary[i]
|
||||
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino)
|
||||
if (!dataFound) {
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
|
||||
|
|
@ -338,9 +400,13 @@ class Scanner {
|
|||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
}
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
if (this.cancelLibraryScan[libraryId]) {
|
||||
console.log('1', this.cancelLibraryScan)
|
||||
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||
delete this.cancelLibraryScan[libraryId]
|
||||
this.librariesScanning = this.librariesScanning.filter(l => l !== libraryId)
|
||||
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: scanResults })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,21 +419,26 @@ class Scanner {
|
|||
|
||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||
this.emitter('scan_progress', {
|
||||
scanType: 'files',
|
||||
id: libraryId,
|
||||
name: library.name,
|
||||
scanType: 'library',
|
||||
progress: {
|
||||
total: audiobookDataFound.length,
|
||||
done: i + 1,
|
||||
progress
|
||||
}
|
||||
})
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
if (this.cancelLibraryScan[libraryId]) {
|
||||
console.log(this.cancelLibraryScan)
|
||||
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||
delete this.cancelLibraryScan[libraryId]
|
||||
break
|
||||
}
|
||||
}
|
||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | ${scanResults.missing} missing | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||
return scanResults
|
||||
this.librariesScanning = this.librariesScanning.filter(l => l !== libraryId)
|
||||
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: scanResults })
|
||||
}
|
||||
|
||||
async scanAudiobookById(audiobookId) {
|
||||
|
|
@ -376,78 +447,173 @@ class Scanner {
|
|||
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
const library = this.db.libraries.find(lib => lib.id === audiobook.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Scan audiobook by id library not found "${audiobook.libraryId}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
const folder = library.folders.find(f => f.id === audiobook.folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[Scanner] Scan audiobook by id folder not found "${audiobook.folderId}" in library "${library.name}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`)
|
||||
return this.scanAudiobook(audiobook.fullPath, true)
|
||||
return this.scanAudiobook(folder, audiobook.fullPath, true)
|
||||
}
|
||||
|
||||
async scanAudiobook(audiobookPath, forceAudioFileScan = false) {
|
||||
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
|
||||
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
|
||||
async scanAudiobook(folder, audiobookFullPath, forceAudioFileScan = false) {
|
||||
Logger.debug('[Scanner] scanAudiobook', audiobookFullPath)
|
||||
var audiobookData = await getAudiobookFileData(folder, audiobookFullPath, this.db.serverSettings)
|
||||
if (!audiobookData) {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
audiobookData.ino = await getIno(audiobookData.fullPath)
|
||||
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
// }
|
||||
|
||||
async scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup) {
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Library "${libraryId}" not found for scan library updates`)
|
||||
return null
|
||||
}
|
||||
var folder = library.folders.find(f => f.id === folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[Scanner] Folder "${folderId}" not found in library "${library.name}" for scan library updates`)
|
||||
return null
|
||||
}
|
||||
Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`)
|
||||
|
||||
var bookGroupingResults = {}
|
||||
for (const bookDir in fileUpdateBookGroup) {
|
||||
var fullPath = Path.join(folder.fullPath, bookDir)
|
||||
|
||||
// Check if book dir group is already an audiobook or in a subdir of an audiobook
|
||||
var existingAudiobook = this.db.audiobooks.find(ab => fullPath.startsWith(ab.fullPath))
|
||||
if (existingAudiobook) {
|
||||
|
||||
// Is the audiobook exactly - check if was deleted
|
||||
if (existingAudiobook.fullPath === fullPath) {
|
||||
var exists = await fs.pathExists(fullPath)
|
||||
if (!exists) {
|
||||
Logger.info(`[Scanner] Scanning file update group and audiobook was deleted "${existingAudiobook.title}" - marking as missing`)
|
||||
existingAudiobook.isMissing = true
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
|
||||
bookGroupingResults[bookDir] = ScanResult.REMOVED
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan audiobook for updates
|
||||
Logger.debug(`[Scanner] Folder update for relative path "${bookDir}" is in audiobook "${existingAudiobook.title}" - scan for updates`)
|
||||
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, existingAudiobook.fullPath)
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Check if an audiobook is a subdirectory of this dir
|
||||
var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(fullPath))
|
||||
if (childAudiobook) {
|
||||
Logger.warn(`[Scanner] Files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`)
|
||||
bookGroupingResults[bookDir] = ScanResult.NOTHING
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
|
||||
return ScanResult.NOTHING
|
||||
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
|
||||
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, fullPath)
|
||||
}
|
||||
|
||||
// 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)
|
||||
return bookGroupingResults
|
||||
}
|
||||
|
||||
// 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, true)
|
||||
// Array of file update objects that may have been renamed, removed or added
|
||||
async filesChanged(fileUpdates) {
|
||||
if (!fileUpdates.length) return null
|
||||
|
||||
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)
|
||||
// Group files by folder
|
||||
var folderGroups = {}
|
||||
fileUpdates.forEach((file) => {
|
||||
if (folderGroups[file.folderId]) {
|
||||
folderGroups[file.folderId].fileUpdates.push(file)
|
||||
} else {
|
||||
folderGroups[file.folderId] = {
|
||||
libraryId: file.libraryId,
|
||||
folderId: file.folderId,
|
||||
fileUpdates: [file]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const libraryScanResults = {}
|
||||
|
||||
// Group files by book
|
||||
for (const folderId in folderGroups) {
|
||||
var libraryId = folderGroups[folderId].libraryId
|
||||
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
||||
var fileUpdateBookGroup = groupFilesIntoAudiobookPaths(relFilePaths, true)
|
||||
var folderScanResults = await this.scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup)
|
||||
libraryScanResults[libraryId] = folderScanResults
|
||||
}
|
||||
return results
|
||||
|
||||
Logger.debug(`[Scanner] Finished scanning file changes, results:`, libraryScanResults)
|
||||
return libraryScanResults
|
||||
// var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
||||
// var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
|
||||
|
||||
// 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 scanCovers() {
|
||||
|
|
@ -495,7 +661,8 @@ class Scanner {
|
|||
}
|
||||
return {
|
||||
found,
|
||||
notFound
|
||||
notFound,
|
||||
failed
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
363
server/Server.js
363
server/Server.js
|
|
@ -6,8 +6,13 @@ const fs = require('fs-extra')
|
|||
const fileUpload = require('express-fileupload')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
const { version } = require('../package.json')
|
||||
|
||||
// Utils
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
// Classes
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
const Scanner = require('./Scanner')
|
||||
|
|
@ -18,7 +23,7 @@ const StreamManager = require('./StreamManager')
|
|||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const CoverController = require('./CoverController')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
|
||||
class Server {
|
||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
|
|
@ -32,7 +37,7 @@ class Server {
|
|||
fs.ensureDirSync(METADATA_PATH)
|
||||
fs.ensureDirSync(AUDIOBOOK_PATH)
|
||||
|
||||
this.db = new Db(this.ConfigPath)
|
||||
this.db = new Db(this.ConfigPath, this.AudiobookPath)
|
||||
this.auth = new Auth(this.db)
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
||||
|
|
@ -40,22 +45,24 @@ class Server {
|
|||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||
|
||||
this.expressApp = null
|
||||
this.server = null
|
||||
this.io = null
|
||||
|
||||
this.clients = {}
|
||||
|
||||
this.isScanning = false
|
||||
this.isScanningCovers = false
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
get audiobooks() {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
get libraries() {
|
||||
return this.db.libraries
|
||||
}
|
||||
get serverSettings() {
|
||||
return this.db.serverSettings
|
||||
}
|
||||
|
|
@ -81,86 +88,8 @@ class Server {
|
|||
})
|
||||
}
|
||||
|
||||
async filesChanged(files) {
|
||||
Logger.info('[Server]', files.length, 'Files Changed')
|
||||
var result = await this.scanner.filesChanged(files)
|
||||
Logger.debug('[Server] Files changed result', result)
|
||||
}
|
||||
|
||||
async scan(forceAudioFileScan = false) {
|
||||
Logger.info('[Server] Starting Scan')
|
||||
this.isScanning = true
|
||||
this.isInitialized = true
|
||||
this.emitter('scan_start', 'files')
|
||||
var results = await this.scanner.scan(forceAudioFileScan)
|
||||
this.isScanning = false
|
||||
this.emitter('scan_complete', { scanType: 'files', results })
|
||||
Logger.info('[Server] Scan complete')
|
||||
}
|
||||
|
||||
async scanAudiobook(socket, audiobookId) {
|
||||
var result = await this.scanner.scanAudiobookById(audiobookId)
|
||||
var scanResultName = ''
|
||||
for (const key in ScanResult) {
|
||||
if (ScanResult[key] === result) {
|
||||
scanResultName = key
|
||||
}
|
||||
}
|
||||
socket.emit('audiobook_scan_complete', scanResultName)
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
Logger.info('[Server] Start cover scan')
|
||||
this.isScanningCovers = true
|
||||
this.emitter('scan_start', 'covers')
|
||||
var results = await this.scanner.scanCovers()
|
||||
this.isScanningCovers = false
|
||||
this.emitter('scan_complete', { scanType: 'covers', results })
|
||||
Logger.info('[Server] Cover scan complete')
|
||||
}
|
||||
|
||||
cancelScan() {
|
||||
if (!this.isScanningCovers && !this.isScanning) return
|
||||
this.scanner.cancelScan = true
|
||||
}
|
||||
|
||||
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
|
||||
async saveMetadata(socket, audiobookId = null) {
|
||||
Logger.info('[Server] Starting save metadata files')
|
||||
var response = await this.scanner.saveMetadata(audiobookId)
|
||||
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
|
||||
socket.emit('save_metadata_complete', response)
|
||||
}
|
||||
|
||||
// Remove unused /metadata/books/{id} folders
|
||||
async purgeMetadata() {
|
||||
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
||||
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
||||
if (!booksMetadataExists) return
|
||||
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
||||
|
||||
var purged = 0
|
||||
await Promise.all(foldersInBooksMetadata.map(async foldername => {
|
||||
var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
|
||||
if (!hasMatchingAudiobook) {
|
||||
var folderPath = Path.join(booksMetadata, foldername)
|
||||
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
|
||||
|
||||
await fs.remove(folderPath).then(() => {
|
||||
purged++
|
||||
}).catch((err) => {
|
||||
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
|
||||
})
|
||||
}
|
||||
}))
|
||||
if (purged > 0) {
|
||||
Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
|
||||
}
|
||||
return purged
|
||||
}
|
||||
|
||||
async init() {
|
||||
Logger.info('[Server] Init')
|
||||
Logger.info('[Server] Init v' + version)
|
||||
await this.streamManager.ensureStreamsDir()
|
||||
await this.streamManager.removeOrphanStreams()
|
||||
await this.downloadManager.removeOrphanDownloads()
|
||||
|
|
@ -170,105 +99,66 @@ class Server {
|
|||
|
||||
await this.purgeMetadata()
|
||||
|
||||
this.watcher.initWatcher()
|
||||
this.watcher.initWatcher(this.libraries)
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
}
|
||||
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.json({
|
||||
error: 'Invalid post data received'
|
||||
})
|
||||
}
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
if (exists) {
|
||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||
return res.json({
|
||||
error: `Directory "${outputDirectory}" already exists`
|
||||
})
|
||||
}
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// First time login rate limit is hit
|
||||
loginLimitReached(req, res, options) {
|
||||
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
||||
options.message = 'Too many attempts. Login temporarily locked.'
|
||||
}
|
||||
|
||||
getLoginRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
||||
max: this.db.serverSettings.rateLimitLoginRequests,
|
||||
skipSuccessfulRequests: true,
|
||||
onLimitReached: this.loginLimitReached
|
||||
})
|
||||
}
|
||||
|
||||
async start() {
|
||||
Logger.info('=== Starting Server ===')
|
||||
await this.init()
|
||||
|
||||
const app = express()
|
||||
this.expressApp = app
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
app.use(this.auth.cors)
|
||||
app.use(fileUpload())
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static(distPath))
|
||||
app.use('/local', express.static(this.AudiobookPath))
|
||||
} else {
|
||||
app.use(express.static(this.AudiobookPath))
|
||||
}
|
||||
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
||||
|
||||
app.use(express.static(this.MetadataPath))
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json())
|
||||
|
||||
// Dynamic routes are not generated on client
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
app.use(express.static(distPath))
|
||||
|
||||
// Old static path for covers
|
||||
app.use('/local', this.authMiddleware.bind(this), express.static(this.AudiobookPath))
|
||||
|
||||
// Metadata folder static path
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
||||
|
||||
// Static folder
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
// Static file routes
|
||||
app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (req, res) => {
|
||||
var library = this.libraries.find(lib => lib.id === req.params.library)
|
||||
if (!library) return res.sendStatus(404)
|
||||
var folder = library.folders.find(fol => fol.id === req.params.folder)
|
||||
if (!folder) return res.status(404).send('Folder not found')
|
||||
|
||||
var remainingPath = decodeURIComponent(req.params['0'])
|
||||
|
||||
var fullPath = Path.join(folder.fullPath, remainingPath)
|
||||
res.sendFile(fullPath)
|
||||
})
|
||||
|
||||
// Book static file routes
|
||||
app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => {
|
||||
var audiobook = this.audiobooks.find(ab => ab.id === req.params.id)
|
||||
if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id)
|
||||
|
||||
var remainingPath = decodeURIComponent(req.params['0'])
|
||||
|
||||
var fullPath = Path.join(audiobook.fullPath, remainingPath)
|
||||
res.sendFile(fullPath)
|
||||
})
|
||||
|
||||
// Client routes
|
||||
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
|
||||
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
||||
|
|
@ -368,6 +258,143 @@ class Server {
|
|||
})
|
||||
}
|
||||
|
||||
async filesChanged(fileUpdates) {
|
||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||
await this.scanner.filesChanged(fileUpdates)
|
||||
// Logger.debug('[Server] Files changed result', result)
|
||||
}
|
||||
|
||||
async scan(libraryId, forceAudioFileScan = false) {
|
||||
Logger.info('[Server] Starting Scan')
|
||||
await this.scanner.scan(libraryId, forceAudioFileScan)
|
||||
Logger.info('[Server] Scan complete')
|
||||
}
|
||||
|
||||
async scanAudiobook(socket, audiobookId) {
|
||||
var result = await this.scanner.scanAudiobookById(audiobookId)
|
||||
var scanResultName = ''
|
||||
for (const key in ScanResult) {
|
||||
if (ScanResult[key] === result) {
|
||||
scanResultName = key
|
||||
}
|
||||
}
|
||||
socket.emit('audiobook_scan_complete', scanResultName)
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
Logger.info('[Server] Start cover scan')
|
||||
this.isScanningCovers = true
|
||||
// this.emitter('scan_start', 'covers')
|
||||
var results = await this.scanner.scanCovers()
|
||||
this.isScanningCovers = false
|
||||
// this.emitter('scan_complete', { scanType: 'covers', results })
|
||||
Logger.info('[Server] Cover scan complete')
|
||||
}
|
||||
|
||||
cancelScan(id) {
|
||||
console.log('Cancel scan', id)
|
||||
this.scanner.cancelLibraryScan[id] = true
|
||||
}
|
||||
|
||||
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
|
||||
async saveMetadata(socket, audiobookId = null) {
|
||||
Logger.info('[Server] Starting save metadata files')
|
||||
var response = await this.scanner.saveMetadata(audiobookId)
|
||||
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
|
||||
socket.emit('save_metadata_complete', response)
|
||||
}
|
||||
|
||||
// Remove unused /metadata/books/{id} folders
|
||||
async purgeMetadata() {
|
||||
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
||||
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
||||
if (!booksMetadataExists) return
|
||||
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
||||
|
||||
var purged = 0
|
||||
await Promise.all(foldersInBooksMetadata.map(async foldername => {
|
||||
var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
|
||||
if (!hasMatchingAudiobook) {
|
||||
var folderPath = Path.join(booksMetadata, foldername)
|
||||
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
|
||||
|
||||
await fs.remove(folderPath).then(() => {
|
||||
purged++
|
||||
}).catch((err) => {
|
||||
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
|
||||
})
|
||||
}
|
||||
}))
|
||||
if (purged > 0) {
|
||||
Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
|
||||
}
|
||||
return purged
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
}
|
||||
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.json({
|
||||
error: 'Invalid post data received'
|
||||
})
|
||||
}
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
if (exists) {
|
||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||
return res.json({
|
||||
error: `Directory "${outputDirectory}" already exists`
|
||||
})
|
||||
}
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// First time login rate limit is hit
|
||||
loginLimitReached(req, res, options) {
|
||||
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
||||
options.message = 'Too many attempts. Login temporarily locked.'
|
||||
}
|
||||
|
||||
getLoginRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
||||
max: this.db.serverSettings.rateLimitLoginRequests,
|
||||
skipSuccessfulRequests: true,
|
||||
onLimitReached: this.loginLimitReached
|
||||
})
|
||||
}
|
||||
|
||||
logout(req, res) {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
|
@ -407,8 +434,6 @@ class Server {
|
|||
|
||||
const initialPayload = {
|
||||
serverSettings: this.serverSettings.toJSON(),
|
||||
isScanning: this.isScanning,
|
||||
isInitialized: this.isInitialized,
|
||||
audiobookPath: this.AudiobookPath,
|
||||
metadataPath: this.MetadataPath,
|
||||
configPath: this.ConfigPath,
|
||||
|
|
|
|||
|
|
@ -4,107 +4,184 @@ const Watcher = require('watcher')
|
|||
const Logger = require('./Logger')
|
||||
|
||||
class FolderWatcher extends EventEmitter {
|
||||
constructor(audiobookPath) {
|
||||
constructor() {
|
||||
super()
|
||||
this.AudiobookPath = audiobookPath
|
||||
this.folderMap = {}
|
||||
this.watcher = null
|
||||
this.paths = [] // Not used
|
||||
this.pendingFiles = [] // Not used
|
||||
|
||||
this.pendingFiles = []
|
||||
this.libraryWatchers = []
|
||||
this.pendingFileUpdates = []
|
||||
this.pendingDelay = 4000
|
||||
this.pendingTimeout = null
|
||||
}
|
||||
|
||||
initWatcher() {
|
||||
try {
|
||||
Logger.info('[FolderWatcher] Initializing..')
|
||||
this.watcher = new Watcher(this.AudiobookPath, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
recursive: true,
|
||||
ignoreInitial: true,
|
||||
persistent: true
|
||||
get pendingFilePaths() {
|
||||
return this.pendingFileUpdates.map(f => f.path)
|
||||
}
|
||||
|
||||
buildLibraryWatcher(library) {
|
||||
if (this.libraryWatchers.find(w => w.id === library.id)) {
|
||||
Logger.warn('[Watcher] Already watching library', library.name)
|
||||
return
|
||||
}
|
||||
Logger.info(`[Watcher] Initializing watcher for "${library.name}"..`)
|
||||
var folderPaths = library.folderPaths
|
||||
var watcher = new Watcher(folderPaths, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
recursive: true,
|
||||
ignoreInitial: true,
|
||||
persistent: true
|
||||
})
|
||||
watcher
|
||||
.on('add', (path) => {
|
||||
this.onNewFile(library.id, path)
|
||||
}).on('change', (path) => {
|
||||
// This is triggered from metadata changes, not what we want
|
||||
// this.onFileUpdated(path)
|
||||
}).on('unlink', path => {
|
||||
this.onFileRemoved(library.id, path)
|
||||
}).on('rename', (path, pathNext) => {
|
||||
this.onRename(library.id, path, pathNext)
|
||||
}).on('error', (error) => {
|
||||
Logger.error(`[FolderWatcher] ${error}`)
|
||||
}).on('ready', () => {
|
||||
Logger.info('[FolderWatcher] Ready')
|
||||
})
|
||||
this.watcher
|
||||
.on('add', (path) => {
|
||||
this.onNewFile(path)
|
||||
}).on('change', (path) => {
|
||||
// This is triggered from metadata changes, not what we want
|
||||
// this.onFileUpdated(path)
|
||||
}).on('unlink', path => {
|
||||
this.onFileRemoved(path)
|
||||
}).on('rename', (path, pathNext) => {
|
||||
this.onRename(path, pathNext)
|
||||
}).on('error', (error) => {
|
||||
Logger.error(`[FolderWatcher] ${error}`)
|
||||
}).on('ready', () => {
|
||||
Logger.info('[FolderWatcher] Ready')
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error('Chokidar watcher failed', error)
|
||||
|
||||
this.libraryWatchers.push({
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
folders: library.folders,
|
||||
paths: library.folderPaths,
|
||||
watcher
|
||||
})
|
||||
}
|
||||
|
||||
initWatcher(libraries) {
|
||||
libraries.forEach((lib) => {
|
||||
this.buildLibraryWatcher(lib)
|
||||
})
|
||||
}
|
||||
|
||||
addLibrary(library) {
|
||||
this.buildLibraryWatcher(library)
|
||||
}
|
||||
|
||||
updateLibrary(library) {
|
||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||
if (libwatcher) {
|
||||
libwatcher.name = library.name
|
||||
|
||||
var pathsToAdd = library.folderPaths.filter(path => !libwatcher.paths.includes(path))
|
||||
if (pathsToAdd.length) {
|
||||
Logger.info(`[Watcher] Adding paths to library watcher "${library.name}"`)
|
||||
libwatcher.paths = library.folderPaths
|
||||
libwatcher.folders = library.folders
|
||||
libwatcher.watcher.watchPaths(pathsToAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeLibrary(library) {
|
||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||
if (libwatcher) {
|
||||
Logger.info(`[Watcher] Removed watcher for "${library.name}"`)
|
||||
libwatcher.watcher.close()
|
||||
this.libraryWatchers = this.libraryWatchers.filter(lib => lib.id !== library.id)
|
||||
} else {
|
||||
Logger.error(`[Watcher] Library watcher not found for "${library.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
return this.watcher.close()
|
||||
return this.libraryWatchers.map(lib => lib.watcher.close())
|
||||
}
|
||||
|
||||
// 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 (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(path)
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
}, this.pendingDelay)
|
||||
onNewFile(libraryId, path) {
|
||||
Logger.debug('[Watcher] File Added', path)
|
||||
this.addFileUpdate(libraryId, path, 'added')
|
||||
}
|
||||
|
||||
onFileRemoved(path) {
|
||||
Logger.debug('[FolderWatcher] File Removed', path)
|
||||
onFileRemoved(libraryId, path) {
|
||||
Logger.debug('[Watcher] File Removed', path)
|
||||
this.addFileUpdate(libraryId, path, 'deleted')
|
||||
// var dir = Path.dirname(path)
|
||||
// if (dir === this.AudiobookPath) {
|
||||
// Logger.debug('New File added to root dir, ignoring it')
|
||||
// return
|
||||
// }
|
||||
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(path)
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
}, this.pendingDelay)
|
||||
// this.pendingFiles.push(path)
|
||||
// clearTimeout(this.pendingTimeout)
|
||||
// this.pendingTimeout = setTimeout(() => {
|
||||
// this.emit('files', this.pendingFiles.map(f => f))
|
||||
// this.pendingFiles = []
|
||||
// }, this.pendingDelay)
|
||||
}
|
||||
|
||||
onFileUpdated(path) {
|
||||
Logger.debug('[FolderWatcher] Updated File', path)
|
||||
Logger.debug('[Watcher] Updated File', path)
|
||||
}
|
||||
|
||||
onRename(pathFrom, pathTo) {
|
||||
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
|
||||
onRename(libraryId, pathFrom, pathTo) {
|
||||
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
||||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||
// var dir = Path.dirname(pathTo)
|
||||
// if (dir === this.AudiobookPath) {
|
||||
// Logger.debug('New File added to root dir, ignoring it')
|
||||
// return
|
||||
// }
|
||||
|
||||
var dir = Path.dirname(pathTo)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
// this.pendingFiles.push(pathTo)
|
||||
// clearTimeout(this.pendingTimeout)
|
||||
// this.pendingTimeout = setTimeout(() => {
|
||||
// this.emit('files', this.pendingFiles.map(f => f))
|
||||
// this.pendingFiles = []
|
||||
// }, this.pendingDelay)
|
||||
}
|
||||
|
||||
addFileUpdate(libraryId, path, type) {
|
||||
if (this.pendingFilePaths.includes(path)) return
|
||||
|
||||
// Get file library
|
||||
var libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId)
|
||||
if (!libwatcher) {
|
||||
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(pathTo)
|
||||
// Get file folder
|
||||
var folder = libwatcher.folders.find(fold => path.startsWith(fold.fullPath))
|
||||
if (!folder) {
|
||||
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file was added to root directory
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === folder.fullPath) {
|
||||
Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`)
|
||||
return
|
||||
}
|
||||
|
||||
var relPath = path.replace(folder.fullPath, '')
|
||||
Logger.debug(`[Watcher] New File in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
|
||||
|
||||
this.pendingFileUpdates.push({
|
||||
path,
|
||||
relPath,
|
||||
folderId: folder.id,
|
||||
libraryId,
|
||||
type
|
||||
})
|
||||
|
||||
// Notify server of update after "pendingDelay"
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
this.emit('files', this.pendingFileUpdates)
|
||||
this.pendingFileUpdates = []
|
||||
}, this.pendingDelay)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ class AudioFile {
|
|||
this.exclude = false
|
||||
this.error = null
|
||||
|
||||
// TEMP: For forcing rescan
|
||||
this.isOldAudioFile = false
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
|
|
@ -103,7 +100,6 @@ class AudioFile {
|
|||
// Old version of AudioFile used `tagAlbum` etc.
|
||||
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
|
||||
if (isOldVersion) {
|
||||
this.isOldAudioFile = true
|
||||
this.metadata = new AudioFileMetadata(data)
|
||||
} else {
|
||||
this.metadata = new AudioFileMetadata(data.metadata || {})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno } = require('../utils/index')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
|
|
@ -14,11 +15,15 @@ class Audiobook {
|
|||
this.id = null
|
||||
this.ino = null // Inode
|
||||
|
||||
this.libraryId = null
|
||||
this.folderId = null
|
||||
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
|
||||
this.addedAt = null
|
||||
this.lastUpdate = null
|
||||
this.lastScan = null
|
||||
this.scanVersion = null
|
||||
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
|
|
@ -41,11 +46,14 @@ class Audiobook {
|
|||
construct(audiobook) {
|
||||
this.id = audiobook.id
|
||||
this.ino = audiobook.ino || null
|
||||
|
||||
this.libraryId = audiobook.libraryId || 'main'
|
||||
this.folderId = audiobook.folderId || 'audiobooks'
|
||||
this.path = audiobook.path
|
||||
this.fullPath = audiobook.fullPath
|
||||
this.addedAt = audiobook.addedAt
|
||||
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
||||
this.lastScan = audiobook.lastScan || null
|
||||
this.scanVersion = audiobook.scanVersion || null
|
||||
|
||||
this.tracks = audiobook.tracks.map(track => new AudioTrack(track))
|
||||
this.missingParts = audiobook.missingParts
|
||||
|
|
@ -127,10 +135,6 @@ class Audiobook {
|
|||
return !!this._audioFiles.find(af => af.embeddedCoverArt)
|
||||
}
|
||||
|
||||
get hasDescriptionTextFile() {
|
||||
return !!this._otherFiles.find(of => of.filename === 'desc.txt')
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
return this.book ? this.book.toJSON() : null
|
||||
}
|
||||
|
|
@ -144,6 +148,8 @@ class Audiobook {
|
|||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.folderId,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
|
|
@ -151,6 +157,8 @@ class Audiobook {
|
|||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
lastScan: this.lastScan,
|
||||
scanVersion: this.scanVersion,
|
||||
missingParts: this.missingParts,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
|
|
@ -166,6 +174,8 @@ class Audiobook {
|
|||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.folderId,
|
||||
book: this.bookToJSON(),
|
||||
tags: this.tags,
|
||||
path: this.path,
|
||||
|
|
@ -188,6 +198,9 @@ class Audiobook {
|
|||
toJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.folderId,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
|
|
@ -284,13 +297,10 @@ class Audiobook {
|
|||
return hasUpdates
|
||||
}
|
||||
|
||||
// Scans in v1.3.0 or lower will need to rescan audiofiles to pickup metadata and embedded cover
|
||||
checkNeedsAudioFileRescan() {
|
||||
return !!(this.audioFiles || []).find(af => af.isOldAudioFile || af.codec === null)
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.libraryId = data.libraryId || 'main'
|
||||
this.folderId = data.folderId || 'audiobooks'
|
||||
this.ino = data.ino || null
|
||||
|
||||
this.path = data.path
|
||||
|
|
@ -307,7 +317,26 @@ class Audiobook {
|
|||
this.setBook(data)
|
||||
}
|
||||
|
||||
checkHasOldCoverPath() {
|
||||
return this.book.cover && !this.book.coverFullPath
|
||||
}
|
||||
|
||||
setLastScan(version) {
|
||||
this.lastScan = Date.now()
|
||||
this.lastUpdate = Date.now()
|
||||
this.scanVersion = version
|
||||
}
|
||||
|
||||
setBook(data) {
|
||||
// Use first image file as cover
|
||||
if (this.otherFiles && this.otherFiles.length) {
|
||||
var imageFile = this.otherFiles.find(f => f.filetype === 'image')
|
||||
if (imageFile) {
|
||||
data.coverFullPath = imageFile.fullPath
|
||||
data.cover = Path.normalize(Path.join(`/s/book/${this.id}`, imageFile.path))
|
||||
}
|
||||
}
|
||||
|
||||
this.book = new Book()
|
||||
this.book.setData(data)
|
||||
}
|
||||
|
|
@ -432,12 +461,13 @@ class Audiobook {
|
|||
}
|
||||
|
||||
// On scan check other files found with other files saved
|
||||
async syncOtherFiles(newOtherFiles, forceRescan = false) {
|
||||
async syncOtherFiles(newOtherFiles, metadataPath, forceRescan = false) {
|
||||
var hasUpdates = false
|
||||
|
||||
var currOtherFileNum = this.otherFiles.length
|
||||
|
||||
var alreadyHadDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
|
||||
var alreadyHasDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
|
||||
var alreadyHasReaderTxt = this.otherFiles.find(of => of.filename === 'reader.txt')
|
||||
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
|
@ -448,9 +478,9 @@ class Audiobook {
|
|||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If desc.txt is new or forcing rescan then read it and update description if empty
|
||||
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (descriptionTxt && (!alreadyHadDescTxt || forceRescan)) {
|
||||
// If desc.txt is new or forcing rescan then read it and update description (will overwrite)
|
||||
var descriptionTxt = this.otherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) {
|
||||
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
||||
if (newDescription) {
|
||||
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
||||
|
|
@ -458,10 +488,19 @@ class Audiobook {
|
|||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
// If reader.txt is new or forcing rescan then read it and update narrarator (will overwrite)
|
||||
var readerTxt = this.otherFiles.find(file => file.filename === 'reader.txt')
|
||||
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
|
||||
var newReader = await readTextFile(readerTxt.fullPath)
|
||||
if (newReader) {
|
||||
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
|
||||
this.update({ book: { narrarator: newReader } })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Should use inode
|
||||
newOtherFiles.forEach((file) => {
|
||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
||||
var existingOtherFile = this.otherFiles.find(f => f.ino === file.ino)
|
||||
if (!existingOtherFile) {
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
|
||||
this.addOtherFile(file)
|
||||
|
|
@ -469,21 +508,76 @@ class Audiobook {
|
|||
}
|
||||
})
|
||||
|
||||
// Check if cover was a local image and that it still exists
|
||||
|
||||
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
||||
|
||||
// OLD Path Check if cover was a local image and that it still exists
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
||||
var coverStillExists = imageFiles.find(f => comparePaths(f.path, this.book.cover.substr('/local/'.length)))
|
||||
var coverStripped = this.book.cover.substr('/local/'.length)
|
||||
// Check if was removed first
|
||||
var coverStillExists = imageFiles.find(f => comparePaths(f.path, coverStripped))
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Local cover was removed | "${this.title}"`)
|
||||
this.book.cover = null
|
||||
this.book.removeCover()
|
||||
} else {
|
||||
var oldFormat = this.book.cover
|
||||
|
||||
// Update book cover path to new format
|
||||
this.book.fullCoverPath = Path.join(this.fullPath, this.book.cover.substr(7))
|
||||
this.book.cover = Path.normalize(coverStripped.replace(this.path, `/s/book/${this.id}`))
|
||||
Logger.debug(`[Audiobook] updated book cover to new format "${oldFormat}" => "${this.book.cover}"`)
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Check if book was removed from book dir
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('s/book/')) {
|
||||
// Fixing old cover paths
|
||||
if (!this.book.coverFullPath) {
|
||||
this.book.coverFullPath = Path.join(this.fullPath, this.book.cover.substr(`/s/book/${this.id}`.length))
|
||||
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
var coverStillExists = imageFiles.find(f => f.fullPath === this.book.coverFullPath)
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Local cover "${this.book.cover}" was removed | "${this.title}"`)
|
||||
this.book.removeCover()
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('metadata')) {
|
||||
// Fixing old cover paths
|
||||
if (!this.book.coverFullPath) {
|
||||
this.book.coverFullPath = Path.join(metadataPath, this.book.cover.substr('/metadata/'.length))
|
||||
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
var coverStillExists = imageFiles.find(f => f.fullPath === this.book.coverFullPath)
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Metadata cover "${this.book.cover}" was removed | "${this.title}"`)
|
||||
this.book.removeCover()
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.book.cover && !this.book.coverFullPath) {
|
||||
if (this.book.cover.startsWith('http')) {
|
||||
Logger.debug(`[Audiobook] Still using http path for cover "${this.book.cover}" - should update to local`)
|
||||
this.book.coverFullPath = this.book.cover
|
||||
hasUpdates = true
|
||||
} else {
|
||||
Logger.warn(`[Audiobook] Full cover path still not set "${this.book.cover}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// If no cover set and image file exists then use it
|
||||
if (!this.book.cover && imageFiles.length) {
|
||||
this.book.cover = Path.join('/local', imageFiles[0].path)
|
||||
Logger.info(`[Audiobook] Local cover was set | "${this.title}"`)
|
||||
var imagePathRelativeToBook = imageFiles[0].path.replace(this.path, '')
|
||||
this.book.cover = Path.join(`/s/book/${this.id}`, imagePathRelativeToBook)
|
||||
this.book.coverFullPath = imageFiles[0].fullPath
|
||||
Logger.info(`[Audiobook] Local cover was set to "${this.book.cover}" | "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
|
|
@ -582,6 +676,12 @@ class Audiobook {
|
|||
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||
var coverFilePath = Path.join(coverDirFullPath, coverFilename)
|
||||
|
||||
var coverAlreadyExists = await fs.pathExists(coverFilePath)
|
||||
if (coverAlreadyExists) {
|
||||
Logger.warn(`[Audiobook] Extract embedded cover art but cover already exists for "${this.title}" - bail`)
|
||||
return false
|
||||
}
|
||||
|
||||
var success = await extractCoverArt(audioFileWithCover.fullPath, coverFilePath)
|
||||
if (success) {
|
||||
var coverRelPath = Path.join(coverDirRelPath, coverFilename)
|
||||
|
|
@ -591,16 +691,32 @@ class Audiobook {
|
|||
return false
|
||||
}
|
||||
|
||||
// If desc.txt exists then use it as description
|
||||
async saveDescriptionFromTextFile() {
|
||||
var descriptionTextFile = this.otherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (!descriptionTextFile) return false
|
||||
var newDescription = await readTextFile(descriptionTextFile.fullPath)
|
||||
if (!newDescription) return false
|
||||
return this.update({ book: { description: newDescription } })
|
||||
// Look for desc.txt and reader.txt and update details if found
|
||||
async saveDataFromTextFiles() {
|
||||
var bookUpdatePayload = {}
|
||||
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
|
||||
if (descriptionText) {
|
||||
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
||||
bookUpdatePayload.description = descriptionText
|
||||
}
|
||||
var readerText = await this.fetchTextFromTextFile('reader.txt')
|
||||
if (readerText) {
|
||||
Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrarator with "${readerText}"`)
|
||||
bookUpdatePayload.narrarator = readerText
|
||||
}
|
||||
if (Object.keys(bookUpdatePayload).length) {
|
||||
return this.update({ book: bookUpdatePayload })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Audio file metadata tags map to EMPTY book details
|
||||
fetchTextFromTextFile(textfileName) {
|
||||
var textFile = this.otherFiles.find(file => file.filename === textfileName)
|
||||
if (!textFile) return false
|
||||
return readTextFile(textFile.fullPath)
|
||||
}
|
||||
|
||||
// Audio file metadata tags map to book details (will not overwrite)
|
||||
setDetailsFromFileMetadata() {
|
||||
if (!this.audioFiles.length) return false
|
||||
var audioFile = this.audioFiles[0]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class Book {
|
|||
this.publisher = null
|
||||
this.description = null
|
||||
this.cover = null
|
||||
this.coverFullPath = null
|
||||
this.genres = []
|
||||
this.lastUpdate = null
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ class Book {
|
|||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
this.cover = book.cover
|
||||
this.coverFullPath = book.coverFullPath || null
|
||||
this.genres = book.genres
|
||||
this.lastUpdate = book.lastUpdate || Date.now()
|
||||
}
|
||||
|
|
@ -65,6 +67,7 @@ class Book {
|
|||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
coverFullPath: this.coverFullPath,
|
||||
genres: this.genres,
|
||||
lastUpdate: this.lastUpdate
|
||||
}
|
||||
|
|
@ -100,20 +103,13 @@ class Book {
|
|||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.coverFullPath = data.coverFullPath || null
|
||||
this.genres = data.genres || []
|
||||
this.lastUpdate = Date.now()
|
||||
|
||||
if (data.author) {
|
||||
this.setParseAuthor(this.author)
|
||||
}
|
||||
|
||||
// Use first image file as cover
|
||||
if (data.otherFiles && data.otherFiles.length) {
|
||||
var imageFile = data.otherFiles.find(f => f.filetype === 'image')
|
||||
if (imageFile) {
|
||||
this.cover = Path.normalize(Path.join('/local', imageFile.path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
|
|
@ -168,6 +164,12 @@ class Book {
|
|||
return true
|
||||
}
|
||||
|
||||
removeCover() {
|
||||
this.cover = null
|
||||
this.coverFullPath = null
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
// If audiobook directory path was changed, check and update properties set from dirnames
|
||||
// May be worthwhile checking if these were manually updated and not override manual updates
|
||||
syncPathsUpdated(audiobookData) {
|
||||
|
|
|
|||
36
server/objects/Folder.js
Normal file
36
server/objects/Folder.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
class Folder {
|
||||
constructor(folder = null) {
|
||||
this.id = null
|
||||
this.fullPath = null
|
||||
this.libraryId = null
|
||||
this.addedAt = null
|
||||
|
||||
if (folder) {
|
||||
this.construct(folder)
|
||||
}
|
||||
}
|
||||
|
||||
construct(folder) {
|
||||
this.id = folder.id
|
||||
this.fullPath = folder.fullPath
|
||||
this.libraryId = folder.libraryId
|
||||
this.addedAt = folder.addedAt
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
fullPath: this.fullPath,
|
||||
libraryId: this.libraryId,
|
||||
addedAt: this.addedAt
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = data.id ? data.id : 'fol' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.fullPath = data.fullPath
|
||||
this.libraryId = data.libraryId
|
||||
this.addedAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = Folder
|
||||
95
server/objects/Library.js
Normal file
95
server/objects/Library.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const Folder = require('./Folder')
|
||||
|
||||
class Library {
|
||||
constructor(library = null) {
|
||||
this.id = null
|
||||
this.name = null
|
||||
this.folders = []
|
||||
|
||||
this.createdAt = null
|
||||
this.lastUpdate = null
|
||||
|
||||
if (library) {
|
||||
this.construct(library)
|
||||
}
|
||||
}
|
||||
|
||||
get folderPaths() {
|
||||
return this.folders.map(f => f.fullPath)
|
||||
}
|
||||
|
||||
construct(library) {
|
||||
this.id = library.id
|
||||
this.name = library.name
|
||||
this.folders = (library.folders || []).map(f => new Folder(f))
|
||||
this.createdAt = library.createdAt
|
||||
this.lastUpdate = library.lastUpdate
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
folders: (this.folders || []).map(f => f.toJSON()),
|
||||
createdAt: this.createdAt,
|
||||
lastUpdate: this.lastUpdate
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = data.id ? data.id : 'lib' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.name = data.name
|
||||
if (data.folder) {
|
||||
this.folders = [
|
||||
new Folder(data.folder)
|
||||
]
|
||||
} else if (data.folders) {
|
||||
this.folders = data.folders.map(folder => {
|
||||
var newFolder = new Folder()
|
||||
newFolder.setData({
|
||||
fullPath: folder.fullPath,
|
||||
libraryId: this.id
|
||||
})
|
||||
return newFolder
|
||||
})
|
||||
}
|
||||
this.createdAt = Date.now()
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
if (payload.name && payload.name !== this.name) {
|
||||
this.name = payload.name
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.folders) {
|
||||
var newFolders = payload.folders.filter(f => !f.id)
|
||||
var removedFolders = this.folders.filter(f => !payload.folders.find(_f => _f.id === f.id))
|
||||
|
||||
if (removedFolders.length) {
|
||||
var removedFolderIds = removedFolders.map(f => f.id)
|
||||
this.folders = this.folders.filter(f => !removedFolderIds.includes(f.id))
|
||||
}
|
||||
|
||||
if (newFolders.length) {
|
||||
newFolders.forEach((folderData) => {
|
||||
var newFolder = new Folder()
|
||||
newFolder.setData(folderData)
|
||||
this.folders.push(newFolder)
|
||||
})
|
||||
}
|
||||
|
||||
hasUpdates = newFolders.length || removedFolders.length
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
checkFullPathInLibrary(fullPath) {
|
||||
return this.folders.find(folder => fullPath.startsWith(folder.fullPath))
|
||||
}
|
||||
}
|
||||
module.exports = Library
|
||||
|
|
@ -8,6 +8,7 @@ class ServerSettings {
|
|||
this.autoTagNew = false
|
||||
this.newTagExpireDays = 15
|
||||
this.scannerParseSubtitle = false
|
||||
this.scannerFindCovers = false
|
||||
this.coverDestination = CoverDestination.METADATA
|
||||
this.saveMetadataFile = false
|
||||
this.rateLimitLoginRequests = 10
|
||||
|
|
@ -22,6 +23,7 @@ class ServerSettings {
|
|||
construct(settings) {
|
||||
this.autoTagNew = settings.autoTagNew
|
||||
this.newTagExpireDays = settings.newTagExpireDays
|
||||
this.scannerFindCovers = !!settings.scannerFindCovers
|
||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
|
||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||
|
|
@ -39,6 +41,7 @@ class ServerSettings {
|
|||
id: this.id,
|
||||
autoTagNew: this.autoTagNew,
|
||||
newTagExpireDays: this.newTagExpireDays,
|
||||
scannerFindCovers: this.scannerFindCovers,
|
||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||
coverDestination: this.coverDestination,
|
||||
saveMetadataFile: !!this.saveMetadataFile,
|
||||
|
|
|
|||
|
|
@ -192,7 +192,6 @@ module.exports.scanAudioFiles = scanAudioFiles
|
|||
|
||||
|
||||
async function rescanAudioFiles(audiobook) {
|
||||
|
||||
var audioFiles = audiobook.audioFiles
|
||||
var updates = 0
|
||||
|
||||
|
|
@ -215,7 +214,7 @@ async function rescanAudioFiles(audiobook) {
|
|||
// Fallback to checking path
|
||||
matchingAudioTrack = audiobook.tracks.find(t => t.path === audioFile.path)
|
||||
if (matchingAudioTrack) {
|
||||
Logger.warn(`[AudioFileScanner] Audio File mismatch ino with audio track "${audioFile.filename}"`)
|
||||
Logger.error(`[AudioFileScanner] Audio File mismatch ino with audio track "${audioFile.filename}"`)
|
||||
matchingAudioTrack.ino = audioFile.ino
|
||||
matchingAudioTrack.syncMetadata(audioFile)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ function isAudioFile(path) {
|
|||
return globals.SupportedAudioTypes.includes(ext.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
// Input: array of relative file paths
|
||||
// Output: map of files grouped into potential audiobook dirs
|
||||
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
||||
// 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)
|
||||
|
|
@ -110,25 +112,26 @@ function getFileType(ext) {
|
|||
return 'unknown'
|
||||
}
|
||||
|
||||
// Primary scan: abRootPath is /audiobooks
|
||||
async function scanRootDir(abRootPath, serverSettings = {}) {
|
||||
// Scan folder
|
||||
async function scanRootDir(folder, serverSettings = {}) {
|
||||
var folderPath = folder.fullPath
|
||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
|
||||
var pathdata = await getPaths(abRootPath)
|
||||
var pathdata = await getPaths(folderPath)
|
||||
var filepaths = pathdata.files.map(filepath => {
|
||||
return Path.normalize(filepath).replace(abRootPath, '')
|
||||
return Path.normalize(filepath).replace(folderPath, '')
|
||||
})
|
||||
|
||||
var audiobookGrouping = groupFilesIntoAudiobookPaths(filepaths)
|
||||
|
||||
if (!Object.keys(audiobookGrouping).length) {
|
||||
Logger.error('Root path has no audiobooks')
|
||||
Logger.error('Root path has no audiobooks', filepaths)
|
||||
return []
|
||||
}
|
||||
|
||||
var audiobooks = []
|
||||
for (const audiobookPath in audiobookGrouping) {
|
||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
|
||||
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
|
||||
|
||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
for (let i = 0; i < fileObjs.length; i++) {
|
||||
|
|
@ -136,6 +139,8 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
|
|||
}
|
||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
||||
audiobooks.push({
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: audiobookIno,
|
||||
...audiobookData,
|
||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||
|
|
@ -147,7 +152,7 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
|
|||
module.exports.scanRootDir = scanRootDir
|
||||
|
||||
// Input relative filepath, output all details that can be parsed
|
||||
function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
||||
function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) {
|
||||
var splitDir = dir.split(Path.sep)
|
||||
|
||||
// Audio files will always be in the directory named for the title
|
||||
|
|
@ -218,11 +223,11 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
|||
volumeNumber,
|
||||
publishYear,
|
||||
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
||||
fullPath: Path.join(folderPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
||||
}
|
||||
}
|
||||
|
||||
async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings = {}) {
|
||||
async function getAudiobookFileData(folder, audiobookPath, serverSettings = {}) {
|
||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
|
||||
var paths = await getPaths(audiobookPath)
|
||||
|
|
@ -235,9 +240,11 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
|||
return pathsA - pathsB
|
||||
})
|
||||
|
||||
var audiobookDir = Path.normalize(audiobookPath).replace(abRootPath, '').slice(1)
|
||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookDir, parseSubtitle)
|
||||
var audiobookDir = Path.normalize(audiobookPath).replace(folder.fullPath, '').slice(1)
|
||||
var audiobookData = getAudiobookDataFromDir(folder.fullPath, audiobookDir, parseSubtitle)
|
||||
var audiobook = {
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
...audiobookData,
|
||||
audioFiles: [],
|
||||
otherFiles: []
|
||||
|
|
@ -246,7 +253,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
|||
for (let i = 0; i < filepaths.length; i++) {
|
||||
var filepath = filepaths[i]
|
||||
|
||||
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
|
||||
var relpath = Path.normalize(filepath).replace(folder.fullPath, '').slice(1)
|
||||
var extname = Path.extname(filepath)
|
||||
var basename = Path.basename(filepath)
|
||||
var ino = await getIno(filepath)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue