mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-04-25 01:16:40 +02:00
Add:Create media item shares with expiration #1768
This commit is contained in:
parent
e52b695f7e
commit
d6eae9b43e
195
client/components/modals/ShareModal.vue
Normal file
195
client/components/modals/ShareModal.vue
Normal 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>
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
34
client/pages/share/_slug.vue
Normal file
34
client/pages/share/_slug.vue
Normal 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>
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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'))))
|
||||
|
||||
|
@ -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()
|
137
server/managers/ShareManager.js
Normal file
137
server/managers/ShareManager.js
Normal 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()
|
130
server/models/MediaItemShare.js
Normal file
130
server/models/MediaItemShare.js
Normal 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
|
@ -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
|
||||
//
|
||||
|
15
server/routers/PublicRouter.js
Normal file
15
server/routers/PublicRouter.js
Normal 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
|
Loading…
Reference in New Issue
Block a user