mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-01 16:30:39 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cae110360 | |||
| c60d74774a | |||
| 7557f3e2b9 | |||
| 5f680d7277 | |||
| cbbdb0ec29 | |||
| c8682c8456 | |||
| e7e0056288 | |||
| 50e84fc2d5 | |||
| a762e6ca03 | |||
| fe4d3c0852 | |||
| 048790b33a | |||
| fc17a74865 | |||
| cfe3deff3b | |||
| 5a96d8aeb3 | |||
| 23b480b11a | |||
| ad89fb2eac | |||
| 62bd7e73f4 |
@@ -112,6 +112,14 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.$store.state.pluginsEnabled) {
|
||||||
|
configRoutes.push({
|
||||||
|
id: 'config-plugins',
|
||||||
|
title: 'Plugins',
|
||||||
|
path: '/config/plugins'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentLibraryId) {
|
if (this.currentLibraryId) {
|
||||||
configRoutes.push({
|
configRoutes.push({
|
||||||
id: 'library-stats',
|
id: 'library-stats',
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||||
|
|
||||||
|
<div v-if="formFields.length" class="mb-6 space-y-2">
|
||||||
|
<template v-for="field in formFields">
|
||||||
|
<ui-select-input v-if="field.type === 'select'" :key="field.name" v-model="formData[field.name]" :label="field.label" :items="field.options" class="px-1" />
|
||||||
|
<ui-textarea-with-label v-else-if="field.type === 'textarea'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
|
||||||
|
<ui-text-input-with-label v-else-if="field.type === 'text'" :key="field.name" v-model="formData[field.name]" :label="field.label" class="px-1" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex px-1 items-center">
|
<div class="flex px-1 items-center">
|
||||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -25,7 +33,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
el: null,
|
el: null,
|
||||||
content: null,
|
content: null,
|
||||||
checkboxValue: false
|
checkboxValue: false,
|
||||||
|
formData: {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -61,6 +70,9 @@ export default {
|
|||||||
persistent() {
|
persistent() {
|
||||||
return !!this.confirmPromptOptions.persistent
|
return !!this.confirmPromptOptions.persistent
|
||||||
},
|
},
|
||||||
|
formFields() {
|
||||||
|
return this.confirmPromptOptions.formFields || []
|
||||||
|
},
|
||||||
checkboxLabel() {
|
checkboxLabel() {
|
||||||
return this.confirmPromptOptions.checkboxLabel
|
return this.confirmPromptOptions.checkboxLabel
|
||||||
},
|
},
|
||||||
@@ -100,11 +112,31 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
confirm() {
|
confirm() {
|
||||||
if (this.callback) this.callback(true, this.checkboxValue)
|
if (this.callback) {
|
||||||
|
if (this.formFields.length) {
|
||||||
|
const formFieldData = {
|
||||||
|
...this.formData
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callback(true, formFieldData)
|
||||||
|
} else {
|
||||||
|
this.callback(true, this.checkboxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
setShow() {
|
setShow() {
|
||||||
this.checkboxValue = this.checkboxDefaultValue
|
this.checkboxValue = this.checkboxDefaultValue
|
||||||
|
|
||||||
|
if (this.formFields.length) {
|
||||||
|
this.formFields.forEach((field) => {
|
||||||
|
let defaultValue = ''
|
||||||
|
if (field.type === 'boolean') defaultValue = false
|
||||||
|
if (field.type === 'select') defaultValue = field.options[0].value
|
||||||
|
this.$set(this.formData, field.name, defaultValue)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
this.$eventBus.$emit('showing-prompt', true)
|
this.$eventBus.$emit('showing-prompt', true)
|
||||||
document.body.appendChild(this.el)
|
document.body.appendChild(this.el)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
|
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
|
||||||
|
<span v-if="item.icon" class="material-symbols text-base mr-1">{{ item.icon }}</span>
|
||||||
<p class="text-left">{{ item.text }}</p>
|
<p class="text-left">{{ item.text }}</p>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="`Plugin: ${pluginManifest.name}`">
|
||||||
|
<template #header-prefix>
|
||||||
|
<nuxt-link to="/config/plugins" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
|
||||||
|
<span class="material-symbols text-2xl">arrow_back</span>
|
||||||
|
</nuxt-link>
|
||||||
|
</template>
|
||||||
|
<template #header-items>
|
||||||
|
<ui-tooltip v-if="pluginManifest.documentationUrl" :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
|
<a :href="pluginManifest.documentationUrl" target="_blank" class="inline-flex">
|
||||||
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
|
</a>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<a v-if="repositoryUrl" :href="repositoryUrl" target="_blank" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-4 py-1 text-sm inline-flex items-center space-x-2"><span>Source</span><span class="material-symbols text-base">open_in_new</span> </a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="py-4">
|
||||||
|
<p v-if="configDescription" class="mb-4">{{ configDescription }}</p>
|
||||||
|
|
||||||
|
<form v-if="configFormFields.length" @submit.prevent="handleFormSubmit">
|
||||||
|
<template v-for="field in configFormFields">
|
||||||
|
<div :key="field.name" class="flex items-center mb-4">
|
||||||
|
<label :for="field.name" class="w-1/3 text-gray-200">{{ field.label }}</label>
|
||||||
|
<div class="w-2/3">
|
||||||
|
<input :id="field.name" :type="field.type" :placeholder="field.placeholder" class="w-full bg-bg border border-white border-opacity-20 rounded-md p-2 text-gray-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ui-btn class="bg-primary bg-opacity-70 text-white rounded-md p-2" :loading="processing" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, redirect, params, app }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
const pluginConfigData = await app.$axios.$get(`/api/plugins/${params.id}/config`).catch((error) => {
|
||||||
|
console.error('Failed to get plugin config', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!pluginConfigData) {
|
||||||
|
redirect('/config/plugins')
|
||||||
|
}
|
||||||
|
const pluginManifest = store.state.plugins.find((plugin) => plugin.id === params.id)
|
||||||
|
if (!pluginManifest) {
|
||||||
|
redirect('/config/plugins')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pluginManifest,
|
||||||
|
pluginConfig: pluginConfigData.config
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
pluginManifestConfig() {
|
||||||
|
return this.pluginManifest.config
|
||||||
|
},
|
||||||
|
pluginLocalization() {
|
||||||
|
return this.pluginManifest.localization || {}
|
||||||
|
},
|
||||||
|
localizedStrings() {
|
||||||
|
const localeKey = this.$languageCodes.current
|
||||||
|
if (!localeKey) return {}
|
||||||
|
return this.pluginLocalization[localeKey] || {}
|
||||||
|
},
|
||||||
|
configDescription() {
|
||||||
|
if (this.pluginManifestConfig.descriptionKey && this.localizedStrings[this.pluginManifestConfig.descriptionKey]) {
|
||||||
|
return this.localizedStrings[this.pluginManifestConfig.descriptionKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pluginManifestConfig.description
|
||||||
|
},
|
||||||
|
configFormFields() {
|
||||||
|
return this.pluginManifestConfig.formFields || []
|
||||||
|
},
|
||||||
|
repositoryUrl() {
|
||||||
|
return this.pluginManifest.repositoryUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getFormData() {
|
||||||
|
const formData = {}
|
||||||
|
this.configFormFields.forEach((field) => {
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
formData[field.name] = document.getElementById(field.name).checked
|
||||||
|
} else {
|
||||||
|
formData[field.name] = document.getElementById(field.name).value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return formData
|
||||||
|
},
|
||||||
|
handleFormSubmit() {
|
||||||
|
const formData = this.getFormData()
|
||||||
|
console.log('Form data', formData)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
config: formData
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/plugins/${this.pluginManifest.id}/config`, payload)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Plugin configuration saved')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = error.response?.data || 'Error saving plugin configuration'
|
||||||
|
console.error('Failed to save config:', error)
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
initializeForm() {
|
||||||
|
if (!this.pluginConfig) return
|
||||||
|
this.configFormFields.forEach((field) => {
|
||||||
|
if (this.pluginConfig[field.name] === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.pluginConfig[field.name]
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
document.getElementById(field.name).checked = value
|
||||||
|
} else {
|
||||||
|
document.getElementById(field.name).value = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
console.log('Plugin manifest', this.pluginManifest, 'config', this.pluginConfig)
|
||||||
|
this.initializeForm()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="'Plugins'">
|
||||||
|
<template #header-items>
|
||||||
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
|
<a href="https://www.audiobookshelf.org/guides" target="_blank" class="inline-flex">
|
||||||
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
|
</a>
|
||||||
|
</ui-tooltip>
|
||||||
|
</template>
|
||||||
|
<div class="py-4">
|
||||||
|
<p v-if="!plugins.length" class="text-gray-300">No plugins installed</p>
|
||||||
|
|
||||||
|
<template v-for="plugin in plugins">
|
||||||
|
<nuxt-link :key="plugin.id" :to="`/config/plugins/${plugin.id}`" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<p class="text-lg">{{ plugin.name }}</p>
|
||||||
|
<p class="text-sm text-gray-300">{{ plugin.description }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<span class="material-symbols">arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
plugins() {
|
||||||
|
return this.$store.state.plugins
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -364,6 +364,9 @@ export default {
|
|||||||
showCollectionsButton() {
|
showCollectionsButton() {
|
||||||
return this.isBook && this.userCanUpdate
|
return this.isBook && this.userCanUpdate
|
||||||
},
|
},
|
||||||
|
pluginExtensions() {
|
||||||
|
return this.$store.getters['getPluginExtensions']('item.detail.actions')
|
||||||
|
},
|
||||||
contextMenuItems() {
|
contextMenuItems() {
|
||||||
const items = []
|
const items = []
|
||||||
|
|
||||||
@@ -429,6 +432,18 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.pluginExtensions.length) {
|
||||||
|
this.pluginExtensions.forEach((plugin) => {
|
||||||
|
plugin.extensions.forEach((pext) => {
|
||||||
|
items.push({
|
||||||
|
text: pext.label,
|
||||||
|
action: `plugin-${plugin.id}-action-${pext.name}`,
|
||||||
|
icon: 'extension'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -763,7 +778,54 @@ export default {
|
|||||||
} else if (action === 'share') {
|
} else if (action === 'share') {
|
||||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
||||||
|
} else if (action.startsWith('plugin-')) {
|
||||||
|
const actionStrSplit = action.replace('plugin-', '').split('-action-')
|
||||||
|
const pluginId = actionStrSplit[0]
|
||||||
|
const pluginAction = actionStrSplit[1]
|
||||||
|
this.onPluginAction(pluginId, pluginAction)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onPluginAction(pluginId, pluginAction) {
|
||||||
|
const plugin = this.pluginExtensions.find((p) => p.id === pluginId)
|
||||||
|
const extension = plugin.extensions.find((ext) => ext.name === pluginAction)
|
||||||
|
|
||||||
|
if (extension.prompt) {
|
||||||
|
const payload = {
|
||||||
|
message: extension.prompt.message,
|
||||||
|
formFields: extension.prompt.formFields || [],
|
||||||
|
yesButtonText: this.$strings.ButtonSubmit,
|
||||||
|
callback: (confirmed, promptData) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.sendPluginAction(pluginId, pluginAction, promptData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
} else {
|
||||||
|
this.sendPluginAction(pluginId, pluginAction)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendPluginAction(pluginId, pluginAction, promptData = null) {
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/plugins/${pluginId}/action`, {
|
||||||
|
pluginAction,
|
||||||
|
target: 'item.detail.actions',
|
||||||
|
data: {
|
||||||
|
entityId: this.libraryItemId,
|
||||||
|
entityType: 'libraryItem',
|
||||||
|
userId: this.$store.state.user.user.id,
|
||||||
|
promptData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
console.log('Plugin action response', data)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = error.response?.data || 'Plugin action failed'
|
||||||
|
console.error('Plugin action failed:', error)
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -166,10 +166,14 @@ export default {
|
|||||||
|
|
||||||
location.reload()
|
location.reload()
|
||||||
},
|
},
|
||||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
|
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) {
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
this.$store.commit('setSource', Source)
|
this.$store.commit('setSource', Source)
|
||||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||||
|
if (plugins !== undefined) {
|
||||||
|
this.$store.commit('setPlugins', plugins)
|
||||||
|
}
|
||||||
|
|
||||||
this.$setServerLanguageCode(serverSettings.language)
|
this.$setServerLanguageCode(serverSettings.language)
|
||||||
|
|
||||||
if (serverSettings.chromecastEnabled) {
|
if (serverSettings.chromecastEnabled) {
|
||||||
|
|||||||
+21
-1
@@ -28,7 +28,9 @@ export const state = () => ({
|
|||||||
openModal: null,
|
openModal: null,
|
||||||
innerModalOpen: false,
|
innerModalOpen: false,
|
||||||
lastBookshelfScrollData: {},
|
lastBookshelfScrollData: {},
|
||||||
routerBasePath: '/'
|
routerBasePath: '/',
|
||||||
|
plugins: [],
|
||||||
|
pluginsEnabled: false
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -61,6 +63,20 @@ export const getters = {
|
|||||||
getHomeBookshelfView: (state) => {
|
getHomeBookshelfView: (state) => {
|
||||||
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
|
if (!state.serverSettings || isNaN(state.serverSettings.homeBookshelfView)) return Constants.BookshelfView.STANDARD
|
||||||
return state.serverSettings.homeBookshelfView
|
return state.serverSettings.homeBookshelfView
|
||||||
|
},
|
||||||
|
getPluginExtensions: (state) => (target) => {
|
||||||
|
if (!state.pluginsEnabled) return []
|
||||||
|
return state.plugins
|
||||||
|
.map((pext) => {
|
||||||
|
const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || []
|
||||||
|
if (!extensionsMatchingTarget.length) return null
|
||||||
|
return {
|
||||||
|
id: pext.id,
|
||||||
|
name: pext.name,
|
||||||
|
extensions: extensionsMatchingTarget
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,5 +255,9 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
setInnerModalOpen(state, val) {
|
setInnerModalOpen(state, val) {
|
||||||
state.innerModalOpen = val
|
state.innerModalOpen = val
|
||||||
|
},
|
||||||
|
setPlugins(state, val) {
|
||||||
|
state.plugins = val
|
||||||
|
state.pluginsEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ if (isDev) {
|
|||||||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||||
|
if (devEnv.AllowPlugins) process.env.ALLOW_PLUGINS = '1'
|
||||||
|
if (devEnv.DevPluginsPath) process.env.DEV_PLUGINS_PATH = devEnv.DevPluginsPath
|
||||||
process.env.SOURCE = 'local'
|
process.env.SOURCE = 'local'
|
||||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const server = require('./server/Server')
|
|||||||
|
|
||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
var inputConfig = options.config ? Path.resolve(options.config) : null
|
const inputConfig = options.config ? Path.resolve(options.config) : null
|
||||||
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
const inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||||
|
|
||||||
const PORT = options.port || process.env.PORT || 3333
|
const PORT = options.port || process.env.PORT || 3333
|
||||||
const HOST = options.host || process.env.HOST
|
const HOST = options.host || process.env.HOST
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const Logger = require('./Logger')
|
|||||||
*/
|
*/
|
||||||
class Auth {
|
class Auth {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.pluginManifests = []
|
||||||
|
|
||||||
// Map of openId sessions indexed by oauth2 state-variable
|
// Map of openId sessions indexed by oauth2 state-variable
|
||||||
this.openIdAuthSession = new Map()
|
this.openIdAuthSession = new Map()
|
||||||
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
|
this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/]
|
||||||
@@ -933,11 +935,28 @@ class Auth {
|
|||||||
*/
|
*/
|
||||||
async getUserLoginResponsePayload(user) {
|
async getUserLoginResponsePayload(user) {
|
||||||
const libraryIds = await Database.libraryModel.getAllLibraryIds()
|
const libraryIds = await Database.libraryModel.getAllLibraryIds()
|
||||||
|
|
||||||
|
let plugins = undefined
|
||||||
|
if (process.env.ALLOW_PLUGINS === '1') {
|
||||||
|
// TODO: Should be better handled by the PluginManager
|
||||||
|
// restrict plugin extensions that are not allowed for the user type
|
||||||
|
plugins = this.pluginManifests.map((manifest) => {
|
||||||
|
const manifestExtensions = (manifest.extensions || []).filter((ext) => {
|
||||||
|
if (ext.restrictToAccountTypes?.length) {
|
||||||
|
return ext.restrictToAccountTypes.includes(user.type)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return { ...manifest, extensions: manifestExtensions }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: user.toOldJSONForBrowser(),
|
user: user.toOldJSONForBrowser(),
|
||||||
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
|
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
|
||||||
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
||||||
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
||||||
|
plugins,
|
||||||
Source: global.Source
|
Source: global.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ class Database {
|
|||||||
return this.models.device
|
return this.models.device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Plugin')} */
|
||||||
|
get pluginModel() {
|
||||||
|
return this.models.plugin
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
@@ -305,6 +310,7 @@ class Database {
|
|||||||
require('./models/Setting').init(this.sequelize)
|
require('./models/Setting').init(this.sequelize)
|
||||||
require('./models/CustomMetadataProvider').init(this.sequelize)
|
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||||
require('./models/MediaItemShare').init(this.sequelize)
|
require('./models/MediaItemShare').init(this.sequelize)
|
||||||
|
require('./models/Plugin').init(this.sequelize)
|
||||||
|
|
||||||
return this.sequelize.sync({ force, alter: false })
|
return this.sequelize.sync({ force, alter: false })
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -28,7 +28,6 @@ const AbMergeManager = require('./managers/AbMergeManager')
|
|||||||
const CacheManager = require('./managers/CacheManager')
|
const CacheManager = require('./managers/CacheManager')
|
||||||
const BackupManager = require('./managers/BackupManager')
|
const BackupManager = require('./managers/BackupManager')
|
||||||
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||||
const PodcastManager = require('./managers/PodcastManager')
|
|
||||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||||
const RssFeedManager = require('./managers/RssFeedManager')
|
const RssFeedManager = require('./managers/RssFeedManager')
|
||||||
const CronManager = require('./managers/CronManager')
|
const CronManager = require('./managers/CronManager')
|
||||||
@@ -36,6 +35,7 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
|
|||||||
const BinaryManager = require('./managers/BinaryManager')
|
const BinaryManager = require('./managers/BinaryManager')
|
||||||
const ShareManager = require('./managers/ShareManager')
|
const ShareManager = require('./managers/ShareManager')
|
||||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||||
|
const PluginManager = require('./managers/PluginManager')
|
||||||
|
|
||||||
//Import the main Passport and Express-Session library
|
//Import the main Passport and Express-Session library
|
||||||
const passport = require('passport')
|
const passport = require('passport')
|
||||||
@@ -79,9 +79,8 @@ class Server {
|
|||||||
this.backupManager = new BackupManager()
|
this.backupManager = new BackupManager()
|
||||||
this.abMergeManager = new AbMergeManager()
|
this.abMergeManager = new AbMergeManager()
|
||||||
this.playbackSessionManager = new PlaybackSessionManager()
|
this.playbackSessionManager = new PlaybackSessionManager()
|
||||||
this.podcastManager = new PodcastManager()
|
|
||||||
this.audioMetadataManager = new AudioMetadataMangaer()
|
this.audioMetadataManager = new AudioMetadataMangaer()
|
||||||
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
|
this.cronManager = new CronManager(this.playbackSessionManager)
|
||||||
this.apiCacheManager = new ApiCacheManager()
|
this.apiCacheManager = new ApiCacheManager()
|
||||||
this.binaryManager = new BinaryManager()
|
this.binaryManager = new BinaryManager()
|
||||||
|
|
||||||
@@ -161,6 +160,15 @@ class Server {
|
|||||||
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)
|
LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.ALLOW_PLUGINS === '1') {
|
||||||
|
Logger.info(`[Server] Experimental plugin support enabled`)
|
||||||
|
|
||||||
|
// Initialize plugins
|
||||||
|
await PluginManager.init()
|
||||||
|
// TODO: Prevents circular dependency for SocketAuthority
|
||||||
|
this.auth.pluginManifests = PluginManager.pluginManifests
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const Scanner = require('../scanner/Scanner')
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Watcher = require('../Watcher')
|
const Watcher = require('../Watcher')
|
||||||
const RssFeedManager = require('../managers/RssFeedManager')
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
const PodcastManager = require('../managers/PodcastManager')
|
||||||
|
|
||||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||||
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
|
||||||
@@ -219,7 +220,7 @@ class LibraryController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getEpisodeDownloadQueue(req, res) {
|
async getEpisodeDownloadQueue(req, res) {
|
||||||
const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
|
const libraryDownloadQueueDetails = PodcastManager.getDownloadQueueDetails(req.library.id)
|
||||||
res.json(libraryDownloadQueueDetails)
|
res.json(libraryDownloadQueueDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1288,7 +1289,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
|
const opmlText = PodcastManager.generateOPMLFileText(podcasts)
|
||||||
res.type('application/xml')
|
res.type('application/xml')
|
||||||
res.send(opmlText)
|
res.send(opmlText)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const RssFeedManager = require('../managers/RssFeedManager')
|
|||||||
const CacheManager = require('../managers/CacheManager')
|
const CacheManager = require('../managers/CacheManager')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
const ShareManager = require('../managers/ShareManager')
|
const ShareManager = require('../managers/ShareManager')
|
||||||
|
const PodcastManager = require('../managers/PodcastManager')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RequestUserObject
|
* @typedef RequestUserObject
|
||||||
@@ -59,10 +60,10 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
|
if (item.mediaType === 'podcast' && includeEntities.includes('downloads')) {
|
||||||
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
const downloadsInQueue = 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) {
|
if (PodcastManager.currentDownload?.libraryItemId === req.libraryItem.id) {
|
||||||
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
|
item.episodesDownloading = [PodcastManager.currentDownload.toJSONForClient()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
const { Request, Response, NextFunction } = require('express')
|
||||||
|
const PluginManager = require('../managers/PluginManager')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
class PluginController {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
getConfig(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
config: req.pluginData.instance.config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/plugins/:id/action
|
||||||
|
*
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async handleAction(req, res) {
|
||||||
|
const actionName = req.body.pluginAction
|
||||||
|
const target = req.body.target
|
||||||
|
const data = req.body.data
|
||||||
|
Logger.info(`[PluginController] Handle plugin "${req.pluginData.manifest.name}" action ${actionName} ${target}`, data)
|
||||||
|
const actionData = await PluginManager.onAction(req.pluginData, actionName, target, data)
|
||||||
|
if (!actionData || actionData.error) {
|
||||||
|
return res.status(400).send(actionData?.error || 'Error performing action')
|
||||||
|
}
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/plugins/:id/config
|
||||||
|
*
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async handleConfigSave(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
if (!req.body.config || typeof req.body.config !== 'object') {
|
||||||
|
return res.status(400).send('Invalid config')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = req.body.config
|
||||||
|
Logger.info(`[PluginController] Handle save config for plugin ${req.pluginData.manifest.name}`, config)
|
||||||
|
const saveData = await PluginManager.onConfigSave(req.pluginData, config)
|
||||||
|
if (!saveData || saveData.error) {
|
||||||
|
return res.status(400).send(saveData?.error || 'Error saving config')
|
||||||
|
}
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
*/
|
||||||
|
async middleware(req, res, next) {
|
||||||
|
if (req.params.id) {
|
||||||
|
const pluginData = PluginManager.getPluginDataById(req.params.id)
|
||||||
|
if (!pluginData) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
await pluginData.instance.reload()
|
||||||
|
req.pluginData = pluginData
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = new PluginController()
|
||||||
@@ -11,6 +11,7 @@ const { validateUrl } = require('../utils/index')
|
|||||||
|
|
||||||
const Scanner = require('../scanner/Scanner')
|
const Scanner = require('../scanner/Scanner')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
const PodcastManager = require('../managers/PodcastManager')
|
||||||
|
|
||||||
const LibraryItem = require('../objects/LibraryItem')
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ class PodcastController {
|
|||||||
|
|
||||||
if (payload.episodesToDownload?.length) {
|
if (payload.episodesToDownload?.length) {
|
||||||
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
|
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
|
||||||
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
|
PodcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turn on podcast auto download cron if not already on
|
// Turn on podcast auto download cron if not already on
|
||||||
@@ -169,7 +170,7 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
|
feeds: PodcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +204,7 @@ class PodcastController {
|
|||||||
return res.status(404).send('Folder not found')
|
return res.status(404).send('Folder not found')
|
||||||
}
|
}
|
||||||
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
|
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
|
||||||
this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
|
PodcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@@ -230,7 +231,7 @@ class PodcastController {
|
|||||||
|
|
||||||
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
|
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
|
||||||
|
|
||||||
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
|
var newEpisodes = await PodcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
|
||||||
res.json({
|
res.json({
|
||||||
episodes: newEpisodes || []
|
episodes: newEpisodes || []
|
||||||
})
|
})
|
||||||
@@ -239,8 +240,6 @@ class PodcastController {
|
|||||||
/**
|
/**
|
||||||
* GET: /api/podcasts/:id/clear-queue
|
* GET: /api/podcasts/:id/clear-queue
|
||||||
*
|
*
|
||||||
* @this {import('../routers/ApiRouter')}
|
|
||||||
*
|
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -249,22 +248,20 @@ class PodcastController {
|
|||||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
|
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
this.podcastManager.clearDownloadQueue(req.params.id)
|
PodcastManager.clearDownloadQueue(req.params.id)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/podcasts/:id/downloads
|
* GET: /api/podcasts/:id/downloads
|
||||||
*
|
*
|
||||||
* @this {import('../routers/ApiRouter')}
|
|
||||||
*
|
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
getEpisodeDownloads(req, res) {
|
getEpisodeDownloads(req, res) {
|
||||||
var libraryItem = req.libraryItem
|
var libraryItem = req.libraryItem
|
||||||
|
|
||||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
var downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
||||||
res.json({
|
res.json({
|
||||||
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
|
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
|
||||||
})
|
})
|
||||||
@@ -290,8 +287,6 @@ class PodcastController {
|
|||||||
/**
|
/**
|
||||||
* POST: /api/podcasts/:id/download-episodes
|
* POST: /api/podcasts/:id/download-episodes
|
||||||
*
|
*
|
||||||
* @this {import('../routers/ApiRouter')}
|
|
||||||
*
|
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
@@ -306,7 +301,7 @@ class PodcastController {
|
|||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
|
PodcastManager.downloadPodcastEpisodes(libraryItem, episodes)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ const Database = require('../Database')
|
|||||||
const LibraryScanner = require('../scanner/LibraryScanner')
|
const LibraryScanner = require('../scanner/LibraryScanner')
|
||||||
|
|
||||||
const ShareManager = require('./ShareManager')
|
const ShareManager = require('./ShareManager')
|
||||||
|
const PodcastManager = require('./PodcastManager')
|
||||||
|
|
||||||
class CronManager {
|
class CronManager {
|
||||||
constructor(podcastManager, playbackSessionManager) {
|
constructor(playbackSessionManager) {
|
||||||
/** @type {import('./PodcastManager')} */
|
|
||||||
this.podcastManager = podcastManager
|
|
||||||
/** @type {import('./PlaybackSessionManager')} */
|
/** @type {import('./PlaybackSessionManager')} */
|
||||||
this.playbackSessionManager = playbackSessionManager
|
this.playbackSessionManager = playbackSessionManager
|
||||||
|
|
||||||
@@ -163,7 +162,7 @@ class CronManager {
|
|||||||
task
|
task
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
Logger.error(`[CronManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +191,7 @@ class CronManager {
|
|||||||
|
|
||||||
// Run episode checks
|
// Run episode checks
|
||||||
for (const libraryItem of libraryItems) {
|
for (const libraryItem of libraryItems) {
|
||||||
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
|
const keepAutoDownloading = await PodcastManager.runEpisodeCheck(libraryItem)
|
||||||
if (!keepAutoDownloading) {
|
if (!keepAutoDownloading) {
|
||||||
// auto download was disabled
|
// auto download was disabled
|
||||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out
|
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
const Path = require('path')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const TaskManager = require('../managers/TaskManager')
|
||||||
|
const ShareManager = require('../managers/ShareManager')
|
||||||
|
const RssFeedManager = require('../managers/RssFeedManager')
|
||||||
|
const PodcastManager = require('../managers/PodcastManager')
|
||||||
|
const fsExtra = require('../libs/fsExtra')
|
||||||
|
const { isUUID, parseSemverStrict } = require('../utils')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef PluginContext
|
||||||
|
* @property {import('../Logger')} Logger
|
||||||
|
* @property {import('../Database')} Database
|
||||||
|
* @property {import('../SocketAuthority')} SocketAuthority
|
||||||
|
* @property {import('../managers/TaskManager')} TaskManager
|
||||||
|
* @property {import('../models/Plugin')} pluginInstance
|
||||||
|
* @property {import('../managers/ShareManager')} ShareManager
|
||||||
|
* @property {import('../managers/RssFeedManager')} RssFeedManager
|
||||||
|
* @property {import('../managers/PodcastManager')} PodcastManager
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef PluginData
|
||||||
|
* @property {string} id
|
||||||
|
* @property {Object} manifest
|
||||||
|
* @property {import('../models/Plugin')} instance
|
||||||
|
* @property {Function} init
|
||||||
|
* @property {Function} onAction
|
||||||
|
* @property {Function} onConfigSave
|
||||||
|
*/
|
||||||
|
|
||||||
|
class PluginManager {
|
||||||
|
constructor() {
|
||||||
|
/** @type {PluginData[]} */
|
||||||
|
this.plugins = []
|
||||||
|
}
|
||||||
|
|
||||||
|
get pluginMetadataPath() {
|
||||||
|
return Path.posix.join(global.MetadataPath, 'plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
get pluginManifests() {
|
||||||
|
return this.plugins.map((plugin) => plugin.manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/Plugin')} pluginInstance
|
||||||
|
* @returns {PluginContext}
|
||||||
|
*/
|
||||||
|
getPluginContext(pluginInstance) {
|
||||||
|
return {
|
||||||
|
Logger,
|
||||||
|
Database,
|
||||||
|
SocketAuthority,
|
||||||
|
TaskManager,
|
||||||
|
pluginInstance,
|
||||||
|
ShareManager,
|
||||||
|
RssFeedManager,
|
||||||
|
PodcastManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {PluginData}
|
||||||
|
*/
|
||||||
|
getPluginDataById(id) {
|
||||||
|
return this.plugins.find((plugin) => plugin.manifest.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and load a plugin from a directory
|
||||||
|
* TODO: Validatation
|
||||||
|
*
|
||||||
|
* @param {string} dirname
|
||||||
|
* @param {string} pluginPath
|
||||||
|
* @returns {Promise<PluginData>}
|
||||||
|
*/
|
||||||
|
async loadPlugin(dirname, pluginPath) {
|
||||||
|
const pluginFiles = await fsExtra.readdir(pluginPath, { withFileTypes: true }).then((files) => files.filter((file) => !file.isDirectory()))
|
||||||
|
|
||||||
|
if (!pluginFiles.length) {
|
||||||
|
Logger.error(`No files found in plugin ${pluginPath}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const manifestFile = pluginFiles.find((file) => file.name === 'manifest.json')
|
||||||
|
if (!manifestFile) {
|
||||||
|
Logger.error(`No manifest found for plugin ${pluginPath}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const indexFile = pluginFiles.find((file) => file.name === 'index.js')
|
||||||
|
if (!indexFile) {
|
||||||
|
Logger.error(`No index file found for plugin ${pluginPath}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestJson = null
|
||||||
|
try {
|
||||||
|
manifestJson = await fsExtra.readFile(Path.join(pluginPath, manifestFile.name), 'utf8').then((data) => JSON.parse(data))
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Error parsing manifest file for plugin ${pluginPath}`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Validate manifest json
|
||||||
|
if (!isUUID(manifestJson.id)) {
|
||||||
|
Logger.error(`Invalid plugin ID in manifest for plugin ${pluginPath}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!parseSemverStrict(manifestJson.version)) {
|
||||||
|
Logger.error(`Invalid plugin version in manifest for plugin ${pluginPath}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// TODO: Enforcing plugin name to be the same as the directory name? Ensures plugins are identifiable in the file system. May have issues with unicode characters.
|
||||||
|
if (dirname !== manifestJson.name) {
|
||||||
|
Logger.error(`Plugin directory name "${dirname}" does not match manifest name "${manifestJson.name}"`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let pluginContents = null
|
||||||
|
try {
|
||||||
|
pluginContents = require(Path.join(pluginPath, indexFile.name))
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Error loading plugin ${pluginPath}`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof pluginContents.init !== 'function') {
|
||||||
|
Logger.error(`Plugin ${pluginPath} does not have an init function`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: manifestJson.id,
|
||||||
|
manifest: manifestJson,
|
||||||
|
init: pluginContents.init,
|
||||||
|
onAction: pluginContents.onAction,
|
||||||
|
onConfigSave: pluginContents.onConfigSave
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all plugins from the /metadata/plugins directory
|
||||||
|
*/
|
||||||
|
async getPluginsFromDirPath(pluginsPath) {
|
||||||
|
// Get all directories in the plugins directory
|
||||||
|
const pluginDirs = await fsExtra.readdir(pluginsPath, { withFileTypes: true }).then((files) => files.filter((file) => file.isDirectory()))
|
||||||
|
|
||||||
|
const pluginsFound = []
|
||||||
|
for (const pluginDir of pluginDirs) {
|
||||||
|
Logger.debug(`[PluginManager] Checking if directory "${pluginDir.name}" is a plugin`)
|
||||||
|
const plugin = await this.loadPlugin(pluginDir.name, Path.join(pluginsPath, pluginDir.name))
|
||||||
|
if (plugin) {
|
||||||
|
Logger.debug(`[PluginManager] Found plugin "${plugin.manifest.name}"`)
|
||||||
|
pluginsFound.push(plugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pluginsFound
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load plugins from the /metadata/plugins directory and update the database
|
||||||
|
*/
|
||||||
|
async loadPlugins() {
|
||||||
|
await fsExtra.ensureDir(this.pluginMetadataPath)
|
||||||
|
|
||||||
|
const pluginsFound = await this.getPluginsFromDirPath(this.pluginMetadataPath)
|
||||||
|
|
||||||
|
if (process.env.DEV_PLUGINS_PATH) {
|
||||||
|
const devPluginsFound = await this.getPluginsFromDirPath(process.env.DEV_PLUGINS_PATH)
|
||||||
|
if (!devPluginsFound.length) {
|
||||||
|
Logger.warn(`[PluginManager] No plugins found in DEV_PLUGINS_PATH: ${process.env.DEV_PLUGINS_PATH}`)
|
||||||
|
} else {
|
||||||
|
pluginsFound.push(...devPluginsFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPlugins = await Database.pluginModel.findAll()
|
||||||
|
|
||||||
|
// Add new plugins or update existing plugins
|
||||||
|
for (const plugin of pluginsFound) {
|
||||||
|
const existingPlugin = existingPlugins.find((p) => p.id === plugin.manifest.id)
|
||||||
|
if (existingPlugin) {
|
||||||
|
// TODO: Should automatically update?
|
||||||
|
if (existingPlugin.version !== plugin.manifest.version) {
|
||||||
|
Logger.info(`[PluginManager] Updating plugin "${plugin.manifest.name}" version from "${existingPlugin.version}" to version "${plugin.manifest.version}"`)
|
||||||
|
await existingPlugin.update({ version: plugin.manifest.version, isMissing: false })
|
||||||
|
} else if (existingPlugin.isMissing) {
|
||||||
|
Logger.info(`[PluginManager] Plugin "${plugin.manifest.name}" was missing but is now found`)
|
||||||
|
await existingPlugin.update({ isMissing: false })
|
||||||
|
} else {
|
||||||
|
Logger.debug(`[PluginManager] Plugin "${plugin.manifest.name}" already exists in the database with version "${plugin.manifest.version}"`)
|
||||||
|
}
|
||||||
|
plugin.instance = existingPlugin
|
||||||
|
} else {
|
||||||
|
plugin.instance = await Database.pluginModel.create({
|
||||||
|
id: plugin.manifest.id,
|
||||||
|
name: plugin.manifest.name,
|
||||||
|
version: plugin.manifest.version
|
||||||
|
})
|
||||||
|
Logger.info(`[PluginManager] Added plugin "${plugin.manifest.name}" to the database`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark missing plugins
|
||||||
|
for (const plugin of existingPlugins) {
|
||||||
|
const foundPlugin = pluginsFound.find((p) => p.manifest.id === plugin.id)
|
||||||
|
if (!foundPlugin && !plugin.isMissing) {
|
||||||
|
Logger.info(`[PluginManager] Plugin "${plugin.name}" not found or invalid - marking as missing`)
|
||||||
|
await plugin.update({ isMissing: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.plugins = pluginsFound
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and initialize all plugins
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
await this.loadPlugins()
|
||||||
|
|
||||||
|
for (const plugin of this.plugins) {
|
||||||
|
Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`)
|
||||||
|
plugin.init(this.getPluginContext(plugin.instance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {PluginData} plugin
|
||||||
|
* @param {string} actionName
|
||||||
|
* @param {string} target
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Promise<boolean|{error:string}>}
|
||||||
|
*/
|
||||||
|
onAction(plugin, actionName, target, data) {
|
||||||
|
if (!plugin.onAction) {
|
||||||
|
Logger.error(`[PluginManager] onAction not implemented for plugin ${plugin.manifest.name}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginExtension = plugin.manifest.extensions.find((extension) => extension.name === actionName)
|
||||||
|
if (!pluginExtension) {
|
||||||
|
Logger.error(`[PluginManager] Extension ${actionName} not found for plugin ${plugin.manifest.name}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`)
|
||||||
|
return plugin.onAction(this.getPluginContext(plugin.instance), actionName, target, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {PluginData} plugin
|
||||||
|
* @param {Object} config
|
||||||
|
* @returns {Promise<boolean|{error:string}>}
|
||||||
|
*/
|
||||||
|
onConfigSave(plugin, config) {
|
||||||
|
if (!plugin.onConfigSave) {
|
||||||
|
Logger.error(`[PluginManager] onConfigSave not implemented for plugin ${plugin.manifest.name}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`)
|
||||||
|
return plugin.onConfigSave(this.getPluginContext(plugin.instance), config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = new PluginManager()
|
||||||
@@ -586,4 +586,4 @@ class PodcastManager {
|
|||||||
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
|
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = PodcastManager
|
module.exports = new PodcastManager()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @typedef MigrationContext
|
||||||
|
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||||
|
* @property {import('../Logger')} logger - a Logger object.
|
||||||
|
*
|
||||||
|
* @typedef MigrationOptions
|
||||||
|
* @property {MigrationContext} context - an object containing the migration context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const migrationVersion = '2.18.0'
|
||||||
|
const migrationName = `${migrationVersion}-add-plugins-table`
|
||||||
|
const loggerPrefix = `[${migrationVersion} migration]`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This upward migration creates the plugins table if it does not exist.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
// Upwards migration script
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
if (!(await queryInterface.tableExists('plugins'))) {
|
||||||
|
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||||
|
await queryInterface.createTable('plugins', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
version: DataTypes.STRING,
|
||||||
|
isMissing: DataTypes.BOOLEAN,
|
||||||
|
config: DataTypes.JSON,
|
||||||
|
extraData: DataTypes.JSON,
|
||||||
|
createdAt: DataTypes.DATE,
|
||||||
|
updatedAt: DataTypes.DATE
|
||||||
|
})
|
||||||
|
logger.info(`${loggerPrefix} Table 'plugins' created`)
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} Table 'plugins' already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This downward migration script drops the plugins table if it exists.
|
||||||
|
*
|
||||||
|
* @param {MigrationOptions} options - an object containing the migration context.
|
||||||
|
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||||
|
*/
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
// Downward migration script
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||||
|
|
||||||
|
if (await queryInterface.tableExists('plugins')) {
|
||||||
|
await queryInterface.dropTable('plugins')
|
||||||
|
logger.info(`${loggerPrefix} Table 'plugins' dropped`)
|
||||||
|
} else {
|
||||||
|
logger.info(`${loggerPrefix} Table 'plugins' does not exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
|
||||||
|
class Plugin extends Model {
|
||||||
|
constructor(values, options) {
|
||||||
|
super(values, options)
|
||||||
|
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.id
|
||||||
|
/** @type {string} */
|
||||||
|
this.name
|
||||||
|
/** @type {string} */
|
||||||
|
this.version
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.isMissing
|
||||||
|
/** @type {Object} */
|
||||||
|
this.config
|
||||||
|
/** @type {Object} */
|
||||||
|
this.extraData
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init(
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
version: DataTypes.STRING,
|
||||||
|
isMissing: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
config: DataTypes.JSON,
|
||||||
|
extraData: DataTypes.JSON
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'plugin'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Plugin
|
||||||
@@ -33,6 +33,7 @@ const RSSFeedController = require('../controllers/RSSFeedController')
|
|||||||
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
|
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
|
||||||
const MiscController = require('../controllers/MiscController')
|
const MiscController = require('../controllers/MiscController')
|
||||||
const ShareController = require('../controllers/ShareController')
|
const ShareController = require('../controllers/ShareController')
|
||||||
|
const PluginController = require('../controllers/PluginController')
|
||||||
|
|
||||||
const { getTitleIgnorePrefix } = require('../utils/index')
|
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||||
|
|
||||||
@@ -46,8 +47,6 @@ class ApiRouter {
|
|||||||
this.abMergeManager = Server.abMergeManager
|
this.abMergeManager = Server.abMergeManager
|
||||||
/** @type {import('../managers/BackupManager')} */
|
/** @type {import('../managers/BackupManager')} */
|
||||||
this.backupManager = Server.backupManager
|
this.backupManager = Server.backupManager
|
||||||
/** @type {import('../managers/PodcastManager')} */
|
|
||||||
this.podcastManager = Server.podcastManager
|
|
||||||
/** @type {import('../managers/AudioMetadataManager')} */
|
/** @type {import('../managers/AudioMetadataManager')} */
|
||||||
this.audioMetadataManager = Server.audioMetadataManager
|
this.audioMetadataManager = Server.audioMetadataManager
|
||||||
/** @type {import('../managers/CronManager')} */
|
/** @type {import('../managers/CronManager')} */
|
||||||
@@ -320,6 +319,13 @@ class ApiRouter {
|
|||||||
this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this))
|
this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this))
|
||||||
this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this))
|
this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this))
|
||||||
|
|
||||||
|
//
|
||||||
|
// Plugin routes
|
||||||
|
//
|
||||||
|
this.router.get('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.getConfig.bind(this))
|
||||||
|
this.router.post('/plugins/:id/action', PluginController.middleware.bind(this), PluginController.handleAction.bind(this))
|
||||||
|
this.router.post('/plugins/:id/config', PluginController.middleware.bind(this), PluginController.handleConfigSave.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -243,3 +243,21 @@ module.exports.isValidASIN = (str) => {
|
|||||||
if (!str || typeof str !== 'string') return false
|
if (!str || typeof str !== 'string') return false
|
||||||
return /^[A-Z0-9]{10}$/.test(str)
|
return /^[A-Z0-9]{10}$/.test(str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse semver string that must be in format "major.minor.patch" all numbers
|
||||||
|
*
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {{major: number, minor: number, patch: number} | null}
|
||||||
|
*/
|
||||||
|
module.exports.parseSemverStrict = (version) => {
|
||||||
|
if (typeof version !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const [major, minor, patch] = version.split('.').map(Number)
|
||||||
|
|
||||||
|
if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { major, minor, patch }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
describe('PluginManager', () => {
|
||||||
|
it('should register a plugin', () => {
|
||||||
|
// Test implementation
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Called on initialization of the plugin
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
*/
|
||||||
|
module.exports.init = async (context) => {
|
||||||
|
// Set default config on first init
|
||||||
|
if (!context.pluginInstance.config) {
|
||||||
|
context.Logger.info('[ExamplePlugin] First init. Setting default config')
|
||||||
|
context.pluginInstance.config = {
|
||||||
|
requestAddress: '',
|
||||||
|
enable: false
|
||||||
|
}
|
||||||
|
await context.pluginInstance.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Database.mediaProgressModel.addHook('afterSave', (instance, options) => {
|
||||||
|
context.Logger.debug(`[ExamplePlugin] mediaProgressModel afterSave hook for mediaProgress ${instance.id}`)
|
||||||
|
handleMediaProgressUpdate(context, instance)
|
||||||
|
})
|
||||||
|
|
||||||
|
context.Logger.info('[ExamplePlugin] Example plugin initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an extension action is triggered
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {string} actionName
|
||||||
|
* @param {string} target
|
||||||
|
* @param {*} data
|
||||||
|
* @returns {Promise<boolean|{error: string}>}
|
||||||
|
*/
|
||||||
|
module.exports.onAction = async (context, actionName, target, data) => {
|
||||||
|
context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data)
|
||||||
|
|
||||||
|
createTask(context)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the plugin config page is saved
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {*} config
|
||||||
|
* @returns {Promise<boolean|{error: string}>}
|
||||||
|
*/
|
||||||
|
module.exports.onConfigSave = async (context, config) => {
|
||||||
|
context.Logger.info('[ExamplePlugin] Example plugin onConfigSave', config)
|
||||||
|
|
||||||
|
if (!config.requestAddress || typeof config.requestAddress !== 'string') {
|
||||||
|
context.Logger.error('[ExamplePlugin] Invalid request address')
|
||||||
|
return {
|
||||||
|
error: 'Invalid request address'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof config.enable !== 'boolean') {
|
||||||
|
context.Logger.error('[ExamplePlugin] Invalid enable value')
|
||||||
|
return {
|
||||||
|
error: 'Invalid enable value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config would need to be validated
|
||||||
|
const updatedConfig = {
|
||||||
|
requestAddress: config.requestAddress,
|
||||||
|
enable: config.enable
|
||||||
|
}
|
||||||
|
context.pluginInstance.config = updatedConfig
|
||||||
|
await context.pluginInstance.save()
|
||||||
|
context.Logger.info('[ExamplePlugin] Example plugin config saved', updatedConfig)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Helper functions
|
||||||
|
//
|
||||||
|
let numProgressSyncs = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send media progress update to external requestAddress defined in config
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {import('../../../server/models/MediaProgress')} mediaProgress
|
||||||
|
*/
|
||||||
|
async function handleMediaProgressUpdate(context, mediaProgress) {
|
||||||
|
// Need to reload the model instance since it was passed in during init and may have values changed
|
||||||
|
await context.pluginInstance.reload()
|
||||||
|
|
||||||
|
if (!context.pluginInstance.config?.enable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const requestAddress = context.pluginInstance.config.requestAddress
|
||||||
|
if (!requestAddress) {
|
||||||
|
context.Logger.error('[ExamplePlugin] Request address not set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaItem = await mediaProgress.getMediaItem()
|
||||||
|
if (!mediaItem) {
|
||||||
|
context.Logger.error(`[ExamplePlugin] Media item not found for mediaProgress ${mediaProgress.id}`)
|
||||||
|
} else {
|
||||||
|
const mediaProgressDuration = mediaProgress.duration
|
||||||
|
const progressPercent = mediaProgressDuration > 0 ? (mediaProgress.currentTime / mediaProgressDuration) * 100 : 0
|
||||||
|
context.Logger.info(`[ExamplePlugin] Media progress update for "${mediaItem.title}" ${Math.round(progressPercent)}% (total numProgressSyncs: ${numProgressSyncs})`)
|
||||||
|
|
||||||
|
fetch(requestAddress, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: mediaItem.title,
|
||||||
|
progress: progressPercent
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
context.Logger.info(`[ExamplePlugin] Media progress update sent for "${mediaItem.title}" ${Math.round(progressPercent)}%`)
|
||||||
|
numProgressSyncs++
|
||||||
|
sendAdminMessageToast(context, `Synced "${mediaItem.title}" (total syncs: ${numProgressSyncs})`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
context.Logger.error(`[ExamplePlugin] Error sending media progress update: ${error.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test socket authority
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
async function sendAdminMessageToast(context, message) {
|
||||||
|
context.SocketAuthority.adminEmitter('admin_message', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test task manager
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
*/
|
||||||
|
async function createTask(context) {
|
||||||
|
const task = context.TaskManager.createAndAddTask('example_action', { text: 'Example Task' }, { text: 'This is an example task' }, true)
|
||||||
|
const pluginConfigEnabled = !!context.pluginInstance.config.enable
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
task.setFinished({ text: `Plugin is ${pluginConfigEnabled ? 'enabled' : 'disabled'}` })
|
||||||
|
context.TaskManager.taskFinished(task)
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"id": "e6205690-916c-4add-9a2b-2548266996ef",
|
||||||
|
"name": "Example",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"owner": "advplyr",
|
||||||
|
"repositoryUrl": "https://github.com/example/example-plugin",
|
||||||
|
"documentationUrl": "https://example.com",
|
||||||
|
"description": "This is an example plugin",
|
||||||
|
"descriptionKey": "ExamplePluginDescription",
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"target": "item.detail.actions",
|
||||||
|
"name": "itemActionExample",
|
||||||
|
"label": "Item Example Action",
|
||||||
|
"labelKey": "ItemExampleAction"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"description": "This is a description on how to configure the plugin",
|
||||||
|
"descriptionKey": "ExamplePluginConfigurationDescription",
|
||||||
|
"formFields": [
|
||||||
|
{
|
||||||
|
"name": "requestAddress",
|
||||||
|
"label": "Request Address",
|
||||||
|
"labelKey": "LabelRequestAddress",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "enable",
|
||||||
|
"label": "Enable",
|
||||||
|
"labelKey": "LabelEnable",
|
||||||
|
"type": "checkbox"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"localization": {
|
||||||
|
"de": {
|
||||||
|
"ExamplePluginDescription": "Dies ist ein Beispiel-Plugin",
|
||||||
|
"ItemExampleAction": "Item Example Action",
|
||||||
|
"LabelEnable": "Enable",
|
||||||
|
"ExamplePluginConfigurationDescription": "This is a description on how to configure the plugin",
|
||||||
|
"LabelRequestAddress": "Request Address"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"changelog": "Initial release",
|
||||||
|
"timestamp": "2022-01-01T00:00:00Z",
|
||||||
|
"downloadUrl": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Called on initialization of the plugin
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
*/
|
||||||
|
module.exports.init = async (context) => {
|
||||||
|
context.Logger.info('[TemplatePlugin] plugin initialized')
|
||||||
|
// Can be used to initialize plugin config and/or setup Database hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when an extension action is triggered
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {string} actionName
|
||||||
|
* @param {string} target
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Promise<boolean|{error: string}>}
|
||||||
|
*/
|
||||||
|
module.exports.onAction = async (context, actionName, target, data) => {
|
||||||
|
context.Logger.info('[TemplatePlugin] plugin onAction', actionName, target, data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the plugin config page is saved
|
||||||
|
*
|
||||||
|
* @param {import('../../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {Object} config
|
||||||
|
* @returns {Promise<boolean|{error: string}>}
|
||||||
|
*/
|
||||||
|
module.exports.onConfigSave = async (context, config) => {
|
||||||
|
context.Logger.info('[TemplatePlugin] plugin onConfigSave', config)
|
||||||
|
// Maintener is responsible for validating and saving the config to their `pluginInstance`
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "e6205690-916c-4add-9a2b-2548266996eg",
|
||||||
|
"name": "Template",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"owner": "advplyr",
|
||||||
|
"repositoryUrl": "https://github.com/advplr/abs-report-for-review-plugin",
|
||||||
|
"documentationUrl": "https://audiobookshelf.org/guides",
|
||||||
|
"description": "This is a minimal template for an abs plugin",
|
||||||
|
"extensions": [],
|
||||||
|
"config": {},
|
||||||
|
"localization": {},
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"changelog": "Initial release",
|
||||||
|
"timestamp": "2022-01-01T00:00:00Z",
|
||||||
|
"downloadUrl": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user