Merge branch 'advplyr:master' into master

This commit is contained in:
Lauri Vuorela 2024-10-29 22:08:01 +01:00 committed by GitHub
commit 2fdab39e27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1252 additions and 144 deletions

View file

@ -235,12 +235,14 @@ class LibraryController {
for (const key of keysToCheck) {
if (!req.body[key]) continue
if (typeof req.body[key] !== 'string') {
Logger.error(`[LibraryController] Invalid request. ${key} must be a string`)
return res.status(400).send(`Invalid request. ${key} must be a string`)
}
updatePayload[key] = req.body[key]
}
if (req.body.displayOrder !== undefined) {
if (isNaN(req.body.displayOrder)) {
Logger.error(`[LibraryController] Invalid request. displayOrder must be a number`)
return res.status(400).send('Invalid request. displayOrder must be a number')
}
updatePayload.displayOrder = req.body.displayOrder
@ -255,18 +257,29 @@ class LibraryController {
}
// Validate settings
const defaultLibrarySettings = Database.libraryModel.getDefaultLibrarySettingsForMediaType(req.library.mediaType)
const updatedSettings = {
...(req.library.settings || Database.libraryModel.getDefaultLibrarySettingsForMediaType(req.library.mediaType))
...(req.library.settings || defaultLibrarySettings)
}
// In case new settings are added in the future, ensure all settings are present
for (const key in defaultLibrarySettings) {
if (updatedSettings[key] === undefined) {
updatedSettings[key] = defaultLibrarySettings[key]
}
}
let hasUpdates = false
let hasUpdatedDisableWatcher = false
let hasUpdatedScanCron = false
if (req.body.settings) {
for (const key in req.body.settings) {
if (updatedSettings[key] === undefined) continue
if (!Object.keys(defaultLibrarySettings).includes(key)) {
continue
}
if (key === 'metadataPrecedence') {
if (!Array.isArray(req.body.settings[key])) {
Logger.error(`[LibraryController] Invalid request. Settings "metadataPrecedence" must be an array`)
return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array')
}
if (JSON.stringify(req.body.settings[key]) !== JSON.stringify(updatedSettings[key])) {
@ -276,6 +289,7 @@ class LibraryController {
}
} else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {
if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') {
Logger.error(`[LibraryController] Invalid request. Settings "${key}" must be a string`)
return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
@ -285,8 +299,35 @@ class LibraryController {
updatedSettings[key] = req.body.settings[key]
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
}
} else if (key === 'markAsFinishedPercentComplete') {
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`)
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
} else if (req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be between 0 and 100`)
return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true
updatedSettings[key] = Number(req.body.settings[key])
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
}
} else if (key === 'markAsFinishedTimeRemaining') {
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`)
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
} else if (req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be greater than or equal to 0`)
return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true
updatedSettings[key] = Number(req.body.settings[key])
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
}
} else {
if (typeof req.body.settings[key] !== typeof updatedSettings[key]) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`)
return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
@ -328,6 +369,7 @@ class LibraryController {
return false
})
if (!success) {
Logger.error(`[LibraryController] Invalid folder directory "${path}"`)
return res.status(400).send(`Invalid folder directory "${path}"`)
}
}

View file

@ -115,6 +115,16 @@ class LibraryItemController {
res.sendStatus(200)
}
static handleDownloadError(error, res) {
if (!res.headersSent) {
if (error.code === 'ENOENT') {
return res.status(404).send('File not found')
} else {
return res.status(500).send('Download failed')
}
}
}
/**
* GET: /api/items/:id/download
* Download library item. Zip file if multiple files.
@ -122,7 +132,7 @@ class LibraryItemController {
* @param {RequestWithUser} req
* @param {Response} res
*/
download(req, res) {
async download(req, res) {
if (!req.user.canDownload) {
Logger.warn(`User "${req.user.username}" attempted to download without permission`)
return res.sendStatus(403)
@ -130,21 +140,26 @@ class LibraryItemController {
const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
res.download(libraryItemPath, req.libraryItem.relPath)
return
}
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
const filename = `${itemTitle}.zip`
zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
try {
// If library item is a single file in root dir then no need to zip
if (req.libraryItem.isFile) {
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve())))
} else {
const filename = `${itemTitle}.zip`
await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res)
}
Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error)
LibraryItemController.handleDownloadError(error, res)
}
}
/**
@ -845,7 +860,13 @@ class LibraryItemController {
res.setHeader('Content-Type', audioMimeType)
}
res.download(libraryFile.metadata.path, libraryFile.metadata.filename)
try {
await new Promise((resolve, reject) => res.download(libraryFile.metadata.path, libraryFile.metadata.filename, (error) => (error ? reject(error) : resolve())))
Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error)
LibraryItemController.handleDownloadError(error, res)
}
}
/**
@ -883,7 +904,13 @@ class LibraryItemController {
return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send()
}
res.sendFile(ebookFilePath)
try {
await new Promise((resolve, reject) => res.sendFile(ebookFilePath, (error) => (error ? reject(error) : resolve())))
Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`)
} catch (error) {
Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error)
LibraryItemController.handleDownloadError(error, res)
}
}
/**

View file

@ -394,6 +394,58 @@ class MeController {
res.json(req.user.toOldJSONForBrowser())
}
/**
* POST: /api/me/ereader-devices
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateUserEReaderDevices(req, res) {
if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {
return res.status(400).send('Invalid payload. ereaderDevices array required')
}
const userEReaderDevices = req.body.ereaderDevices
for (const device of userEReaderDevices) {
if (!device.name || !device.email) {
return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')
} else if (device.availabilityOption !== 'specificUsers' || device.users?.length !== 1 || device.users[0] !== req.user.id) {
return res.status(400).send('Invalid payload. ereaderDevices array items must have availabilityOption "specificUsers" and only the current user')
}
}
const otherDevices = Database.emailSettings.ereaderDevices.filter((device) => {
return !Database.emailSettings.checkUserCanAccessDevice(device, req.user) || device.users?.length !== 1
})
const ereaderDevices = otherDevices.concat(userEReaderDevices)
// Check for duplicate names
const nameSet = new Set()
const hasDupes = ereaderDevices.some((device) => {
if (nameSet.has(device.name)) {
return true // Duplicate found
}
nameSet.add(device.name)
return false
})
if (hasDupes) {
return res.status(400).send('Invalid payload. Duplicate "name" field found.')
}
const updated = Database.emailSettings.update({ ereaderDevices })
if (updated) {
await Database.updateSetting(Database.emailSettings)
SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', {
ereaderDevices: Database.emailSettings.ereaderDevices
})
}
res.json({
ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)
})
}
/**
* GET: /api/me/stats/year/:year
*

View file

@ -119,6 +119,7 @@ class PlaybackSessionManager {
* @returns
*/
async syncLocalSession(user, sessionJson, deviceInfo) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId)
const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null
if (!libraryItem || (libraryItem.isPodcast && !episode)) {
@ -130,6 +131,16 @@ class PlaybackSessionManager {
}
}
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
if (!library) {
Logger.error(`[PlaybackSessionManager] syncLocalSession: Library not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`)
return {
id: sessionJson.id,
success: false,
error: 'Library not found'
}
}
sessionJson.userId = user.id
sessionJson.serverVersion = serverVersion
@ -199,7 +210,9 @@ class PlaybackSessionManager {
const updateResponse = await user.createUpdateMediaProgressFromPayload({
libraryItemId: libraryItem.id,
episodeId: session.episodeId,
...session.mediaProgressObject
...session.mediaProgressObject,
markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete,
markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining
})
result.progressSynced = !!updateResponse.mediaProgress
if (result.progressSynced) {
@ -211,7 +224,9 @@ class PlaybackSessionManager {
const updateResponse = await user.createUpdateMediaProgressFromPayload({
libraryItemId: libraryItem.id,
episodeId: session.episodeId,
...session.mediaProgressObject
...session.mediaProgressObject,
markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete,
markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining
})
result.progressSynced = !!updateResponse.mediaProgress
if (result.progressSynced) {
@ -330,12 +345,19 @@ class PlaybackSessionManager {
* @returns
*/
async syncSession(user, session, syncData) {
// TODO: Combine libraryItem query with library query
const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId)
if (!libraryItem) {
Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
return null
}
const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
if (!library) {
Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`)
return null
}
session.currentTime = syncData.currentTime
session.addListeningTime(syncData.timeListened)
Logger.debug(`[PlaybackSessionManager] syncSession "${session.id}" (Device: ${session.deviceDescription}) | Total Time Listened: ${session.timeListening}`)
@ -344,12 +366,11 @@ class PlaybackSessionManager {
libraryItemId: libraryItem.id,
episodeId: session.episodeId,
// duration no longer required (v2.15.1) but used if available
duration: syncData.duration || libraryItem.media.duration || 0,
duration: syncData.duration || session.duration || 0,
currentTime: syncData.currentTime,
progress: session.progress
// TODO: Add support for passing in these values from library settings
// markAsFinishedTimeRemaining: 5,
// markAsFinishedPercentageComplete: 95
progress: session.progress,
markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining,
markAsFinishedPercentComplete: library.librarySettings.markAsFinishedPercentComplete
})
if (updateResponse.mediaProgress) {
SocketAuthority.clientEmitter(user.id, 'user_item_progress_updated', {

View file

@ -12,6 +12,8 @@ const Logger = require('../Logger')
* @property {boolean} hideSingleBookSeries Do not show series that only have 1 book
* @property {boolean} onlyShowLaterBooksInContinueSeries Skip showing books that are earlier than the max sequence read
* @property {string[]} metadataPrecedence
* @property {number} markAsFinishedTimeRemaining Time remaining in seconds to mark as finished. (defaults to 10s)
* @property {number} markAsFinishedPercentComplete Percent complete to mark as finished (0-100). If this is set it will be used over markAsFinishedTimeRemaining.
*/
class Library extends Model {
@ -57,7 +59,9 @@ class Library extends Model {
coverAspectRatio: 1, // Square
disableWatcher: false,
autoScanCronExpression: null,
podcastSearchRegion: 'us'
podcastSearchRegion: 'us',
markAsFinishedPercentComplete: null,
markAsFinishedTimeRemaining: 10
}
} else {
return {
@ -70,7 +74,9 @@ class Library extends Model {
epubsAllowScriptedContent: false,
hideSingleBookSeries: false,
onlyShowLaterBooksInContinueSeries: false,
metadataPrecedence: this.defaultMetadataPrecedence
metadataPrecedence: this.defaultMetadataPrecedence,
markAsFinishedPercentComplete: null,
markAsFinishedTimeRemaining: 10
}
}
}
@ -196,6 +202,13 @@ class Library extends Model {
return this.extraData?.lastScanMetadataPrecedence || []
}
/**
* @returns {LibrarySettingsObject}
*/
get librarySettings() {
return this.settings || Library.getDefaultLibrarySettingsForMediaType(this.mediaType)
}
/**
* TODO: Update to use new model
*/

View file

@ -229,30 +229,30 @@ class MediaProgress extends Model {
const timeRemaining = this.duration - this.currentTime
// Check if progress is far enough to mark as finished
// - If markAsFinishedPercentageComplete is provided, use that otherwise use markAsFinishedTimeRemaining (default 5 seconds)
// - If markAsFinishedPercentComplete is provided, use that otherwise use markAsFinishedTimeRemaining (default 10 seconds)
let shouldMarkAsFinished = false
if (!this.isFinished && this.duration) {
if (!isNullOrNaN(progressPayload.markAsFinishedPercentageComplete)) {
const markAsFinishedPercentageComplete = Number(progressPayload.markAsFinishedPercentageComplete) / 100
shouldMarkAsFinished = markAsFinishedPercentageComplete <= this.progress
if (this.duration) {
if (!isNullOrNaN(progressPayload.markAsFinishedPercentComplete) && progressPayload.markAsFinishedPercentComplete > 0) {
const markAsFinishedPercentComplete = Number(progressPayload.markAsFinishedPercentComplete) / 100
shouldMarkAsFinished = markAsFinishedPercentComplete < this.progress
if (shouldMarkAsFinished) {
Logger.debug(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentageComplete}`)
Logger.debug(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete}`)
}
} else {
const markAsFinishedTimeRemaining = isNullOrNaN(progressPayload.markAsFinishedTimeRemaining) ? 5 : Number(progressPayload.markAsFinishedTimeRemaining)
shouldMarkAsFinished = timeRemaining <= markAsFinishedTimeRemaining
const markAsFinishedTimeRemaining = isNullOrNaN(progressPayload.markAsFinishedTimeRemaining) ? 10 : Number(progressPayload.markAsFinishedTimeRemaining)
shouldMarkAsFinished = timeRemaining < markAsFinishedTimeRemaining
if (shouldMarkAsFinished) {
Logger.debug(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds`)
}
}
}
if (shouldMarkAsFinished) {
if (!this.isFinished && shouldMarkAsFinished) {
this.isFinished = true
this.finishedAt = this.finishedAt || Date.now()
this.extraData.progress = 1
this.changed('extraData', true)
} else if (this.isFinished && this.changed('currentTime') && this.currentTime < this.duration) {
} else if (this.isFinished && this.changed('currentTime') && !shouldMarkAsFinished) {
this.isFinished = false
this.finishedAt = null
}

View file

@ -28,7 +28,7 @@ const { DataTypes, Model } = sequelize
* @property {string} [finishedAt]
* @property {number} [lastUpdate]
* @property {number} [markAsFinishedTimeRemaining]
* @property {number} [markAsFinishedPercentageComplete]
* @property {number} [markAsFinishedPercentComplete]
*/
class User extends Model {
@ -82,6 +82,7 @@ class User extends Model {
canAccessExplicitContent: 'accessExplicitContent',
canAccessAllLibraries: 'accessAllLibraries',
canAccessAllTags: 'accessAllTags',
canCreateEReader: 'createEreader',
tagsAreDenylist: 'selectedTagsNotAccessible',
// Direct mapping for array-based permissions
allowedLibraries: 'librariesAccessible',
@ -122,6 +123,7 @@ class User extends Model {
update: type === 'root' || type === 'admin',
delete: type === 'root',
upload: type === 'root' || type === 'admin',
createEreader: type === 'root' || type === 'admin',
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: type === 'root' || type === 'admin',

View file

@ -190,6 +190,7 @@ class ApiRouter {
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this))
//
// Backup Routes