mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-27 03:31:46 +00:00
Merge branch 'master' into social
This commit is contained in:
commit
12b7976ef3
245 changed files with 11554 additions and 3676 deletions
|
|
@ -9,6 +9,7 @@ class AuthorController {
|
|||
constructor() { }
|
||||
|
||||
async findOne(req, res) {
|
||||
const libraryId = req.query.library
|
||||
const include = (req.query.include || '').split(',')
|
||||
|
||||
const authorJson = req.author.toJSON()
|
||||
|
|
@ -16,6 +17,7 @@ class AuthorController {
|
|||
// Used on author landing page to include library items and items grouped in series
|
||||
if (include.includes('items')) {
|
||||
authorJson.libraryItems = this.db.libraryItems.filter(li => {
|
||||
if (libraryId && li.libraryId !== libraryId) return false
|
||||
if (!req.user.checkCanAccessLibraryItem(li)) return false // filter out library items user cannot access
|
||||
return li.media.metadata.hasAuthor && li.media.metadata.hasAuthor(req.author.id)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
const Logger = require('../Logger')
|
||||
const UserCollection = require('../objects/UserCollection')
|
||||
const Collection = require('../objects/Collection')
|
||||
|
||||
class CollectionController {
|
||||
constructor() { }
|
||||
|
||||
async create(req, res) {
|
||||
var newCollection = new UserCollection()
|
||||
var newCollection = new Collection()
|
||||
req.body.userId = req.user.id
|
||||
var success = newCollection.setData(req.body)
|
||||
if (!success) {
|
||||
|
|
@ -18,8 +18,7 @@ class CollectionController {
|
|||
}
|
||||
|
||||
findAll(req, res) {
|
||||
var collections = this.db.collections.filter(c => c.userId === req.user.id)
|
||||
var expandedCollections = collections.map(c => c.toJSONExpanded(this.db.libraryItems))
|
||||
var expandedCollections = this.db.collections.map(c => c.toJSONExpanded(this.db.libraryItems))
|
||||
res.json(expandedCollections)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,13 @@ class LibraryController {
|
|||
// Remove library watcher
|
||||
this.watcher.removeLibrary(library)
|
||||
|
||||
// Remove collections for library
|
||||
var collections = this.db.collections.filter(c => c.libraryId === library.id)
|
||||
for (const collection of collections) {
|
||||
Logger.info(`[Server] deleting collection "${collection.name}" for library "${library.name}"`)
|
||||
await this.db.removeEntity('collection', collection.id)
|
||||
}
|
||||
|
||||
// Remove items in this library
|
||||
var libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
|
||||
Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`)
|
||||
|
|
@ -160,21 +167,53 @@ class LibraryController {
|
|||
minified: req.query.minified === '1',
|
||||
collapseseries: req.query.collapseseries === '1'
|
||||
}
|
||||
var mediaIsBook = payload.mediaType === 'book'
|
||||
|
||||
// Step 1 - Filter the retrieved library items
|
||||
var filterSeries = null
|
||||
if (payload.filterBy) {
|
||||
// If filtering by series, will include seriesName and seriesSequence on media metadata
|
||||
filterSeries = (payload.mediaType == 'book' && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
|
||||
if (filterSeries === 'No Series') filterSeries = null
|
||||
|
||||
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user, this.rssFeedManager.feedsArray)
|
||||
payload.total = libraryItems.length
|
||||
|
||||
// Determining if we are filtering titles by a series, and if so, which series
|
||||
filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
|
||||
if (filterSeries === 'No Series') filterSeries = null
|
||||
}
|
||||
|
||||
// Step 2 - If selected, collapse library items by the series they belong to.
|
||||
// If also filtering by series, will not collapse the filtered series as this would lead
|
||||
// to series having a collapsed series that is just that series.
|
||||
if (payload.collapseseries) {
|
||||
let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, this.db.series, filterSeries)
|
||||
|
||||
if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
|
||||
libraryItems = collapsedItems
|
||||
|
||||
// Get accurate total entities
|
||||
// let uniqueEntities = new Set()
|
||||
// libraryItems.forEach((item) => {
|
||||
// if (item.collapsedSeries) {
|
||||
// item.collapsedSeries.books.forEach(book => uniqueEntities.add(book.id))
|
||||
// } else {
|
||||
// uniqueEntities.add(item.id)
|
||||
// }
|
||||
// })
|
||||
payload.total = libraryItems.length
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 - Sort the retrieved library items.
|
||||
var sortArray = []
|
||||
|
||||
// When on the series page, sort by sequence only
|
||||
if (payload.sortBy === 'book.volumeNumber') payload.sortBy = null // TODO: Remove temp fix after mobile release 0.9.60
|
||||
if (filterSeries && !payload.sortBy) {
|
||||
sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
|
||||
}
|
||||
|
||||
if (payload.sortBy) {
|
||||
var sortKey = payload.sortBy
|
||||
|
||||
// old sort key TODO: should be mutated in dbMigration
|
||||
var sortKey = payload.sortBy
|
||||
if (sortKey.startsWith('book.')) {
|
||||
sortKey = sortKey.replace('book.', 'media.metadata.')
|
||||
}
|
||||
|
|
@ -186,29 +225,42 @@ class LibraryController {
|
|||
sortKey += 'IgnorePrefix'
|
||||
}
|
||||
|
||||
// Start sort
|
||||
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
var sortArray = [
|
||||
{
|
||||
[direction]: (li) => {
|
||||
// When collapsing by series and sorting by title use the series name instead of the book title
|
||||
if (payload.mediaType === 'book' && payload.collapseseries && li.media.metadata.seriesName) {
|
||||
if (sortByTitle) {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? li.media.metadata.seriesNameIgnorePrefix : li.media.metadata.seriesName
|
||||
} else {
|
||||
// When not sorting by title always show the collapsed series at the end
|
||||
return direction === 'desc' ? -1 : 'zzzz'
|
||||
}
|
||||
// If series are collapsed and not sorting by title or sequence,
|
||||
// sort all collapsed series to the end in alphabetical order
|
||||
const sortBySequence = filterSeries && (sortKey === 'sequence')
|
||||
if (payload.collapseseries && !(sortByTitle || sortBySequence)) {
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (li.collapsedSeries) {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ?
|
||||
li.collapsedSeries.nameIgnorePrefix :
|
||||
li.collapsedSeries.name
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Sort series based on the sortBy attribute
|
||||
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
sortArray.push({
|
||||
[direction]: (li) => {
|
||||
if (mediaIsBook && sortBySequence) {
|
||||
return li.media.metadata.getSeries(filterSeries).sequence
|
||||
} else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ?
|
||||
li.collapsedSeries.nameIgnorePrefix :
|
||||
li.collapsedSeries.name
|
||||
} else {
|
||||
// Supports dot notation strings i.e. "media.metadata.title"
|
||||
return sortKey.split('.').reduce((a, b) => a[b], li)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Secondary sort when sorting by book author use series sort title
|
||||
if (payload.mediaType === 'book' && payload.sortBy.includes('author')) {
|
||||
if (mediaIsBook && payload.sortBy.includes('author')) {
|
||||
sortArray.push({
|
||||
asc: (li) => {
|
||||
if (li.media.metadata.series && li.media.metadata.series.length) {
|
||||
|
|
@ -218,30 +270,61 @@ class LibraryController {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (sortArray.length) {
|
||||
libraryItems = naturalSort(libraryItems).by(sortArray)
|
||||
}
|
||||
|
||||
if (payload.collapseseries) {
|
||||
libraryItems = libraryHelpers.collapseBookSeries(libraryItems)
|
||||
payload.total = libraryItems.length
|
||||
} else if (filterSeries) {
|
||||
// Book media when filtering series will include series object on media metadata
|
||||
libraryItems = libraryItems.map(li => {
|
||||
var series = li.media.metadata.getSeries(filterSeries)
|
||||
var liJson = payload.minified ? li.toJSONMinified() : li.toJSON()
|
||||
liJson.media.metadata.series = series
|
||||
return liJson
|
||||
})
|
||||
libraryItems = naturalSort(libraryItems).asc(li => li.media.metadata.series.sequence)
|
||||
} else {
|
||||
libraryItems = libraryItems.map(li => payload.minified ? li.toJSONMinified() : li.toJSON())
|
||||
}
|
||||
|
||||
// Step 3.5: Limit items
|
||||
if (payload.limit) {
|
||||
var startIndex = payload.page * payload.limit
|
||||
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
|
||||
}
|
||||
payload.results = libraryItems
|
||||
|
||||
// Step 4 - Transform the items to pass to the client side
|
||||
payload.results = libraryItems.map(li => {
|
||||
let json = payload.minified ? li.toJSONMinified() : li.toJSON()
|
||||
|
||||
if (li.collapsedSeries) {
|
||||
json.collapsedSeries = {
|
||||
id: li.collapsedSeries.id,
|
||||
name: li.collapsedSeries.name,
|
||||
nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
|
||||
libraryItemIds: li.collapsedSeries.books.map(b => b.id),
|
||||
numBooks: li.collapsedSeries.books.length
|
||||
}
|
||||
|
||||
// If collapsing by series and filtering by a series, generate the list of sequences the collapsed
|
||||
// series represents in the filtered series
|
||||
if (filterSeries) {
|
||||
json.collapsedSeries.seriesSequenceList =
|
||||
naturalSort(li.collapsedSeries.books.map(b => b.filterSeriesSequence)).asc()
|
||||
.reduce((ranges, currentSequence) => {
|
||||
let lastRange = ranges.at(-1)
|
||||
let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
|
||||
if (isNumber) currentSequence = parseFloat(currentSequence)
|
||||
|
||||
if (lastRange && isNumber && lastRange.isNumber && ((lastRange.end + 1) == currentSequence)) {
|
||||
lastRange.end = currentSequence
|
||||
}
|
||||
else {
|
||||
ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
|
||||
}
|
||||
|
||||
return ranges
|
||||
}, [])
|
||||
.map(r => r.start == r.end ? r.start : `${r.start}-${r.end}`)
|
||||
.join(', ')
|
||||
}
|
||||
} else if (filterSeries) {
|
||||
// If filtering by series, make sure to include the series metadata
|
||||
json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
|
||||
}
|
||||
|
||||
return json
|
||||
})
|
||||
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
|
|
@ -275,10 +358,25 @@ class LibraryController {
|
|||
minified: req.query.minified === '1'
|
||||
}
|
||||
|
||||
var series = libraryHelpers.getSeriesFromBooks(libraryItems, payload.minified)
|
||||
series = sort(series).asc(s => {
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? s.nameIgnorePrefix : s.name
|
||||
})
|
||||
var series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified)
|
||||
|
||||
const direction = payload.sortDesc ? 'desc' : 'asc'
|
||||
series = naturalSort(series).by([
|
||||
{
|
||||
[direction]: (se) => {
|
||||
if (payload.sortBy === 'numBooks') {
|
||||
return se.books.length
|
||||
} else if (payload.sortBy === 'totalDuration') {
|
||||
return se.totalDuration
|
||||
} else if (payload.sortBy === 'addedAt') {
|
||||
return se.addedAt
|
||||
} else { // sort by name
|
||||
return this.db.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
payload.total = series.length
|
||||
|
||||
if (payload.limit) {
|
||||
|
|
@ -485,7 +583,7 @@ class LibraryController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/scan (Root)
|
||||
// GET: api/libraries/:id/scan
|
||||
async scan(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryController] Non-root user attempted to scan library`, req.user)
|
||||
|
|
@ -499,6 +597,46 @@ class LibraryController {
|
|||
Logger.info('[LibraryController] Scan complete')
|
||||
}
|
||||
|
||||
// GET: api/libraries/:id/recent-episode
|
||||
async getRecentEpisodes(req, res) {
|
||||
if (!req.library.isPodcast) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const payload = {
|
||||
episodes: [],
|
||||
total: 0,
|
||||
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
|
||||
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
|
||||
}
|
||||
|
||||
var allUnfinishedEpisodes = []
|
||||
for (const libraryItem of req.libraryItems) {
|
||||
const unfinishedEpisodes = libraryItem.media.episodes.filter(ep => {
|
||||
const userProgress = req.user.getMediaProgress(libraryItem.id, ep.id)
|
||||
return !userProgress || !userProgress.isFinished
|
||||
}).map(_ep => {
|
||||
const ep = _ep.toJSONExpanded()
|
||||
ep.podcast = libraryItem.media.toJSONMinified()
|
||||
ep.libraryItemId = libraryItem.id
|
||||
ep.libraryId = libraryItem.libraryId
|
||||
return ep
|
||||
})
|
||||
allUnfinishedEpisodes.push(...unfinishedEpisodes)
|
||||
}
|
||||
|
||||
payload.total = allUnfinishedEpisodes.length
|
||||
|
||||
allUnfinishedEpisodes = sort(allUnfinishedEpisodes).desc(ep => ep.publishedAt)
|
||||
|
||||
if (payload.limit) {
|
||||
var startIndex = payload.page * payload.limit
|
||||
allUnfinishedEpisodes = allUnfinishedEpisodes.slice(startIndex, startIndex + payload.limit)
|
||||
}
|
||||
payload.episodes = allUnfinishedEpisodes
|
||||
res.json(payload)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.checkCanAccessLibrary(req.params.id)) {
|
||||
Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const Logger = require('../Logger')
|
||||
const { reqSupportsWebp } = require('../utils/index')
|
||||
const { reqSupportsWebp, isNullOrNaN } = require('../utils/index')
|
||||
const { ScanResult } = require('../utils/constants')
|
||||
|
||||
class LibraryItemController {
|
||||
|
|
@ -305,6 +305,42 @@ class LibraryItemController {
|
|||
res.json(libraryItems)
|
||||
}
|
||||
|
||||
// POST: api/items/batch/quickmatch
|
||||
async batchQuickMatch(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('User other than admin attempted to batch quick match library items', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var itemsUpdated = 0
|
||||
var itemsUnmatched = 0
|
||||
|
||||
var matchData = req.body
|
||||
var options = matchData.options || {}
|
||||
var items = matchData.libraryItemIds
|
||||
if (!items || !items.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
var libraryItem = this.db.libraryItems.find(_li => _li.id === items[i])
|
||||
var matchResult = await this.scanner.quickMatchLibraryItem(libraryItem, options)
|
||||
if (matchResult.updated) {
|
||||
itemsUpdated++
|
||||
} else if (matchResult.warning) {
|
||||
itemsUnmatched++
|
||||
}
|
||||
}
|
||||
|
||||
var result = {
|
||||
success: itemsUpdated > 0,
|
||||
updates: itemsUpdated,
|
||||
unmatched: itemsUnmatched
|
||||
}
|
||||
this.clientEmitter(req.user.id, 'batch_quickmatch_complete', result)
|
||||
}
|
||||
|
||||
// DELETE: api/items/all
|
||||
async deleteAll(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
|
|
@ -335,6 +371,20 @@ class LibraryItemController {
|
|||
})
|
||||
}
|
||||
|
||||
getToneMetadataObject(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to get tone metadata object`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
res.json(this.audioMetadataManager.getToneMetadataObjectForApi(req.libraryItem))
|
||||
}
|
||||
|
||||
// GET: api/items/:id/audio-metadata
|
||||
async updateAudioFileMetadata(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
|
|
@ -347,7 +397,9 @@ class LibraryItemController {
|
|||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
this.audioMetadataManager.updateAudioFileMetadataForItem(req.user, req.libraryItem)
|
||||
const useTone = req.query.tone === '1'
|
||||
const forceEmbedChapters = req.query.forceEmbedChapters === '1'
|
||||
this.audioMetadataManager.updateMetadataForItem(req.user, req.libraryItem, useTone, forceEmbedChapters)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
|
@ -385,7 +437,7 @@ class LibraryItemController {
|
|||
async openRSSFeed(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to open RSS feed`, req.user.username)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const feedData = await this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
|
||||
|
|
@ -405,7 +457,7 @@ class LibraryItemController {
|
|||
async closeRSSFeed(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-admin user attempted to close RSS feed`, req.user.username)
|
||||
return res.sendStatus(500)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
await this.rssFeedManager.closeFeedForItem(req.params.id)
|
||||
|
|
@ -413,6 +465,22 @@ class LibraryItemController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async toneScan(req, res) {
|
||||
if (!req.libraryItem.media.audioFiles.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const audioFileIndex = isNullOrNaN(req.params.index) ? 1 : Number(req.params.index)
|
||||
const audioFile = req.libraryItem.media.audioFiles.find(af => af.index === audioFileIndex)
|
||||
if (!audioFile) {
|
||||
Logger.error(`[LibraryItemController] toneScan: Audio file not found with index ${audioFileIndex}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const toneData = await this.scanner.probeAudioFileWithTone(audioFile)
|
||||
res.json(toneData)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
|
|
|||
|
|
@ -276,5 +276,47 @@ class MeController {
|
|||
libraryItems: itemsInProgress
|
||||
})
|
||||
}
|
||||
|
||||
// GET: api/me/series/:id/remove-from-continue-listening
|
||||
async removeSeriesFromContinueListening(req, res) {
|
||||
const series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) {
|
||||
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
// GET: api/me/series/:id/readd-to-continue-listening
|
||||
async readdSeriesFromContinueListening(req, res) {
|
||||
const series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) {
|
||||
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
// GET: api/me/progress/:id/remove-from-continue-listening
|
||||
async removeItemFromContinueListening(req, res) {
|
||||
const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.json(req.user.toJSONForBrowser())
|
||||
}
|
||||
}
|
||||
module.exports = new MeController()
|
||||
|
|
@ -82,26 +82,26 @@ class MiscController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/audiobook-merge/:id
|
||||
async mergeAudiobook(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
// GET: api/encode-m4b/:id
|
||||
async encodeM4b(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var libraryItem = this.db.getLibraryItem(req.params.id)
|
||||
if (!libraryItem || libraryItem.isMissing || libraryItem.isInvalid) {
|
||||
Logger.error(`[MiscController] mergeAudiboook: library item not found or invalid ${req.params.id}`)
|
||||
Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`)
|
||||
return res.status(404).send('Audiobook not found')
|
||||
}
|
||||
|
||||
if (libraryItem.mediaType !== 'book') {
|
||||
Logger.error(`[MiscController] mergeAudiboook: Invalid library item ${req.params.id}: not a book`)
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`)
|
||||
return res.status(500).send('Invalid library item: not a book')
|
||||
}
|
||||
|
||||
if (libraryItem.media.tracks.length <= 0) {
|
||||
Logger.error(`[MiscController] mergeAudiboook: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`)
|
||||
return res.status(500).send('Invalid audiobook: no audio tracks')
|
||||
}
|
||||
|
||||
|
|
@ -110,53 +110,26 @@ class MiscController {
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/download/:id
|
||||
async getDownload(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
// POST: api/encode-m4b/:id/cancel
|
||||
async cancelM4bEncode(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var downloadId = req.params.id
|
||||
Logger.info('Download Request', downloadId)
|
||||
var download = this.abMergeManager.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.path, download.filename, options, (err) => {
|
||||
if (err) {
|
||||
Logger.error('Download Error', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id)
|
||||
if (!workerTask) return res.sendStatus(404)
|
||||
|
||||
this.abMergeManager.cancelEncode(workerTask.task)
|
||||
|
||||
// DELETE: api/download/:id
|
||||
async removeDownload(req, res) {
|
||||
if (!req.user.canDownload || !req.user.canDelete) {
|
||||
Logger.error('User attempting to remove download without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
this.abMergeManager.removeDownloadById(req.params.id)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// GET: api/downloads
|
||||
async getDownloads(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to get downloads without permission', req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var downloads = {
|
||||
downloads: this.abMergeManager.downloads,
|
||||
pendingDownloads: this.abMergeManager.pendingDownloads
|
||||
}
|
||||
res.json(downloads)
|
||||
// GET: api/tasks
|
||||
getTasks(req, res) {
|
||||
res.json({
|
||||
tasks: this.taskManager.tasks.map(t => t.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
// PATCH: api/settings (admin)
|
||||
|
|
@ -185,16 +158,26 @@ class MiscController {
|
|||
})
|
||||
}
|
||||
|
||||
// POST: api/purgecache (admin)
|
||||
// POST: api/cache/purge (admin)
|
||||
async purgeCache(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[ApiRouter] Purging all cache`)
|
||||
Logger.info(`[MiscController] Purging all cache`)
|
||||
await this.cacheManager.purgeAll()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/cache/items/purge
|
||||
async purgeItemsCache(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info(`[MiscController] Purging items cache`)
|
||||
await this.cacheManager.purgeItems()
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async findBooks(req, res) {
|
||||
var provider = req.query.provider || 'google'
|
||||
var title = req.query.title || ''
|
||||
|
|
@ -227,7 +210,8 @@ class MiscController {
|
|||
|
||||
async findChapters(req, res) {
|
||||
var asin = req.query.asin
|
||||
var chapterData = await this.bookFinder.findChapters(asin)
|
||||
var region = (req.query.region || 'us').toLowerCase()
|
||||
var chapterData = await this.bookFinder.findChapters(asin, region)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found' })
|
||||
}
|
||||
|
|
|
|||
79
server/controllers/NotificationController.js
Normal file
79
server/controllers/NotificationController.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
const Logger = require('../Logger')
|
||||
const { version } = require('../../package.json')
|
||||
|
||||
class NotificationController {
|
||||
constructor() { }
|
||||
|
||||
get(req, res) {
|
||||
res.json({
|
||||
data: this.notificationManager.getData(),
|
||||
settings: this.db.notificationSettings
|
||||
})
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const updated = this.db.notificationSettings.update(req.body)
|
||||
if (updated) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
getData(req, res) {
|
||||
res.json(this.notificationManager.getData())
|
||||
}
|
||||
|
||||
async fireTestEvent(req, res) {
|
||||
await this.notificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1')
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async createNotification(req, res) {
|
||||
const success = this.db.notificationSettings.createNotification(req.body)
|
||||
|
||||
if (success) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async deleteNotification(req, res) {
|
||||
if (this.db.notificationSettings.removeNotification(req.notification.id)) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async updateNotification(req, res) {
|
||||
const success = this.db.notificationSettings.updateNotification(req.body)
|
||||
if (success) {
|
||||
await this.db.updateEntity('settings', this.db.notificationSettings)
|
||||
}
|
||||
res.json(this.db.notificationSettings)
|
||||
}
|
||||
|
||||
async sendNotificationTest(req, res) {
|
||||
if (!this.db.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured')
|
||||
|
||||
const success = await this.notificationManager.sendTestNotification(req.notification)
|
||||
if (success) res.sendStatus(200)
|
||||
else res.sendStatus(500)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
const notification = this.db.notificationSettings.getNotification(req.params.id)
|
||||
if (!notification) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
req.notification = notification
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new NotificationController()
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
const axios = require('axios')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
|
||||
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
class PodcastController {
|
||||
|
|
@ -91,32 +90,17 @@ class PodcastController {
|
|||
}
|
||||
}
|
||||
|
||||
getPodcastFeed(req, res) {
|
||||
async getPodcastFeed(req, res) {
|
||||
var url = req.body.rssFeed
|
||||
if (!url) {
|
||||
return res.status(400).send('Bad request')
|
||||
}
|
||||
var includeRaw = req.query.raw == 1 // Include raw json
|
||||
|
||||
axios.get(url).then(async (data) => {
|
||||
if (!data || !data.data) {
|
||||
Logger.error('Invalid podcast feed request response')
|
||||
return res.status(500).send('Bad response from feed request')
|
||||
}
|
||||
Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
||||
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
|
||||
if (!payload) {
|
||||
return res.status(500).send('Invalid podcast RSS feed')
|
||||
}
|
||||
|
||||
// RSS feed may be a private RSS feed
|
||||
payload.podcast.metadata.feedUrl = url
|
||||
|
||||
res.json(payload)
|
||||
}).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
res.status(500).send(error)
|
||||
})
|
||||
const podcast = await getPodcastFeed(url)
|
||||
if (!podcast) {
|
||||
return res.status(404).send('Podcast RSS feed request failed or invalid response data')
|
||||
}
|
||||
res.json({ podcast })
|
||||
}
|
||||
|
||||
async getOPMLFeeds(req, res) {
|
||||
|
|
@ -177,9 +161,7 @@ class PodcastController {
|
|||
if (!searchTitle) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
searchTitle = searchTitle.toLowerCase().trim()
|
||||
|
||||
const episodes = await this.podcastManager.findEpisode(rssFeedUrl, searchTitle)
|
||||
const episodes = await findMatchingEpisodes(rssFeedUrl, searchTitle)
|
||||
res.json({
|
||||
episodes: episodes || []
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ class SeriesController {
|
|||
res.json(series)
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
const hasUpdated = req.series.update(req.body)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('series', req.series)
|
||||
this.emitter('series_updated', req.series)
|
||||
}
|
||||
res.json(req.series)
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var series = this.db.series.find(se => se.id === req.params.id)
|
||||
if (!series) return res.sendStatus(404)
|
||||
|
|
|
|||
|
|
@ -28,10 +28,6 @@ class UserController {
|
|||
}
|
||||
|
||||
async create(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.warn('Non-admin user attempted to create user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var account = req.body
|
||||
|
||||
var username = account.username
|
||||
|
|
@ -58,15 +54,7 @@ class UserController {
|
|||
}
|
||||
|
||||
async update(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('[UserController] User other than admin 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 user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to update root user`, req.user.username)
|
||||
|
|
@ -97,9 +85,9 @@ class UserController {
|
|||
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
|
||||
}
|
||||
await this.db.updateEntity('user', user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
res.json({
|
||||
success: true,
|
||||
user: user.toJSONForBrowser()
|
||||
|
|
@ -107,31 +95,15 @@ class UserController {
|
|||
}
|
||||
|
||||
async delete(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error('User other than admin attempting to delete user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (req.params.id === 'root') {
|
||||
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
if (req.user.id === req.params.id) {
|
||||
Logger.error('Attempting to delete themselves...')
|
||||
Logger.error(`[UserController] ${req.user.username} is attempting to delete themselves... why? WHY?`)
|
||||
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])
|
||||
}
|
||||
var user = req.reqUser
|
||||
|
||||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
|
|
@ -145,10 +117,6 @@ class UserController {
|
|||
|
||||
// GET: api/users/:id/listening-sessions
|
||||
async getListeningSessions(req, res) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
||||
|
||||
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||
|
|
@ -170,11 +138,72 @@ class UserController {
|
|||
|
||||
// GET: api/users/:id/listening-stats
|
||||
async getListeningStats(req, res) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var listeningStats = await this.getUserListeningStatsHelpers(req.params.id)
|
||||
res.json(listeningStats)
|
||||
}
|
||||
|
||||
// POST: api/users/:id/purge-media-progress
|
||||
async purgeMediaProgress(req, res) {
|
||||
const user = req.reqUser
|
||||
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.error(`[UserController] Admin user attempted to purge media progress of root user`, req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var progressPurged = 0
|
||||
user.mediaProgress = user.mediaProgress.filter(mp => {
|
||||
const libraryItem = this.db.libraryItems.find(li => li.id === mp.libraryItemId)
|
||||
if (!libraryItem) {
|
||||
progressPurged++
|
||||
return false
|
||||
} else if (mp.episodeId) {
|
||||
const episode = libraryItem.mediaType === 'podcast' ? libraryItem.media.getEpisode(mp.episodeId) : null
|
||||
if (!episode) { // Episode not found
|
||||
progressPurged++
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (progressPurged) {
|
||||
Logger.info(`[UserController] Purged ${progressPurged} media progress for user ${user.username}`)
|
||||
await this.db.updateEntity('user', user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
res.json(this.userJsonWithItemProgressDetails(user, !req.user.isRoot))
|
||||
}
|
||||
|
||||
// POST: api/users/online (admin)
|
||||
async getOnlineUsers(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const usersOnline = this.getUsersOnline()
|
||||
|
||||
res.json({
|
||||
usersOnline,
|
||||
openSessions: this.playbackSessionManager.sessions
|
||||
})
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||
return res.sendStatus(403)
|
||||
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.params.id) {
|
||||
req.reqUser = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!req.reqUser) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new UserController()
|
||||
Loading…
Add table
Add a link
Reference in a new issue