Add:Create media item shares with expiration #1768

This commit is contained in:
advplyr 2024-06-22 16:42:13 -05:00
parent e52b695f7e
commit d6eae9b43e
12 changed files with 801 additions and 104 deletions

View file

@ -142,7 +142,7 @@ class Database {
* @returns {boolean}
*/
async checkHasDb() {
if (!await fs.pathExists(this.dbPath)) {
if (!(await fs.pathExists(this.dbPath))) {
Logger.info(`[Database] absdatabase.sqlite not found at ${this.dbPath}`)
return false
}
@ -159,14 +159,13 @@ class Database {
// First check if this is a new database
this.isNew = !(await this.checkHasDb()) || force
if (!await this.connect()) {
if (!(await this.connect())) {
throw new Error('Database connection failed')
}
await this.buildModels(force)
Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', '))
await this.loadData()
}
@ -179,11 +178,11 @@ class Database {
let logging = false
let benchmark = false
if (process.env.QUERY_LOGGING === "log") {
if (process.env.QUERY_LOGGING === 'log') {
// Setting QUERY_LOGGING=log will log all Sequelize queries before they run
Logger.info(`[Database] Query logging enabled`)
logging = (query) => Logger.debug(`Running the following query:\n ${query}`)
} else if (process.env.QUERY_LOGGING === "benchmark") {
} else if (process.env.QUERY_LOGGING === 'benchmark') {
// Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run
Logger.info(`[Database] Query benchmarking enabled"`)
logging = (query, time) => Logger.debug(`Ran the following query in ${time}ms:\n ${query}`)
@ -199,7 +198,7 @@ class Database {
})
// Helper function
this.sequelize.uppercaseFirst = str => str ? `${str[0].toUpperCase()}${str.substr(1)}` : ''
this.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
try {
await this.sequelize.authenticate()
@ -250,30 +249,31 @@ class Database {
require('./models/FeedEpisode').init(this.sequelize)
require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}
/**
* Compare two server versions
* @param {string} v1
* @param {string} v2
* @param {string} v1
* @param {string} v2
* @returns {-1|0|1} 1 if v1 > v2
*/
compareVersions(v1, v2) {
if (!v1 || !v2) return 0
return v1.localeCompare(v2, undefined, { numeric: true, sensitivity: "case", caseFirst: "upper" })
return v1.localeCompare(v2, undefined, { numeric: true, sensitivity: 'case', caseFirst: 'upper' })
}
/**
* Checks if migration to sqlite db is necessary & runs migration.
*
*
* Check if version was upgraded and run any version specific migrations.
*
*
* Loads most of the data from the database. This is a temporary solution.
*/
async loadData() {
if (this.isNew && await dbMigration.checkShouldMigrate()) {
if (this.isNew && (await dbMigration.checkShouldMigrate())) {
Logger.info(`[Database] New database was created and old database was detected - migrating old to new`)
await dbMigration.migrate(this.models)
}
@ -323,9 +323,9 @@ class Database {
/**
* Create root user
* @param {string} username
* @param {string} pash
* @param {Auth} auth
* @param {string} username
* @param {string} pash
* @param {Auth} auth
* @returns {boolean} true if created
*/
async createRootUser(username, pash, auth) {
@ -359,7 +359,7 @@ class Database {
updateBulkUsers(oldUsers) {
if (!this.sequelize) return false
return Promise.all(oldUsers.map(u => this.updateUser(u)))
return Promise.all(oldUsers.map((u) => this.updateUser(u)))
}
removeUser(userId) {
@ -379,7 +379,7 @@ class Database {
updateBulkBooks(oldBooks) {
if (!this.sequelize) return false
return Promise.all(oldBooks.map(oldBook => this.models.book.saveFromOld(oldBook)))
return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook)))
}
createLibrary(oldLibrary) {
@ -420,8 +420,8 @@ class Database {
/**
* Save metadata file and update library item
*
* @param {import('./objects/LibraryItem')} oldLibraryItem
*
* @param {import('./objects/LibraryItem')} oldLibraryItem
* @returns {Promise<boolean>}
*/
async updateLibraryItem(oldLibraryItem) {
@ -548,7 +548,7 @@ class Database {
replaceTagInFilterData(oldTag, newTag) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].tags.findIndex(n => n === oldTag)
const indexOf = this.libraryFilterData[libraryId].tags.findIndex((n) => n === oldTag)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag)
}
@ -557,7 +557,7 @@ class Database {
removeTagFromFilterData(tag) {
for (const libraryId in this.libraryFilterData) {
this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag)
this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter((t) => t !== tag)
}
}
@ -572,7 +572,7 @@ class Database {
replaceGenreInFilterData(oldGenre, newGenre) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].genres.findIndex(n => n === oldGenre)
const indexOf = this.libraryFilterData[libraryId].genres.findIndex((n) => n === oldGenre)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre)
}
@ -581,7 +581,7 @@ class Database {
removeGenreFromFilterData(genre) {
for (const libraryId in this.libraryFilterData) {
this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter(g => g !== genre)
this.libraryFilterData[libraryId].genres = this.libraryFilterData[libraryId].genres.filter((g) => g !== genre)
}
}
@ -596,7 +596,7 @@ class Database {
replaceNarratorInFilterData(oldNarrator, newNarrator) {
for (const libraryId in this.libraryFilterData) {
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex(n => n === oldNarrator)
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex((n) => n === oldNarrator)
if (indexOf >= 0) {
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
}
@ -605,7 +605,7 @@ class Database {
removeNarratorFromFilterData(narrator) {
for (const libraryId in this.libraryFilterData) {
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter(n => n !== narrator)
this.libraryFilterData[libraryId].narrators = this.libraryFilterData[libraryId].narrators.filter((n) => n !== narrator)
}
}
@ -620,13 +620,13 @@ class Database {
removeSeriesFromFilterData(libraryId, seriesId) {
if (!this.libraryFilterData[libraryId]) return
this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter(se => se.id !== seriesId)
this.libraryFilterData[libraryId].series = this.libraryFilterData[libraryId].series.filter((se) => se.id !== seriesId)
}
addSeriesToFilterData(libraryId, seriesName, seriesId) {
if (!this.libraryFilterData[libraryId]) return
// Check if series is already added
if (this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)) return
if (this.libraryFilterData[libraryId].series.some((se) => se.id === seriesId)) return
this.libraryFilterData[libraryId].series.push({
id: seriesId,
name: seriesName
@ -635,13 +635,13 @@ class Database {
removeAuthorFromFilterData(libraryId, authorId) {
if (!this.libraryFilterData[libraryId]) return
this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter(au => au.id !== authorId)
this.libraryFilterData[libraryId].authors = this.libraryFilterData[libraryId].authors.filter((au) => au.id !== authorId)
}
addAuthorToFilterData(libraryId, authorName, authorId) {
if (!this.libraryFilterData[libraryId]) return
// Check if author is already added
if (this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)) return
if (this.libraryFilterData[libraryId].authors.some((au) => au.id === authorId)) return
this.libraryFilterData[libraryId].authors.push({
id: authorId,
name: authorName
@ -662,63 +662,63 @@ class Database {
* Used when updating items to make sure author id exists
* If library filter data is set then use that for check
* otherwise lookup in db
* @param {string} libraryId
* @param {string} authorId
* @param {string} libraryId
* @param {string} authorId
* @returns {Promise<boolean>}
*/
async checkAuthorExists(libraryId, authorId) {
if (!this.libraryFilterData[libraryId]) {
return this.authorModel.checkExistsById(authorId)
}
return this.libraryFilterData[libraryId].authors.some(au => au.id === authorId)
return this.libraryFilterData[libraryId].authors.some((au) => au.id === authorId)
}
/**
* Used when updating items to make sure series id exists
* If library filter data is set then use that for check
* otherwise lookup in db
* @param {string} libraryId
* @param {string} seriesId
* @param {string} libraryId
* @param {string} seriesId
* @returns {Promise<boolean>}
*/
async checkSeriesExists(libraryId, seriesId) {
if (!this.libraryFilterData[libraryId]) {
return this.seriesModel.checkExistsById(seriesId)
}
return this.libraryFilterData[libraryId].series.some(se => se.id === seriesId)
return this.libraryFilterData[libraryId].series.some((se) => se.id === seriesId)
}
/**
* Get author id for library by name. Uses library filter data if available
*
* @param {string} libraryId
* @param {string} authorName
* @returns {Promise<string>} author id or null if not found
*
* @param {string} libraryId
* @param {string} authorName
* @returns {Promise<string>} author id or null if not found
*/
async getAuthorIdByName(libraryId, authorName) {
if (!this.libraryFilterData[libraryId]) {
return (await this.authorModel.getOldByNameAndLibrary(authorName, libraryId))?.id || null
}
return this.libraryFilterData[libraryId].authors.find(au => au.name === authorName)?.id || null
return this.libraryFilterData[libraryId].authors.find((au) => au.name === authorName)?.id || null
}
/**
* Get series id for library by name. Uses library filter data if available
*
* @param {string} libraryId
* @param {string} seriesName
*
* @param {string} libraryId
* @param {string} seriesName
* @returns {Promise<string>} series id or null if not found
*/
async getSeriesIdByName(libraryId, seriesName) {
if (!this.libraryFilterData[libraryId]) {
return (await this.seriesModel.getOldByNameAndLibrary(seriesName, libraryId))?.id || null
}
return this.libraryFilterData[libraryId].series.find(se => se.name === seriesName)?.id || null
return this.libraryFilterData[libraryId].series.find((se) => se.name === seriesName)?.id || null
}
/**
* Reset numIssues for library
* @param {string} libraryId
* @param {string} libraryId
*/
async resetLibraryIssuesFilterData(libraryId) {
if (!this.libraryFilterData[libraryId]) return // Do nothing if filter data is not set
@ -798,4 +798,4 @@ class Database {
}
}
module.exports = new Database()
module.exports = new Database()

View file

@ -20,6 +20,7 @@ const SocketAuthority = require('./SocketAuthority')
const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter')
const PublicRouter = require('./routers/PublicRouter')
const LogManager = require('./managers/LogManager')
const NotificationManager = require('./managers/NotificationManager')
@ -34,6 +35,7 @@ const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager')
const ApiCacheManager = require('./managers/ApiCacheManager')
const BinaryManager = require('./managers/BinaryManager')
const ShareManager = require('./managers/ShareManager')
const LibraryScanner = require('./scanner/LibraryScanner')
//Import the main Passport and Express-Session library
@ -79,6 +81,7 @@ class Server {
// Routers
this.apiRouter = new ApiRouter(this)
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
this.publicRouter = new PublicRouter()
Logger.logManager = new LogManager()
@ -116,6 +119,7 @@ class Server {
await this.cleanUserData() // Remove invalid user item progress
await CacheManager.ensureCachePaths()
await ShareManager.init()
await this.backupManager.init()
await this.rssFeedManager.init()
@ -250,6 +254,7 @@ class Server {
router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
router.use('/public', this.publicRouter.router)
// RSS Feed temp route
router.get('/feed/:slug', (req, res) => {
@ -287,7 +292,8 @@ class Server {
'/config/users/:id/sessions',
'/config/item-metadata-utils/:id',
'/collection/:id',
'/playlist/:id'
'/playlist/:id',
'/share/:slug'
]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))

View file

@ -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()

View 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()

View file

@ -0,0 +1,137 @@
const Database = require('../Database')
const Logger = require('../Logger')
/**
* @typedef OpenMediaItemShareObject
* @property {string} id
* @property {import('../models/MediaItemShare').MediaItemShareObject} mediaItemShare
* @property {NodeJS.Timeout} timeout
*/
class ShareManager {
constructor() {
/** @type {OpenMediaItemShareObject[]} */
this.openMediaItemShares = []
}
init() {
this.loadMediaItemShares()
}
/**
* Find an open media item share by media item ID
* @param {string} mediaItemId
* @returns {import('../models/MediaItemShare').MediaItemShareForClient}
*/
findByMediaItemId(mediaItemId) {
const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.mediaItemId === mediaItemId)?.mediaItemShare
if (mediaItemShareObject) {
const mediaItemShareObjectForClient = { ...mediaItemShareObject }
delete mediaItemShareObjectForClient.pash
delete mediaItemShareObjectForClient.userId
delete mediaItemShareObjectForClient.extraData
return mediaItemShareObjectForClient
}
return null
}
/**
* Find an open media item share by slug
* @param {string} slug
* @returns {import('../models/MediaItemShare').MediaItemShareForClient}
*/
findBySlug(slug) {
const mediaItemShareObject = this.openMediaItemShares.find((s) => s.mediaItemShare.slug === slug)?.mediaItemShare
if (mediaItemShareObject) {
const mediaItemShareObjectForClient = { ...mediaItemShareObject }
delete mediaItemShareObjectForClient.pash
delete mediaItemShareObjectForClient.userId
delete mediaItemShareObjectForClient.extraData
return mediaItemShareObjectForClient
}
return null
}
/**
* Load all media item shares from the database
* Remove expired & schedule active
*/
async loadMediaItemShares() {
/** @type {import('../models/MediaItemShare').MediaItemShareModel[]} */
const mediaItemShares = await Database.models.mediaItemShare.findAll()
for (const mediaItemShare of mediaItemShares) {
if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
await this.destroyMediaItemShare(mediaItemShare.id)
} else if (mediaItemShare.expiresAt) {
this.scheduleMediaItemShare(mediaItemShare)
} else {
Logger.info(`[ShareManager] Loaded permanent media item share "${mediaItemShare.id}"`)
this.openMediaItemShares.push({
id: mediaItemShare.id,
mediaItemShare: mediaItemShare.toJSON()
})
}
}
}
/**
*
* @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare
*/
scheduleMediaItemShare(mediaItemShare) {
if (!mediaItemShare?.expiresAt) return
const expiresAtDuration = mediaItemShare.expiresAt.valueOf() - Date.now()
if (expiresAtDuration <= 0) {
Logger.warn(`[ShareManager] Attempted to schedule expired media item share "${mediaItemShare.id}"`)
this.destroyMediaItemShare(mediaItemShare.id)
return
}
const timeout = setTimeout(() => {
Logger.info(`[ShareManager] Removing expired media item share "${mediaItemShare.id}"`)
this.removeMediaItemShare(mediaItemShare.id)
}, expiresAtDuration)
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON(), timeout })
Logger.info(`[ShareManager] Scheduled media item share "${mediaItemShare.id}" to expire in ${expiresAtDuration}ms`)
}
/**
*
* @param {import('../models/MediaItemShare').MediaItemShareModel} mediaItemShare
*/
openMediaItemShare(mediaItemShare) {
if (mediaItemShare.expiresAt) {
this.scheduleMediaItemShare(mediaItemShare)
} else {
this.openMediaItemShares.push({ id: mediaItemShare.id, mediaItemShare: mediaItemShare.toJSON() })
}
}
/**
*
* @param {string} mediaItemShareId
*/
async removeMediaItemShare(mediaItemShareId) {
const mediaItemShare = this.openMediaItemShares.find((s) => s.id === mediaItemShareId)
if (!mediaItemShare) return
if (mediaItemShare.timeout) {
clearTimeout(mediaItemShare.timeout)
}
this.openMediaItemShares = this.openMediaItemShares.filter((s) => s.id !== mediaItemShareId)
await this.destroyMediaItemShare(mediaItemShareId)
}
/**
*
* @param {string} mediaItemShareId
*/
destroyMediaItemShare(mediaItemShareId) {
return Database.models.mediaItemShare.destroy({ where: { id: mediaItemShareId } })
}
}
module.exports = new ShareManager()

View file

@ -0,0 +1,130 @@
const { DataTypes, Model } = require('sequelize')
/**
* @typedef MediaItemShareObject
* @property {UUIDV4} id
* @property {UUIDV4} mediaItemId
* @property {string} mediaItemType
* @property {string} slug
* @property {string} pash
* @property {UUIDV4} userId
* @property {Date} expiresAt
* @property {Object} extraData
* @property {Date} createdAt
* @property {Date} updatedAt
*
* @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel
*/
/**
* @typedef MediaItemShareForClient
* @property {UUIDV4} id
* @property {UUIDV4} mediaItemId
* @property {string} mediaItemType
* @property {string} slug
* @property {Date} expiresAt
* @property {Date} createdAt
* @property {Date} updatedAt
*/
class MediaItemShare extends Model {
constructor(values, options) {
super(values, options)
}
toJSONForClient() {
return {
id: this.id,
mediaItemId: this.mediaItemId,
mediaItemType: this.mediaItemType,
slug: this.slug,
expiresAt: this.expiresAt,
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<import('./Book')|import('./PodcastEpisode')>}
*/
getMediaItem(options) {
if (!this.mediaItemType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}`
return this[mixinMethodName](options)
}
/**
* Initialize model
*
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
mediaItemId: DataTypes.UUIDV4,
mediaItemType: DataTypes.STRING,
slug: DataTypes.STRING,
pash: DataTypes.STRING,
expiresAt: DataTypes.DATE,
extraData: DataTypes.JSON
},
{
sequelize,
modelName: 'mediaItemShare'
}
)
const { user, book, podcastEpisode } = sequelize.models
user.hasMany(MediaItemShare)
MediaItemShare.belongsTo(user)
book.hasMany(MediaItemShare, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'book'
}
})
MediaItemShare.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false })
podcastEpisode.hasOne(MediaItemShare, {
foreignKey: 'mediaItemId',
constraints: false,
scope: {
mediaItemType: 'podcastEpisode'
}
})
MediaItemShare.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false })
MediaItemShare.addHook('afterFind', (findResult) => {
if (!findResult) return
if (!Array.isArray(findResult)) findResult = [findResult]
for (const instance of findResult) {
if (instance.mediaItemType === 'book' && instance.book !== undefined) {
instance.mediaItem = instance.book
instance.dataValues.mediaItem = instance.dataValues.book
} else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) {
instance.mediaItem = instance.podcastEpisode
instance.dataValues.mediaItem = instance.dataValues.podcastEpisode
}
// To prevent mistakes:
delete instance.book
delete instance.dataValues.book
delete instance.podcastEpisode
delete instance.dataValues.podcastEpisode
}
})
}
}
module.exports = MediaItemShare

View file

@ -30,6 +30,7 @@ const ToolsController = require('../controllers/ToolsController')
const RSSFeedController = require('../controllers/RSSFeedController')
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController')
const Author = require('../objects/entities/Author')
const Series = require('../objects/entities/Series')
@ -310,6 +311,12 @@ class ApiRouter {
this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this))
this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this))
//
// Share routes
//
this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this))
this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this))
//
// Misc Routes
//

View file

@ -0,0 +1,15 @@
const express = require('express')
const ShareController = require('../controllers/ShareController')
class PublicRouter {
constructor() {
this.router = express()
this.router.disable('x-powered-by')
this.init()
}
init() {
this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this))
}
}
module.exports = PublicRouter