Support for libraries and folder mapping, updating static cover path, detect reader.txt

This commit is contained in:
advplyr 2021-10-04 22:11:42 -05:00
parent a590e795e3
commit 577f3bead9
43 changed files with 2548 additions and 768 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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) => {

View file

@ -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
}
}

View file

@ -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,

View file

@ -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)
}
}

View file

@ -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 || {})

View file

@ -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]

View file

@ -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
View 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
View 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

View file

@ -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,

View file

@ -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 {

View file

@ -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)