mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-28 09:38:56 +01:00
351 lines
12 KiB
JavaScript
351 lines
12 KiB
JavaScript
const Path = require('path')
|
|
const express = require('express')
|
|
const http = require('http')
|
|
const fs = require('./libs/fsExtra')
|
|
const fileUpload = require('./libs/expressFileupload')
|
|
const rateLimit = require('./libs/expressRateLimit')
|
|
|
|
const { version } = require('../package.json')
|
|
|
|
// Utils
|
|
const filePerms = require('./utils/filePerms')
|
|
const fileUtils = require('./utils/fileUtils')
|
|
const Logger = require('./Logger')
|
|
|
|
const Auth = require('./Auth')
|
|
const Watcher = require('./Watcher')
|
|
const Scanner = require('./scanner/Scanner')
|
|
const Database = require('./Database')
|
|
const SocketAuthority = require('./SocketAuthority')
|
|
|
|
const routes = require('./routes/index')
|
|
|
|
const ApiRouter = require('./routers/ApiRouter')
|
|
const HlsRouter = require('./routers/HlsRouter')
|
|
|
|
const NotificationManager = require('./managers/NotificationManager')
|
|
const EmailManager = require('./managers/EmailManager')
|
|
const CoverManager = require('./managers/CoverManager')
|
|
const AbMergeManager = require('./managers/AbMergeManager')
|
|
const CacheManager = require('./managers/CacheManager')
|
|
const LogManager = require('./managers/LogManager')
|
|
const BackupManager = require('./managers/BackupManager')
|
|
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
|
const PodcastManager = require('./managers/PodcastManager')
|
|
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
|
const RssFeedManager = require('./managers/RssFeedManager')
|
|
const CronManager = require('./managers/CronManager')
|
|
const TaskManager = require('./managers/TaskManager')
|
|
|
|
class Server {
|
|
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
|
this.Port = PORT
|
|
this.Host = HOST
|
|
global.Source = SOURCE
|
|
global.isWin = process.platform === 'win32'
|
|
global.Uid = isNaN(UID) ? undefined : Number(UID)
|
|
global.Gid = isNaN(GID) ? undefined : Number(GID)
|
|
global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH))
|
|
global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH))
|
|
global.RouterBasePath = ROUTER_BASE_PATH
|
|
global.XAccel = process.env.USE_X_ACCEL
|
|
|
|
if (!fs.pathExistsSync(global.ConfigPath)) {
|
|
fs.mkdirSync(global.ConfigPath)
|
|
filePerms.setDefaultDirSync(global.ConfigPath, false)
|
|
}
|
|
if (!fs.pathExistsSync(global.MetadataPath)) {
|
|
fs.mkdirSync(global.MetadataPath)
|
|
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
|
}
|
|
|
|
this.watcher = new Watcher()
|
|
this.auth = new Auth()
|
|
|
|
// Managers
|
|
this.taskManager = new TaskManager()
|
|
this.notificationManager = new NotificationManager()
|
|
this.emailManager = new EmailManager()
|
|
this.backupManager = new BackupManager()
|
|
this.logManager = new LogManager()
|
|
this.cacheManager = new CacheManager()
|
|
this.abMergeManager = new AbMergeManager(this.taskManager)
|
|
this.playbackSessionManager = new PlaybackSessionManager()
|
|
this.coverManager = new CoverManager(this.cacheManager)
|
|
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager)
|
|
this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager)
|
|
this.rssFeedManager = new RssFeedManager()
|
|
|
|
this.scanner = new Scanner(this.coverManager, this.taskManager)
|
|
this.cronManager = new CronManager(this.scanner, this.podcastManager)
|
|
|
|
// Routers
|
|
this.apiRouter = new ApiRouter(this)
|
|
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
|
|
|
|
Logger.logManager = this.logManager
|
|
|
|
this.server = null
|
|
this.io = null
|
|
}
|
|
|
|
authMiddleware(req, res, next) {
|
|
this.auth.authMiddleware(req, res, next)
|
|
}
|
|
|
|
async init() {
|
|
Logger.info('[Server] Init v' + version)
|
|
await this.playbackSessionManager.removeOrphanStreams()
|
|
|
|
await Database.init(false)
|
|
|
|
// Create token secret if does not exist (Added v2.1.0)
|
|
if (!Database.serverSettings.tokenSecret) {
|
|
await this.auth.initTokenSecret()
|
|
}
|
|
|
|
await this.cleanUserData() // Remove invalid user item progress
|
|
await this.purgeMetadata() // Remove metadata folders without library item
|
|
await this.cacheManager.ensureCachePaths()
|
|
|
|
await this.backupManager.init()
|
|
await this.logManager.init()
|
|
await this.apiRouter.checkRemoveEmptySeries(Database.series) // Remove empty series
|
|
await this.rssFeedManager.init()
|
|
this.cronManager.init()
|
|
|
|
if (Database.serverSettings.scannerDisableWatcher) {
|
|
Logger.info(`[Server] Watcher is disabled`)
|
|
this.watcher.disabled = true
|
|
} else {
|
|
this.watcher.initWatcher(Database.libraries)
|
|
this.watcher.on('files', this.filesChanged.bind(this))
|
|
}
|
|
}
|
|
|
|
async start() {
|
|
Logger.info('=== Starting Server ===')
|
|
await this.init()
|
|
|
|
const app = express()
|
|
const router = express.Router()
|
|
app.use(global.RouterBasePath, router)
|
|
app.disable('x-powered-by')
|
|
|
|
this.server = http.createServer(app)
|
|
|
|
router.use(this.auth.cors)
|
|
router.use(fileUpload({
|
|
defCharset: 'utf8',
|
|
defParamCharset: 'utf8',
|
|
useTempFiles: true,
|
|
tempFileDir: Path.join(global.MetadataPath, 'tmp')
|
|
}))
|
|
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
|
|
router.use(express.json({ limit: "5mb" }))
|
|
|
|
// Static path to generated nuxt
|
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
|
router.use(express.static(distPath))
|
|
|
|
// Static folder
|
|
router.use(express.static(Path.join(global.appRoot, 'static')))
|
|
|
|
// router.use('/api/v1', routes) // TODO: New routes
|
|
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
|
|
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
|
|
|
|
// RSS Feed temp route
|
|
router.get('/feed/:slug', (req, res) => {
|
|
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
|
this.rssFeedManager.getFeed(req, res)
|
|
})
|
|
router.get('/feed/:slug/cover', (req, res) => {
|
|
this.rssFeedManager.getFeedCover(req, res)
|
|
})
|
|
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
|
|
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
|
|
this.rssFeedManager.getFeedItem(req, res)
|
|
})
|
|
|
|
// Client dynamic routes
|
|
const dyanimicRoutes = [
|
|
'/item/:id',
|
|
'/author/:id',
|
|
'/audiobook/:id/chapters',
|
|
'/audiobook/:id/edit',
|
|
'/audiobook/:id/manage',
|
|
'/library/:library',
|
|
'/library/:library/search',
|
|
'/library/:library/bookshelf/:id?',
|
|
'/library/:library/authors',
|
|
'/library/:library/series/:id?',
|
|
'/library/:library/podcast/search',
|
|
'/library/:library/podcast/latest',
|
|
'/config/users/:id',
|
|
'/config/users/:id/sessions',
|
|
'/config/item-metadata-utils/:id',
|
|
'/collection/:id',
|
|
'/playlist/:id'
|
|
]
|
|
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
|
|
|
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
|
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
|
router.post('/init', (req, res) => {
|
|
if (Database.hasRootUser) {
|
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
|
return res.sendStatus(500)
|
|
}
|
|
this.initializeServer(req, res)
|
|
})
|
|
router.get('/status', (req, res) => {
|
|
// status check for client to see if server has been initialized
|
|
// server has been initialized if a root user exists
|
|
const payload = {
|
|
isInit: Database.hasRootUser,
|
|
language: Database.serverSettings.language
|
|
}
|
|
if (!payload.isInit) {
|
|
payload.ConfigPath = global.ConfigPath
|
|
payload.MetadataPath = global.MetadataPath
|
|
}
|
|
res.json(payload)
|
|
})
|
|
router.get('/ping', (req, res) => {
|
|
Logger.info('Received ping')
|
|
res.json({ success: true })
|
|
})
|
|
app.get('/healthcheck', (req, res) => res.sendStatus(200))
|
|
|
|
this.server.listen(this.Port, this.Host, () => {
|
|
if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
|
|
else Logger.info(`Listening on port :${this.Port}`)
|
|
})
|
|
|
|
// Start listening for socket connections
|
|
SocketAuthority.initialize(this)
|
|
}
|
|
|
|
async initializeServer(req, res) {
|
|
Logger.info(`[Server] Initializing new server`)
|
|
const newRoot = req.body.newRoot
|
|
const rootUsername = newRoot.username || 'root'
|
|
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
|
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
|
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
|
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async filesChanged(fileUpdates) {
|
|
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
|
await this.scanner.scanFilesChanged(fileUpdates)
|
|
}
|
|
|
|
// Remove unused /metadata/items/{id} folders
|
|
async purgeMetadata() {
|
|
const itemsMetadata = Path.join(global.MetadataPath, 'items')
|
|
if (!(await fs.pathExists(itemsMetadata))) return
|
|
const foldersInItemsMetadata = await fs.readdir(itemsMetadata)
|
|
|
|
let purged = 0
|
|
await Promise.all(foldersInItemsMetadata.map(async foldername => {
|
|
const itemFullPath = fileUtils.filePathToPOSIX(Path.join(itemsMetadata, foldername))
|
|
|
|
const hasMatchingItem = Database.libraryItems.find(li => {
|
|
if (!li.media.coverPath) return false
|
|
return itemFullPath === fileUtils.filePathToPOSIX(Path.dirname(li.media.coverPath))
|
|
})
|
|
if (!hasMatchingItem) {
|
|
Logger.debug(`[Server] Purging unused metadata ${itemFullPath}`)
|
|
|
|
await fs.remove(itemFullPath).then(() => {
|
|
purged++
|
|
}).catch((err) => {
|
|
Logger.error(`[Server] Failed to delete folder path ${itemFullPath}`, err)
|
|
})
|
|
}
|
|
}))
|
|
if (purged > 0) {
|
|
Logger.info(`[Server] Purged ${purged} unused library item metadata`)
|
|
}
|
|
return purged
|
|
}
|
|
|
|
// Remove user media progress with items that no longer exist & remove seriesHideFrom that no longer exist
|
|
async cleanUserData() {
|
|
for (const _user of Database.users) {
|
|
if (_user.mediaProgress.length) {
|
|
for (const mediaProgress of _user.mediaProgress) {
|
|
const libraryItem = Database.libraryItems.find(li => li.id === mediaProgress.libraryItemId)
|
|
if (libraryItem && mediaProgress.episodeId) {
|
|
const episode = libraryItem.media.checkHasEpisode?.(mediaProgress.episodeId)
|
|
if (episode) continue
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
Logger.debug(`[Server] Removing media progress ${mediaProgress.id} data from user ${_user.username}`)
|
|
await Database.removeMediaProgress(mediaProgress.id)
|
|
}
|
|
}
|
|
|
|
let hasUpdated = false
|
|
if (_user.seriesHideFromContinueListening.length) {
|
|
_user.seriesHideFromContinueListening = _user.seriesHideFromContinueListening.filter(seriesId => {
|
|
if (!Database.series.some(se => se.id === seriesId)) { // Series removed
|
|
hasUpdated = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
if (hasUpdated) {
|
|
await Database.updateUser(_user)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: Database.serverSettings.rateLimitLoginWindow, // 5 minutes
|
|
max: Database.serverSettings.rateLimitLoginRequests,
|
|
skipSuccessfulRequests: true,
|
|
onLimitReached: this.loginLimitReached
|
|
})
|
|
}
|
|
|
|
logout(req, res) {
|
|
if (req.body.socketId) {
|
|
Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${req.body.socketId}`)
|
|
SocketAuthority.logout(req.body.socketId)
|
|
}
|
|
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async stop() {
|
|
await this.watcher.close()
|
|
Logger.info('Watcher Closed')
|
|
|
|
return new Promise((resolve) => {
|
|
this.server.close((err) => {
|
|
if (err) {
|
|
Logger.error('Failed to close server', err)
|
|
} else {
|
|
Logger.info('Server successfully closed')
|
|
}
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
}
|
|
module.exports = Server
|