mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-29 15:24:50 +02:00
Add:Create media item shares with expiration #1768
This commit is contained in:
parent
e52b695f7e
commit
d6eae9b43e
12 changed files with 801 additions and 104 deletions
|
@ -13,18 +13,19 @@ const AudioFileScanner = require('../scanner/AudioFileScanner')
|
|||
const Scanner = require('../scanner/Scanner')
|
||||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
|
||||
class LibraryItemController {
|
||||
constructor() { }
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* GET: /api/items/:id
|
||||
* Optional query params:
|
||||
* ?include=progress,rssfeed,downloads
|
||||
* ?expanded=1
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async findOne(req, res) {
|
||||
const includeEntities = (req.query.include || '').split(',')
|
||||
|
@ -42,9 +43,13 @@ class LibraryItemController {
|
|||
item.rssFeed = feedData?.toJSONMinified() || null
|
||||
}
|
||||
|
||||
if (item.mediaType === 'book' && includeEntities.includes('share')) {
|
||||
item.mediaItemShare = ShareManager.findByMediaItemId(item.media.id)
|
||||
}
|
||||
|
||||
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
|
||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||
item.episodeDownloadsQueued = downloadsInQueue.map(d => d.toJSONForClient())
|
||||
item.episodeDownloadsQueued = downloadsInQueue.map((d) => d.toJSONForClient())
|
||||
if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
|
||||
}
|
||||
|
@ -88,9 +93,9 @@ class LibraryItemController {
|
|||
/**
|
||||
* GET: /api/items/:id/download
|
||||
* Download library item. Zip file if multiple files.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
download(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
|
@ -120,9 +125,9 @@ class LibraryItemController {
|
|||
/**
|
||||
* PATCH: /items/:id/media
|
||||
* Update media for a library item. Will create new authors & series when necessary
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async updateMedia(req, res) {
|
||||
const libraryItem = req.libraryItem
|
||||
|
@ -151,8 +156,8 @@ class LibraryItemController {
|
|||
// Book specific - Get all series being removed from this item
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series?.map(se => se.id) || []
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
|
||||
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || []
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
}
|
||||
|
||||
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url
|
||||
|
@ -162,7 +167,10 @@ class LibraryItemController {
|
|||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
|
||||
await this.checkRemoveEmptySeries(
|
||||
libraryItem.media.id,
|
||||
seriesRemoved.map((se) => se.id)
|
||||
)
|
||||
}
|
||||
|
||||
if (isPodcastAutoDownloadUpdated) {
|
||||
|
@ -252,12 +260,14 @@ class LibraryItemController {
|
|||
|
||||
/**
|
||||
* GET: api/items/:id/cover
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async getCover(req, res) {
|
||||
const { query: { width, height, format, raw } } = req
|
||||
const {
|
||||
query: { width, height, format, raw }
|
||||
} = req
|
||||
|
||||
const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, {
|
||||
attributes: ['id', 'mediaType', 'mediaId', 'libraryId'],
|
||||
|
@ -283,14 +293,14 @@ class LibraryItemController {
|
|||
}
|
||||
|
||||
// Check if library item media has a cover path
|
||||
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
||||
if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (req.query.ts)
|
||||
res.set('Cache-Control', 'private, max-age=86400')
|
||||
if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400')
|
||||
|
||||
if (raw) { // any value
|
||||
if (raw) {
|
||||
// any value
|
||||
if (global.XAccel) {
|
||||
const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath)
|
||||
Logger.debug(`Use X-Accel to serve static file ${encodedURI}`)
|
||||
|
@ -325,7 +335,7 @@ class LibraryItemController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
var episodeId = req.params.episodeId
|
||||
if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
|
||||
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) {
|
||||
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
@ -412,8 +422,8 @@ class LibraryItemController {
|
|||
|
||||
let seriesRemoved = []
|
||||
if (libraryItem.isBook && mediaPayload.metadata?.series) {
|
||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id)
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id))
|
||||
const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
|
||||
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
|
||||
}
|
||||
|
||||
if (libraryItem.media.update(mediaPayload)) {
|
||||
|
@ -422,7 +432,10 @@ class LibraryItemController {
|
|||
if (seriesRemoved.length) {
|
||||
// Check remove empty series
|
||||
Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
|
||||
await this.checkRemoveEmptySeries(libraryItem.media.id, seriesRemoved.map(se => se.id))
|
||||
await this.checkRemoveEmptySeries(
|
||||
libraryItem.media.id,
|
||||
seriesRemoved.map((se) => se.id)
|
||||
)
|
||||
}
|
||||
|
||||
await Database.updateLibraryItem(libraryItem)
|
||||
|
@ -447,7 +460,7 @@ class LibraryItemController {
|
|||
id: libraryItemIds
|
||||
})
|
||||
res.json({
|
||||
libraryItems: libraryItems.map(li => li.toJSONExpanded())
|
||||
libraryItems: libraryItems.map((li) => li.toJSONExpanded())
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -542,7 +555,7 @@ class LibraryItemController {
|
|||
const result = await LibraryItemScanner.scanLibraryItem(req.libraryItem.id)
|
||||
await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
|
||||
res.json({
|
||||
result: Object.keys(ScanResult).find(key => ScanResult[key] == result)
|
||||
result: Object.keys(ScanResult).find((key) => ScanResult[key] == result)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -593,9 +606,9 @@ class LibraryItemController {
|
|||
/**
|
||||
* GET api/items/:id/ffprobe/:fileid
|
||||
* FFProbe JSON result from audio file
|
||||
*
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async getFFprobeData(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
|
@ -619,9 +632,9 @@ class LibraryItemController {
|
|||
|
||||
/**
|
||||
* GET api/items/:id/file/:fileid
|
||||
*
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async getLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryFile
|
||||
|
@ -642,9 +655,9 @@ class LibraryItemController {
|
|||
|
||||
/**
|
||||
* DELETE api/items/:id/file/:fileid
|
||||
*
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async deleteLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryFile
|
||||
|
@ -672,7 +685,7 @@ class LibraryItemController {
|
|||
* GET api/items/:id/file/:fileid/download
|
||||
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async downloadLibraryFile(req, res) {
|
||||
const libraryFile = req.libraryFile
|
||||
|
@ -704,14 +717,14 @@ class LibraryItemController {
|
|||
* fileid is the inode value stored in LibraryFile.ino or EBookFile.ino
|
||||
* fileid is only required when reading a supplementary ebook
|
||||
* when no fileid is passed in the primary ebook will be returned
|
||||
*
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async getEBookFile(req, res) {
|
||||
let ebookFile = null
|
||||
if (req.params.fileid) {
|
||||
ebookFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
|
||||
ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
if (!ebookFile?.isEBookFile) {
|
||||
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
|
||||
return res.status(400).send('Invalid ebook file id')
|
||||
|
@ -740,12 +753,12 @@ class LibraryItemController {
|
|||
* toggle the status of an ebook file.
|
||||
* if an ebook file is the primary ebook, then it will be changed to supplementary
|
||||
* if an ebook file is supplementary, then it will be changed to primary
|
||||
*
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
async updateEbookFileStatus(req, res) {
|
||||
const ebookLibraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
|
||||
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
if (!ebookLibraryFile?.isEBookFile) {
|
||||
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
|
||||
return res.status(400).send('Invalid ebook file id')
|
||||
|
@ -777,7 +790,7 @@ class LibraryItemController {
|
|||
|
||||
// For library file routes, get the library file
|
||||
if (req.params.fileid) {
|
||||
req.libraryFile = req.libraryItem.libraryFiles.find(lf => lf.ino === req.params.fileid)
|
||||
req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
|
||||
if (!req.libraryFile) {
|
||||
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
|
||||
return res.sendStatus(404)
|
||||
|
@ -797,4 +810,4 @@ class LibraryItemController {
|
|||
next()
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryItemController()
|
||||
module.exports = new LibraryItemController()
|
||||
|
|
137
server/controllers/ShareController.js
Normal file
137
server/controllers/ShareController.js
Normal file
|
@ -0,0 +1,137 @@
|
|||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const { Op } = require('sequelize')
|
||||
|
||||
const ShareManager = require('../managers/ShareManager')
|
||||
|
||||
class ShareController {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Public route
|
||||
* GET: /api/share/mediaitem/:slug
|
||||
* Get media item share by slug
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async getMediaItemShareBySlug(req, res) {
|
||||
const { slug } = req.params
|
||||
|
||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404)
|
||||
}
|
||||
if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {
|
||||
ShareManager.removeMediaItemShare(mediaItemShare.id)
|
||||
return res.status(404).send('Media item share not found')
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaItemModel = mediaItemShare.mediaItemType === 'book' ? Database.bookModel : Database.podcastEpisodeModel
|
||||
mediaItemShare.mediaItem = await mediaItemModel.findByPk(mediaItemShare.mediaItemId)
|
||||
|
||||
if (!mediaItemShare.mediaItem) {
|
||||
return res.status(404).send('Media item not found')
|
||||
}
|
||||
res.json(mediaItemShare)
|
||||
} catch (error) {
|
||||
Logger.error(`[ShareController] Failed`, error)
|
||||
res.status(500).send('Internal server error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/share/mediaitem
|
||||
* Create a new media item share
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async createMediaItemShare(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[ShareController] Non-admin user "${req.user.username}" attempted to create item share`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const { slug, expiresAt, mediaItemType, mediaItemId } = req.body
|
||||
|
||||
if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') {
|
||||
return res.status(400).send('Missing or invalid required fields')
|
||||
}
|
||||
if (expiresAt === null || isNaN(expiresAt) || expiresAt < 0) {
|
||||
return res.status(400).send('Invalid expiration date')
|
||||
}
|
||||
if (!['book', 'podcastEpisode'].includes(mediaItemType)) {
|
||||
return res.status(400).send('Invalid media item type')
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if the media item share already exists by slug or mediaItemId
|
||||
const existingMediaItemShare = await Database.models.mediaItemShare.findOne({
|
||||
where: {
|
||||
[Op.or]: [{ slug }, { mediaItemId }]
|
||||
}
|
||||
})
|
||||
if (existingMediaItemShare) {
|
||||
if (existingMediaItemShare.mediaItemId === mediaItemId) {
|
||||
return res.status(409).send('Item is already shared')
|
||||
} else {
|
||||
return res.status(409).send('Slug is already in use')
|
||||
}
|
||||
}
|
||||
|
||||
// Check that media item exists
|
||||
const mediaItemModel = mediaItemType === 'book' ? Database.bookModel : Database.podcastEpisodeModel
|
||||
const mediaItem = await mediaItemModel.findByPk(mediaItemId)
|
||||
if (!mediaItem) {
|
||||
return res.status(404).send('Media item not found')
|
||||
}
|
||||
|
||||
const mediaItemShare = await Database.models.mediaItemShare.create({
|
||||
slug,
|
||||
expiresAt: expiresAt || null,
|
||||
mediaItemId,
|
||||
mediaItemType,
|
||||
userId: req.user.id
|
||||
})
|
||||
|
||||
ShareManager.openMediaItemShare(mediaItemShare)
|
||||
|
||||
res.status(201).json(mediaItemShare?.toJSONForClient())
|
||||
} catch (error) {
|
||||
Logger.error(`[ShareController] Failed`, error)
|
||||
res.status(500).send('Internal server error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/share/mediaitem/:id
|
||||
* Delete media item share
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async deleteMediaItemShare(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[ShareController] Non-admin user "${req.user.username}" attempted to delete item share`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
try {
|
||||
const mediaItemShare = await Database.models.mediaItemShare.findByPk(req.params.id)
|
||||
if (!mediaItemShare) {
|
||||
return res.status(404).send('Media item share not found')
|
||||
}
|
||||
|
||||
ShareManager.removeMediaItemShare(mediaItemShare.id)
|
||||
|
||||
await mediaItemShare.destroy()
|
||||
res.sendStatus(204)
|
||||
} catch (error) {
|
||||
Logger.error(`[ShareController] Failed`, error)
|
||||
res.status(500).send('Internal server error')
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = new ShareController()
|
Loading…
Add table
Add a link
Reference in a new issue