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

@ -0,0 +1,195 @@
<template>
<modals-modal ref="modal" v-model="show" name="share" :width="600" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="text-3xl text-white truncate">Share media item</p>
</div>
</template>
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-if="currentShare">
<div class="w-full py-2">
<label class="px-1 text-sm font-semibold block">Share URL</label>
<ui-text-input v-model="currentShareUrl" readonly class="text-base h-10" />
</div>
<div class="w-full py-2 px-1">
<p v-if="currentShare.expiresAt" class="text-base">Expires in {{ currentShareTimeRemaining }}</p>
<p v-else>Permanent</p>
</div>
</template>
<template v-else>
<div class="flex items-center justify-between space-x-4">
<div class="w-40">
<label class="px-1 text-sm font-semibold block">Slug</label>
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
</div>
<div class="flex-grow" />
<div class="w-80">
<label class="px-1 text-sm font-semibold block">Share Duration</label>
<div class="inline-flex items-center space-x-2">
<div>
<ui-icon-btn icon="remove" :size="10" @click="clickMinus" />
</div>
<ui-text-input v-model="newShareDuration" type="number" text-center no-spinner class="text-center w-28 h-10 text-base" />
<div>
<ui-icon-btn icon="add" :size="10" @click="clickPlus" />
</div>
<ui-dropdown v-model="shareDurationUnit" :items="durationUnits" />
</div>
</div>
</div>
<p class="text-sm text-gray-300 py-4 px-1">
Share URL will be: <span class="">{{ demoShareUrl }}</span>
</p>
</template>
<div class="flex items-center pt-6">
<div class="flex-grow" />
<ui-btn v-if="currentShare" color="error" small @click="deleteShare">{{ $strings.ButtonDelete }}</ui-btn>
<ui-btn v-if="!currentShare" color="success" small @click="openShare">{{ $strings.ButtonShare }}</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItem: {
type: Object,
default: () => null
},
mediaItemShare: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
newShareSlug: '',
newShareDuration: 0,
currentShare: null,
shareDurationUnit: 'minutes',
durationUnits: [
{
text: 'Minutes',
value: 'minutes'
},
{
text: 'Hours',
value: 'hours'
},
{
text: 'Days',
value: 'days'
}
]
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
user() {
return this.$store.state.user.user
},
demoShareUrl() {
return `${window.origin}/share/${this.newShareSlug}`
},
currentShareUrl() {
if (!this.currentShare) return ''
return `${window.origin}/share/${this.currentShare.slug}`
},
currentShareTimeRemaining() {
if (!this.currentShare) return 'Error'
if (!this.currentShare.expiresAt) return 'Permanent'
const msRemaining = new Date(this.currentShare.expiresAt).valueOf() - Date.now()
if (msRemaining <= 0) return 'Expired'
return this.$elapsedPretty(msRemaining / 1000, true)
},
expireDurationSeconds() {
let shareDuration = Number(this.newShareDuration)
if (!shareDuration || isNaN(shareDuration)) return 0
return this.newShareDuration * (this.shareDurationUnit === 'minutes' ? 60 : this.shareDurationUnit === 'hours' ? 3600 : 86400)
}
},
methods: {
clickPlus() {
this.newShareDuration++
},
clickMinus() {
if (this.newShareDuration > 0) {
this.newShareDuration--
}
},
deleteShare() {
if (!this.currentShare) return
this.processing = true
this.$axios
.$delete(`/api/share/mediaitem/${this.currentShare.id}`)
.then(() => {
this.currentShare = null
this.$emit('removed')
})
.catch((error) => {
console.error('deleteShare', error)
let errorMsg = error.response?.data || 'Failed to delete share'
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
},
openShare() {
if (!this.newShareSlug) {
this.$toast.error('Slug is required')
return
}
const payload = {
slug: this.newShareSlug,
mediaItemType: 'book',
mediaItemId: this.libraryItem.media.id,
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
}
this.processing = true
this.$axios
.$post(`/api/share/mediaitem`, payload)
.then((data) => {
this.currentShare = data
this.$emit('opened', data)
})
.catch((error) => {
console.error('openShare', error)
let errorMsg = error.response?.data || 'Failed to share item'
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
},
init() {
this.newShareSlug = this.$randomId(10)
if (this.mediaItemShare) {
this.currentShare = { ...this.mediaItemShare }
} else {
this.currentShare = null
}
}
},
mounted() {}
}
</script>

View File

@ -147,6 +147,7 @@
</div>
</div>
<modals-share-modal v-model="showShareModal" :media-item-share="mediaItemShare" :library-item="libraryItem" @opened="openedShare" @removed="removedShare" />
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :library-item-id="libraryItemId" hide-create @select="selectBookmark" />
</div>
@ -160,7 +161,7 @@ export default {
}
// Include episode downloads for podcasts
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed`).catch((error) => {
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => {
console.error('Failed', error)
return false
})
@ -170,7 +171,8 @@ export default {
}
return {
libraryItem: item,
rssFeed: item.rssFeed || null
rssFeed: item.rssFeed || null,
mediaItemShare: item.mediaItemShare || null
}
},
data() {
@ -184,7 +186,8 @@ export default {
episodeDownloadsQueued: [],
showBookmarksModal: false,
isDescriptionClamped: false,
showFullDescription: false
showFullDescription: false,
showShareModal: false
}
},
computed: {
@ -437,6 +440,13 @@ export default {
})
}
if (this.userIsAdminOrUp && !this.isPodcast) {
items.push({
text: 'Share',
action: 'share'
})
}
if (this.userCanDelete) {
items.push({
text: this.$strings.ButtonDelete,
@ -448,6 +458,12 @@ export default {
}
},
methods: {
openedShare(mediaItemShare) {
this.mediaItemShare = mediaItemShare
},
removedShare() {
this.mediaItemShare = null
},
selectBookmark(bookmark) {
if (!bookmark) return
if (this.isStreaming) {
@ -761,6 +777,8 @@ export default {
this.deleteLibraryItem()
} else if (action === 'sendToDevice') {
this.sendToDevice(data)
} else if (action === 'share') {
this.showShareModal = true
}
}
},

View File

@ -0,0 +1,34 @@
<template>
<div id="page-wrapper" class="w-full h-screen overflow-y-auto">
<div class="w-full h-full flex items-center justify-center">
<p class="text-xl">{{ mediaItemShare.mediaItem.title }}</p>
</div>
</div>
</template>
<script>
export default {
layout: 'blank',
async asyncData({ params, error, app }) {
const mediaItemShare = await app.$axios.$get(`/public/share/${params.slug}`).catch((error) => {
console.error('Failed', error)
return null
})
if (!mediaItemShare) {
return error({ statusCode: 404, message: 'Not found' })
}
return {
mediaItemShare: mediaItemShare
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {
console.log('Loaded media item share', this.mediaItemShare)
}
}
</script>

View File

@ -2,7 +2,10 @@ import Vue from 'vue'
import cronParser from 'cron-parser'
import { nanoid } from 'nanoid'
Vue.prototype.$randomId = () => nanoid()
Vue.prototype.$randomId = (len = null) => {
if (len && !isNaN(len)) return nanoid(len)
return nanoid()
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) {
@ -119,7 +122,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
value: '* * * * *'
}
]
const patternMatch = commonPatterns.find(p => p.value === expression)
const patternMatch = commonPatterns.find((p) => p.value === expression)
if (patternMatch) {
return {
description: patternMatch.text
@ -132,13 +135,17 @@ Vue.prototype.$parseCronExpression = (expression) => {
if (pieces[2] !== '*' || pieces[3] !== '*') {
return null
}
if (pieces[4] !== '*' && pieces[4].split(',').some(p => isNaN(p))) {
if (pieces[4] !== '*' && pieces[4].split(',').some((p) => isNaN(p))) {
return null
}
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
var weekdayText = 'day'
if (pieces[4] !== '*') weekdayText = pieces[4].split(',').map(p => weekdays[p]).join(', ')
if (pieces[4] !== '*')
weekdayText = pieces[4]
.split(',')
.map((p) => weekdays[p])
.join(', ')
return {
description: `Run every ${weekdayText} at ${pieces[1]}:${pieces[0].padStart(2, '0')}`
@ -146,7 +153,7 @@ Vue.prototype.$parseCronExpression = (expression) => {
}
Vue.prototype.$getNextScheduledDate = (expression) => {
const interval = cronParser.parseExpression(expression);
const interval = cronParser.parseExpression(expression)
return interval.next().toDate()
}
@ -171,10 +178,8 @@ Vue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => {
export function supplant(str, subs) {
// source: http://crockford.com/javascript/remedial.html
return str.replace(/{([^{}]*)}/g,
function (a, b) {
var r = subs[b]
return typeof r === 'string' || typeof r === 'number' ? r : a
}
)
return str.replace(/{([^{}]*)}/g, function (a, b) {
var r = subs[b]
return typeof r === 'string' || typeof r === 'number' ? r : a
})
}

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