mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-03 19:07:00 -05:00 
			
		
		
		
	
		
			
				
	
	
		
			358 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			358 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
const Path = require('path')
 | 
						|
const express = require('express')
 | 
						|
const http = require('http')
 | 
						|
const SocketIO = require('socket.io')
 | 
						|
const fs = require('fs-extra')
 | 
						|
const fileUpload = require('express-fileupload')
 | 
						|
 | 
						|
const Auth = require('./Auth')
 | 
						|
const Watcher = require('./Watcher')
 | 
						|
const Scanner = require('./Scanner')
 | 
						|
const Db = require('./Db')
 | 
						|
const ApiController = require('./ApiController')
 | 
						|
const HlsController = require('./HlsController')
 | 
						|
const StreamManager = require('./StreamManager')
 | 
						|
const RssFeeds = require('./RssFeeds')
 | 
						|
const DownloadManager = require('./DownloadManager')
 | 
						|
const Logger = require('./Logger')
 | 
						|
 | 
						|
class Server {
 | 
						|
  constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
 | 
						|
    this.Port = PORT
 | 
						|
    this.Host = '0.0.0.0'
 | 
						|
    this.ConfigPath = Path.normalize(CONFIG_PATH)
 | 
						|
    this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
 | 
						|
    this.MetadataPath = Path.normalize(METADATA_PATH)
 | 
						|
 | 
						|
    fs.ensureDirSync(CONFIG_PATH)
 | 
						|
    fs.ensureDirSync(METADATA_PATH)
 | 
						|
    fs.ensureDirSync(AUDIOBOOK_PATH)
 | 
						|
 | 
						|
    this.db = new Db(this.ConfigPath)
 | 
						|
    this.auth = new Auth(this.db)
 | 
						|
    this.watcher = new Watcher(this.AudiobookPath)
 | 
						|
    this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
 | 
						|
    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.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.server = null
 | 
						|
    this.io = null
 | 
						|
 | 
						|
    this.clients = {}
 | 
						|
 | 
						|
    this.isScanning = false
 | 
						|
    this.isScanningCovers = false
 | 
						|
    this.isInitialized = false
 | 
						|
  }
 | 
						|
 | 
						|
  get audiobooks() {
 | 
						|
    return this.db.audiobooks
 | 
						|
  }
 | 
						|
  get serverSettings() {
 | 
						|
    return this.db.serverSettings
 | 
						|
  }
 | 
						|
 | 
						|
  getClientsForUser(userId) {
 | 
						|
    return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
 | 
						|
  }
 | 
						|
 | 
						|
  emitter(ev, data) {
 | 
						|
    // Logger.debug('EMITTER', ev)
 | 
						|
    this.io.emit(ev, data)
 | 
						|
  }
 | 
						|
 | 
						|
  clientEmitter(userId, ev, data) {
 | 
						|
    var clients = this.getClientsForUser(userId)
 | 
						|
    if (!clients.length) {
 | 
						|
      return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
 | 
						|
    }
 | 
						|
    clients.forEach((client) => {
 | 
						|
      if (client.socket) {
 | 
						|
        client.socket.emit(ev, data)
 | 
						|
      }
 | 
						|
    })
 | 
						|
  }
 | 
						|
 | 
						|
  async filesChanged(files) {
 | 
						|
    Logger.info('[Server]', files.length, 'Files Changed')
 | 
						|
    var result = await this.scanner.filesChanged(files)
 | 
						|
    Logger.info('[Server] Files changed result', result)
 | 
						|
  }
 | 
						|
 | 
						|
  async scan() {
 | 
						|
    Logger.info('[Server] Starting Scan')
 | 
						|
    this.isScanning = true
 | 
						|
    this.isInitialized = true
 | 
						|
    this.emitter('scan_start', 'files')
 | 
						|
    var results = await this.scanner.scan()
 | 
						|
    this.isScanning = false
 | 
						|
    this.emitter('scan_complete', { scanType: 'files', results })
 | 
						|
    Logger.info('[Server] Scan complete')
 | 
						|
  }
 | 
						|
 | 
						|
  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
 | 
						|
  }
 | 
						|
 | 
						|
  async init() {
 | 
						|
    Logger.info('[Server] Init')
 | 
						|
    await this.streamManager.ensureStreamsDir()
 | 
						|
    await this.streamManager.removeOrphanStreams()
 | 
						|
    await this.downloadManager.removeOrphanDownloads()
 | 
						|
 | 
						|
    await this.db.init()
 | 
						|
    this.auth.init()
 | 
						|
 | 
						|
    this.watcher.initWatcher()
 | 
						|
    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)
 | 
						|
  }
 | 
						|
 | 
						|
  async start() {
 | 
						|
    Logger.info('=== Starting Server ===')
 | 
						|
    await this.init()
 | 
						|
 | 
						|
    const app = express()
 | 
						|
 | 
						|
    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
 | 
						|
    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.use('/api', this.authMiddleware.bind(this), this.apiController.router)
 | 
						|
    app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
 | 
						|
    // app.use('/hls', this.hlsController.router)
 | 
						|
    app.use('/feeds', this.rssFeeds.router)
 | 
						|
 | 
						|
    app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
 | 
						|
 | 
						|
    app.post('/login', (req, res) => this.auth.login(req, res))
 | 
						|
    app.post('/logout', this.logout.bind(this))
 | 
						|
    app.get('/ping', (req, res) => {
 | 
						|
      Logger.info('Recieved ping')
 | 
						|
      res.json({ success: true })
 | 
						|
    })
 | 
						|
 | 
						|
    // Used in development to set-up streams without authentication
 | 
						|
    if (process.env.NODE_ENV !== 'production') {
 | 
						|
      app.use('/test-hls', this.hlsController.router)
 | 
						|
      app.get('/test-stream/:id', async (req, res) => {
 | 
						|
        var uri = await this.streamManager.openTestStream(this.MetadataPath, req.params.id)
 | 
						|
        res.send(uri)
 | 
						|
      })
 | 
						|
      app.get('/catalog.json', (req, res) => {
 | 
						|
        Logger.error('Catalog request made', req.headers)
 | 
						|
        res.json()
 | 
						|
      })
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    this.server.listen(this.Port, this.Host, () => {
 | 
						|
      Logger.info(`Running on http://${this.Host}:${this.Port}`)
 | 
						|
    })
 | 
						|
 | 
						|
    this.io = new SocketIO.Server(this.server, {
 | 
						|
      cors: {
 | 
						|
        origin: '*',
 | 
						|
        methods: ["GET", "POST"]
 | 
						|
      }
 | 
						|
    })
 | 
						|
 | 
						|
    this.io.on('connection', (socket) => {
 | 
						|
      this.clients[socket.id] = {
 | 
						|
        id: socket.id,
 | 
						|
        socket,
 | 
						|
        connected_at: Date.now()
 | 
						|
      }
 | 
						|
      socket.sheepClient = this.clients[socket.id]
 | 
						|
 | 
						|
      Logger.info('[SOCKET] Socket Connected', socket.id)
 | 
						|
 | 
						|
      socket.on('auth', (token) => this.authenticateSocket(socket, token))
 | 
						|
 | 
						|
      // Scanning
 | 
						|
      socket.on('scan', this.scan.bind(this))
 | 
						|
      socket.on('scan_covers', this.scanCovers.bind(this))
 | 
						|
      socket.on('cancel_scan', this.cancelScan.bind(this))
 | 
						|
 | 
						|
      // Streaming
 | 
						|
      socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
 | 
						|
      socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
 | 
						|
      socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
 | 
						|
 | 
						|
      socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket.sheepClient, payload))
 | 
						|
 | 
						|
      // Downloading
 | 
						|
      socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
 | 
						|
      socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
 | 
						|
 | 
						|
      socket.on('test', () => {
 | 
						|
        socket.emit('test_received', socket.id)
 | 
						|
      })
 | 
						|
 | 
						|
      socket.on('disconnect', () => {
 | 
						|
        var _client = this.clients[socket.id]
 | 
						|
        if (!_client) {
 | 
						|
          Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
 | 
						|
        } else if (!_client.user) {
 | 
						|
          Logger.info('[SOCKET] Unauth socket disconnected ' + socket.id)
 | 
						|
          delete this.clients[socket.id]
 | 
						|
        } else {
 | 
						|
          const disconnectTime = Date.now() - _client.connected_at
 | 
						|
          Logger.info(`[SOCKET] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
 | 
						|
          delete this.clients[socket.id]
 | 
						|
        }
 | 
						|
      })
 | 
						|
    })
 | 
						|
  }
 | 
						|
 | 
						|
  logout(req, res) {
 | 
						|
    res.sendStatus(200)
 | 
						|
  }
 | 
						|
 | 
						|
  audiobookProgressUpdate(client, progressPayload) {
 | 
						|
    if (!client || !client.user) {
 | 
						|
      Logger.error('[Server] audiobookProgressUpdate invalid socket client')
 | 
						|
      return
 | 
						|
    }
 | 
						|
    client.user.updateAudiobookProgress(progressPayload.audiobookId, progressPayload)
 | 
						|
  }
 | 
						|
 | 
						|
  async authenticateSocket(socket, token) {
 | 
						|
    var user = await this.auth.verifyToken(token)
 | 
						|
    if (!user) {
 | 
						|
      Logger.error('Cannot validate socket - invalid token')
 | 
						|
      return socket.emit('invalid_token')
 | 
						|
    }
 | 
						|
    var client = this.clients[socket.id]
 | 
						|
    client.user = user
 | 
						|
 | 
						|
    if (!client.user.toJSONForBrowser) {
 | 
						|
      Logger.error('Invalid user...', client.user)
 | 
						|
      return
 | 
						|
    }
 | 
						|
 | 
						|
    // Check if user has stream open
 | 
						|
    if (client.user.stream) {
 | 
						|
      Logger.info('User has stream open already', client.user.stream)
 | 
						|
      client.stream = this.streamManager.getStream(client.user.stream)
 | 
						|
      if (!client.stream) {
 | 
						|
        Logger.error('Invalid user stream id', client.user.stream)
 | 
						|
        this.streamManager.removeOrphanStreamFiles(client.user.stream)
 | 
						|
        await this.db.updateUserStream(client.user.id, null)
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    const initialPayload = {
 | 
						|
      serverSettings: this.serverSettings.toJSON(),
 | 
						|
      isScanning: this.isScanning,
 | 
						|
      isInitialized: this.isInitialized,
 | 
						|
      audiobookPath: this.AudiobookPath,
 | 
						|
      metadataPath: this.MetadataPath,
 | 
						|
      configPath: this.ConfigPath,
 | 
						|
      user: client.user.toJSONForBrowser(),
 | 
						|
      stream: client.stream || null
 | 
						|
    }
 | 
						|
    client.socket.emit('init', initialPayload)
 | 
						|
  }
 | 
						|
 | 
						|
  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 |