merge upstream

This commit is contained in:
David Leimroth 2022-02-28 18:18:25 +01:00
commit 61bf30e08a
89 changed files with 2963 additions and 1805 deletions

View file

@ -18,9 +18,10 @@ const AuthorFinder = require('./AuthorFinder')
const FileSystemController = require('./controllers/FileSystemController')
class ApiController {
constructor(MetadataPath, db, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) {
constructor(db, auth, scanner, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) {
this.db = db
this.auth = auth
this.scanner = scanner
this.streamManager = streamManager
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
@ -30,10 +31,9 @@ class ApiController {
this.cacheManager = cacheManager
this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
this.bookFinder = new BookFinder()
this.authorFinder = new AuthorFinder(this.MetadataPath)
this.authorFinder = new AuthorFinder()
this.router = express()
this.init()
@ -59,6 +59,7 @@ class ApiController {
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
// TEMP: Support old syntax for mobile app
@ -82,6 +83,7 @@ class ApiController {
this.router.post('/books/:id/cover', BookController.uploadCover.bind(this))
this.router.get('/books/:id/cover', BookController.getCover.bind(this))
this.router.patch('/books/:id/coverfile', BookController.updateCoverFromFile.bind(this))
this.router.post('/books/:id/match', BookController.match.bind(this))
// TEMP: Support old syntax for mobile app
this.router.get('/audiobooks', BookController.findAll.bind(this)) // Old route should pass library id
@ -176,6 +178,8 @@ class ApiController {
this.router.post('/syncStream', this.syncStream.bind(this))
this.router.post('/syncLocal', this.syncLocal.bind(this))
this.router.post('/streams/:id/close', this.closeStream.bind(this))
}
async findBooks(req, res) {
@ -283,7 +287,7 @@ class ApiController {
this.backupManager.updateCronSchedule()
}
await this.db.updateEntity('settings', this.db.serverSettings)
await this.db.updateServerSettings()
}
return res.json({
success: true,
@ -416,6 +420,7 @@ class ApiController {
data: audiobookProgress || null
})
}
res.sendStatus(200)
}
//
@ -537,5 +542,12 @@ class ApiController {
await this.cacheManager.purgeAll()
res.sendStatus(200)
}
async closeStream(req, res) {
const streamId = req.params.id
const userId = req.user.id
this.streamManager.closeStreamApiRequest(userId, streamId)
res.sendStatus(200)
}
}
module.exports = ApiController

View file

@ -30,7 +30,7 @@ class Auth {
cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*')
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') {
res.sendStatus(200)

View file

@ -7,9 +7,8 @@ const Audnexus = require('./providers/Audnexus')
const { downloadFile } = require('./utils/fileUtils')
class AuthorFinder {
constructor(MetadataPath) {
this.MetadataPath = MetadataPath
this.AuthorPath = Path.join(MetadataPath, 'authors')
constructor() {
this.AuthorPath = Path.join(global.MetadataPath, 'authors')
this.audnexus = new Audnexus()
}

View file

@ -13,9 +13,9 @@ const Logger = require('./Logger')
const Backup = require('./objects/Backup')
class BackupManager {
constructor(MetadataPath, Uid, Gid, db) {
this.MetadataPath = MetadataPath
this.BackupPath = Path.join(this.MetadataPath, 'backups')
constructor(Uid, Gid, db) {
this.BackupPath = Path.join(global.MetadataPath, 'backups')
this.MetadataBooksPath = Path.join(global.MetadataPath, 'books')
this.Uid = Uid
this.Gid = Gid
@ -142,10 +142,9 @@ class BackupManager {
return
}
const zip = new StreamZip.async({ file: backup.fullPath })
await zip.extract('config/', this.db.ConfigPath)
await zip.extract('config/', global.ConfigPath)
if (backup.backupMetadataCovers) {
var metadataBooksPath = Path.join(this.MetadataPath, 'books')
await zip.extract('metadata-books/', metadataBooksPath)
await zip.extract('metadata-books/', this.MetadataBooksPath)
}
await this.db.reinit()
socket.emit('apply_backup_complete', true)
@ -157,7 +156,7 @@ class BackupManager {
var lastBackup = this.backups.shift()
const zip = new StreamZip.async({ file: lastBackup.fullPath })
await zip.extract('config/', this.db.ConfigPath)
await zip.extract('config/', global.ConfigPath)
console.log('Set Last Backup')
await this.db.reinit()
}
@ -196,7 +195,7 @@ class BackupManager {
async runBackup() {
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
Logger.info(`[BackupManager] Running Backup`)
var metadataBooksPath = this.serverSettings.backupMetadataCovers ? Path.join(this.MetadataPath, 'books') : null
var metadataBooksPath = this.serverSettings.backupMetadataCovers ? this.MetadataBooksPath : null
var newBackup = new Backup()

View file

@ -5,9 +5,8 @@ const Logger = require('./Logger')
const { resizeImage } = require('./utils/ffmpegHelpers')
class CacheManager {
constructor(MetadataPath) {
this.MetadataPath = MetadataPath
this.CachePath = Path.join(this.MetadataPath, 'cache')
constructor() {
this.CachePath = Path.join(global.MetadataPath, 'cache')
this.CoverCachePath = Path.join(this.CachePath, 'covers')
}

View file

@ -6,21 +6,18 @@ const readChunk = require('read-chunk')
const imageType = require('image-type')
const globals = require('./utils/globals')
const { CoverDestination } = require('./utils/constants')
const { downloadFile } = require('./utils/fileUtils')
class CoverController {
constructor(db, cacheManager, MetadataPath, AudiobookPath) {
constructor(db, cacheManager) {
this.db = db
this.cacheManager = cacheManager
this.MetadataPath = MetadataPath.replace(/\\/g, '/')
this.BookMetadataPath = Path.posix.join(this.MetadataPath, 'books')
this.AudiobookPath = AudiobookPath
this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books')
}
getCoverDirectory(audiobook) {
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
if (this.db.serverSettings.storeCoverWithBook) {
return {
fullPath: audiobook.fullPath,
relPath: '/s/book/' + audiobook.id

View file

@ -13,18 +13,15 @@ const ServerSettings = require('./objects/ServerSettings')
const SSOSettings = require('./objects/SSOSettings')
class Db {
constructor(ConfigPath, AudiobookPath) {
this.ConfigPath = ConfigPath
this.AudiobookPath = AudiobookPath
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
this.UsersPath = Path.join(ConfigPath, 'users')
this.SessionsPath = Path.join(ConfigPath, 'sessions')
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
this.SettingsPath = Path.join(ConfigPath, 'settings')
this.SSOPath = Path.join(ConfigPath, 'sso')
this.CollectionsPath = Path.join(ConfigPath, 'collections')
this.AuthorsPath = Path.join(ConfigPath, 'authors')
constructor() {
this.AudiobooksPath = Path.join(global.ConfigPath, 'audiobooks')
this.UsersPath = Path.join(global.ConfigPath, 'users')
this.SessionsPath = Path.join(global.ConfigPath, 'sessions')
this.LibrariesPath = Path.join(global.ConfigPath, 'libraries')
this.SettingsPath = Path.join(global.ConfigPath, 'settings')
this.CollectionsPath = Path.join(global.ConfigPath, 'collections')
this.AuthorsPath = Path.join(global.ConfigPath, 'authors')
this.SSOPath = Path.join(global.ConfigPath, 'sso')
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
this.usersDb = new njodb.Database(this.UsersPath)
@ -91,7 +88,7 @@ class Db {
name: 'Main',
folder: { // Generates default folder
id: 'audiobooks',
fullPath: this.AudiobookPath,
fullPath: global.AudiobookPath,
libraryId: 'main'
}
})
@ -136,6 +133,7 @@ class Db {
this.SSOSettings = new SSOSettings()
await this.insertEntity('settings', this.SSOSettings)
}
global.ServerSettings = this.serverSettings.toJSON()
}
async load() {
@ -184,11 +182,19 @@ class Db {
// Update server version in server settings
if (this.previousVersion) {
this.serverSettings.version = version
await this.updateEntity('settings', this.serverSettings)
await this.updateServerSettings()
}
}
updateAudiobook(audiobook) {
async updateAudiobook(audiobook) {
if (audiobook && audiobook.saveAbMetadata) {
// TODO: Book may have updates where this save is not necessary
// add check first if metadata update is needed
await audiobook.saveAbMetadata()
} else {
Logger.error(`[Db] Invalid audiobook object passed to updateAudiobook`, audiobook)
}
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
return true
@ -198,6 +204,28 @@ class Db {
})
}
insertAudiobook(audiobook) {
return this.insertAudiobooks([audiobook])
}
async insertAudiobooks(audiobooks) {
// TODO: Books may have updates where this save is not necessary
// add check first if metadata update is needed
await Promise.all(audiobooks.map(async (ab) => {
if (ab && ab.saveAbMetadata) return ab.saveAbMetadata()
return null
}))
return this.audiobooksDb.insert(audiobooks).then((results) => {
Logger.debug(`[DB] Audiobooks inserted ${results.inserted}`)
this.audiobooks = this.audiobooks.concat(audiobooks)
return true
}).catch((error) => {
Logger.error(`[DB] Audiobooks insert failed ${error}`)
return false
})
}
updateUserStream(userId, streamId) {
return this.usersDb.update((record) => record.id === userId, (user) => {
user.stream = streamId
@ -215,6 +243,11 @@ class Db {
})
}
updateServerSettings() {
global.ServerSettings = this.serverSettings.toJSON()
return this.updateEntity('settings', this.serverSettings)
}
insertEntities(entityName, entities) {
var entityDb = this.getEntityDb(entityName)
return entityDb.insert(entities).then((results) => {
@ -322,5 +355,12 @@ class Db {
return []
})
}
// Check if server was updated and previous version was earlier than param
checkPreviousVersionIsBefore(version) {
if (!this.previousVersion) return false
// true if version > previousVersion
return version.localeCompare(this.previousVersion) >= 0
}
}
module.exports = Db

View file

@ -11,14 +11,12 @@ const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
const { getFileSize } = require('./utils/fileUtils')
const TAG = 'DownloadManager'
class DownloadManager {
constructor(db, MetadataPath, AudiobookPath, Uid, Gid) {
constructor(db, Uid, Gid) {
this.Uid = Uid
this.Gid = Gid
this.db = db
this.MetadataPath = MetadataPath
this.AudiobookPath = AudiobookPath
this.downloadDirPath = Path.join(this.MetadataPath, 'downloads')
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
this.pendingDownloads = []
this.downloads = []
@ -248,7 +246,7 @@ class DownloadManager {
// Supporting old local file prefix
var bookCoverPath = audiobook.book.cover ? audiobook.book.cover.replace(/\\/g, '/') : null
if (!_cover && bookCoverPath && bookCoverPath.startsWith('/local')) {
_cover = Path.posix.join(this.AudiobookPath.replace(/\\/g, '/'), _cover.replace('/local', ''))
_cover = Path.posix.join(global.AudiobookPath, _cover.replace('/local', ''))
Logger.debug('Local cover url', _cover)
}

View file

@ -4,12 +4,11 @@ const fs = require('fs-extra')
const Logger = require('./Logger')
class HlsController {
constructor(db, auth, streamManager, emitter, StreamsPath) {
constructor(db, auth, streamManager, emitter) {
this.db = db
this.auth = auth
this.streamManager = streamManager
this.emitter = emitter
this.StreamsPath = StreamsPath
this.router = express()
this.init()
@ -27,7 +26,7 @@ class HlsController {
async streamFileRequest(req, res) {
var streamId = req.params.stream
var fullFilePath = Path.join(this.StreamsPath, streamId, req.params.file)
var fullFilePath = Path.join(this.streamManager.StreamsPath, streamId, req.params.file)
// development test stream - ignore
if (streamId === 'test') {

View file

@ -8,11 +8,10 @@ const Logger = require('./Logger')
const TAG = '[LogManager]'
class LogManager {
constructor(MetadataPath, db) {
constructor(db) {
this.db = db
this.MetadataPath = MetadataPath
this.logDirPath = Path.join(this.MetadataPath, 'logs')
this.logDirPath = Path.join(global.MetadataPath, 'logs')
this.dailyLogDirPath = Path.join(this.logDirPath, 'daily')
this.currentDailyLog = null

View file

@ -39,27 +39,35 @@ class Server {
this.Uid = isNaN(UID) ? 0 : Number(UID)
this.Gid = isNaN(GID) ? 0 : Number(GID)
this.Host = '0.0.0.0'
this.ConfigPath = Path.normalize(CONFIG_PATH)
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
this.MetadataPath = Path.normalize(METADATA_PATH)
global.Uid = this.Uid
global.Gid = this.Gid
global.ConfigPath = Path.normalize(CONFIG_PATH)
global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
global.MetadataPath = Path.normalize(METADATA_PATH)
// Fix backslash if not on Windows
if (process.platform !== 'win32') {
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
global.AudiobookPath = global.AudiobookPath.replace(/\\/g, '/')
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
}
fs.ensureDirSync(CONFIG_PATH, 0o774)
fs.ensureDirSync(METADATA_PATH, 0o774)
fs.ensureDirSync(AUDIOBOOK_PATH, 0o774)
fs.ensureDirSync(global.ConfigPath, 0o774)
fs.ensureDirSync(global.MetadataPath, 0o774)
fs.ensureDirSync(global.AudiobookPath, 0o774)
this.db = new Db(this.ConfigPath, this.AudiobookPath)
this.db = new Db()
this.auth = new Auth(this.db)
this.backupManager = new BackupManager(this.MetadataPath, this.Uid, this.Gid, this.db)
this.logManager = new LogManager(this.MetadataPath, this.db)
this.cacheManager = new CacheManager(this.MetadataPath)
this.watcher = new Watcher(this.AudiobookPath)
this.coverController = new CoverController(this.db, this.cacheManager, this.MetadataPath, this.AudiobookPath)
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
this.backupManager = new BackupManager(this.Uid, this.Gid, this.db)
this.logManager = new LogManager(this.db)
this.cacheManager = new CacheManager()
this.watcher = new Watcher()
this.coverController = new CoverController(this.db, this.cacheManager)
this.scanner = new Scanner(this.db, this.coverController, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this), this.clientEmitter.bind(this))
this.streamManager = new StreamManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.Uid, this.Gid)
this.apiController = new ApiController(this.MetadataPath, this.db, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.downloadManager = new DownloadManager(this.db, this.Uid, this.Gid)
this.apiController = new ApiController(this.db, this.auth, this.scanner, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsController = new HlsController(this.db, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
Logger.logManager = this.logManager
@ -101,7 +109,7 @@ class Server {
clientEmitter(userId, ev, data) {
var clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
return Logger.debug(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
@ -133,10 +141,18 @@ class Server {
Logger.info(`[Server] Running scan for duplicate book IDs`)
await this.scanner.fixDuplicateIds()
}
// If server upgrade and last version was 1.7.0 or earlier - add abmetadata files
// if (this.db.checkPreviousVersionIsBefore('1.7.1')) {
// TODO: wait until stable
// }
this.watcher.initWatcher(this.libraries)
this.watcher.on('files', this.filesChanged.bind(this))
if (this.db.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`)
this.watcher.disabled = true
} else {
this.watcher.initWatcher(this.libraries)
this.watcher.on('files', this.filesChanged.bind(this))
}
this.passportInit()
}
@ -194,10 +210,10 @@ class Server {
app.use(express.static(distPath))
// Old static path for covers
app.use('/local', this.authMiddleware.bind(this), express.static(this.AudiobookPath))
app.use('/local', this.authMiddleware.bind(this), express.static(global.AudiobookPath))
// Metadata folder static path
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
// Downloads folder static path
app.use('/downloads', this.authMiddleware.bind(this), express.static(this.downloadManager.downloadDirPath))
@ -314,7 +330,6 @@ class Server {
// Streaming
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
@ -399,7 +414,7 @@ class Server {
// Remove unused /metadata/books/{id} folders
async purgeMetadata() {
var booksMetadata = Path.join(this.MetadataPath, 'books')
var booksMetadata = Path.join(global.MetadataPath, 'books')
var booksMetadataExists = await fs.pathExists(booksMetadata)
if (!booksMetadataExists) return
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
@ -458,25 +473,27 @@ class Server {
var library = this.db.libraries.find(lib => lib.id === libraryId)
if (!library) {
return res.status(500).error(`Library not found with id ${libraryId}`)
return res.status(500).send(`Library not found with id ${libraryId}`)
}
var folder = library.folders.find(fold => fold.id === folderId)
if (!folder) {
return res.status(500).error(`Folder not found with id ${folderId} in library ${library.name}`)
return res.status(500).send(`Folder not found with id ${folderId} in library ${library.name}`)
}
if (!files.length || !title || !author) {
return res.status(500).error(`Invalid post data`)
if (!files.length || !title) {
return res.status(500).send(`Invalid post data`)
}
// For setting permissions recursively
var firstDirPath = Path.join(folder.fullPath, author)
var outputDirectory = ''
if (series && series.length && series !== 'null') {
if (series && author) {
outputDirectory = Path.join(folder.fullPath, author, series, title)
} else {
} else if (author) {
outputDirectory = Path.join(folder.fullPath, author, title)
} else {
outputDirectory = Path.join(folder.fullPath, title)
}
var exists = await fs.pathExists(outputDirectory)
@ -672,9 +689,9 @@ class Server {
const initialPayload = {
serverSettings: this.serverSettings.toJSON(),
audiobookPath: this.AudiobookPath,
metadataPath: this.MetadataPath,
configPath: this.ConfigPath,
audiobookPath: global.AudiobookPath,
metadataPath: global.MetadataPath,
configPath: global.ConfigPath,
user: client.user.toJSONForBrowser(),
stream: client.stream || null,
librariesScanning: this.scanner.librariesScanning,

View file

@ -5,15 +5,14 @@ const fs = require('fs-extra')
const Path = require('path')
class StreamManager {
constructor(db, MetadataPath, emitter, clientEmitter) {
constructor(db, emitter, clientEmitter) {
this.db = db
this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
this.streams = []
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
this.StreamsPath = Path.join(global.MetadataPath, 'streams')
}
get audiobooks() {
@ -68,12 +67,12 @@ class StreamManager {
async tempCheckStrayStreams() {
try {
var dirs = await fs.readdir(this.MetadataPath)
var dirs = await fs.readdir(global.MetadataPath)
if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => {
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads' && dirname !== 'backups' && dirname !== 'logs' && dirname !== 'cache') {
var fullPath = Path.join(this.MetadataPath, dirname)
var fullPath = Path.join(global.MetadataPath, dirname)
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
return fs.remove(fullPath)
}
@ -155,6 +154,30 @@ class StreamManager {
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
}
async closeStreamApiRequest(userId, streamId) {
Logger.info('[StreamManager] Close Stream Api Request', streamId)
var stream = this.streams.find(s => s.id === streamId)
if (!stream) {
Logger.warn('[StreamManager] Stream not found', streamId)
return
}
if (!stream.client || !stream.client.user || stream.client.user.id !== userId) {
Logger.warn(`[StreamManager] Stream close request from invalid user ${userId}`, stream.client)
return
}
stream.client.user.stream = null
stream.client.stream = null
this.db.updateUserStream(stream.client.user.id, null)
await stream.close()
this.streams = this.streams.filter(s => s.id !== streamId)
Logger.info(`[StreamManager] Stream ${streamId} closed via API request by ${userId}`)
}
streamSync(socket, syncData) {
const client = socket.sheepClient
if (!client || !client.stream) {
@ -233,35 +256,5 @@ class StreamManager {
res.sendStatus(200)
}
streamUpdate(socket, { currentTime, streamId }) {
var client = socket.sheepClient
if (!client || !client.stream) {
Logger.error('No stream for client', (client && client.user) ? client.user.id : 'No Client')
return
}
if (client.stream.id !== streamId) {
Logger.error('Stream id mismatch on stream update', streamId, client.stream.id)
return
}
client.stream.updateClientCurrentTime(currentTime)
if (!client.user) {
Logger.error('No User for client', client)
return
}
if (!client.user.updateAudiobookProgressFromStream) {
Logger.error('Invalid User for client', client)
return
}
var userAudiobook = client.user.updateAudiobookProgressFromStream(client.stream)
this.db.updateEntity('user', client.user)
if (userAudiobook) {
this.clientEmitter(client.user.id, 'current_user_audiobook_update', {
id: userAudiobook.audiobookId,
data: userAudiobook.toJSON()
})
}
}
}
module.exports = StreamManager

View file

@ -13,6 +13,8 @@ class FolderWatcher extends EventEmitter {
this.pendingFileUpdates = []
this.pendingDelay = 4000
this.pendingTimeout = null
this.disabled = false
}
get pendingFilePaths() {
@ -66,15 +68,19 @@ class FolderWatcher extends EventEmitter {
initWatcher(libraries) {
libraries.forEach((lib) => {
this.buildLibraryWatcher(lib)
if (!lib.disableWatcher) {
this.buildLibraryWatcher(lib)
}
})
}
addLibrary(library) {
if (this.disabled || library.disableWatcher) return
this.buildLibraryWatcher(library)
}
updateLibrary(library) {
if (this.disabled || library.disableWatcher) return
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
if (libwatcher) {
libwatcher.name = library.name
@ -90,6 +96,7 @@ class FolderWatcher extends EventEmitter {
}
removeLibrary(library) {
if (this.disabled) return
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
if (libwatcher) {
Logger.info(`[Watcher] Removed watcher for "${library.name}"`)

View file

@ -18,9 +18,6 @@ class BookController {
}
findOne(req, res) {
if (!req.user) {
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
@ -48,8 +45,8 @@ class BookController {
var hasUpdates = audiobook.update(req.body)
if (hasUpdates) {
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
}
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
res.json(audiobook.toJSON())
}
@ -259,5 +256,24 @@ class BookController {
}
return this.cacheManager.handleCoverCache(res, audiobook, options)
}
// POST api/books/:id/match
async match(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to match without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
// Check user can access this audiobooks library
if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) {
return res.sendStatus(403)
}
var options = req.body || {}
var matchResult = await this.scanner.quickMatchBook(audiobook, options)
res.json(matchResult)
}
}
module.exports = new BookController()

View file

@ -144,10 +144,19 @@ class LibraryController {
}
if (payload.sortBy) {
var sortKey = payload.sortBy
// Handle server setting sortingIgnorePrefix
if ((sortKey === 'book.series' || sortKey === 'book.title') && this.db.serverSettings.sortingIgnorePrefix) {
// Book.js has seriesIgnorePrefix and titleIgnorePrefix getters
sortKey += 'IgnorePrefix'
}
var direction = payload.sortDesc ? 'desc' : 'asc'
audiobooks = naturalSort(audiobooks)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
return payload.sortBy.split('.').reduce((a, b) => a[b], ab)
return sortKey.split('.').reduce((a, b) => a[b], ab)
})
}
@ -202,7 +211,14 @@ class LibraryController {
}
var series = libraryHelpers.getSeriesFromBooks(audiobooks, payload.minified)
series = sort(series).asc(s => s.name)
var sortingIgnorePrefix = this.db.serverSettings.sortingIgnorePrefix
series = sort(series).asc(s => {
if (sortingIgnorePrefix && s.name.toLowerCase().startsWith('the ')) {
return s.name.substr(4)
}
return s.name
})
payload.total = series.length
if (payload.limit) {
@ -318,7 +334,7 @@ class LibraryController {
async reorder(req, res) {
if (!req.user.isRoot) {
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
return res.sendStatus(401)
return res.sendStatus(403)
}
var orderdata = req.body
@ -454,6 +470,15 @@ class LibraryController {
res.json(Object.values(authors))
}
async matchBooks(req, res) {
if (!req.user.isRoot) {
Logger.error(`[LibraryController] Non-root user attempted to match library books`, req.user)
return res.sendStatus(403)
}
this.scanner.matchLibraryBooks(req.library)
res.sendStatus(200)
}
middleware(req, res, next) {
var librariesAccessible = req.user.librariesAccessible || []
if (librariesAccessible && librariesAccessible.length && !librariesAccessible.includes(req.params.id)) {

View file

@ -11,6 +11,9 @@ class AudioFile {
this.ext = null
this.path = null
this.fullPath = null
this.mtimeMs = null
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
this.trackNumFromMeta = null
@ -51,6 +54,9 @@ class AudioFile {
ext: this.ext,
path: this.path,
fullPath: this.fullPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
trackNumFromMeta: this.trackNumFromMeta,
discNumFromMeta: this.discNumFromMeta,
@ -82,6 +88,9 @@ class AudioFile {
this.ext = data.ext
this.path = data.path
this.fullPath = data.fullPath
this.mtimeMs = data.mtimeMs || 0
this.ctimeMs = data.ctimeMs || 0
this.birthtimeMs = data.birthtimeMs || 0
this.addedAt = data.addedAt
this.manuallyVerified = !!data.manuallyVerified
this.invalid = !!data.invalid
@ -124,6 +133,9 @@ class AudioFile {
this.ext = fileData.ext
this.path = fileData.path
this.fullPath = fileData.fullPath
this.mtimeMs = fileData.mtimeMs || 0
this.ctimeMs = fileData.ctimeMs || 0
this.birthtimeMs = fileData.birthtimeMs || 0
this.addedAt = Date.now()
this.trackNumFromMeta = fileData.trackNumFromMeta

View file

@ -1,10 +1,11 @@
const Path = require('path')
const fs = require('fs-extra')
const { bytesPretty, readTextFile } = require('../utils/fileUtils')
const { comparePaths, getIno, getId, elapsedPretty } = require('../utils/index')
const { bytesPretty, readTextFile, getIno } = require('../utils/fileUtils')
const { comparePaths, getId, elapsedPretty } = require('../utils/index')
const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
const nfoGenerator = require('../utils/nfoGenerator')
const abmetadataGenerator = require('../utils/abmetadataGenerator')
const Logger = require('../Logger')
const Book = require('./Book')
const AudioTrack = require('./AudioTrack')
@ -21,6 +22,9 @@ class Audiobook {
this.path = null
this.fullPath = null
this.mtimeMs = null
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
this.lastUpdate = null
this.lastScan = null
@ -44,6 +48,9 @@ class Audiobook {
if (audiobook) {
this.construct(audiobook)
}
// Temp flags
this.isSavingMetadata = false
}
construct(audiobook) {
@ -53,6 +60,9 @@ class Audiobook {
this.folderId = audiobook.folderId || 'audiobooks'
this.path = audiobook.path
this.fullPath = audiobook.fullPath
this.mtimeMs = audiobook.mtimeMs || 0
this.ctimeMs = audiobook.ctimeMs || 0
this.birthtimeMs = audiobook.birthtimeMs || 0
this.addedAt = audiobook.addedAt
this.lastUpdate = audiobook.lastUpdate || this.addedAt
this.lastScan = audiobook.lastScan || null
@ -173,11 +183,11 @@ class Audiobook {
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
title: this.title,
author: this.author,
cover: this.cover,
path: this.path,
fullPath: this.fullPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
lastUpdate: this.lastUpdate,
lastScan: this.lastScan,
@ -204,6 +214,9 @@ class Audiobook {
tags: this.tags,
path: this.path,
fullPath: this.fullPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
lastUpdate: this.lastUpdate,
duration: this.duration,
@ -227,6 +240,9 @@ class Audiobook {
folderId: this.folderId,
path: this.path,
fullPath: this.fullPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
lastUpdate: this.lastUpdate,
duration: this.duration,
@ -334,6 +350,9 @@ class Audiobook {
this.path = data.path
this.fullPath = data.fullPath
this.mtimeMs = data.mtimeMs || 0
this.ctimeMs = data.ctimeMs || 0
this.birthtimeMs = data.birthtimeMs || 0
this.addedAt = Date.now()
this.lastUpdate = this.addedAt
@ -425,13 +444,8 @@ class Audiobook {
hasUpdates = true
}
if (payload.book) {
if (!this.book) {
this.setBook(payload.book)
hasUpdates = true
} else if (this.book.update(payload.book)) {
hasUpdates = true
}
if (payload.book && this.book.update(payload.book)) {
hasUpdates = true
}
if (hasUpdates) {
@ -526,7 +540,7 @@ class Audiobook {
}
// On scan check other files found with other files saved
async syncOtherFiles(newOtherFiles, metadataPath, opfMetadataOverrideDetails, forceRescan = false) {
async syncOtherFiles(newOtherFiles, opfMetadataOverrideDetails) {
var hasUpdates = false
var currOtherFileNum = this.otherFiles.length
@ -535,6 +549,8 @@ class Audiobook {
var alreadyHasDescTxt = otherFilenamesAlreadyInBook.includes('desc.txt')
var alreadyHasReaderTxt = otherFilenamesAlreadyInBook.includes('reader.txt')
var existingAbMetadata = this.otherFiles.find(file => file.filename === 'metadata.abs')
// Filter out other files no longer in directory
var newOtherFilePaths = newOtherFiles.map(f => f.path)
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
@ -543,9 +559,9 @@ class Audiobook {
hasUpdates = true
}
// If desc.txt is new or forcing rescan then read it and update description (will overwrite)
// If desc.txt is new then read it and update description (will overwrite)
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) {
if (descriptionTxt && !alreadyHasDescTxt) {
var newDescription = await readTextFile(descriptionTxt.fullPath)
if (newDescription) {
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
@ -553,9 +569,9 @@ class Audiobook {
hasUpdates = true
}
}
// If reader.txt is new or forcing rescan then read it and update narrator (will overwrite)
// If reader.txt is new then read it and update narrator (will overwrite)
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
if (readerTxt && !alreadyHasReaderTxt) {
var newReader = await readTextFile(readerTxt.fullPath)
if (newReader) {
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
@ -564,7 +580,28 @@ class Audiobook {
}
}
// If OPF file and was not already there
// If metadata.abs is new OR modified then read it and set all defined keys (will overwrite)
var metadataAbs = newOtherFiles.find(file => file.filename === 'metadata.abs')
var shouldUpdateAbs = !!metadataAbs && (metadataAbs.modified || !existingAbMetadata)
if (metadataAbs && metadataAbs.modified) {
Logger.debug(`[Audiobook] metadata.abs file was modified for "${this.title}"`)
}
if (shouldUpdateAbs) {
var abmetadataText = await readTextFile(metadataAbs.fullPath)
if (abmetadataText) {
var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText)
if (metadataUpdateObject && metadataUpdateObject.book) {
if (this.update(metadataUpdateObject)) {
Logger.debug(`[Audiobook] Some details were updated from metadata.abs for "${this.title}"`, metadataUpdateObject)
hasUpdates = true
}
}
}
}
// If OPF file and was not already there OR prefer opf metadata
var metadataOpf = newOtherFiles.find(file => file.ext === '.opf' || file.filename === 'metadata.xml')
if (metadataOpf && (!otherFilenamesAlreadyInBook.includes(metadataOpf.filename) || opfMetadataOverrideDetails)) {
var xmlText = await readTextFile(metadataOpf.fullPath)
@ -643,7 +680,7 @@ class Audiobook {
if (bookCoverPath && bookCoverPath.startsWith('/metadata')) {
// Fixing old cover paths
if (!this.book.coverFullPath) {
this.book.coverFullPath = Path.join(metadataPath, this.book.cover.substr('/metadata/'.length)).replace(/\\/g, '/').replace(/\/\//g, '/')
this.book.coverFullPath = Path.join(global.MetadataPath, this.book.cover.substr('/metadata/'.length)).replace(/\\/g, '/').replace(/\/\//g, '/')
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
hasUpdates = true
}
@ -800,9 +837,10 @@ class Audiobook {
return false
}
// Look for desc.txt and reader.txt and update details if found
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
async saveDataFromTextFiles(opfMetadataOverrideDetails) {
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)}..."`)
@ -814,6 +852,22 @@ class Audiobook {
bookUpdatePayload.narrator = readerText
}
// abmetadata will always overwrite
var abmetadataText = await this.fetchTextFromTextFile('metadata.abs')
if (abmetadataText) {
var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText)
if (metadataUpdateObject && metadataUpdateObject.book) {
Logger.debug(`[Audiobook] "${this.title}" found metadata.abs file`)
for (const key in metadataUpdateObject.book) {
var value = metadataUpdateObject.book[key]
if (key && value !== undefined) {
bookUpdatePayload[key] = value
}
}
}
}
// Opf only overwrites if detail is empty
var metadataOpf = this.otherFiles.find(file => file.isOPFFile || file.filename === 'metadata.xml')
if (metadataOpf) {
var xmlText = await readTextFile(metadataOpf.fullPath)
@ -873,12 +927,6 @@ class Audiobook {
}
}
if (existingFile.filename !== fileFound.filename) {
existingFile.filename = fileFound.filename
existingFile.ext = fileFound.ext
hasUpdated = true
}
if (existingFile.path !== fileFound.path) {
existingFile.path = fileFound.path
existingFile.fullPath = fileFound.fullPath
@ -888,6 +936,20 @@ class Audiobook {
hasUpdated = true
}
var keysToCheck = ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size']
keysToCheck.forEach((key) => {
if (existingFile[key] !== fileFound[key]) {
// Add modified flag on file data object if exists and was changed
if (key === 'mtimeMs' && existingFile[key]) {
fileFound.modified = true
}
existingFile[key] = fileFound[key]
hasUpdated = true
}
})
if (!isAudioFile && existingFile.filetype !== fileFound.filetype) {
existingFile.filetype = fileFound.filetype
hasUpdated = true
@ -927,6 +989,14 @@ class Audiobook {
hasUpdated = true
}
var keysToCheck = ['mtimeMs', 'ctimeMs', 'birthtimeMs']
keysToCheck.forEach((key) => {
if (dataFound[key] != this[key]) {
this[key] = dataFound[key] || 0
hasUpdated = true
}
})
var newAudioFileData = []
var newOtherFileData = []
var existingAudioFileData = []
@ -1017,14 +1087,14 @@ class Audiobook {
}
// Temp fix for cover is set but coverFullPath is not set
fixFullCoverPath(metadataPath) {
fixFullCoverPath() {
if (!this.book.cover) return
var bookCoverPath = this.book.cover.replace(/\\/g, '/')
var newFullCoverPath = null
if (bookCoverPath.startsWith('/s/book/')) {
newFullCoverPath = Path.join(this.fullPath, bookCoverPath.substr(`/s/book/${this.id}`.length)).replace(/\/\//g, '/')
} else if (bookCoverPath.startsWith('/metadata/')) {
newFullCoverPath = Path.join(metadataPath, bookCoverPath.substr('/metadata/'.length)).replace(/\/\//g, '/')
newFullCoverPath = Path.join(global.MetadataPath, bookCoverPath.substr('/metadata/'.length)).replace(/\/\//g, '/')
}
if (newFullCoverPath) {
Logger.debug(`[Audiobook] "${this.title}" fixing full cover path "${this.book.cover}" => "${newFullCoverPath}"`)
@ -1033,5 +1103,26 @@ class Audiobook {
}
return false
}
async saveAbMetadata() {
if (this.isSavingMetadata) return
this.isSavingMetadata = true
var metadataPath = Path.join(global.MetadataPath, 'books', this.id)
if (global.ServerSettings.storeMetadataWithBook) {
metadataPath = this.fullPath
} else {
// Make sure metadata book dir exists
await fs.ensureDir(metadataPath)
}
metadataPath = Path.join(metadataPath, 'metadata.abs')
return abmetadataGenerator.generate(this, metadataPath).then((success) => {
this.isSavingMetadata = false
if (!success) Logger.error(`[Audiobook] Failed saving abmetadata to "${metadataPath}"`)
else Logger.debug(`[Audiobook] Success saving abmetadata to "${metadataPath}"`)
return success
})
}
}
module.exports = Audiobook

View file

@ -6,6 +6,11 @@ class AudiobookFile {
this.ext = null
this.path = null
this.fullPath = null
this.size = null
this.mtimeMs = null
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
if (data) {
@ -25,6 +30,10 @@ class AudiobookFile {
ext: this.ext,
path: this.path,
fullPath: this.fullPath,
size: this.size,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt
}
}
@ -36,6 +45,10 @@ class AudiobookFile {
this.ext = data.ext
this.path = data.path
this.fullPath = data.fullPath
this.size = data.size || 0
this.mtimeMs = data.mtimeMs || 0
this.ctimeMs = data.ctimeMs || 0
this.birthtimeMs = data.birthtimeMs || 0
this.addedAt = data.addedAt
}
@ -46,6 +59,10 @@ class AudiobookFile {
this.ext = data.ext
this.path = data.path
this.fullPath = data.fullPath
this.size = data.size || 0
this.mtimeMs = data.mtimeMs || 0
this.ctimeMs = data.ctimeMs || 0
this.birthtimeMs = data.birthtimeMs || 0
this.addedAt = Date.now()
}
}

View file

@ -49,6 +49,20 @@ class Book {
get _asin() { return this.asin || '' }
get genresCommaSeparated() { return this._genres.join(', ') }
get titleIgnorePrefix() {
if (this._title.toLowerCase().startsWith('the ')) {
return this._title.substr(4) + ', The'
}
return this._title
}
get seriesIgnorePrefix() {
if (this._series.toLowerCase().startsWith('the ')) {
return this._series.substr(4) + ', The'
}
return this._series
}
get shouldSearchForCover() {
if (this.cover) return false
if (this.authorFL !== this.lastCoverSearchAuthor || this.title !== this.lastCoverSearchTitle || !this.lastCoverSearch) return true

View file

@ -8,6 +8,8 @@ class Library {
this.folders = []
this.displayOrder = 1
this.icon = 'database'
this.provider = 'google'
this.disableWatcher = false
this.lastScan = 0
@ -29,6 +31,8 @@ class Library {
this.folders = (library.folders || []).map(f => new Folder(f))
this.displayOrder = library.displayOrder || 1
this.icon = library.icon || 'database'
this.provider = library.provider || 'google'
this.disableWatcher = !!library.disableWatcher
this.createdAt = library.createdAt
this.lastUpdate = library.lastUpdate
@ -41,6 +45,8 @@ class Library {
folders: (this.folders || []).map(f => f.toJSON()),
displayOrder: this.displayOrder,
icon: this.icon,
provider: this.provider,
disableWatcher: this.disableWatcher,
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
@ -65,6 +71,7 @@ class Library {
}
this.displayOrder = data.displayOrder || 1
this.icon = data.icon || 'database'
this.disableWatcher = !!data.disableWatcher
this.createdAt = Date.now()
this.lastUpdate = Date.now()
}
@ -75,6 +82,14 @@ class Library {
this.name = payload.name
hasUpdates = true
}
if (payload.provider && payload.provider !== this.provider) {
this.provider = payload.provider
hasUpdates = true
}
if (payload.disableWatcher !== this.disableWatcher) {
this.disableWatcher = !!payload.disableWatcher
hasUpdates = true
}
if (!isNaN(payload.displayOrder) && payload.displayOrder !== this.displayOrder) {
this.displayOrder = Number(payload.displayOrder)
hasUpdates = true

View file

@ -1,4 +1,4 @@
const { CoverDestination, BookCoverAspectRatio, BookshelfView } = require('../utils/constants')
const { BookCoverAspectRatio, BookshelfView } = require('../utils/constants')
const Logger = require('../Logger')
class ServerSettings {
@ -15,10 +15,11 @@ class ServerSettings {
this.scannerCoverProvider = 'google'
this.scannerPreferAudioMetadata = false
this.scannerPreferOpfMetadata = false
this.scannerDisableWatcher = false
// Metadata
this.coverDestination = CoverDestination.METADATA
this.saveMetadataFile = false
this.storeCoverWithBook = false
this.storeMetadataWithBook = false
// Security/Rate limits
this.rateLimitLoginRequests = 10
@ -38,6 +39,8 @@ class ServerSettings {
this.coverAspectRatio = BookCoverAspectRatio.SQUARE
this.bookshelfView = BookshelfView.STANDARD
this.sortingIgnorePrefix = false
this.chromecastEnabled = false
this.logLevel = Logger.logLevel
this.version = null
@ -54,9 +57,14 @@ class ServerSettings {
this.scannerParseSubtitle = settings.scannerParseSubtitle
this.scannerPreferAudioMetadata = !!settings.scannerPreferAudioMetadata
this.scannerPreferOpfMetadata = !!settings.scannerPreferOpfMetadata
this.scannerDisableWatcher = !!settings.scannerDisableWatcher
this.storeCoverWithBook = settings.storeCoverWithBook
if (this.storeCoverWithBook == undefined) { // storeCoverWithBook added in 1.7.1 to replace coverDestination
this.storeCoverWithBook = !!settings.coverDestination
}
this.storeMetadataWithBook = !!settings.storeCoverWithBook
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
this.saveMetadataFile = !!settings.saveMetadataFile
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
@ -70,6 +78,8 @@ class ServerSettings {
this.coverAspectRatio = !isNaN(settings.coverAspectRatio) ? settings.coverAspectRatio : BookCoverAspectRatio.SQUARE
this.bookshelfView = settings.bookshelfView || BookshelfView.STANDARD
this.sortingIgnorePrefix = !!settings.sortingIgnorePrefix
this.chromecastEnabled = !!settings.chromecastEnabled
this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null
@ -88,8 +98,9 @@ class ServerSettings {
scannerParseSubtitle: this.scannerParseSubtitle,
scannerPreferAudioMetadata: this.scannerPreferAudioMetadata,
scannerPreferOpfMetadata: this.scannerPreferOpfMetadata,
coverDestination: this.coverDestination,
saveMetadataFile: !!this.saveMetadataFile,
scannerDisableWatcher: this.scannerDisableWatcher,
storeCoverWithBook: this.storeCoverWithBook,
storeMetadataWithBook: this.storeMetadataWithBook,
rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow,
backupSchedule: this.backupSchedule,
@ -99,6 +110,8 @@ class ServerSettings {
loggerScannerLogsToKeep: this.loggerScannerLogsToKeep,
coverAspectRatio: this.coverAspectRatio,
bookshelfView: this.bookshelfView,
sortingIgnorePrefix: this.sortingIgnorePrefix,
chromecastEnabled: this.chromecastEnabled,
logLevel: this.logLevel,
version: this.version
}

View file

@ -175,11 +175,6 @@ class Stream extends EventEmitter {
return false
}
updateClientCurrentTime(currentTime) {
Logger.debug('[Stream] Updated client current time', secondsToTimestamp(currentTime))
this.clientCurrentTime = currentTime
}
syncStream({ timeListened, currentTime }) {
var syncLog = ''
// Set user current time

View file

@ -26,8 +26,10 @@ class Audible {
}
getBestImageLink(images) {
var keys = Object.keys(images);
return images[keys[keys.length - 1]];
if (!images) return null
var keys = Object.keys(images)
if (!keys.length) return null
return images[keys[keys.length - 1]]
}
getPrimarySeries(series, publication_name) {

View file

@ -1,10 +1,9 @@
const AuthorFinder = require('../AuthorFinder')
class AuthorScanner {
constructor(db, MetadataPath) {
constructor(db) {
this.db = db
this.MetadataPath = MetadataPath
this.authorFinder = new AuthorFinder(MetadataPath)
this.authorFinder = new AuthorFinder()
}
getUniqueAuthors() {

View file

@ -10,6 +10,7 @@ const { getId, secondsToTimestamp } = require('../utils/index')
class LibraryScan {
constructor() {
this.id = null
this.type = null
this.libraryId = null
this.libraryName = null
this.folders = null
@ -46,6 +47,7 @@ class LibraryScan {
get getScanEmitData() {
return {
id: this.libraryId,
type: this.type,
name: this.libraryName,
results: {
added: this.resultsAdded,
@ -64,10 +66,11 @@ class LibraryScan {
toJSON() {
return {
id: this.id,
type: this.type,
libraryId: this.libraryId,
libraryName: this.libraryName,
folders: this.folders.map(f => f.toJSON()),
scanOptions: this.scanOptions.toJSON(),
scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null,
startedAt: this.startedAt,
finishedAt: this.finishedAt,
elapsed: this.elapsed,
@ -77,8 +80,9 @@ class LibraryScan {
}
}
setData(library, scanOptions) {
setData(library, scanOptions, type = 'scan') {
this.id = getId('lscan')
this.type = type
this.libraryId = library.id
this.libraryName = library.name
this.folders = library.folders.map(folder => new Folder(folder.toJSON()))

View file

@ -1,5 +1,3 @@
const { CoverDestination } = require('../utils/constants')
class ScanOptions {
constructor(options) {
this.forceRescan = false
@ -7,7 +5,7 @@ class ScanOptions {
// Server settings
this.parseSubtitles = false
this.findCovers = false
this.coverDestination = CoverDestination.METADATA
this.storeCoverWithBook = false
this.preferAudioMetadata = false
this.preferOpfMetadata = false
@ -32,7 +30,7 @@ class ScanOptions {
metadataPrecedence: this.metadataPrecedence,
parseSubtitles: this.parseSubtitles,
findCovers: this.findCovers,
coverDestination: this.coverDestination,
storeCoverWithBook: this.storeCoverWithBook,
preferAudioMetadata: this.preferAudioMetadata,
preferOpfMetadata: this.preferOpfMetadata
}
@ -43,7 +41,7 @@ class ScanOptions {
this.parseSubtitles = !!serverSettings.scannerParseSubtitle
this.findCovers = !!serverSettings.scannerFindCovers
this.coverDestination = serverSettings.coverDestination
this.storeCoverWithBook = serverSettings.storeCoverWithBook
this.preferAudioMetadata = serverSettings.scannerPreferAudioMetadata
this.preferOpfMetadata = serverSettings.scannerPreferOpfMetadata
}

View file

@ -5,8 +5,8 @@ const Path = require('path')
const Logger = require('../Logger')
const { version } = require('../../package.json')
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('../utils/scandir')
const { comparePaths, getIno, getId, msToTimestamp } = require('../utils/index')
const { ScanResult, CoverDestination, LogLevel } = require('../utils/constants')
const { comparePaths, getId } = require('../utils/index')
const { ScanResult, LogLevel } = require('../utils/constants')
const AudioFileScanner = require('./AudioFileScanner')
const BookFinder = require('../BookFinder')
@ -15,12 +15,9 @@ const LibraryScan = require('./LibraryScan')
const ScanOptions = require('./ScanOptions')
class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH
this.BookMetadataPath = Path.posix.join(this.MetadataPath.replace(/\\/g, '/'), 'books')
var LogDirPath = Path.join(this.MetadataPath, 'logs')
this.ScanLogPath = Path.join(LogDirPath, 'scans')
constructor(db, coverController, emitter) {
this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books')
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
this.db = db
this.coverController = coverController
@ -33,7 +30,7 @@ class Scanner {
}
getCoverDirectory(audiobook) {
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
if (this.db.serverSettings.storeCoverWithBook) {
return {
fullPath: audiobook.fullPath,
relPath: '/s/book/' + audiobook.id
@ -88,8 +85,8 @@ class Scanner {
// Sync other files first so that local images are used as cover art
// TODO: Cleanup other file sync
var allOtherFiles = checkRes.newOtherFileData.concat(audiobook._otherFiles)
if (await audiobook.syncOtherFiles(allOtherFiles, this.MetadataPath, this.db.serverSettings.scannerPreferOpfMetadata)) {
var allOtherFiles = checkRes.newOtherFileData.concat(checkRes.existingOtherFileData)
if (await audiobook.syncOtherFiles(allOtherFiles, this.db.serverSettings.scannerPreferOpfMetadata)) {
hasUpdated = true
}
@ -120,7 +117,7 @@ class Scanner {
if (hasUpdated) {
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
await this.db.updateEntity('audiobook', audiobook)
await this.db.updateAudiobook(audiobook)
return ScanResult.UPDATED
}
return ScanResult.UPTODATE
@ -208,6 +205,7 @@ class Scanner {
// Check for existing & removed audiobooks
for (let i = 0; i < audiobooksInLibrary.length; i++) {
var audiobook = audiobooksInLibrary[i]
// Find audiobook folder with matching inode or matching path
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path))
if (!dataFound) {
libraryScan.addLog(LogLevel.WARN, `Audiobook "${audiobook.title}" is missing`)
@ -317,7 +315,7 @@ class Scanner {
}))
newAudiobooks = newAudiobooks.filter(ab => ab) // Filter out nulls
libraryScan.resultsAdded += newAudiobooks.length
await this.db.insertEntities('audiobook', newAudiobooks)
await this.db.insertAudiobooks(newAudiobooks)
this.emitter('audiobooks_added', newAudiobooks.map(ab => ab.toJSONExpanded()))
}
@ -330,7 +328,7 @@ class Scanner {
if (newOtherFileData.length || libraryScan.scanOptions.forceRescan) {
// TODO: Cleanup other file sync
var allOtherFiles = newOtherFileData.concat(existingOtherFileData)
if (await audiobook.syncOtherFiles(allOtherFiles, this.MetadataPath, libraryScan.preferOpfMetadata)) {
if (await audiobook.syncOtherFiles(allOtherFiles, libraryScan.preferOpfMetadata)) {
hasUpdated = true
}
}
@ -525,7 +523,7 @@ class Scanner {
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
var newAudiobook = await this.scanPotentialNewAudiobook(folder, fullPath)
if (newAudiobook) {
await this.db.insertEntity('audiobook', newAudiobook)
await this.db.insertAudiobook(newAudiobook)
this.emitter('audiobook_added', newAudiobook.toJSONExpanded())
}
bookGroupingResults[bookDir] = newAudiobook ? ScanResult.ADDED : ScanResult.NOTHING
@ -615,7 +613,7 @@ class Scanner {
}
Logger.warn('Found duplicate ID - updating from', ab.id, 'to', abCopy.id)
await this.db.removeEntity('audiobook', ab.id)
await this.db.insertEntity('audiobook', abCopy)
await this.db.insertAudiobook(abCopy)
audiobooksUpdated++
} else {
ids[ab.id] = true
@ -625,5 +623,101 @@ class Scanner {
Logger.info(`[Scanner] Updated ${audiobooksUpdated} audiobook IDs`)
}
}
async quickMatchBook(audiobook, options = {}) {
var provider = options.provider || 'google'
var searchTitle = options.title || audiobook.book._title
var searchAuthor = options.author || audiobook.book._author
var results = await this.bookFinder.search(provider, searchTitle, searchAuthor)
if (!results.length) {
return {
warning: `No ${provider} match found`
}
}
var matchData = results[0]
// Update cover if not set OR overrideCover flag
var hasUpdated = false
if (matchData.cover && (!audiobook.book.cover || options.overrideCover)) {
Logger.debug(`[BookController] Updating cover "${matchData.cover}"`)
var coverResult = await this.coverController.downloadCoverFromUrl(audiobook, matchData.cover)
if (!coverResult || coverResult.error || !coverResult.cover) {
Logger.warn(`[BookController] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
} else {
hasUpdated = true
}
}
// Update book details if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'author', 'narrator', 'publisher', 'publishYear', 'series', 'volumeNumber', 'asin', 'isbn']
const updatePayload = {}
for (const key in matchData) {
if (matchData[key] && detailKeysToUpdate.includes(key) && (!audiobook.book[key] || options.overrideDetails)) {
updatePayload[key] = matchData[key]
}
}
if (Object.keys(updatePayload).length) {
Logger.debug('[BookController] Updating details', updatePayload)
if (audiobook.update({ book: updatePayload })) {
hasUpdated = true
}
}
if (hasUpdated) {
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONExpanded())
}
return {
updated: hasUpdated,
audiobook: audiobook.toJSONExpanded()
}
}
async matchLibraryBooks(library) {
if (this.isLibraryScanning(library.id)) {
Logger.error(`[Scanner] Already scanning ${library.id}`)
return
}
const provider = library.provider || 'google'
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
if (!audiobooksInLibrary.length) {
return
}
var libraryScan = new LibraryScan()
libraryScan.setData(library, null, 'match')
this.librariesScanning.push(libraryScan.getScanEmitData)
this.emitter('scan_start', libraryScan.getScanEmitData)
Logger.info(`[Scanner] Starting library match books scan ${libraryScan.id} for ${libraryScan.libraryName}`)
for (let i = 0; i < audiobooksInLibrary.length; i++) {
var audiobook = audiobooksInLibrary[i]
Logger.debug(`[Scanner] Quick matching "${audiobook.title}" (${i + 1} of ${audiobooksInLibrary.length})`)
var result = await this.quickMatchBook(audiobook, { provider })
if (result.warning) {
Logger.warn(`[Scanner] Match warning ${result.warning} for audiobook "${audiobook.title}"`)
} else if (result.updated) {
libraryScan.resultsUpdated++
}
if (this.cancelLibraryScan[libraryScan.libraryId]) {
Logger.info(`[Scanner] Library match scan canceled for "${libraryScan.libraryName}"`)
delete this.cancelLibraryScan[libraryScan.libraryId]
var scanData = libraryScan.getScanEmitData
scanData.results = false
this.emitter('scan_complete', scanData)
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
return
}
}
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
this.emitter('scan_complete', libraryScan.getScanEmitData)
}
}
module.exports = Scanner

View file

@ -0,0 +1,102 @@
const fs = require('fs-extra')
const filePerms = require('./filePerms')
const package = require('../../package.json')
const Logger = require('../Logger')
const bookKeyMap = {
title: 'title',
subtitle: 'subtitle',
author: 'authorFL',
narrator: 'narratorFL',
series: 'series',
volumeNumber: 'volumeNumber',
publishYear: 'publishYear',
publisher: 'publisher',
description: 'description',
isbn: 'isbn',
asin: 'asin',
language: 'language',
genres: 'genresCommaSeparated'
}
function generate(audiobook, outputPath) {
var fileString = ';ABMETADATA1\n'
fileString += `#audiobookshelf v${package.version}\n\n`
for (const key in bookKeyMap) {
const value = audiobook.book[bookKeyMap[key]] || ''
fileString += `${key}=${value}\n`
}
if (audiobook.chapters.length) {
fileString += '\n'
audiobook.chapters.forEach((chapter) => {
fileString += `[CHAPTER]\n`
fileString += `start=${chapter.start}\n`
fileString += `end=${chapter.end}\n`
fileString += `title=${chapter.title}\n`
})
}
return fs.writeFile(outputPath, fileString).then(() => {
return filePerms(outputPath, 0o774, global.Uid, global.Gid, true).then((data) => true)
}).catch((error) => {
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
return false
})
}
module.exports.generate = generate
function parseAbMetadataText(text) {
if (!text) return null
var lines = text.split(/\r?\n/)
// Check first line and get abmetadata version number
var firstLine = lines.shift().toLowerCase()
if (!firstLine.startsWith(';abmetadata')) {
Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`)
return null
}
var abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim())
if (isNaN(abmetadataVersion)) {
Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - using 1`)
abmetadataVersion = 1
}
// Remove comments and empty lines
const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following
lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0]))
// Get lines that map to book details (all lines before the first chapter section)
var firstSectionLine = lines.findIndex(l => l.startsWith('['))
var detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines
// Put valid book detail values into map
const bookDetails = {}
for (let i = 0; i < detailLines.length; i++) {
var line = detailLines[i]
var keyValue = line.split('=')
if (keyValue.length < 2) {
Logger.warn('abmetadata invalid line has no =', line)
} else if (!bookKeyMap[keyValue[0].trim()]) {
Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid book detail key`)
} else {
var key = keyValue[0].trim()
bookDetails[key] = keyValue[1].trim()
// Genres convert to array of strings
if (key === 'genres') {
bookDetails[key] = bookDetails[key] ? bookDetails[key].split(',').map(genre => genre.trim()) : []
} else if (!bookDetails[key]) { // Use null for empty details
bookDetails[key] = null
}
}
}
// TODO: Chapter support
return {
book: bookDetails
}
}
module.exports.parse = parseAbMetadataText

View file

@ -1,37 +0,0 @@
const fs = require('fs-extra')
const filePerms = require('./filePerms')
const package = require('../../package.json')
const Logger = require('../Logger')
const bookKeyMap = {
title: 'title',
subtitle: 'subtitle',
author: 'authorFL',
narrator: 'narratorFL',
series: 'series',
volumeNumber: 'volumeNumber',
publishYear: 'publishYear',
publisher: 'publisher',
description: 'description',
isbn: 'isbn',
asin: 'asin',
language: 'language',
genres: 'genresCommaSeparated'
}
function generate(audiobook, outputPath, uid, gid) {
var fileString = `[audiobookshelf v${package.version}]\n`
for (const key in bookKeyMap) {
const value = audiobook.book[bookKeyMap[key]] || ''
fileString += `${key}=${value}\n`
}
return fs.writeFile(outputPath, fileString).then(() => {
return filePerms(outputPath, 0o774, uid, gid).then(() => true)
}).catch((error) => {
Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error)
return false
})
}
module.exports.generate = generate

View file

@ -6,11 +6,6 @@ module.exports.ScanResult = {
UPTODATE: 4
}
module.exports.CoverDestination = {
METADATA: 0,
AUDIOBOOK: 1
}
module.exports.BookCoverAspectRatio = {
STANDARD: 0, // 1.6:1
SQUARE: 1

View file

@ -50,7 +50,7 @@ const chmodr = (p, mode, uid, gid, cb) => {
// any error other than ENOTDIR means it's not readable, or
// doesn't exist. give up.
if (er && er.code !== 'ENOTDIR') return cb(er)
if (er) {
if (er) { // Is a file
return fs.chmod(p, mode).then(() => {
fs.chown(p, uid, gid, cb)
})
@ -77,9 +77,9 @@ const chmodr = (p, mode, uid, gid, cb) => {
})
}
module.exports = (path, mode, uid, gid) => {
module.exports = (path, mode, uid, gid, silent = false) => {
return new Promise((resolve) => {
Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`)
chmodr(path, mode, uid, gid, resolve)
})
}

View file

@ -20,6 +20,23 @@ async function getFileStat(path) {
}
module.exports.getFileStat = getFileStat
async function getFileTimestampsWithIno(path) {
try {
var stat = await fs.stat(path, { bigint: true })
return {
size: Number(stat.size),
mtimeMs: Number(stat.mtimeMs),
ctimeMs: Number(stat.ctimeMs),
birthtimeMs: Number(stat.birthtimeMs),
ino: String(stat.ino)
}
} catch (err) {
console.error('Failed to getFileTimestampsWithIno', err)
return false
}
}
module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno
async function getFileSize(path) {
var stat = await getFileStat(path)
if (!stat) return 0
@ -27,6 +44,15 @@ async function getFileSize(path) {
}
module.exports.getFileSize = getFileSize
function getIno(path) {
return fs.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
Logger.error('[Utils] Failed to get ino for path', path, err)
return null
})
}
module.exports.getIno = getIno
async function readTextFile(path) {
try {
var data = await fs.readFile(path)

View file

@ -38,13 +38,6 @@ module.exports.comparePaths = (path1, path2) => {
return path1 === path2 || Path.normalize(path1) === Path.normalize(path2)
}
module.exports.getIno = (path) => {
return fs.promises.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
Logger.error('[Utils] Failed to get ino for path', path, err)
return null
})
}
module.exports.isNullOrNaN = (num) => {
return num === null || isNaN(num)
}

View file

@ -1,9 +1,4 @@
const ffprobe = require('node-ffprobe')
if (process.env.FFPROBE_PATH) {
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
}
const AudioProbeData = require('../scanner/AudioProbeData')
const Logger = require('../Logger')
@ -281,6 +276,10 @@ function parseProbeData(data, verbose = false) {
// Updated probe returns AudioProbeData object
function probe(filepath, verbose = false) {
if (process.env.FFPROBE_PATH) {
ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH
}
return ffprobe(filepath)
.then(raw => {
var rawProbeData = parseProbeData(raw, verbose)

View file

@ -1,8 +1,7 @@
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('../Logger')
const { getIno } = require('./index')
const { recurseFiles } = require('./fileUtils')
const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils')
const globals = require('./globals')
function isBookFile(path) {
@ -114,16 +113,20 @@ function groupFileItemsIntoBooks(fileItems) {
}
function cleanFileObjects(basepath, abrelpath, files) {
return files.map((file) => {
return Promise.all(files.map(async (file) => {
var fullPath = Path.posix.join(basepath, file)
var fileTsData = await getFileTimestampsWithIno(fullPath)
var ext = Path.extname(file)
return {
filetype: getFileType(ext),
filename: Path.basename(file),
path: Path.posix.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3
fullPath: Path.posix.join(basepath, file), // /audiobooks/AUDIOBOOK/PATH/filename.mp3
ext: ext
fullPath, // /audiobooks/AUDIOBOOK/PATH/filename.mp3
ext: ext,
...fileTsData
}
})
}))
}
function getFileType(ext) {
@ -162,15 +165,15 @@ async function scanRootDir(folder, serverSettings = {}) {
for (const audiobookPath in audiobookGrouping) {
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
for (let i = 0; i < fileObjs.length; i++) {
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
}
var audiobookIno = await getIno(audiobookData.fullPath)
var fileObjs = await cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
audiobooks.push({
folderId: folder.id,
libraryId: folder.libraryId,
ino: audiobookIno,
ino: audiobookFolderStats.ino,
mtimeMs: audiobookFolderStats.mtimeMs || 0,
ctimeMs: audiobookFolderStats.ctimeMs || 0,
birthtimeMs: audiobookFolderStats.birthtimeMs || 0,
...audiobookData,
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
@ -196,32 +199,42 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) {
// If in a series directory check for volume number match
/* ACCEPTS:
/* ACCEPTS
Book 2 - Title Here - Subtitle Here
Title Here - Subtitle Here - Vol 12
Title Here - volume 9 - Subtitle Here
Vol. 3 Title Here - Subtitle Here
1980 - Book 2-Title Here
Title Here-Volume 999-Subtitle Here
2 - Book Title
100 - Book Title
0.5 - Book Title
*/
var volumeNumber = null
if (series) {
// New volume regex to match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
volumeNumber = volumeMatch[3]
var replaceChunk = volumeMatch[2]
// Added 1.7.1: If title starts with a # that is 3 digits or less (or w/ 2 decimal), then use as volume number
var volumeMatch = title.match(/^(\d{1,3}(?:\.\d{1,2})?) - ./)
if (volumeMatch && volumeMatch.length > 1) {
volumeNumber = volumeMatch[1]
title = title.replace(`${volumeNumber} - `, '')
} else {
// Match volumes with decimal (OLD: /(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{0,3}(?:\.\d{1,2})?))\b( ?-?)/i)
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
volumeNumber = volumeMatch[3]
var replaceChunk = volumeMatch[2]
// "1980 - Book 2-Title Here"
// Group 1 would be "- "
// Group 3 would be "-"
// Only remove the first group
if (volumeMatch[1]) {
replaceChunk = volumeMatch[1] + replaceChunk
} else if (volumeMatch[4]) {
replaceChunk += volumeMatch[4]
// "1980 - Book 2-Title Here"
// Group 1 would be "- "
// Group 3 would be "-"
// Only remove the first group
if (volumeMatch[1]) {
replaceChunk = volumeMatch[1] + replaceChunk
} else if (volumeMatch[4]) {
replaceChunk += volumeMatch[4]
}
title = title.replace(replaceChunk, '').trim()
}
title = title.replace(replaceChunk, '').trim()
}
}
@ -272,8 +285,12 @@ async function getAudiobookFileData(folder, audiobookPath, serverSettings = {})
var audiobookDir = audiobookPath.replace(folderFullPath, '').slice(1)
var audiobookData = getAudiobookDataFromDir(folderFullPath, audiobookDir, parseSubtitle)
var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath)
var audiobook = {
ino: await getIno(audiobookData.fullPath),
ino: audiobookFolderStats.ino,
mtimeMs: audiobookFolderStats.mtimeMs || 0,
ctimeMs: audiobookFolderStats.ctimeMs || 0,
birthtimeMs: audiobookFolderStats.birthtimeMs || 0,
folderId: folder.id,
libraryId: folder.libraryId,
...audiobookData,
@ -284,14 +301,14 @@ async function getAudiobookFileData(folder, audiobookPath, serverSettings = {})
for (let i = 0; i < fileItems.length; i++) {
var fileItem = fileItems[i]
var ino = await getIno(fileItem.fullpath)
var fileStatData = await getFileTimestampsWithIno(fileItem.fullpath)
var fileObj = {
ino,
filetype: getFileType(fileItem.extension),
filename: fileItem.name,
path: fileItem.path,
fullPath: fileItem.fullpath,
ext: fileItem.extension
ext: fileItem.extension,
...fileStatData
}
if (fileObj.filetype === 'audio') {
audiobook.audioFiles.push(fileObj)