mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-10-06 10:12:04 +02:00
1106 lines
37 KiB
JavaScript
1106 lines
37 KiB
JavaScript
const express = require('express')
|
|
const Path = require('path')
|
|
const fs = require('fs-extra')
|
|
const date = require('date-and-time')
|
|
|
|
const Logger = require('./Logger')
|
|
const { isObject } = require('./utils/index')
|
|
const audioFileScanner = require('./utils/audioFileScanner')
|
|
|
|
const BookFinder = require('./BookFinder')
|
|
|
|
const Library = require('./objects/Library')
|
|
const User = require('./objects/User')
|
|
const UserCollection = require('./objects/UserCollection')
|
|
|
|
class ApiController {
|
|
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, emitter, clientEmitter) {
|
|
this.db = db
|
|
this.scanner = scanner
|
|
this.auth = auth
|
|
this.streamManager = streamManager
|
|
this.rssFeeds = rssFeeds
|
|
this.downloadManager = downloadManager
|
|
this.coverController = coverController
|
|
this.backupManager = backupManager
|
|
this.watcher = watcher
|
|
this.emitter = emitter
|
|
this.clientEmitter = clientEmitter
|
|
this.MetadataPath = MetadataPath
|
|
|
|
this.bookFinder = new BookFinder()
|
|
|
|
this.router = express()
|
|
this.init()
|
|
}
|
|
|
|
init() {
|
|
this.router.get('/find/covers', this.findCovers.bind(this))
|
|
this.router.get('/find/:method', this.find.bind(this))
|
|
|
|
this.router.get('/libraries', this.getLibraries.bind(this))
|
|
this.router.patch('/libraries/order', this.reorderLibraries.bind(this))
|
|
this.router.get('/library/:id/search', this.searchLibrary.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))
|
|
|
|
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
|
|
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
|
|
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
|
|
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
|
|
this.router.patch('/audiobook/:id/coverfile', this.updateAudiobookCoverFromFile.bind(this))
|
|
this.router.get('/audiobook/:id/match', this.matchAudiobookBook.bind(this))
|
|
this.router.get('/audiobook/:id/stream', this.openAudiobookStream.bind(this))
|
|
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
|
|
|
this.router.patch('/match/:id', this.match.bind(this))
|
|
|
|
// Old Route : Wait until refactor of mobile app to replace with path /reset-progress
|
|
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
|
|
|
this.router.patch('/user/audiobook/:id/reset-progress', this.resetUserAudiobookProgress.bind(this))
|
|
this.router.patch('/user/audiobook/:id', this.updateUserAudiobookData.bind(this))
|
|
this.router.patch('/user/audiobooks', this.batchUpdateUserAudiobookData.bind(this))
|
|
|
|
this.router.patch('/user/password', this.userChangePassword.bind(this))
|
|
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
|
this.router.get('/users', this.getUsers.bind(this))
|
|
this.router.post('/user', this.createUser.bind(this))
|
|
this.router.get('/user/:id', this.getUser.bind(this))
|
|
this.router.get('/user/:id/listeningSessions', this.getUserListeningSessions.bind(this))
|
|
this.router.get('/user/:id/listeningStats', this.getUserListeningStats.bind(this))
|
|
this.router.patch('/user/:id', this.updateUser.bind(this))
|
|
this.router.delete('/user/:id', this.deleteUser.bind(this))
|
|
|
|
this.router.get('/collections', this.getUserCollections.bind(this))
|
|
this.router.get('/collection/:id', this.getUserCollection.bind(this))
|
|
this.router.post('/collection', this.createUserCollection.bind(this))
|
|
this.router.post('/collection/:id/book', this.addBookToUserCollection.bind(this))
|
|
this.router.delete('/collection/:id/book/:bookId', this.removeBookFromUserCollection.bind(this))
|
|
this.router.patch('/collection/:id', this.updateUserCollection.bind(this))
|
|
this.router.delete('/collection/:id', this.deleteUserCollection.bind(this))
|
|
|
|
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
|
|
|
|
this.router.delete('/backup/:id', this.deleteBackup.bind(this))
|
|
this.router.post('/backup/upload', this.uploadBackup.bind(this))
|
|
|
|
this.router.post('/authorize', this.authorize.bind(this))
|
|
|
|
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))
|
|
|
|
this.router.get('/scantracks/:id', this.scanAudioTrackNums.bind(this))
|
|
|
|
this.router.get('/listeningSessions', this.getCurrentUserListeningSessions.bind(this))
|
|
this.router.get('/listeningStats', this.getCurrentUserListeningStats.bind(this))
|
|
}
|
|
|
|
async find(req, res) {
|
|
var provider = req.query.provider || 'google'
|
|
var title = req.query.title || ''
|
|
var author = req.query.author || ''
|
|
var results = await this.bookFinder.search(provider, title, author)
|
|
res.json(results)
|
|
}
|
|
|
|
findCovers(req, res) {
|
|
this.scanner.findCovers(req, res)
|
|
}
|
|
|
|
authorize(req, res) {
|
|
if (!req.user) {
|
|
Logger.error('Invalid user in authorize')
|
|
return res.sendStatus(401)
|
|
}
|
|
res.json({ user: req.user })
|
|
}
|
|
|
|
getLibraries(req, res) {
|
|
var libraries = this.db.libraries.map(lib => lib.toJSON())
|
|
res.json(libraries)
|
|
}
|
|
|
|
async reorderLibraries(req, res) {
|
|
if (!req.user || !req.user.isRoot) {
|
|
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
|
|
return res.sendStatus(401)
|
|
}
|
|
|
|
var orderdata = req.body
|
|
var hasUpdates = false
|
|
for (let i = 0; i < orderdata.length; i++) {
|
|
var library = this.db.libraries.find(lib => lib.id === orderdata[i].id)
|
|
if (!library) {
|
|
Logger.error(`[ApiController] Invalid library not found in reorder ${orderdata[i].id}`)
|
|
return res.sendStatus(500)
|
|
}
|
|
if (library.update({ displayOrder: orderdata[i].newOrder })) {
|
|
hasUpdates = true
|
|
await this.db.updateEntity('library', library)
|
|
}
|
|
}
|
|
|
|
if (hasUpdates) {
|
|
Logger.info(`[ApiController] Updated library display orders`)
|
|
} else {
|
|
Logger.info(`[ApiController] Library orders were up to date`)
|
|
}
|
|
|
|
var libraries = this.db.libraries.map(lib => lib.toJSON())
|
|
res.json(libraries)
|
|
}
|
|
|
|
searchLibrary(req, res) {
|
|
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
|
if (!library) {
|
|
return res.status(404).send('Library not found')
|
|
}
|
|
if (!req.query.q) {
|
|
return res.status(400).send('No query string')
|
|
}
|
|
var maxResults = req.query.max || 3
|
|
|
|
var bookMatches = []
|
|
var authorMatches = {}
|
|
var seriesMatches = {}
|
|
var tagMatches = {}
|
|
|
|
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
|
audiobooksInLibrary.forEach((ab) => {
|
|
var queryResult = ab.searchQuery(req.query.q)
|
|
if (queryResult.book) {
|
|
var bookMatchObj = {
|
|
audiobook: ab,
|
|
matchKey: queryResult.book,
|
|
matchText: queryResult.bookMatchText
|
|
}
|
|
bookMatches.push(bookMatchObj)
|
|
}
|
|
if (queryResult.authors) {
|
|
queryResult.authors.forEach((author) => {
|
|
if (!authorMatches[author]) {
|
|
authorMatches[author] = {
|
|
author: author
|
|
}
|
|
}
|
|
})
|
|
}
|
|
if (queryResult.series) {
|
|
if (!seriesMatches[queryResult.series]) {
|
|
seriesMatches[queryResult.series] = {
|
|
series: queryResult.series,
|
|
audiobooks: [ab]
|
|
}
|
|
} else {
|
|
seriesMatches[queryResult.series].audiobooks.push(ab)
|
|
}
|
|
}
|
|
if (queryResult.tags && queryResult.tags.length) {
|
|
queryResult.tags.forEach((tag) => {
|
|
if (!tagMatches[tag]) {
|
|
tagMatches[tag] = {
|
|
tag,
|
|
audiobooks: [ab]
|
|
}
|
|
} else {
|
|
tagMatches[tag].audiobooks.push(ab)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
res.json({
|
|
audiobooks: bookMatches.slice(0, maxResults),
|
|
tags: Object.values(tagMatches).slice(0, maxResults),
|
|
authors: Object.values(authorMatches).slice(0, maxResults),
|
|
series: Object.values(seriesMatches).slice(0, maxResults)
|
|
})
|
|
}
|
|
|
|
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()
|
|
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
|
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) {
|
|
audiobooks = this.db.audiobooks.filter(ab => {
|
|
return ab.isSearchMatch(req.query.q)
|
|
}).map(ab => ab.toJSONMinified())
|
|
} else {
|
|
audiobooks = this.db.audiobooks.map(ab => ab.toJSONMinified())
|
|
}
|
|
res.json(audiobooks)
|
|
}
|
|
|
|
async deleteAllAudiobooks(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.warn('User other than root attempted to delete all audiobooks', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
Logger.info('Removing all Audiobooks')
|
|
var success = await this.db.recreateAudiobookDb()
|
|
if (success) res.sendStatus(200)
|
|
else res.sendStatus(500)
|
|
}
|
|
|
|
getAudiobook(req, res) {
|
|
if (!req.user) {
|
|
return res.sendStatus(403)
|
|
}
|
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
|
|
// Check user can access this audiobooks library
|
|
if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) {
|
|
return res.sendStatus(403)
|
|
}
|
|
|
|
res.json(audiobook.toJSONExpanded())
|
|
}
|
|
|
|
async handleDeleteAudiobook(audiobook) {
|
|
// Remove audiobook from users
|
|
for (let i = 0; i < this.db.users.length; i++) {
|
|
var user = this.db.users[i]
|
|
var madeUpdates = user.deleteAudiobookData(audiobook.id)
|
|
if (madeUpdates) {
|
|
await this.db.updateEntity('user', user)
|
|
}
|
|
}
|
|
|
|
// remove any streams open for this audiobook
|
|
var streams = this.streamManager.streams.filter(stream => stream.audiobookId === audiobook.id)
|
|
for (let i = 0; i < streams.length; i++) {
|
|
var stream = streams[i]
|
|
var client = stream.client
|
|
await stream.close()
|
|
if (client && client.user) {
|
|
client.user.stream = null
|
|
client.stream = null
|
|
this.db.updateUserStream(client.user.id, null)
|
|
}
|
|
}
|
|
|
|
// remove book from collections
|
|
var collectionsWithBook = this.db.collections.filter(c => c.books.includes(audiobook.id))
|
|
for (let i = 0; i < collectionsWithBook.length; i++) {
|
|
var collection = collectionsWithBook[i]
|
|
collection.removeBook(audiobook.id)
|
|
await this.db.updateEntity('collection', collection)
|
|
this.clientEmitter(collection.userId, 'collection_updated', collection.toJSONExpanded(this.db.audiobooks))
|
|
}
|
|
|
|
var audiobookJSON = audiobook.toJSONMinified()
|
|
await this.db.removeEntity('audiobook', audiobook.id)
|
|
this.emitter('audiobook_removed', audiobookJSON)
|
|
}
|
|
|
|
async deleteAudiobook(req, res) {
|
|
if (!req.user.canDelete) {
|
|
Logger.warn('User attempted to delete without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
|
|
await this.handleDeleteAudiobook(audiobook)
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async batchDeleteAudiobooks(req, res) {
|
|
if (!req.user.canDelete) {
|
|
Logger.warn('User attempted to delete without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var { audiobookIds } = req.body
|
|
if (!audiobookIds || !audiobookIds.length) {
|
|
return res.sendStatus(500)
|
|
}
|
|
|
|
var audiobooksToDelete = this.db.audiobooks.filter(ab => audiobookIds.includes(ab.id))
|
|
if (!audiobooksToDelete.length) {
|
|
return res.sendStatus(404)
|
|
}
|
|
for (let i = 0; i < audiobooksToDelete.length; i++) {
|
|
Logger.info(`[ApiController] Deleting Audiobook "${audiobooksToDelete[i].title}"`)
|
|
await this.handleDeleteAudiobook(audiobooksToDelete[i])
|
|
}
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async batchUpdateAudiobooks(req, res) {
|
|
if (!req.user.canUpdate) {
|
|
Logger.warn('User attempted to batch update without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var audiobooks = req.body
|
|
if (!audiobooks || !audiobooks.length) {
|
|
return res.sendStatus(500)
|
|
}
|
|
|
|
var audiobooksUpdated = 0
|
|
audiobooks = audiobooks.map((ab) => {
|
|
var _ab = this.db.audiobooks.find(__ab => __ab.id === ab.id)
|
|
if (!_ab) return null
|
|
var hasUpdated = _ab.update(ab)
|
|
if (!hasUpdated) return null
|
|
audiobooksUpdated++
|
|
return _ab
|
|
}).filter(ab => ab)
|
|
|
|
if (audiobooksUpdated) {
|
|
Logger.info(`[ApiController] ${audiobooksUpdated} Audiobooks have updates`)
|
|
for (let i = 0; i < audiobooks.length; i++) {
|
|
await this.db.updateAudiobook(audiobooks[i])
|
|
this.emitter('audiobook_updated', audiobooks[i].toJSONMinified())
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
updates: audiobooksUpdated
|
|
})
|
|
}
|
|
|
|
async updateAudiobookTracks(req, res) {
|
|
if (!req.user.canUpdate) {
|
|
Logger.warn('User attempted to update audiotracks without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
var orderedFileData = req.body.orderedFileData
|
|
Logger.info(`Updating audiobook tracks called ${audiobook.id}`)
|
|
audiobook.updateAudioTracks(orderedFileData)
|
|
await this.db.updateAudiobook(audiobook)
|
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
|
res.json(audiobook.toJSON())
|
|
}
|
|
|
|
async uploadAudiobookCover(req, res) {
|
|
if (!req.user.canUpload || !req.user.canUpdate) {
|
|
Logger.warn('User attempted to upload a cover without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
|
|
var audiobookId = req.params.id
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
|
if (!audiobook) {
|
|
return res.status(404).send('Audiobook not found')
|
|
}
|
|
|
|
var result = null
|
|
if (req.body && req.body.url) {
|
|
Logger.debug(`[ApiController] Requesting download cover from url "${req.body.url}"`)
|
|
result = await this.coverController.downloadCoverFromUrl(audiobook, req.body.url)
|
|
} else if (req.files && req.files.cover) {
|
|
Logger.debug(`[ApiController] Handling uploaded cover`)
|
|
var coverFile = req.files.cover
|
|
result = await this.coverController.uploadCover(audiobook, coverFile)
|
|
} else {
|
|
return res.status(400).send('Invalid request no file or url')
|
|
}
|
|
|
|
if (result && result.error) {
|
|
return res.status(400).send(result.error)
|
|
} else if (!result || !result.cover) {
|
|
return res.status(500).send('Unknown error occurred')
|
|
}
|
|
|
|
await this.db.updateAudiobook(audiobook)
|
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
|
res.json({
|
|
success: true,
|
|
cover: result.cover
|
|
})
|
|
}
|
|
|
|
async updateAudiobookCoverFromFile(req, res) {
|
|
if (!req.user.canUpdate) {
|
|
Logger.warn('User attempted to update without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
|
|
var coverFile = req.body
|
|
var updated = await audiobook.setCoverFromFile(coverFile)
|
|
|
|
if (updated) {
|
|
await this.db.updateAudiobook(audiobook)
|
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
|
}
|
|
|
|
if (updated) res.status(200).send('Cover updated successfully')
|
|
else res.status(200).send('No update was made to cover')
|
|
}
|
|
|
|
async matchAudiobookBook(req, res) {
|
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
|
|
var provider = req.query.provider || 'google'
|
|
var excludeAuthor = req.query.excludeAuthor === '1'
|
|
var authorSearch = excludeAuthor ? null : audiobook.authorFL
|
|
|
|
var results = await this.bookFinder.search(provider, audiobook.title, authorSearch)
|
|
res.json(results)
|
|
}
|
|
|
|
async openAudiobookStream(req, res) {
|
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
|
|
this.streamManager.openStreamApiRequest(res, req.user, audiobook)
|
|
}
|
|
|
|
async updateAudiobook(req, res) {
|
|
if (!req.user.canUpdate) {
|
|
Logger.warn('User attempted to update without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
var hasUpdates = audiobook.update(req.body)
|
|
if (hasUpdates) {
|
|
await this.db.updateAudiobook(audiobook)
|
|
}
|
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
|
res.json(audiobook.toJSON())
|
|
}
|
|
|
|
async match(req, res) {
|
|
var body = req.body
|
|
var audiobookId = req.params.id
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
|
var bookData = {
|
|
olid: body.id,
|
|
publish_year: body.first_publish_year,
|
|
description: body.description,
|
|
title: body.title,
|
|
author: body.author,
|
|
cover: body.cover
|
|
}
|
|
audiobook.setBook(bookData)
|
|
await this.db.updateAudiobook(audiobook)
|
|
|
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
|
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async resetUserAudiobookProgress(req, res) {
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
|
|
if (!audiobook) {
|
|
return res.status(404).send('Audiobook not found')
|
|
}
|
|
req.user.resetAudiobookProgress(audiobook)
|
|
await this.db.updateEntity('user', req.user)
|
|
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async updateUserAudiobookData(req, res) {
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
|
|
if (!audiobook) {
|
|
return res.status(404).send('Audiobook not found')
|
|
}
|
|
var wasUpdated = req.user.updateAudiobookData(audiobook, req.body)
|
|
if (wasUpdated) {
|
|
await this.db.updateEntity('user', req.user)
|
|
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
|
}
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async batchUpdateUserAudiobookData(req, res) {
|
|
var userAbDataPayloads = req.body
|
|
if (!userAbDataPayloads || !userAbDataPayloads.length) {
|
|
return res.sendStatus(500)
|
|
}
|
|
|
|
var shouldUpdate = false
|
|
userAbDataPayloads.forEach((userAbData) => {
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === userAbData.audiobookId)
|
|
if (audiobook) {
|
|
var wasUpdated = req.user.updateAudiobookData(audiobook, userAbData)
|
|
if (wasUpdated) shouldUpdate = true
|
|
}
|
|
})
|
|
|
|
if (shouldUpdate) {
|
|
await this.db.updateEntity('user', req.user)
|
|
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
|
}
|
|
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
userChangePassword(req, res) {
|
|
this.auth.userChangePassword(req, res)
|
|
}
|
|
|
|
async openRssFeed(req, res) {
|
|
var audiobookId = req.body.audiobookId
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
|
if (!audiobook) return res.sendStatus(404)
|
|
var feed = await this.rssFeeds.openFeed(audiobook)
|
|
console.log('Feed open', feed)
|
|
res.json(feed)
|
|
}
|
|
|
|
async userUpdateSettings(req, res) {
|
|
var settingsUpdate = req.body
|
|
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
|
return res.sendStatus(500)
|
|
}
|
|
var madeUpdates = req.user.updateSettings(settingsUpdate)
|
|
if (madeUpdates) {
|
|
await this.db.updateEntity('user', req.user)
|
|
}
|
|
return res.json({
|
|
success: true,
|
|
settings: req.user.settings
|
|
})
|
|
}
|
|
|
|
userJsonWithBookProgressDetails(user) {
|
|
var json = user.toJSONForBrowser()
|
|
|
|
// User audiobook progress attach book details
|
|
if (json.audiobooks && Object.keys(json.audiobooks).length) {
|
|
for (const audiobookId in json.audiobooks) {
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
|
if (!audiobook) {
|
|
Logger.error('[ApiController] Audiobook not found for users progress ' + audiobookId)
|
|
} else {
|
|
json.audiobooks[audiobookId].book = audiobook.book.toJSON()
|
|
}
|
|
}
|
|
}
|
|
|
|
return json
|
|
}
|
|
|
|
getUsers(req, res) {
|
|
if (req.user.type !== 'root') return res.sendStatus(403)
|
|
var users = this.db.users.map(u => this.userJsonWithBookProgressDetails(u))
|
|
res.json(users)
|
|
}
|
|
|
|
async createUser(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.warn('Non-root user attempted to create user', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var account = req.body
|
|
|
|
var username = account.username
|
|
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase())
|
|
if (usernameExists) {
|
|
return res.status(500).send('Username already taken')
|
|
}
|
|
|
|
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
|
account.pash = await this.auth.hashPass(account.password)
|
|
delete account.password
|
|
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
|
account.createdAt = Date.now()
|
|
var newUser = new User(account)
|
|
var success = await this.db.insertEntity('user', newUser)
|
|
if (success) {
|
|
this.clientEmitter(req.user.id, 'user_added', newUser)
|
|
res.json({
|
|
user: newUser.toJSONForBrowser()
|
|
})
|
|
} else {
|
|
return res.status(500).send('Failed to save new user')
|
|
}
|
|
}
|
|
|
|
async getUser(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.error('User other than root attempting to get user', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
|
|
var user = this.db.users.find(u => u.id === req.params.id)
|
|
if (!user) {
|
|
return res.sendStatus(404)
|
|
}
|
|
|
|
res.json(this.userJsonWithBookProgressDetails(user))
|
|
}
|
|
|
|
async updateUser(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.error('User other than root attempting to update user', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
|
|
var user = this.db.users.find(u => u.id === req.params.id)
|
|
if (!user) {
|
|
return res.sendStatus(404)
|
|
}
|
|
|
|
var account = req.body
|
|
|
|
if (account.username !== undefined && account.username !== user.username) {
|
|
var usernameExists = this.db.users.find(u => u.username.toLowerCase() === account.username.toLowerCase())
|
|
if (usernameExists) {
|
|
return res.status(500).send('Username already taken')
|
|
}
|
|
}
|
|
|
|
// Updating password
|
|
if (account.password) {
|
|
account.pash = await this.auth.hashPass(account.password)
|
|
delete account.password
|
|
}
|
|
|
|
var hasUpdated = user.update(account)
|
|
if (hasUpdated) {
|
|
await this.db.updateEntity('user', user)
|
|
}
|
|
|
|
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
|
res.json({
|
|
success: true,
|
|
user: user.toJSONForBrowser()
|
|
})
|
|
}
|
|
|
|
async deleteUser(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.error('User other than root attempting to delete user', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
if (req.params.id === 'root') {
|
|
return res.sendStatus(500)
|
|
}
|
|
if (req.user.id === req.params.id) {
|
|
Logger.error('Attempting to delete themselves...')
|
|
return res.sendStatus(500)
|
|
}
|
|
var user = this.db.users.find(u => u.id === req.params.id)
|
|
if (!user) {
|
|
Logger.error('User not found')
|
|
return res.json({
|
|
error: 'User not found'
|
|
})
|
|
}
|
|
|
|
// delete user collections
|
|
var userCollections = this.db.collections.filter(c => c.userId === user.id)
|
|
var collectionsToRemove = userCollections.map(uc => uc.id)
|
|
for (let i = 0; i < collectionsToRemove.length; i++) {
|
|
await this.db.removeEntity('collection', collectionsToRemove[i])
|
|
}
|
|
|
|
// Todo: check if user is logged in and cancel streams
|
|
|
|
var userJson = user.toJSONForBrowser()
|
|
await this.db.removeEntity('user', user.id)
|
|
this.clientEmitter(req.user.id, 'user_removed', userJson)
|
|
res.json({
|
|
success: true
|
|
})
|
|
}
|
|
|
|
async getUserCollections(req, res) {
|
|
var collections = this.db.collections.filter(c => c.userId === req.user.id)
|
|
var expandedCollections = collections.map(c => c.toJSONExpanded(this.db.audiobooks))
|
|
res.json(expandedCollections)
|
|
}
|
|
|
|
async getUserCollection(req, res) {
|
|
var collection = this.db.collections.find(c => c.id === req.params.id)
|
|
if (!collection) {
|
|
return res.status(404).send('Collection not found')
|
|
}
|
|
res.json(collection.toJSONExpanded(this.db.audiobooks))
|
|
}
|
|
|
|
async createUserCollection(req, res) {
|
|
var newCollection = new UserCollection()
|
|
req.body.userId = req.user.id
|
|
var success = newCollection.setData(req.body)
|
|
if (!success) {
|
|
return res.status(500).send('Invalid collection data')
|
|
}
|
|
var jsonExpanded = newCollection.toJSONExpanded(this.db.audiobooks)
|
|
await this.db.insertEntity('collection', newCollection)
|
|
this.clientEmitter(req.user.id, 'collection_added', jsonExpanded)
|
|
res.json(jsonExpanded)
|
|
}
|
|
|
|
async addBookToUserCollection(req, res) {
|
|
var collection = this.db.collections.find(c => c.id === req.params.id)
|
|
if (!collection) {
|
|
return res.status(404).send('Collection not found')
|
|
}
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === req.body.id)
|
|
if (!audiobook) {
|
|
return res.status(500).send('Book not found')
|
|
}
|
|
if (audiobook.libraryId !== collection.libraryId) {
|
|
return res.status(500).send('Book in different library')
|
|
}
|
|
if (collection.books.includes(req.body.id)) {
|
|
return res.status(500).send('Book already in collection')
|
|
}
|
|
collection.addBook(req.body.id)
|
|
var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks)
|
|
await this.db.updateEntity('collection', collection)
|
|
this.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
|
res.json(jsonExpanded)
|
|
}
|
|
|
|
async removeBookFromUserCollection(req, res) {
|
|
var collection = this.db.collections.find(c => c.id === req.params.id)
|
|
if (!collection) {
|
|
return res.status(404).send('Collection not found')
|
|
}
|
|
|
|
if (collection.books.includes(req.params.bookId)) {
|
|
collection.removeBook(req.params.bookId)
|
|
var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks)
|
|
await this.db.updateEntity('collection', collection)
|
|
this.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
|
}
|
|
res.json(collection.toJSONExpanded(this.db.audiobooks))
|
|
}
|
|
|
|
async updateUserCollection(req, res) {
|
|
var collection = this.db.collections.find(c => c.id === req.params.id)
|
|
if (!collection) {
|
|
return res.status(404).send('Collection not found')
|
|
}
|
|
var wasUpdated = collection.update(req.body)
|
|
var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks)
|
|
if (wasUpdated) {
|
|
await this.db.updateEntity('collection', collection)
|
|
this.clientEmitter(req.user.id, 'collection_updated', jsonExpanded)
|
|
}
|
|
res.json(jsonExpanded)
|
|
}
|
|
|
|
async deleteUserCollection(req, res) {
|
|
var collection = this.db.collections.find(c => c.id === req.params.id)
|
|
if (!collection) {
|
|
return res.status(404).send('Collection not found')
|
|
}
|
|
var jsonExpanded = collection.toJSONExpanded(this.db.audiobooks)
|
|
await this.db.removeEntity('collection', collection.id)
|
|
this.clientEmitter(req.user.id, 'collection_removed', jsonExpanded)
|
|
res.sendStatus(200)
|
|
}
|
|
|
|
async updateServerSettings(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.error('User other than root attempting to update server settings', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var settingsUpdate = req.body
|
|
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
|
return res.status(500).send('Invalid settings update object')
|
|
}
|
|
|
|
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
|
|
if (madeUpdates) {
|
|
// If backup schedule is updated - update backup manager
|
|
if (settingsUpdate.backupSchedule !== undefined) {
|
|
this.backupManager.updateCronSchedule()
|
|
}
|
|
|
|
await this.db.updateEntity('settings', this.db.serverSettings)
|
|
}
|
|
return res.json({
|
|
success: true,
|
|
serverSettings: this.db.serverSettings
|
|
})
|
|
}
|
|
|
|
async deleteBackup(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.error(`[ApiController] Non-Root user attempting to delete backup`, req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
|
if (!backup) {
|
|
return res.sendStatus(404)
|
|
}
|
|
await this.backupManager.removeBackup(backup)
|
|
res.json(this.backupManager.backups.map(b => b.toJSON()))
|
|
}
|
|
|
|
async uploadBackup(req, res) {
|
|
if (!req.user.isRoot) {
|
|
Logger.error(`[ApiController] Non-Root user attempting to upload backup`, req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
if (!req.files.file) {
|
|
Logger.error('[ApiController] Upload backup invalid')
|
|
return res.sendStatus(500)
|
|
}
|
|
this.backupManager.uploadBackup(req, res)
|
|
}
|
|
|
|
async download(req, res) {
|
|
if (!req.user.canDownload) {
|
|
Logger.error('User attempting to download without permission', req.user)
|
|
return res.sendStatus(403)
|
|
}
|
|
var downloadId = req.params.id
|
|
Logger.info('Download Request', downloadId)
|
|
var download = this.downloadManager.getDownload(downloadId)
|
|
if (!download) {
|
|
Logger.error('Download request not found', downloadId)
|
|
return res.sendStatus(404)
|
|
}
|
|
|
|
var options = {
|
|
headers: {
|
|
'Content-Type': download.mimeType
|
|
}
|
|
}
|
|
res.download(download.fullPath, download.filename, options, (err) => {
|
|
if (err) {
|
|
Logger.error('Download Error', err)
|
|
}
|
|
})
|
|
}
|
|
|
|
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(path) && dirname !== 'node_modules') {
|
|
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'].map(dirname => {
|
|
return Path.sep + dirname
|
|
})
|
|
|
|
// Do not include existing mapped library paths in response
|
|
this.db.libraries.forEach(lib => {
|
|
lib.folders.forEach((folder) => {
|
|
var dir = folder.fullPath
|
|
if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '')
|
|
excludedDirs.push(dir)
|
|
})
|
|
})
|
|
|
|
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
|
|
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
|
|
res.json(dirs)
|
|
}
|
|
|
|
async scanAudioTrackNums(req, res) {
|
|
if (!req.user || !req.user.isRoot) {
|
|
return res.sendStatus(403)
|
|
}
|
|
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
|
|
if (!audiobook) {
|
|
return res.status(404).send('Audiobook not found')
|
|
}
|
|
|
|
var scandata = await audioFileScanner.scanTrackNumbers(audiobook)
|
|
res.json(scandata)
|
|
}
|
|
|
|
async getUserListeningSessionsHelper(userId) {
|
|
var userSessions = await this.db.selectUserSessions(userId)
|
|
var listeningSessions = userSessions.filter(us => us.sessionType === 'listeningSession')
|
|
return listeningSessions.sort((a, b) => b.lastUpdate - a.lastUpdate)
|
|
}
|
|
|
|
async getUserListeningSessions(req, res) {
|
|
if (!req.user || !req.user.isRoot) {
|
|
return res.sendStatus(403)
|
|
}
|
|
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
|
res.json(listeningSessions.slice(0, 10))
|
|
}
|
|
|
|
async getCurrentUserListeningSessions(req, res) {
|
|
if (!req.user) {
|
|
return res.sendStatus(500)
|
|
}
|
|
var listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
|
|
res.json(listeningSessions.slice(0, 10))
|
|
}
|
|
|
|
async getUserListeningStatsHelpers(userId) {
|
|
const today = date.format(new Date(), 'YYYY-MM-DD')
|
|
|
|
var listeningSessions = await this.getUserListeningSessionsHelper(userId)
|
|
var listeningStats = {
|
|
totalTime: 0,
|
|
books: {},
|
|
days: {},
|
|
dayOfWeek: {},
|
|
today: 0
|
|
}
|
|
listeningSessions.forEach((s) => {
|
|
if (s.dayOfWeek) {
|
|
if (!listeningStats.dayOfWeek[s.dayOfWeek]) listeningStats.dayOfWeek[s.dayOfWeek] = 0
|
|
listeningStats.dayOfWeek[s.dayOfWeek] += s.timeListening
|
|
}
|
|
if (s.date) {
|
|
if (!listeningStats.days[s.date]) listeningStats.days[s.date] = 0
|
|
listeningStats.days[s.date] += s.timeListening
|
|
|
|
if (s.date === today) {
|
|
listeningStats.today += s.timeListening
|
|
}
|
|
}
|
|
if (!listeningStats.books[s.audiobookId]) listeningStats.books[s.audiobookId] = 0
|
|
listeningStats.books[s.audiobookId] += s.timeListening
|
|
|
|
listeningStats.totalTime += s.timeListening
|
|
})
|
|
return listeningStats
|
|
}
|
|
|
|
async getUserListeningStats(req, res) {
|
|
if (!req.user || !req.user.isRoot) {
|
|
return res.sendStatus(403)
|
|
}
|
|
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
|
|
res.json(listeningStats)
|
|
}
|
|
|
|
async getCurrentUserListeningStats(req, res) {
|
|
if (!req.user) {
|
|
return res.sendStatus(500)
|
|
}
|
|
var listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
|
|
res.json(listeningStats)
|
|
}
|
|
}
|
|
module.exports = ApiController |