Download
diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue
new file mode 100644
index 00000000..9ec07fa9
--- /dev/null
+++ b/client/components/modals/libraries/EditLibrary.vue
@@ -0,0 +1,154 @@
+
+
+
+
arrow_back
+
{{ title }}
+
+
+
+
+
+
+
Folders
+
+
+ folder
+
+ close
+
+
No folders
+
Browse for Folder
+
+
+
+
+
{{ library ? 'Update Library' : 'Create Library' }}
+
+
+
+
+
+
+
+
diff --git a/client/components/modals/libraries/FolderChooser.vue b/client/components/modals/libraries/FolderChooser.vue
new file mode 100644
index 00000000..bf9ed9fd
--- /dev/null
+++ b/client/components/modals/libraries/FolderChooser.vue
@@ -0,0 +1,165 @@
+
+
+
+
{{ selectedPath || '\\' }}
+
+
+
+
+
+
folder
+
{{ dir.dirname }}
+
arrow_right
+
+
+
+
+
folder
+
{{ dir.dirname }}
+
+
+
+
+
+
No Folders Available
+
Note: folders already mapped will not be shown
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/modals/libraries/LibraryItem.vue b/client/components/modals/libraries/LibraryItem.vue
new file mode 100644
index 00000000..2301ad66
--- /dev/null
+++ b/client/components/modals/libraries/LibraryItem.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
{{ library.name }}
+
+
Scan
+
edit
+
delete
+
+
+
+
\ No newline at end of file
diff --git a/client/components/tables/LibrariesTable.vue b/client/components/tables/LibrariesTable.vue
new file mode 100644
index 00000000..8c2a3ed6
--- /dev/null
+++ b/client/components/tables/LibrariesTable.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue
new file mode 100644
index 00000000..870b61a4
--- /dev/null
+++ b/client/components/tables/UsersTable.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+ Username
+ Account Type
+ Created At
+
+
+
+
+ {{ user.username }} ({{ user.id }})
+
+ {{ user.type }}
+
+ {{ new Date(user.createdAt).toISOString() }}
+
+
+
+ edit
+ delete
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/ui/EditableText.vue b/client/components/ui/EditableText.vue
new file mode 100644
index 00000000..60573aed
--- /dev/null
+++ b/client/components/ui/EditableText.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/ui/ToggleSwitch.vue b/client/components/ui/ToggleSwitch.vue
index 9292d59d..ac594c51 100644
--- a/client/components/ui/ToggleSwitch.vue
+++ b/client/components/ui/ToggleSwitch.vue
@@ -1,7 +1,7 @@
-
@@ -35,7 +35,7 @@ export default {
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
- return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
+ return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
}
},
methods: {
diff --git a/client/components/widgets/CloseButton.vue b/client/components/widgets/CloseButton.vue
new file mode 100644
index 00000000..a9a61e1b
--- /dev/null
+++ b/client/components/widgets/CloseButton.vue
@@ -0,0 +1,33 @@
+
+ Cancel
+
+
+
+
+
\ No newline at end of file
diff --git a/client/layouts/default.vue b/client/layouts/default.vue
index a552b4b7..b013ccc7 100644
--- a/client/layouts/default.vue
+++ b/client/layouts/default.vue
@@ -5,12 +5,15 @@
+
-
+
diff --git a/client/pages/index.vue b/client/pages/index.vue
index 11376a2a..a93130dd 100644
--- a/client/pages/index.vue
+++ b/client/pages/index.vue
@@ -20,9 +20,11 @@
diff --git a/client/pages/oops.vue b/client/pages/oops.vue
new file mode 100644
index 00000000..003aa561
--- /dev/null
+++ b/client/pages/oops.vue
@@ -0,0 +1,23 @@
+
+
+
+
Oops... {{ message }}
+
+
+
+
+
\ No newline at end of file
diff --git a/client/store/audiobooks.js b/client/store/audiobooks.js
index da9bd889..937cb7bd 100644
--- a/client/store/audiobooks.js
+++ b/client/store/audiobooks.js
@@ -1,10 +1,12 @@
import { sort } from '@/assets/fastSort'
import { decode } from '@/plugins/init.client'
+import Path from 'path'
const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens', 'Comedy', 'Crime', 'Dystopian', 'Fantasy', 'Fiction', 'Health', 'History', 'Horror', 'Mystery', 'New Adult', 'Nonfiction', 'Philosophy', 'Politics', 'Religion', 'Romance', 'Sci-Fi', 'Self-Help', 'Short Story', 'Technology', 'Thriller', 'True Crime', 'Western', 'Young Adult']
export const state = () => ({
audiobooks: [],
+ loadedLibraryId: '',
lastLoad: 0,
listeners: [],
genres: [...STANDARD_GENRES],
@@ -122,11 +124,12 @@ export const getters = {
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
- getBookCoverSrc: (state, getters, rootState, rootGetters) => (book, placeholder = '/book_placeholder.jpg') => {
+ getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => {
+ var book = bookItem.book
if (!book || !book.cover || book.cover === placeholder) return placeholder
var cover = book.cover
- // Absolute URL covers
+ // Absolute URL covers (should no longer be used)
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
// Server hosted covers
@@ -135,6 +138,14 @@ export const getters = {
var bookLastUpdate = book.lastUpdate || Date.now()
var userToken = rootGetters['user/getToken']
+ // Map old covers to new format /s/book/{bookid}/*
+ if (cover.startsWith('\\local')) {
+ cover = cover.replace('local', `s\\book\\${bookItem.id}`)
+ if (cover.includes(bookItem.path + '\\')) { // Remove book path
+ cover = cover.replace(bookItem.path + '\\', '')
+ }
+ }
+
var url = new URL(cover, document.baseURI)
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
} catch (err) {
@@ -152,18 +163,24 @@ export const actions = {
return false
}
- // Don't load again if already loaded in the last 5 minutes
- var lastLoadDiff = Date.now() - state.lastLoad
- if (lastLoadDiff < 5 * 60 * 1000) {
- // Already up to date
- return false
+ var currentLibraryId = rootState.libraries.currentLibraryId
+
+ if (currentLibraryId === state.loadedLibraryId) {
+ // Don't load again if already loaded in the last 5 minutes
+ var lastLoadDiff = Date.now() - state.lastLoad
+ if (lastLoadDiff < 5 * 60 * 1000) {
+ // Already up to date
+ return false
+ }
}
+ commit('setLoadedLibrary', currentLibraryId)
this.$axios
- .$get(`/api/audiobooks`)
+ .$get(`/api/library/${currentLibraryId}/audiobooks`)
.then((data) => {
commit('set', data)
commit('setLastLoad')
+
})
.catch((error) => {
console.error('Failed', error)
@@ -175,6 +192,9 @@ export const actions = {
}
export const mutations = {
+ setLoadedLibrary(state, val) {
+ state.loadedLibraryId = val
+ },
setLastLoad(state) {
state.lastLoad = Date.now()
},
@@ -223,6 +243,10 @@ export const mutations = {
})
},
addUpdate(state, audiobook) {
+ if (audiobook.libraryId !== state.loadedLibraryId) {
+ return
+ }
+
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
var origAudiobook = null
if (index >= 0) {
diff --git a/client/store/index.js b/client/store/index.js
index 7752c159..a91a90ff 100644
--- a/client/store/index.js
+++ b/client/store/index.js
@@ -9,10 +9,10 @@ export const state = () => ({
showEditModal: false,
selectedAudiobook: null,
playOnLoad: false,
- isScanning: false,
- isScanningCovers: false,
- scanProgress: null,
- coverScanProgress: null,
+ // isScanning: false,
+ // isScanningCovers: false,
+ // scanProgress: null,
+ // coverScanProgress: null,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false,
@@ -113,20 +113,20 @@ export const mutations = {
setShowEditModal(state, val) {
state.showEditModal = val
},
- setIsScanning(state, isScanning) {
- state.isScanning = isScanning
- },
- setScanProgress(state, scanProgress) {
- if (scanProgress && scanProgress.progress > 0) state.isScanning = true
- state.scanProgress = scanProgress
- },
- setIsScanningCovers(state, isScanningCovers) {
- state.isScanningCovers = isScanningCovers
- },
- setCoverScanProgress(state, coverScanProgress) {
- if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
- state.coverScanProgress = coverScanProgress
- },
+ // setIsScanning(state, isScanning) {
+ // state.isScanning = isScanning
+ // },
+ // setScanProgress(state, scanProgress) {
+ // if (scanProgress && scanProgress.progress > 0) state.isScanning = true
+ // state.scanProgress = scanProgress
+ // },
+ // setIsScanningCovers(state, isScanningCovers) {
+ // state.isScanningCovers = isScanningCovers
+ // },
+ // setCoverScanProgress(state, coverScanProgress) {
+ // if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
+ // state.coverScanProgress = coverScanProgress
+ // },
setDeveloperMode(state, val) {
state.developerMode = val
},
diff --git a/client/store/libraries.js b/client/store/libraries.js
new file mode 100644
index 00000000..94ca093c
--- /dev/null
+++ b/client/store/libraries.js
@@ -0,0 +1,144 @@
+export const state = () => ({
+ libraries: [],
+ lastLoad: 0,
+ listeners: [],
+ currentLibraryId: 'main',
+ showModal: false,
+ folders: [],
+ folderLastUpdate: 0
+})
+
+export const getters = {
+ getCurrentLibrary: state => {
+ return state.libraries.find(lib => lib.id === state.currentLibraryId)
+ }
+}
+
+export const actions = {
+ loadFolders({ state, commit }) {
+ if (state.folders.length) {
+ var lastCheck = Date.now() - state.folderLastUpdate
+ if (lastCheck < 1000 * 60 * 10) { // 10 minutes
+ // Folders up to date
+ return state.folders
+ }
+ }
+ console.log('Loading folders')
+ commit('setFoldersLastUpdate')
+
+ return this.$axios
+ .$get('/api/filesystem')
+ .then((res) => {
+ console.log('Settings folders', res)
+ commit('setFolders', res)
+ return res
+ })
+ .catch((error) => {
+ console.error('Failed to load dirs', error)
+ commit('setFolders', [])
+ return []
+ })
+ },
+ fetch({ state, commit, rootState }, libraryId) {
+ if (!rootState.user || !rootState.user.user) {
+ console.error('libraries/fetch - User not set')
+ return false
+ }
+
+ var library = state.libraries.find(lib => lib.id === libraryId)
+ if (library) {
+ commit('setCurrentLibrary', libraryId)
+ return library
+ }
+
+ return this.$axios
+ .$get(`/api/library/${libraryId}`)
+ .then((data) => {
+ commit('addUpdate', data)
+ commit('setCurrentLibrary', libraryId)
+ return data
+ })
+ .catch((error) => {
+ console.error('Failed', error)
+ return false
+ })
+ },
+ // Return true if calling load
+ load({ state, commit, rootState }) {
+ if (!rootState.user || !rootState.user.user) {
+ console.error('libraries/load - User not set')
+ return false
+ }
+
+ // Don't load again if already loaded in the last 5 minutes
+ var lastLoadDiff = Date.now() - state.lastLoad
+ if (lastLoadDiff < 5 * 60 * 1000) {
+ // Already up to date
+ return false
+ }
+
+ this.$axios
+ .$get(`/api/libraries`)
+ .then((data) => {
+ commit('set', data)
+ commit('setLastLoad')
+ })
+ .catch((error) => {
+ console.error('Failed', error)
+ commit('set', [])
+ })
+ return true
+ },
+
+}
+
+export const mutations = {
+ setFolders(state, folders) {
+ state.folders = folders
+ },
+ setFoldersLastUpdate(state) {
+ state.folderLastUpdate = Date.now()
+ },
+ setShowModal(state, val) {
+ state.showModal = val
+ },
+ setLastLoad(state) {
+ state.lastLoad = Date.now()
+ },
+ setCurrentLibrary(state, val) {
+ state.currentLibraryId = val
+ },
+ set(state, libraries) {
+ state.libraries = libraries
+ state.listeners.forEach((listener) => {
+ listener.meth()
+ })
+ },
+ addUpdate(state, library) {
+ var index = state.libraries.findIndex(a => a.id === library.id)
+ if (index >= 0) {
+ state.libraries.splice(index, 1, library)
+ } else {
+ state.libraries.push(library)
+ }
+
+ state.listeners.forEach((listener) => {
+ listener.meth()
+ })
+ },
+ remove(state, library) {
+ state.libraries = state.libraries.filter(a => a.id !== library.id)
+
+ state.listeners.forEach((listener) => {
+ listener.meth()
+ })
+ },
+ addListener(state, listener) {
+ var index = state.listeners.findIndex(l => l.id === listener.id)
+ if (index >= 0) state.listeners.splice(index, 1, listener)
+ else state.listeners.push(listener)
+ },
+ removeListener(state, listenerId) {
+ state.listeners = state.listeners.filter(l => l.id !== listenerId)
+ }
+}
\ No newline at end of file
diff --git a/client/store/scanners.js b/client/store/scanners.js
new file mode 100644
index 00000000..1a106332
--- /dev/null
+++ b/client/store/scanners.js
@@ -0,0 +1,27 @@
+export const state = () => ({
+ libraryScans: []
+})
+
+export const getters = {
+ getLibraryScan: state => id => {
+ return state.libraryScans.find(ls => ls.id === id)
+ }
+}
+
+export const actions = {
+
+}
+
+export const mutations = {
+ addUpdate(state, data) {
+ var index = state.libraryScans.findIndex(lib => lib.id === data.id)
+ if (index >= 0) {
+ state.libraryScans.splice(index, 1, data)
+ } else {
+ state.libraryScans.push(data)
+ }
+ },
+ remove(state, data) {
+ state.libraryScans = state.libraryScans.filter(scan => scan.id !== data.id)
+ }
+}
\ No newline at end of file
diff --git a/server/ApiController.js b/server/ApiController.js
index 7c16b49a..ee18c68f 100644
--- a/server/ApiController.js
+++ b/server/ApiController.js
@@ -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
\ No newline at end of file
diff --git a/server/CoverController.js b/server/CoverController.js
index 0aa5aedd..0489c4a9 100644
--- a/server/CoverController.js
+++ b/server/CoverController.js
@@ -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 {
diff --git a/server/Db.js b/server/Db.js
index 8b877e76..4e6b9a65 100644
--- a/server/Db.js
+++ b/server/Db.js
@@ -4,20 +4,25 @@ const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
const Audiobook = require('./objects/Audiobook')
const User = require('./objects/User')
+const Library = require('./objects/Library')
const ServerSettings = require('./objects/ServerSettings')
class Db {
- constructor(CONFIG_PATH) {
- this.ConfigPath = CONFIG_PATH
- this.AudiobooksPath = Path.join(CONFIG_PATH, 'audiobooks')
- this.UsersPath = Path.join(CONFIG_PATH, 'users')
- this.SettingsPath = Path.join(CONFIG_PATH, 'settings')
+ constructor(ConfigPath, AudiobookPath) {
+ this.ConfigPath = ConfigPath
+ this.AudiobookPath = AudiobookPath
+ this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
+ this.UsersPath = Path.join(ConfigPath, 'users')
+ this.LibrariesPath = Path.join(ConfigPath, 'libraries')
+ this.SettingsPath = Path.join(ConfigPath, 'settings')
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
this.usersDb = new njodb.Database(this.UsersPath)
+ this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
this.users = []
+ this.libraries = []
this.audiobooks = []
this.settings = []
@@ -27,18 +32,14 @@ class Db {
getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb
else if (entityName === 'audiobook') return this.audiobooksDb
+ else if (entityName === 'library') return this.librariesDb
return this.settingsDb
}
- getEntityDbKey(entityName) {
- if (entityName === 'user') return 'usersDb'
- else if (entityName === 'audiobook') return 'audiobooksDb'
- return 'settingsDb'
- }
-
getEntityArrayKey(entityName) {
if (entityName === 'user') return 'users'
else if (entityName === 'audiobook') return 'audiobooks'
+ else if (entityName === 'library') return 'libraries'
return 'settings'
}
@@ -46,7 +47,6 @@ class Db {
return new User({
id: 'root',
type: 'root',
-
username: 'root',
pash: '',
stream: null,
@@ -56,6 +56,20 @@ class Db {
})
}
+ getDefaultLibrary() {
+ var defaultLibrary = new Library()
+ defaultLibrary.setData({
+ id: 'main',
+ name: 'Main',
+ folder: { // Generates default folder
+ id: 'audiobooks',
+ fullPath: this.AudiobookPath,
+ libraryId: 'main'
+ }
+ })
+ return defaultLibrary
+ }
+
async init() {
await this.load()
@@ -63,25 +77,33 @@ class Db {
if (!this.users.find(u => u.type === 'root')) {
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
Logger.debug('Generated default token', token)
- await this.insertUser(this.getDefaultUser(token))
+ await this.insertEntity('user', this.getDefaultUser(token))
+ }
+
+ if (!this.libraries.length) {
+ await this.insertEntity('library', this.getDefaultLibrary())
}
if (!this.serverSettings) {
this.serverSettings = new ServerSettings()
- await this.insertSettings(this.serverSettings)
+ await this.insertEntity('settings', this.serverSettings)
}
}
async load() {
var p1 = this.audiobooksDb.select(() => true).then((results) => {
this.audiobooks = results.data.map(a => new Audiobook(a))
- Logger.info(`[DB] Audiobooks Loaded ${this.audiobooks.length}`)
+ Logger.info(`[DB] ${this.audiobooks.length} Audiobooks Loaded`)
})
var p2 = this.usersDb.select(() => true).then((results) => {
this.users = results.data.map(u => new User(u))
- Logger.info(`[DB] Users Loaded ${this.users.length}`)
+ Logger.info(`[DB] ${this.users.length} Users Loaded`)
})
- var p3 = this.settingsDb.select(() => true).then((results) => {
+ var p3 = this.librariesDb.select(() => true).then((results) => {
+ this.libraries = results.data.map(l => new Library(l))
+ Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
+ })
+ var p4 = this.settingsDb.select(() => true).then((results) => {
if (results.data && results.data.length) {
this.settings = results.data
var serverSettings = this.settings.find(s => s.id === 'server-settings')
@@ -90,30 +112,21 @@ class Db {
}
}
})
- await Promise.all([p1, p2, p3])
+ await Promise.all([p1, p2, p3, p4])
}
- insertSettings(settings) {
- return this.settingsDb.insert([settings]).then((results) => {
- Logger.debug(`[DB] Inserted ${results.inserted} settings`)
- this.settings = this.settings.concat(settings)
- }).catch((error) => {
- Logger.error(`[DB] Insert settings Failed ${error}`)
- })
- }
+ // insertAudiobook(audiobook) {
+ // return this.insertAudiobooks([audiobook])
+ // }
- insertAudiobook(audiobook) {
- return this.insertAudiobooks([audiobook])
- }
-
- insertAudiobooks(audiobooks) {
- return this.audiobooksDb.insert(audiobooks).then((results) => {
- Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
- this.audiobooks = this.audiobooks.concat(audiobooks)
- }).catch((error) => {
- Logger.error(`[DB] Insert audiobooks Failed ${error}`)
- })
- }
+ // insertAudiobooks(audiobooks) {
+ // return this.audiobooksDb.insert(audiobooks).then((results) => {
+ // Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
+ // this.audiobooks = this.audiobooks.concat(audiobooks)
+ // }).catch((error) => {
+ // Logger.error(`[DB] Insert audiobooks Failed ${error}`)
+ // })
+ // }
updateAudiobook(audiobook) {
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
@@ -125,16 +138,25 @@ class Db {
})
}
- insertUser(user) {
- return this.usersDb.insert([user]).then((results) => {
- Logger.debug(`[DB] Inserted user ${results.inserted}`)
- this.users.push(user)
- return true
- }).catch((error) => {
- Logger.error(`[DB] Insert user Failed ${error}`)
- return false
- })
- }
+ // insertUser(user) {
+ // return this.usersDb.insert([user]).then((results) => {
+ // Logger.debug(`[DB] Inserted user ${results.inserted}`)
+ // this.users.push(user)
+ // return true
+ // }).catch((error) => {
+ // Logger.error(`[DB] Insert user Failed ${error}`)
+ // return false
+ // })
+ // }
+
+ // insertSettings(settings) {
+ // return this.settingsDb.insert([settings]).then((results) => {
+ // Logger.debug(`[DB] Inserted ${results.inserted} settings`)
+ // this.settings = this.settings.concat(settings)
+ // }).catch((error) => {
+ // Logger.error(`[DB] Insert settings Failed ${error}`)
+ // })
+ // }
updateUserStream(userId, streamId) {
return this.usersDb.update((record) => record.id === userId, (user) => {
@@ -153,6 +175,20 @@ class Db {
})
}
+ insertEntity(entityName, entity) {
+ var entityDb = this.getEntityDb(entityName)
+ return entityDb.insert([entity]).then((results) => {
+ Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
+
+ var arrayKey = this.getEntityArrayKey(entityName)
+ this[arrayKey].push(entity)
+ return true
+ }).catch((error) => {
+ Logger.error(`[DB] Failed to insert ${entityName}`, error)
+ return false
+ })
+ }
+
updateEntity(entityName, entity) {
var entityDb = this.getEntityDb(entityName)
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {
diff --git a/server/Scanner.js b/server/Scanner.js
index e3881c9c..2efb9d72 100644
--- a/server/Scanner.js
+++ b/server/Scanner.js
@@ -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
}
}
diff --git a/server/Server.js b/server/Server.js
index 7b52c9fa..a79c4b89 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -6,8 +6,13 @@ const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
const rateLimit = require('express-rate-limit')
-const { ScanResult } = require('./utils/constants')
+const { version } = require('../package.json')
+// Utils
+const { ScanResult } = require('./utils/constants')
+const Logger = require('./Logger')
+
+// Classes
const Auth = require('./Auth')
const Watcher = require('./Watcher')
const Scanner = require('./Scanner')
@@ -18,7 +23,7 @@ const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager')
const CoverController = require('./CoverController')
-const Logger = require('./Logger')
+
class Server {
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
@@ -32,7 +37,7 @@ class Server {
fs.ensureDirSync(METADATA_PATH)
fs.ensureDirSync(AUDIOBOOK_PATH)
- this.db = new Db(this.ConfigPath)
+ this.db = new Db(this.ConfigPath, this.AudiobookPath)
this.auth = new Auth(this.db)
this.watcher = new Watcher(this.AudiobookPath)
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
@@ -40,22 +45,24 @@ class Server {
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
- this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.emitter.bind(this), this.clientEmitter.bind(this))
+ this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
+ this.expressApp = null
this.server = null
this.io = null
this.clients = {}
- this.isScanning = false
this.isScanningCovers = false
- this.isInitialized = false
}
get audiobooks() {
return this.db.audiobooks
}
+ get libraries() {
+ return this.db.libraries
+ }
get serverSettings() {
return this.db.serverSettings
}
@@ -81,86 +88,8 @@ class Server {
})
}
- async filesChanged(files) {
- Logger.info('[Server]', files.length, 'Files Changed')
- var result = await this.scanner.filesChanged(files)
- Logger.debug('[Server] Files changed result', result)
- }
-
- async scan(forceAudioFileScan = false) {
- Logger.info('[Server] Starting Scan')
- this.isScanning = true
- this.isInitialized = true
- this.emitter('scan_start', 'files')
- var results = await this.scanner.scan(forceAudioFileScan)
- this.isScanning = false
- this.emitter('scan_complete', { scanType: 'files', results })
- Logger.info('[Server] Scan complete')
- }
-
- async scanAudiobook(socket, audiobookId) {
- var result = await this.scanner.scanAudiobookById(audiobookId)
- var scanResultName = ''
- for (const key in ScanResult) {
- if (ScanResult[key] === result) {
- scanResultName = key
- }
- }
- socket.emit('audiobook_scan_complete', scanResultName)
- }
-
- async scanCovers() {
- Logger.info('[Server] Start cover scan')
- this.isScanningCovers = true
- this.emitter('scan_start', 'covers')
- var results = await this.scanner.scanCovers()
- this.isScanningCovers = false
- this.emitter('scan_complete', { scanType: 'covers', results })
- Logger.info('[Server] Cover scan complete')
- }
-
- cancelScan() {
- if (!this.isScanningCovers && !this.isScanning) return
- this.scanner.cancelScan = true
- }
-
- // Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
- async saveMetadata(socket, audiobookId = null) {
- Logger.info('[Server] Starting save metadata files')
- var response = await this.scanner.saveMetadata(audiobookId)
- Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
- socket.emit('save_metadata_complete', response)
- }
-
- // Remove unused /metadata/books/{id} folders
- async purgeMetadata() {
- var booksMetadata = Path.join(this.MetadataPath, 'books')
- var booksMetadataExists = await fs.pathExists(booksMetadata)
- if (!booksMetadataExists) return
- var foldersInBooksMetadata = await fs.readdir(booksMetadata)
-
- var purged = 0
- await Promise.all(foldersInBooksMetadata.map(async foldername => {
- var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
- if (!hasMatchingAudiobook) {
- var folderPath = Path.join(booksMetadata, foldername)
- Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
-
- await fs.remove(folderPath).then(() => {
- purged++
- }).catch((err) => {
- Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
- })
- }
- }))
- if (purged > 0) {
- Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
- }
- return purged
- }
-
async init() {
- Logger.info('[Server] Init')
+ Logger.info('[Server] Init v' + version)
await this.streamManager.ensureStreamsDir()
await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads()
@@ -170,105 +99,66 @@ class Server {
await this.purgeMetadata()
- this.watcher.initWatcher()
+ this.watcher.initWatcher(this.libraries)
this.watcher.on('files', this.filesChanged.bind(this))
}
- authMiddleware(req, res, next) {
- this.auth.authMiddleware(req, res, next)
- }
-
- async handleUpload(req, res) {
- if (!req.user.canUpload) {
- Logger.warn('User attempted to upload without permission', req.user)
- return res.sendStatus(403)
- }
- var files = Object.values(req.files)
- var title = req.body.title
- var author = req.body.author
- var series = req.body.series
-
- if (!files.length || !title || !author) {
- return res.json({
- error: 'Invalid post data received'
- })
- }
-
- var outputDirectory = ''
- if (series && series.length && series !== 'null') {
- outputDirectory = Path.join(this.AudiobookPath, author, series, title)
- } else {
- outputDirectory = Path.join(this.AudiobookPath, author, title)
- }
-
- var exists = await fs.pathExists(outputDirectory)
- if (exists) {
- Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
- return res.json({
- error: `Directory "${outputDirectory}" already exists`
- })
- }
-
- await fs.ensureDir(outputDirectory)
- Logger.info(`Uploading ${files.length} files to`, outputDirectory)
-
- for (let i = 0; i < files.length; i++) {
- var file = files[i]
-
- var path = Path.join(outputDirectory, file.name)
- await file.mv(path).catch((error) => {
- Logger.error('Failed to move file', path, error)
- })
- }
- res.sendStatus(200)
- }
-
- // First time login rate limit is hit
- loginLimitReached(req, res, options) {
- Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
- options.message = 'Too many attempts. Login temporarily locked.'
- }
-
- getLoginRateLimiter() {
- return rateLimit({
- windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
- max: this.db.serverSettings.rateLimitLoginRequests,
- skipSuccessfulRequests: true,
- onLimitReached: this.loginLimitReached
- })
- }
-
async start() {
Logger.info('=== Starting Server ===')
await this.init()
const app = express()
+ this.expressApp = app
this.server = http.createServer(app)
app.use(this.auth.cors)
app.use(fileUpload())
-
- // Static path to generated nuxt
- const distPath = Path.join(global.appRoot, '/client/dist')
- if (process.env.NODE_ENV === 'production') {
- app.use(express.static(distPath))
- app.use('/local', express.static(this.AudiobookPath))
- } else {
- app.use(express.static(this.AudiobookPath))
- }
-
- app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
-
- app.use(express.static(this.MetadataPath))
- app.use(express.static(Path.join(global.appRoot, 'static')))
app.use(express.urlencoded({ extended: true }));
app.use(express.json())
- // Dynamic routes are not generated on client
+ // Static path to generated nuxt
+ const distPath = Path.join(global.appRoot, '/client/dist')
+ app.use(express.static(distPath))
+
+ // Old static path for covers
+ app.use('/local', this.authMiddleware.bind(this), express.static(this.AudiobookPath))
+
+ // Metadata folder static path
+ app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
+
+ // Static folder
+ app.use(express.static(Path.join(global.appRoot, 'static')))
+
+ // Static file routes
+ app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (req, res) => {
+ var library = this.libraries.find(lib => lib.id === req.params.library)
+ if (!library) return res.sendStatus(404)
+ var folder = library.folders.find(fol => fol.id === req.params.folder)
+ if (!folder) return res.status(404).send('Folder not found')
+
+ var remainingPath = decodeURIComponent(req.params['0'])
+
+ var fullPath = Path.join(folder.fullPath, remainingPath)
+ res.sendFile(fullPath)
+ })
+
+ // Book static file routes
+ app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => {
+ var audiobook = this.audiobooks.find(ab => ab.id === req.params.id)
+ if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id)
+
+ var remainingPath = decodeURIComponent(req.params['0'])
+
+ var fullPath = Path.join(audiobook.fullPath, remainingPath)
+ res.sendFile(fullPath)
+ })
+
+ // Client routes
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
- app.get('/library/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
- app.get('/library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
+ app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
+ app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
+ app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
@@ -368,6 +258,143 @@ class Server {
})
}
+ async filesChanged(fileUpdates) {
+ Logger.info('[Server]', fileUpdates.length, 'Files Changed')
+ await this.scanner.filesChanged(fileUpdates)
+ // Logger.debug('[Server] Files changed result', result)
+ }
+
+ async scan(libraryId, forceAudioFileScan = false) {
+ Logger.info('[Server] Starting Scan')
+ await this.scanner.scan(libraryId, forceAudioFileScan)
+ Logger.info('[Server] Scan complete')
+ }
+
+ async scanAudiobook(socket, audiobookId) {
+ var result = await this.scanner.scanAudiobookById(audiobookId)
+ var scanResultName = ''
+ for (const key in ScanResult) {
+ if (ScanResult[key] === result) {
+ scanResultName = key
+ }
+ }
+ socket.emit('audiobook_scan_complete', scanResultName)
+ }
+
+ async scanCovers() {
+ Logger.info('[Server] Start cover scan')
+ this.isScanningCovers = true
+ // this.emitter('scan_start', 'covers')
+ var results = await this.scanner.scanCovers()
+ this.isScanningCovers = false
+ // this.emitter('scan_complete', { scanType: 'covers', results })
+ Logger.info('[Server] Cover scan complete')
+ }
+
+ cancelScan(id) {
+ console.log('Cancel scan', id)
+ this.scanner.cancelLibraryScan[id] = true
+ }
+
+ // Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
+ async saveMetadata(socket, audiobookId = null) {
+ Logger.info('[Server] Starting save metadata files')
+ var response = await this.scanner.saveMetadata(audiobookId)
+ Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
+ socket.emit('save_metadata_complete', response)
+ }
+
+ // Remove unused /metadata/books/{id} folders
+ async purgeMetadata() {
+ var booksMetadata = Path.join(this.MetadataPath, 'books')
+ var booksMetadataExists = await fs.pathExists(booksMetadata)
+ if (!booksMetadataExists) return
+ var foldersInBooksMetadata = await fs.readdir(booksMetadata)
+
+ var purged = 0
+ await Promise.all(foldersInBooksMetadata.map(async foldername => {
+ var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
+ if (!hasMatchingAudiobook) {
+ var folderPath = Path.join(booksMetadata, foldername)
+ Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
+
+ await fs.remove(folderPath).then(() => {
+ purged++
+ }).catch((err) => {
+ Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
+ })
+ }
+ }))
+ if (purged > 0) {
+ Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
+ }
+ return purged
+ }
+
+ authMiddleware(req, res, next) {
+ this.auth.authMiddleware(req, res, next)
+ }
+
+ async handleUpload(req, res) {
+ if (!req.user.canUpload) {
+ Logger.warn('User attempted to upload without permission', req.user)
+ return res.sendStatus(403)
+ }
+ var files = Object.values(req.files)
+ var title = req.body.title
+ var author = req.body.author
+ var series = req.body.series
+
+ if (!files.length || !title || !author) {
+ return res.json({
+ error: 'Invalid post data received'
+ })
+ }
+
+ var outputDirectory = ''
+ if (series && series.length && series !== 'null') {
+ outputDirectory = Path.join(this.AudiobookPath, author, series, title)
+ } else {
+ outputDirectory = Path.join(this.AudiobookPath, author, title)
+ }
+
+ var exists = await fs.pathExists(outputDirectory)
+ if (exists) {
+ Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
+ return res.json({
+ error: `Directory "${outputDirectory}" already exists`
+ })
+ }
+
+ await fs.ensureDir(outputDirectory)
+ Logger.info(`Uploading ${files.length} files to`, outputDirectory)
+
+ for (let i = 0; i < files.length; i++) {
+ var file = files[i]
+
+ var path = Path.join(outputDirectory, file.name)
+ await file.mv(path).catch((error) => {
+ Logger.error('Failed to move file', path, error)
+ })
+ }
+ res.sendStatus(200)
+ }
+
+ // First time login rate limit is hit
+ loginLimitReached(req, res, options) {
+ Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
+ options.message = 'Too many attempts. Login temporarily locked.'
+ }
+
+ getLoginRateLimiter() {
+ return rateLimit({
+ windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
+ max: this.db.serverSettings.rateLimitLoginRequests,
+ skipSuccessfulRequests: true,
+ onLimitReached: this.loginLimitReached
+ })
+ }
+
logout(req, res) {
res.sendStatus(200)
}
@@ -407,8 +434,6 @@ class Server {
const initialPayload = {
serverSettings: this.serverSettings.toJSON(),
- isScanning: this.isScanning,
- isInitialized: this.isInitialized,
audiobookPath: this.AudiobookPath,
metadataPath: this.MetadataPath,
configPath: this.ConfigPath,
diff --git a/server/Watcher.js b/server/Watcher.js
index c6b9bcc9..1948d00a 100644
--- a/server/Watcher.js
+++ b/server/Watcher.js
@@ -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)
}
}
diff --git a/server/objects/AudioFile.js b/server/objects/AudioFile.js
index 16a773d3..c9248e46 100644
--- a/server/objects/AudioFile.js
+++ b/server/objects/AudioFile.js
@@ -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 || {})
diff --git a/server/objects/Audiobook.js b/server/objects/Audiobook.js
index ab79099e..24eda5f4 100644
--- a/server/objects/Audiobook.js
+++ b/server/objects/Audiobook.js
@@ -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]
diff --git a/server/objects/Book.js b/server/objects/Book.js
index 883994e7..c195a896 100644
--- a/server/objects/Book.js
+++ b/server/objects/Book.js
@@ -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) {
diff --git a/server/objects/Folder.js b/server/objects/Folder.js
new file mode 100644
index 00000000..c4f02594
--- /dev/null
+++ b/server/objects/Folder.js
@@ -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
\ No newline at end of file
diff --git a/server/objects/Library.js b/server/objects/Library.js
new file mode 100644
index 00000000..f3e5ad15
--- /dev/null
+++ b/server/objects/Library.js
@@ -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
\ No newline at end of file
diff --git a/server/objects/ServerSettings.js b/server/objects/ServerSettings.js
index ea89c1e1..160070fa 100644
--- a/server/objects/ServerSettings.js
+++ b/server/objects/ServerSettings.js
@@ -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,
diff --git a/server/utils/audioFileScanner.js b/server/utils/audioFileScanner.js
index ba9434bd..b2973b6d 100644
--- a/server/utils/audioFileScanner.js
+++ b/server/utils/audioFileScanner.js
@@ -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 {
diff --git a/server/utils/scandir.js b/server/utils/scandir.js
index 5fcec371..4d792e5d 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -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)