mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-03 00:06:46 +01:00
Update example plugin and add plugins frontend page with save config endpoint
This commit is contained in:
parent
62bd7e73f4
commit
ad89fb2eac
@ -109,6 +109,11 @@ export default {
|
|||||||
id: 'config-authentication',
|
id: 'config-authentication',
|
||||||
title: this.$strings.HeaderAuthentication,
|
title: this.$strings.HeaderAuthentication,
|
||||||
path: '/config/authentication'
|
path: '/config/authentication'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-plugins',
|
||||||
|
title: 'Plugins',
|
||||||
|
path: '/config/plugins'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
123
client/pages/config/plugins/_slug.vue
Normal file
123
client/pages/config/plugins/_slug.vue
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="`Plugin: ${plugin.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 :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 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 {
|
||||||
|
asyncData({ store, redirect, params }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
const plugin = store.state.plugins.find((plugin) => plugin.slug === params.slug)
|
||||||
|
if (!plugin) {
|
||||||
|
redirect('/config/plugins')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
plugin
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
pluginConfig() {
|
||||||
|
return this.plugin.config
|
||||||
|
},
|
||||||
|
pluginLocalization() {
|
||||||
|
return this.plugin.localization || {}
|
||||||
|
},
|
||||||
|
localizedStrings() {
|
||||||
|
const localeKey = this.$languageCodes.current
|
||||||
|
if (!localeKey) return {}
|
||||||
|
return this.pluginLocalization[localeKey] || {}
|
||||||
|
},
|
||||||
|
configDescription() {
|
||||||
|
if (this.pluginConfig.descriptionKey && this.localizedStrings[this.pluginConfig.descriptionKey]) {
|
||||||
|
return this.localizedStrings[this.pluginConfig.descriptionKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pluginConfig.description
|
||||||
|
},
|
||||||
|
configFormFields() {
|
||||||
|
return this.pluginConfig.formFields || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 = {
|
||||||
|
pluginSlug: this.plugin.slug,
|
||||||
|
config: formData
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/plugins/config`, payload)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Plugin configuration saved')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error saving plugin configuration', error)
|
||||||
|
this.$toast.error('Error saving plugin configuration')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
console.log('Plugin', this.plugin)
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
44
client/pages/config/plugins/index.vue
Normal file
44
client/pages/config/plugins/index.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-medium">Installed Plugins</h2>
|
||||||
|
<template v-for="plugin in plugins">
|
||||||
|
<nuxt-link :key="plugin.slug" :to="`/config/plugins/${plugin.slug}`" class="flex items-center bg-primary rounded-md shadow-sm p-4 my-4 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 text-4xl">chevron_right</span>
|
||||||
|
</nuxt-link>
|
||||||
|
</template>
|
||||||
|
</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>
|
@ -166,11 +166,11 @@ export default {
|
|||||||
|
|
||||||
location.reload()
|
location.reload()
|
||||||
},
|
},
|
||||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, pluginExtensions }) {
|
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)
|
||||||
this.$store.commit('setPluginExtensions', pluginExtensions)
|
this.$store.commit('setPlugins', plugins)
|
||||||
this.$setServerLanguageCode(serverSettings.language)
|
this.$setServerLanguageCode(serverSettings.language)
|
||||||
|
|
||||||
if (serverSettings.chromecastEnabled) {
|
if (serverSettings.chromecastEnabled) {
|
||||||
|
@ -29,7 +29,7 @@ export const state = () => ({
|
|||||||
innerModalOpen: false,
|
innerModalOpen: false,
|
||||||
lastBookshelfScrollData: {},
|
lastBookshelfScrollData: {},
|
||||||
routerBasePath: '/',
|
routerBasePath: '/',
|
||||||
pluginExtensions: []
|
plugins: []
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@ -64,9 +64,9 @@ export const getters = {
|
|||||||
return state.serverSettings.homeBookshelfView
|
return state.serverSettings.homeBookshelfView
|
||||||
},
|
},
|
||||||
getPluginExtensions: (state) => (target) => {
|
getPluginExtensions: (state) => (target) => {
|
||||||
return state.pluginExtensions
|
return state.plugins
|
||||||
.map((pext) => {
|
.map((pext) => {
|
||||||
const extensionsMatchingTarget = pext.extensions.filter((ext) => ext.target === target)
|
const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || []
|
||||||
if (!extensionsMatchingTarget.length) return null
|
if (!extensionsMatchingTarget.length) return null
|
||||||
return {
|
return {
|
||||||
name: pext.name,
|
name: pext.name,
|
||||||
@ -254,7 +254,7 @@ export const mutations = {
|
|||||||
setInnerModalOpen(state, val) {
|
setInnerModalOpen(state, val) {
|
||||||
state.innerModalOpen = val
|
state.innerModalOpen = val
|
||||||
},
|
},
|
||||||
setPluginExtensions(state, val) {
|
setPlugins(state, val) {
|
||||||
state.pluginExtensions = val
|
state.plugins = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -939,7 +939,7 @@ class Auth {
|
|||||||
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),
|
||||||
pluginExtensions: PluginManager.pluginExtensions,
|
plugins: PluginManager.pluginData,
|
||||||
Source: global.Source
|
Source: global.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,5 +20,19 @@ class PluginController {
|
|||||||
PluginManager.onAction(pluginSlug, actionName, target, data)
|
PluginManager.onAction(pluginSlug, actionName, target, data)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/plugins/config
|
||||||
|
*
|
||||||
|
* @param {*} req
|
||||||
|
* @param {*} res
|
||||||
|
*/
|
||||||
|
handleConfigSave(req, res) {
|
||||||
|
const pluginSlug = req.body.pluginSlug
|
||||||
|
const config = req.body.config
|
||||||
|
Logger.info(`[PluginController] Saving config for plugin ${pluginSlug}`, config)
|
||||||
|
PluginManager.onConfigSave(pluginSlug, config)
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new PluginController()
|
module.exports = new PluginController()
|
||||||
|
@ -1,28 +1,31 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const Database = require('../Database')
|
||||||
const PluginAbstract = require('../PluginAbstract')
|
const PluginAbstract = require('../PluginAbstract')
|
||||||
const fs = require('fs').promises
|
const fs = require('fs').promises
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef PluginContext
|
||||||
|
* @property {import('../../server/Logger')} Logger
|
||||||
|
* @property {import('../../server/Database')} Database
|
||||||
|
*/
|
||||||
|
|
||||||
class PluginManager {
|
class PluginManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.plugins = []
|
this.plugins = []
|
||||||
}
|
}
|
||||||
|
|
||||||
get pluginExtensions() {
|
get pluginData() {
|
||||||
return this.plugins
|
return this.plugins.map((plugin) => plugin.manifest)
|
||||||
.filter((plugin) => plugin.manifest.extensions?.length)
|
|
||||||
.map((plugin) => {
|
|
||||||
return {
|
|
||||||
name: plugin.manifest.name,
|
|
||||||
slug: plugin.manifest.slug,
|
|
||||||
extensions: plugin.manifest.extensions
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {PluginContext}
|
||||||
|
*/
|
||||||
get pluginContext() {
|
get pluginContext() {
|
||||||
return {
|
return {
|
||||||
Logger
|
Logger,
|
||||||
|
Database
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,23 +62,27 @@ class PluginManager {
|
|||||||
|
|
||||||
// TODO: Validate manifest json
|
// TODO: Validate manifest json
|
||||||
|
|
||||||
let pluginContents = null
|
let pluginInstance = null
|
||||||
try {
|
try {
|
||||||
pluginContents = require(Path.join(pluginPath, indexFile.name))
|
pluginInstance = require(Path.join(pluginPath, indexFile.name))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`Error loading plugin ${pluginPath}`, error)
|
Logger.error(`Error loading plugin ${pluginPath}`, error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof pluginInstance.init !== 'function') {
|
||||||
|
Logger.error(`Plugin ${pluginPath} does not have an init function`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
manifest: manifestJson,
|
manifest: manifestJson,
|
||||||
contents: pluginContents
|
instance: pluginInstance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPlugins() {
|
async loadPlugins() {
|
||||||
const pluginDirs = await fs.readdir(global.PluginsPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory()))
|
const pluginDirs = await fs.readdir(global.PluginsPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory()))
|
||||||
console.log('pluginDirs', pluginDirs)
|
|
||||||
|
|
||||||
for (const pluginDir of pluginDirs) {
|
for (const pluginDir of pluginDirs) {
|
||||||
Logger.info(`[PluginManager] Loading plugin ${pluginDir.name}`)
|
Logger.info(`[PluginManager] Loading plugin ${pluginDir.name}`)
|
||||||
@ -91,9 +98,9 @@ class PluginManager {
|
|||||||
await this.loadPlugins()
|
await this.loadPlugins()
|
||||||
|
|
||||||
for (const plugin of this.plugins) {
|
for (const plugin of this.plugins) {
|
||||||
if (plugin.contents.init) {
|
if (plugin.instance.init) {
|
||||||
Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`)
|
Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`)
|
||||||
plugin.contents.init(this.pluginContext)
|
plugin.instance.init(this.pluginContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -111,9 +118,22 @@ class PluginManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugin.contents.onAction) {
|
if (plugin.instance.onAction) {
|
||||||
Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`)
|
Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`)
|
||||||
plugin.contents.onAction(this.pluginContext, actionName, target, data)
|
plugin.instance.onAction(this.pluginContext, actionName, target, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfigSave(pluginSlug, config) {
|
||||||
|
const plugin = this.plugins.find((plugin) => plugin.manifest.slug === pluginSlug)
|
||||||
|
if (!plugin) {
|
||||||
|
Logger.error(`[PluginManager] Plugin ${pluginSlug} not found`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.instance.onConfigSave) {
|
||||||
|
Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`)
|
||||||
|
plugin.instance.onConfigSave(this.pluginContext, config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -325,6 +325,7 @@ class ApiRouter {
|
|||||||
// Plugin routes
|
// Plugin routes
|
||||||
//
|
//
|
||||||
this.router.post('/plugins/action', PluginController.handleAction.bind(this))
|
this.router.post('/plugins/action', PluginController.handleAction.bind(this))
|
||||||
|
this.router.post('/plugins/config', PluginController.handleConfigSave.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
|
@ -1,18 +1,54 @@
|
|||||||
const PluginAbstract = require('../../../../../server/PluginAbstract')
|
class ExamplePlugin {
|
||||||
|
|
||||||
class ExamplePlugin extends PluginAbstract {
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
|
||||||
|
|
||||||
this.name = 'Example'
|
this.name = 'Example'
|
||||||
}
|
}
|
||||||
|
|
||||||
init(context) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../../server/managers/PluginManager').PluginContext} context
|
||||||
|
*/
|
||||||
|
async init(context) {
|
||||||
context.Logger.info('[ExamplePlugin] Example plugin loaded successfully')
|
context.Logger.info('[ExamplePlugin] Example plugin loaded successfully')
|
||||||
|
|
||||||
|
context.Database.mediaProgressModel.addHook('afterSave', (instance, options) => {
|
||||||
|
context.Logger.debug(`[ExamplePlugin] mediaProgressModel afterSave hook for mediaProgress ${instance.id}`)
|
||||||
|
this.handleMediaProgressUpdate(context, instance)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {import('../../server/models/MediaProgress')} mediaProgress
|
||||||
|
*/
|
||||||
|
async handleMediaProgressUpdate(context, mediaProgress) {
|
||||||
|
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)}%`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {string} actionName
|
||||||
|
* @param {string} target
|
||||||
|
* @param {*} data
|
||||||
|
*/
|
||||||
async onAction(context, actionName, target, data) {
|
async onAction(context, actionName, target, data) {
|
||||||
context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data)
|
context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../../server/managers/PluginManager').PluginContext} context
|
||||||
|
* @param {*} config
|
||||||
|
*/
|
||||||
|
async onConfigSave(context, config) {
|
||||||
|
context.Logger.info('[ExamplePlugin] Example plugin onConfigSave', config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new ExamplePlugin()
|
module.exports = new ExamplePlugin()
|
||||||
|
@ -12,8 +12,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"title": "Example Plugin Configuration",
|
|
||||||
"titleKey": "ExamplePluginConfiguration",
|
|
||||||
"description": "This is an example plugin",
|
"description": "This is an example plugin",
|
||||||
"descriptionKey": "ExamplePluginConfigurationDescription",
|
"descriptionKey": "ExamplePluginConfigurationDescription",
|
||||||
"formFields": [
|
"formFields": [
|
||||||
@ -44,7 +42,6 @@
|
|||||||
"ItemExampleAction": "Item Example Action",
|
"ItemExampleAction": "Item Example Action",
|
||||||
"LabelApiKey": "API Key",
|
"LabelApiKey": "API Key",
|
||||||
"LabelEnable": "Enable",
|
"LabelEnable": "Enable",
|
||||||
"ExamplePluginConfiguration": "Example Plugin Configuration",
|
|
||||||
"ExamplePluginConfigurationDescription": "This is an example plugin",
|
"ExamplePluginConfigurationDescription": "This is an example plugin",
|
||||||
"LabelRequestAddress": "Request Address"
|
"LabelRequestAddress": "Request Address"
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user