mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update PluginManager to singleton, update PluginContext, support prompt object in plugin extension
This commit is contained in:
		
							parent
							
								
									a762e6ca03
								
							
						
					
					
						commit
						50e84fc2d5
					
				| @ -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" /> | ||||
| 
 | ||||
|         <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"> | ||||
|           <ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn> | ||||
|           <div class="flex-grow" /> | ||||
| @ -25,7 +33,8 @@ export default { | ||||
|     return { | ||||
|       el: null, | ||||
|       content: null, | ||||
|       checkboxValue: false | ||||
|       checkboxValue: false, | ||||
|       formData: {} | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
| @ -61,6 +70,9 @@ export default { | ||||
|     persistent() { | ||||
|       return !!this.confirmPromptOptions.persistent | ||||
|     }, | ||||
|     formFields() { | ||||
|       return this.confirmPromptOptions.formFields || [] | ||||
|     }, | ||||
|     checkboxLabel() { | ||||
|       return this.confirmPromptOptions.checkboxLabel | ||||
|     }, | ||||
| @ -100,11 +112,31 @@ export default { | ||||
|       this.show = false | ||||
|     }, | ||||
|     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 | ||||
|     }, | ||||
|     setShow() { | ||||
|       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) | ||||
|       document.body.appendChild(this.el) | ||||
|       setTimeout(() => { | ||||
|  | ||||
| @ -12,10 +12,14 @@ | ||||
|             <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 class="mb-4">{{ configDescription }}</p> | ||||
|         <p v-if="configDescription" class="mb-4">{{ configDescription }}</p> | ||||
| 
 | ||||
|         <form v-if="configFormFields.length" @submit.prevent="handleFormSubmit"> | ||||
|           <template v-for="field in configFormFields"> | ||||
| @ -46,7 +50,7 @@ export default { | ||||
|       console.error('Failed to get plugin config', error) | ||||
|       return null | ||||
|     }) | ||||
|     if (!pluginConfigData?.config) { | ||||
|     if (!pluginConfigData) { | ||||
|       redirect('/config/plugins') | ||||
|     } | ||||
|     const pluginManifest = store.state.plugins.find((plugin) => plugin.id === params.id) | ||||
| @ -84,6 +88,9 @@ export default { | ||||
|     }, | ||||
|     configFormFields() { | ||||
|       return this.pluginManifestConfig.formFields || [] | ||||
|     }, | ||||
|     repositoryUrl() { | ||||
|       return this.pluginManifest.repositoryUrl | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
| @ -123,6 +130,7 @@ export default { | ||||
|         }) | ||||
|     }, | ||||
|     initializeForm() { | ||||
|       if (!this.pluginConfig) return | ||||
|       this.configFormFields.forEach((field) => { | ||||
|         if (this.pluginConfig[field.name] === undefined) { | ||||
|           return | ||||
|  | ||||
| @ -12,11 +12,13 @@ | ||||
|         <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="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 :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> | ||||
|  | ||||
| @ -786,13 +786,35 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     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 || [], | ||||
|           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' | ||||
|             entityType: 'libraryItem', | ||||
|             userId: this.$store.state.user.user.id, | ||||
|             promptData | ||||
|           } | ||||
|         }) | ||||
|         .then((data) => { | ||||
|  | ||||
| @ -940,7 +940,17 @@ class Auth { | ||||
|       userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), | ||||
|       serverSettings: Database.serverSettings.toJSONForBrowser(), | ||||
|       ereaderDevices: Database.emailSettings.getEReaderDevices(user), | ||||
|       plugins: this.pluginManifests, | ||||
|       // 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 } | ||||
|       }), | ||||
|       Source: global.Source | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @ -28,7 +28,6 @@ const AbMergeManager = require('./managers/AbMergeManager') | ||||
| const CacheManager = require('./managers/CacheManager') | ||||
| const BackupManager = require('./managers/BackupManager') | ||||
| const PlaybackSessionManager = require('./managers/PlaybackSessionManager') | ||||
| const PodcastManager = require('./managers/PodcastManager') | ||||
| const AudioMetadataMangaer = require('./managers/AudioMetadataManager') | ||||
| const RssFeedManager = require('./managers/RssFeedManager') | ||||
| const CronManager = require('./managers/CronManager') | ||||
| @ -70,9 +69,8 @@ class Server { | ||||
|     this.backupManager = new BackupManager() | ||||
|     this.abMergeManager = new AbMergeManager() | ||||
|     this.playbackSessionManager = new PlaybackSessionManager() | ||||
|     this.podcastManager = new PodcastManager() | ||||
|     this.audioMetadataManager = new AudioMetadataMangaer() | ||||
|     this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) | ||||
|     this.cronManager = new CronManager(this.playbackSessionManager) | ||||
|     this.apiCacheManager = new ApiCacheManager() | ||||
|     this.binaryManager = new BinaryManager() | ||||
| 
 | ||||
|  | ||||
| @ -19,6 +19,7 @@ const Scanner = require('../scanner/Scanner') | ||||
| const Database = require('../Database') | ||||
| const Watcher = require('../Watcher') | ||||
| const RssFeedManager = require('../managers/RssFeedManager') | ||||
| const PodcastManager = require('../managers/PodcastManager') | ||||
| 
 | ||||
| const libraryFilters = require('../utils/queries/libraryFilters') | ||||
| const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') | ||||
| @ -219,7 +220,7 @@ class LibraryController { | ||||
|    * @param {Response} res | ||||
|    */ | ||||
|   async getEpisodeDownloadQueue(req, res) { | ||||
|     const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id) | ||||
|     const libraryDownloadQueueDetails = PodcastManager.getDownloadQueueDetails(req.library.id) | ||||
|     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.send(opmlText) | ||||
|   } | ||||
|  | ||||
| @ -18,6 +18,7 @@ const RssFeedManager = require('../managers/RssFeedManager') | ||||
| const CacheManager = require('../managers/CacheManager') | ||||
| const CoverManager = require('../managers/CoverManager') | ||||
| const ShareManager = require('../managers/ShareManager') | ||||
| const PodcastManager = require('../managers/PodcastManager') | ||||
| 
 | ||||
| /** | ||||
|  * @typedef RequestUserObject | ||||
| @ -59,10 +60,10 @@ class LibraryItemController { | ||||
|       } | ||||
| 
 | ||||
|       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()) | ||||
|         if (this.podcastManager.currentDownload?.libraryItemId === req.libraryItem.id) { | ||||
|           item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()] | ||||
|         if (PodcastManager.currentDownload?.libraryItemId === req.libraryItem.id) { | ||||
|           item.episodesDownloading = [PodcastManager.currentDownload.toJSONForClient()] | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|  | ||||
| @ -11,6 +11,7 @@ const { validateUrl } = require('../utils/index') | ||||
| 
 | ||||
| const Scanner = require('../scanner/Scanner') | ||||
| const CoverManager = require('../managers/CoverManager') | ||||
| const PodcastManager = require('../managers/PodcastManager') | ||||
| 
 | ||||
| const LibraryItem = require('../objects/LibraryItem') | ||||
| 
 | ||||
| @ -114,7 +115,7 @@ class PodcastController { | ||||
| 
 | ||||
|     if (payload.episodesToDownload?.length) { | ||||
|       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
 | ||||
| @ -169,7 +170,7 @@ class PodcastController { | ||||
|     } | ||||
| 
 | ||||
|     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') | ||||
|     } | ||||
|     const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes | ||||
|     this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager) | ||||
|     PodcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager) | ||||
| 
 | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| @ -230,7 +231,7 @@ class PodcastController { | ||||
| 
 | ||||
|     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({ | ||||
|       episodes: newEpisodes || [] | ||||
|     }) | ||||
| @ -239,8 +240,6 @@ class PodcastController { | ||||
|   /** | ||||
|    * GET: /api/podcasts/:id/clear-queue | ||||
|    * | ||||
|    * @this {import('../routers/ApiRouter')} | ||||
|    * | ||||
|    * @param {RequestWithUser} req | ||||
|    * @param {Response} res | ||||
|    */ | ||||
| @ -249,22 +248,20 @@ class PodcastController { | ||||
|       Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|     this.podcastManager.clearDownloadQueue(req.params.id) | ||||
|     PodcastManager.clearDownloadQueue(req.params.id) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * GET: /api/podcasts/:id/downloads | ||||
|    * | ||||
|    * @this {import('../routers/ApiRouter')} | ||||
|    * | ||||
|    * @param {RequestWithUser} req | ||||
|    * @param {Response} res | ||||
|    */ | ||||
|   getEpisodeDownloads(req, res) { | ||||
|     var libraryItem = req.libraryItem | ||||
| 
 | ||||
|     var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id) | ||||
|     var downloadsInQueue = PodcastManager.getEpisodeDownloadsInQueue(libraryItem.id) | ||||
|     res.json({ | ||||
|       downloads: downloadsInQueue.map((d) => d.toJSONForClient()) | ||||
|     }) | ||||
| @ -290,8 +287,6 @@ class PodcastController { | ||||
|   /** | ||||
|    * POST: /api/podcasts/:id/download-episodes | ||||
|    * | ||||
|    * @this {import('../routers/ApiRouter')} | ||||
|    * | ||||
|    * @param {RequestWithUser} req | ||||
|    * @param {Response} res | ||||
|    */ | ||||
| @ -306,7 +301,7 @@ class PodcastController { | ||||
|       return res.sendStatus(400) | ||||
|     } | ||||
| 
 | ||||
|     this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes) | ||||
|     PodcastManager.downloadPodcastEpisodes(libraryItem, episodes) | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -5,11 +5,10 @@ const Database = require('../Database') | ||||
| const LibraryScanner = require('../scanner/LibraryScanner') | ||||
| 
 | ||||
| const ShareManager = require('./ShareManager') | ||||
| const PodcastManager = require('./PodcastManager') | ||||
| 
 | ||||
| class CronManager { | ||||
|   constructor(podcastManager, playbackSessionManager) { | ||||
|     /** @type {import('./PodcastManager')} */ | ||||
|     this.podcastManager = podcastManager | ||||
|   constructor(playbackSessionManager) { | ||||
|     /** @type {import('./PlaybackSessionManager')} */ | ||||
|     this.playbackSessionManager = playbackSessionManager | ||||
| 
 | ||||
| @ -163,7 +162,7 @@ class CronManager { | ||||
|         task | ||||
|       }) | ||||
|     } 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
 | ||||
|     for (const libraryItem of libraryItems) { | ||||
|       const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem) | ||||
|       const keepAutoDownloading = await PodcastManager.runEpisodeCheck(libraryItem) | ||||
|       if (!keepAutoDownloading) { | ||||
|         // auto download was disabled
 | ||||
|         podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItem.id) // Filter it out
 | ||||
|  | ||||
| @ -3,6 +3,9 @@ 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') | ||||
| 
 | ||||
| @ -13,6 +16,9 @@ const { isUUID, parseSemverStrict } = require('../utils') | ||||
|  * @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 | ||||
|  */ | ||||
| 
 | ||||
| /** | ||||
| @ -50,7 +56,10 @@ class PluginManager { | ||||
|       Database, | ||||
|       SocketAuthority, | ||||
|       TaskManager, | ||||
|       pluginInstance | ||||
|       pluginInstance, | ||||
|       ShareManager, | ||||
|       RssFeedManager, | ||||
|       PodcastManager | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -586,4 +586,4 @@ class PodcastManager { | ||||
|     Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`) | ||||
|   } | ||||
| } | ||||
| module.exports = PodcastManager | ||||
| module.exports = new PodcastManager() | ||||
|  | ||||
| @ -47,8 +47,6 @@ class ApiRouter { | ||||
|     this.abMergeManager = Server.abMergeManager | ||||
|     /** @type {import('../managers/BackupManager')} */ | ||||
|     this.backupManager = Server.backupManager | ||||
|     /** @type {import('../managers/PodcastManager')} */ | ||||
|     this.podcastManager = Server.podcastManager | ||||
|     /** @type {import('../managers/AudioMetadataManager')} */ | ||||
|     this.audioMetadataManager = Server.audioMetadataManager | ||||
|     /** @type {import('../managers/CronManager')} */ | ||||
|  | ||||
| @ -6,6 +6,7 @@ | ||||
|   "repositoryUrl": "https://github.com/example/example-plugin", | ||||
|   "documentationUrl": "https://example.com", | ||||
|   "description": "This is an example plugin", | ||||
|   "descriptionKey": "ExamplePluginDescription", | ||||
|   "extensions": [ | ||||
|     { | ||||
|       "target": "item.detail.actions", | ||||
| @ -22,8 +23,7 @@ | ||||
|         "name": "requestAddress", | ||||
|         "label": "Request Address", | ||||
|         "labelKey": "LabelRequestAddress", | ||||
|         "type": "text", | ||||
|         "required": true | ||||
|         "type": "text" | ||||
|       }, | ||||
|       { | ||||
|         "name": "enable", | ||||
| @ -34,7 +34,8 @@ | ||||
|     ] | ||||
|   }, | ||||
|   "localization": { | ||||
|     "en-us": { | ||||
|     "de": { | ||||
|       "ExamplePluginDescription": "Dies ist ein Beispiel-Plugin", | ||||
|       "ItemExampleAction": "Item Example Action", | ||||
|       "LabelEnable": "Enable", | ||||
|       "ExamplePluginConfigurationDescription": "This is a description on how to configure the plugin", | ||||
| @ -46,7 +47,7 @@ | ||||
|       "version": "1.0.0", | ||||
|       "changelog": "Initial release", | ||||
|       "timestamp": "2022-01-01T00:00:00Z", | ||||
|       "sourceUrl": "" | ||||
|       "downloadUrl": "" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user