feat(plugins): install/update/uninstall plugins in jobs

* To avoid long running operations in HTTP requests.
* To not collidate with other install/update/uninstall operations.
This commit is contained in:
kontrollanten 2024-06-26 16:24:37 +02:00
parent 014cdb5981
commit c6a11145fe
63 changed files with 755 additions and 152 deletions

View File

@ -11,18 +11,21 @@
<my-edit-button
*ngIf="!isTheme(plugin)" [ptRouterLink]="getShowRouterLink(plugin)" label="Settings" i18n-label
[responsiveLabel]="true"
[disabled]="willUpdate(plugin) || willUninstall(plugin)"
></my-edit-button>
<my-button
class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)"
[attr.disabled]="isUpdating(plugin) || isUninstalling(plugin)"
class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)"
[label]="getUpdateLabel(plugin)" icon="refresh" [responsiveLabel]="true"
></my-button>
<my-delete-button
[loading]="willUpdate(plugin)"
[disabled]="willUpdate(plugin) || willUninstall(plugin)"
></my-button>
<my-delete-button
(click)="uninstall(plugin)"
label="Uninstall" i18n-label [responsiveLabel]="true"
[disabled]="isUpdating(plugin) || isUninstalling(plugin)"
[loading]="willUninstall(plugin)"
[disabled]="willUpdate(plugin) || willUninstall(plugin)"
></my-delete-button>
</div>
</my-plugin-card>

View File

@ -8,4 +8,8 @@
my-edit-button,
my-button {
@include margin-right(10px);
&[disabled=true] {
opacity: 0.6;
}
}

View File

@ -1,11 +1,11 @@
import { Subject } from 'rxjs'
import { Component, OnInit } from '@angular/core'
import { Subject, Subscription, filter } from 'rxjs'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier } from '@app/core'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PeerTubeSocket } from '@app/core'
import { PluginService } from '@app/core/plugins/plugin.service'
import { compareSemVer } from '@peertube/peertube-core-utils'
import { PeerTubePlugin, PluginType, PluginType_Type } from '@peertube/peertube-models'
import { PeerTubePlugin, PluginManagePayload, PluginType, PluginType_Type, UserNotificationType } from '@peertube/peertube-models'
import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { EditButtonComponent } from '../../../shared/shared-main/buttons/edit-button.component'
@ -13,6 +13,8 @@ import { PluginCardComponent } from '../shared/plugin-card.component'
import { InfiniteScrollerDirective } from '../../../shared/shared-main/angular/infinite-scroller.directive'
import { NgIf, NgFor } from '@angular/common'
import { PluginNavigationComponent } from '../shared/plugin-navigation.component'
import { JobService } from '@app/+admin/system'
import { logger } from '@root-helpers/logger'
@Component({
selector: 'my-plugin-list-installed',
@ -30,7 +32,7 @@ import { PluginNavigationComponent } from '../shared/plugin-navigation.component
DeleteButtonComponent
]
})
export class PluginListInstalledComponent implements OnInit {
export class PluginListInstalledComponent implements OnInit, OnDestroy {
pluginType: PluginType_Type
pagination: ComponentPagination = {
@ -41,18 +43,22 @@ export class PluginListInstalledComponent implements OnInit {
sort = 'name'
plugins: PeerTubePlugin[] = []
updating: { [name: string]: boolean } = {}
uninstalling: { [name: string]: boolean } = {}
toBeUpdated: { [name: string]: boolean } = {}
toBeUninstalled: { [name: string]: boolean } = {}
onDataSubject = new Subject<any[]>()
private notificationSub: Subscription
constructor (
private pluginService: PluginService,
private pluginApiService: PluginApiService,
private notifier: Notifier,
private confirmService: ConfirmService,
private router: Router,
private route: ActivatedRoute
private route: ActivatedRoute,
private jobService: JobService,
private peertubeSocket: PeerTubeSocket
) {
}
@ -63,6 +69,43 @@ export class PluginListInstalledComponent implements OnInit {
this.router.navigate([], { queryParams, replaceUrl: true })
}
this.jobService.listUnfinishedJobs({
jobType: 'plugin-manage',
pagination: {
count: 10,
start: 0
},
sort: {
field: 'createdAt',
order: -1
}
}).subscribe({
next: resultList => {
const jobs = resultList.data
jobs.forEach((job) => {
let payload: PluginManagePayload
try {
payload = JSON.parse(job.data)
} catch (err) {}
if (payload.action === 'update') {
this.toBeUpdated[payload.npmName] = true
}
if (payload.action === 'uninstall') {
this.toBeUninstalled[payload.npmName] = true
}
})
},
error: err => {
logger.error('Could not fetch status of installed plugins.', { err })
this.notifier.error($localize`Could not fetch status of installed plugins.`)
}
})
this.route.queryParams.subscribe(query => {
if (!query['pluginType']) return
@ -70,6 +113,12 @@ export class PluginListInstalledComponent implements OnInit {
this.reloadPlugins()
})
this.subscribeToNotifications()
}
ngOnDestroy () {
if (this.notificationSub) this.notificationSub.unsubscribe()
}
reloadPlugins () {
@ -117,12 +166,12 @@ export class PluginListInstalledComponent implements OnInit {
return $localize`Update to ${plugin.latestVersion}`
}
isUpdating (plugin: PeerTubePlugin) {
return !!this.updating[this.getPluginKey(plugin)]
willUpdate (plugin: PeerTubePlugin) {
return !!this.toBeUpdated[this.getPluginKey(plugin)]
}
isUninstalling (plugin: PeerTubePlugin) {
return !!this.uninstalling[this.getPluginKey(plugin)]
willUninstall (plugin: PeerTubePlugin) {
return !!this.toBeUninstalled[this.getPluginKey(plugin)]
}
isTheme (plugin: PeerTubePlugin) {
@ -131,7 +180,7 @@ export class PluginListInstalledComponent implements OnInit {
async uninstall (plugin: PeerTubePlugin) {
const pluginKey = this.getPluginKey(plugin)
if (this.uninstalling[pluginKey]) return
if (this.toBeUninstalled[pluginKey]) return
const res = await this.confirmService.confirm(
$localize`Do you really want to uninstall ${plugin.name}?`,
@ -139,29 +188,24 @@ export class PluginListInstalledComponent implements OnInit {
)
if (res === false) return
this.uninstalling[pluginKey] = true
this.toBeUninstalled[pluginKey] = true
this.pluginApiService.uninstall(plugin.name, plugin.type)
.subscribe({
next: () => {
this.notifier.success($localize`${plugin.name} uninstalled.`)
this.plugins = this.plugins.filter(p => p.name !== plugin.name)
this.pagination.totalItems--
this.uninstalling[pluginKey] = false
this.notifier.success($localize`${plugin.name} will be uninstalled.`)
},
error: err => {
this.notifier.error(err.message)
this.uninstalling[pluginKey] = false
this.toBeUninstalled[pluginKey] = false
}
})
}
async update (plugin: PeerTubePlugin) {
const pluginKey = this.getPluginKey(plugin)
if (this.updating[pluginKey]) return
if (this.toBeUpdated[pluginKey]) return
if (this.isMajorUpgrade(plugin)) {
const res = await this.confirmService.confirm(
@ -173,22 +217,18 @@ export class PluginListInstalledComponent implements OnInit {
if (res === false) return
}
this.updating[pluginKey] = true
this.toBeUpdated[pluginKey] = true
this.pluginApiService.update(plugin.name, plugin.type)
.pipe()
.subscribe({
next: res => {
this.updating[pluginKey] = false
this.notifier.success($localize`${plugin.name} updated.`)
Object.assign(plugin, res)
this.notifier.success($localize`${plugin.name} will be updated.`)
},
error: err => {
this.notifier.error(err.message)
this.updating[pluginKey] = false
this.toBeUpdated[pluginKey] = false
}
})
}
@ -201,8 +241,36 @@ export class PluginListInstalledComponent implements OnInit {
return this.pluginApiService.getPluginOrThemeHref(this.pluginType, name)
}
private getPluginKey (plugin: PeerTubePlugin) {
return plugin.name + plugin.type
private async subscribeToNotifications () {
const obs = await this.peertubeSocket.getMyNotificationsSocket()
this.notificationSub = obs
.pipe(
filter(d => d.notification?.type === UserNotificationType.PLUGIN_MANAGE_FINISHED)
).subscribe(data => {
const pluginName = data.notification.plugin?.name
if (pluginName) {
const npmName = this.getPluginKey(data.notification.plugin)
if (this.toBeUninstalled[npmName]) {
this.toBeUninstalled[npmName] = false
if (!data.notification.hasOperationFailed) {
this.plugins = this.plugins.filter(p => p.name !== pluginName)
}
}
if (this.toBeUpdated[npmName]) {
this.toBeUpdated[npmName] = false
this.reloadPlugins()
}
}
})
}
private getPluginKey (plugin: Pick<PeerTubePlugin, 'name' | 'type'>) {
return this.pluginService.nameToNpmName(plugin.name, plugin.type)
}
private isMajorUpgrade (plugin: PeerTubePlugin) {

View File

@ -1,9 +1,5 @@
<my-plugin-navigation [pluginType]="pluginType"></my-plugin-navigation>
<div class="alert pt-alert-primary" i18n *ngIf="pluginInstalled">
To load your new installed plugins or themes, refresh the page.
</div>
<div class="result-and-search">
<ng-container *ngIf="!search">
<my-global-icon iconName="trending" aria-hidden="true"></my-global-icon>
@ -51,8 +47,9 @@
<my-button
*ngIf="plugin.installed === false" (click)="install(plugin)"
[loading]="isInstalling(plugin)" label="Install" [responsiveLabel]="true"
icon="cloud-download" [attr.disabled]="isInstalling(plugin)"
label="Install" [responsiveLabel]="true"
[loading]="willInstall(plugin)"
icon="cloud-download" [attr.disabled]="willInstall(plugin)"
></my-button>
</div>

View File

@ -29,3 +29,7 @@
.alert {
margin-top: 15px;
}
my-button[disabled=true] {
opacity: 0.6;
}

View File

@ -1,10 +1,10 @@
import { Subject } from 'rxjs'
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
import { Component, OnInit } from '@angular/core'
import { Subject, Subscription } from 'rxjs'
import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PluginService } from '@app/core'
import { PeerTubePluginIndex, PluginType, PluginType_Type } from '@peertube/peertube-models'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PeerTubeSocket, PluginService } from '@app/core'
import { PeerTubePluginIndex, PluginManagePayload, PluginType, PluginType_Type, UserNotificationType } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { EditButtonComponent } from '../../../shared/shared-main/buttons/edit-button.component'
@ -14,6 +14,7 @@ import { AutofocusDirective } from '../../../shared/shared-main/angular/autofocu
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { NgIf, NgFor } from '@angular/common'
import { PluginNavigationComponent } from '../shared/plugin-navigation.component'
import { JobService } from '@app/+admin/system'
@Component({
selector: 'my-plugin-search',
@ -32,7 +33,7 @@ import { PluginNavigationComponent } from '../shared/plugin-navigation.component
ButtonComponent
]
})
export class PluginSearchComponent implements OnInit {
export class PluginSearchComponent implements OnInit, OnDestroy {
pluginType: PluginType_Type
pagination: ComponentPagination = {
@ -46,12 +47,13 @@ export class PluginSearchComponent implements OnInit {
isSearching = false
plugins: PeerTubePluginIndex[] = []
installing: { [name: string]: boolean } = {}
toBeInstalled: { [name: string]: boolean } = {}
pluginInstalled = false
onDataSubject = new Subject<any[]>()
private searchSubject = new Subject<string>()
private notificationSub: Subscription
constructor (
private pluginService: PluginService,
@ -59,7 +61,9 @@ export class PluginSearchComponent implements OnInit {
private notifier: Notifier,
private confirmService: ConfirmService,
private router: Router,
private route: ActivatedRoute
private route: ActivatedRoute,
private jobService: JobService,
private peertubeSocket: PeerTubeSocket
) {
}
@ -70,6 +74,39 @@ export class PluginSearchComponent implements OnInit {
this.router.navigate([], { queryParams })
}
this.jobService.listUnfinishedJobs({
jobType: 'plugin-manage',
pagination: {
count: 10,
start: 0
},
sort: {
field: 'createdAt',
order: -1
}
}).subscribe({
next: resultList => {
const jobs = resultList.data
jobs.forEach((job) => {
let payload: PluginManagePayload
try {
payload = JSON.parse(job.data)
} catch (err) {}
if (payload.action === 'install') {
this.toBeInstalled[payload.npmName] = true
}
})
},
error: err => {
logger.error('Could not fetch status of installed plugins.', { err })
this.notifier.error($localize`Could not fetch status of installed plugins.`)
}
})
this.route.queryParams.subscribe(query => {
if (!query['pluginType']) return
@ -85,6 +122,12 @@ export class PluginSearchComponent implements OnInit {
distinctUntilChanged()
)
.subscribe(search => this.router.navigate([], { queryParams: { search }, queryParamsHandling: 'merge' }))
this.subscribeToNotifications()
}
ngOnDestroy () {
if (this.notificationSub) this.notificationSub.unsubscribe()
}
onSearchChange (event: Event) {
@ -131,8 +174,8 @@ export class PluginSearchComponent implements OnInit {
this.loadMorePlugins()
}
isInstalling (plugin: PeerTubePluginIndex) {
return !!this.installing[plugin.npmName]
willInstall (plugin: PeerTubePluginIndex) {
return !!this.toBeInstalled[plugin.npmName]
}
getShowRouterLink (plugin: PeerTubePluginIndex) {
@ -144,7 +187,7 @@ export class PluginSearchComponent implements OnInit {
}
async install (plugin: PeerTubePluginIndex) {
if (this.installing[plugin.npmName]) return
if (this.toBeInstalled[plugin.npmName]) return
const res = await this.confirmService.confirm(
$localize`Please only install plugins or themes you trust, since they can execute any code on your instance.`,
@ -152,24 +195,46 @@ export class PluginSearchComponent implements OnInit {
)
if (res === false) return
this.installing[plugin.npmName] = true
this.toBeInstalled[plugin.npmName] = true
this.pluginApiService.install(plugin.npmName)
.subscribe({
next: () => {
this.installing[plugin.npmName] = false
this.pluginInstalled = true
this.notifier.success($localize`${plugin.name} installed.`)
plugin.installed = true
this.notifier.success($localize`${plugin.name} will be installed.`)
},
error: err => {
this.installing[plugin.npmName] = false
this.toBeInstalled[plugin.npmName] = false
this.notifier.error(err.message)
}
})
}
private async subscribeToNotifications () {
const obs = await this.peertubeSocket.getMyNotificationsSocket()
this.notificationSub = obs
.pipe(
filter(d => d.notification?.type === UserNotificationType.PLUGIN_MANAGE_FINISHED)
).subscribe(data => {
const pluginName = data.notification.plugin?.name
if (pluginName) {
const npmName = this.pluginService.nameToNpmName(data.notification.plugin.name, data.notification.plugin.type)
if (this.toBeInstalled[npmName]) {
this.toBeInstalled[npmName] = false
if (!data.notification.hasOperationFailed) {
const plugin = this.plugins.find(p => p.name === pluginName)
if (plugin) {
plugin.installed = true
}
}
}
}
})
}
}

View File

@ -20,19 +20,20 @@ export class JobService {
) {}
listJobs (options: {
jobState?: JobStateClient
jobStates?: JobStateClient[]
jobType: JobTypeClient
pagination: RestPagination
sort: SortMeta
}): Observable<ResultList<Job>> {
const { jobState, jobType, pagination, sort } = options
const { jobStates, jobType, pagination, sort } = options
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
if (jobType !== 'all') params = params.append('jobType', jobType)
if (jobStates) params = params.append('states', jobStates.join(','))
return this.authHttp.get<ResultList<Job>>(JobService.BASE_JOB_URL + `/${jobState || ''}`, { params })
return this.authHttp.get<ResultList<Job>>(JobService.BASE_JOB_URL, { params })
.pipe(
map(res => this.restExtractor.convertResultListDateToHuman(res, [ 'createdAt', 'processedOn', 'finishedOn' ], 'precise')),
map(res => this.restExtractor.applyToResultListData(res, this.prettyPrintData.bind(this))),
@ -41,6 +42,17 @@ export class JobService {
)
}
listUnfinishedJobs (options: {
jobType: JobTypeClient
pagination: RestPagination
sort: SortMeta
}): Observable<ResultList<Job>> {
return this.listJobs({
...options,
jobStates: [ 'active', 'waiting', 'delayed', 'paused' ]
})
}
private prettyPrintData (obj: Job) {
const data = JSON.stringify(obj.data, null, 2)

View File

@ -61,6 +61,7 @@ export class JobsComponent extends RestTable implements OnInit {
'move-to-file-system',
'move-to-object-storage',
'notify',
'plugin-manage',
'transcoding-job-builder',
'video-channel-import',
'video-file-import',
@ -154,7 +155,7 @@ export class JobsComponent extends RestTable implements OnInit {
this.jobsService
.listJobs({
jobState,
jobStates: [ jobState ],
jobType: this.jobType,
pagination: this.pagination,
sort: this.sort

View File

@ -49,7 +49,8 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
abuseStateChange: $localize`One of your abuse reports has been accepted or rejected by moderators`,
newPeerTubeVersion: $localize`A new PeerTube version is available`,
newPluginVersion: $localize`One of your plugin/theme has a new available version`,
myVideoStudioEditionFinished: $localize`Video studio edition has finished`
myVideoStudioEditionFinished: $localize`Video studio edition has finished`,
pluginManageFinished: $localize`Plugin or theme has been installed, updated or uninstalled`
}
this.notificationSettingGroups = [
{
@ -89,7 +90,8 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
'newInstanceFollower',
'autoInstanceFollowing',
'newPeerTubeVersion',
'newPluginVersion'
'newPluginVersion',
'pluginManageFinished'
]
}
]

View File

@ -1,4 +1,4 @@
import { Subject } from 'rxjs'
import { Observable, Subject } from 'rxjs'
import { ManagerOptions, Socket, SocketOptions } from 'socket.io-client'
import { Injectable } from '@angular/core'
import { LiveVideoEventPayload, LiveVideoEventType, UserNotification as UserNotificationServer } from '@peertube/peertube-models'
@ -12,6 +12,7 @@ export class PeerTubeSocket {
private io: (uri: string, opts?: Partial<ManagerOptions & SocketOptions>) => Socket
private notificationSubject = new Subject<{ type: NotificationEvent, notification?: UserNotificationServer }>()
private notificationObs: Observable<{ type: NotificationEvent, notification?: UserNotificationServer }>
private liveVideosSubject = new Subject<{ type: LiveVideoEventType, payload: LiveVideoEventPayload }>()
private notificationSocket: Socket
@ -24,7 +25,11 @@ export class PeerTubeSocket {
async getMyNotificationsSocket () {
await this.initNotificationSocket()
return this.notificationSubject.asObservable()
if (!this.notificationObs) {
this.notificationObs = this.notificationSubject.asObservable()
}
return this.notificationObs
}
getLiveVideosObservable () {

View File

@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core'
import { Component, Input, OnInit, booleanAttribute } from '@angular/core'
import { ButtonComponent } from './button.component'
@Component({
@ -7,6 +7,7 @@ import { ButtonComponent } from './button.component'
<my-button
icon="delete" className="grey-button"
[disabled]="disabled" [label]="label" [title]="title"
[loading]="loading"
[responsiveLabel]="responsiveLabel"
></my-button>
`,
@ -18,6 +19,7 @@ export class DeleteButtonComponent implements OnInit {
@Input() title: string
@Input() responsiveLabel = false
@Input() disabled: boolean
@Input({ transform: booleanAttribute }) loading = false
ngOnInit () {
if (this.label === undefined && !this.title) {

View File

@ -1,11 +1,11 @@
import { Component, Input, OnInit } from '@angular/core'
import { Component, Input, OnInit, booleanAttribute } from '@angular/core'
import { ButtonComponent } from './button.component'
@Component({
selector: 'my-edit-button',
template: `
<my-button
icon="edit" [label]="label" [title]="title" [responsiveLabel]="responsiveLabel"
icon="edit" [disabled]="disabled" [label]="label" [title]="title" [responsiveLabel]="responsiveLabel"
[ptRouterLink]="ptRouterLink"
></my-button>
`,
@ -13,6 +13,7 @@ import { ButtonComponent } from './button.component'
imports: [ ButtonComponent ]
})
export class EditButtonComponent implements OnInit {
@Input({ transform: booleanAttribute }) disabled = false
@Input() label: string
@Input() title: string
@Input() ptRouterLink: string[] | string = []

View File

@ -20,6 +20,7 @@ export class UserNotification implements UserNotificationServer {
id: number
type: UserNotificationType_Type
read: boolean
hasOperationFailed: boolean
video?: VideoInfo & {
channel: ActorInfo & { avatarUrl?: string }
@ -119,10 +120,13 @@ export class UserNotification implements UserNotificationServer {
pluginUrl?: string
pluginQueryParams?: { [id: string]: string } = {}
jobUrl?: string
constructor (hash: UserNotificationServer, user: AuthUser) {
this.id = hash.id
this.type = hash.type
this.read = hash.read
this.hasOperationFailed = hash.hasOperationFailed
// We assume that some fields exist
// To prevent a notification popup crash in case of bug, wrap it inside a try/catch
@ -250,6 +254,15 @@ export class UserNotification implements UserNotificationServer {
this.pluginQueryParams.pluginType = this.plugin.type + ''
break
case UserNotificationType.PLUGIN_MANAGE_FINISHED:
this.pluginUrl = '/admin/plugins/list-installed'
this.jobUrl = '/admin/system/jobs'
if (this.plugin) {
this.pluginQueryParams.pluginType = this.plugin.type + ''
}
break
case UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED:
this.videoUrl = this.buildVideoUrl(this.video)
break

View File

@ -239,6 +239,24 @@
}
</ng-container>
<ng-container *ngSwitchCase="22"> <!-- UserNotificationType.MANGE_PLUGIN_FINISHED -->
<my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
<div *ngIf="!notification.hasOperationFailed" class="message" i18n>
<a (click)="markAsRead(notification)" [routerLink]="notification.pluginUrl" [queryParams]="notification.pluginQueryParams">
<span *ngIf="notification.plugin">The plugin/theme {{ notification.plugin.name }}</span>
<span *ngIf="!notification.plugin">A plugin/theme</span>
</a> has been installed, updated or uninstalled.
</div>
<div *ngIf="notification.hasOperationFailed" class="message" i18n>
<a (click)="markAsRead(notification)" [routerLink]="notification.pluginUrl" [queryParams]="notification.pluginQueryParams">
<span *ngIf="notification.plugin">The plugin/theme {{ notification.plugin.name }}</span>
<span *ngIf="!notification.plugin">A plugin/theme</span>
</a> has failed to be installed, updated or uninstalled.
</div>
</ng-container>
<ng-container *ngSwitchDefault>
<my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>

View File

@ -22,6 +22,7 @@ export type JobType =
| 'move-to-object-storage'
| 'move-to-file-system'
| 'notify'
| 'plugin-manage'
| 'video-channel-import'
| 'video-file-import'
| 'video-import'
@ -87,6 +88,18 @@ export type RefreshPayload = {
export type EmailPayload = SendEmailOptions
export type PluginManagePayload = {
action: 'install' | 'update'
npmName: string
path?: string
version?: string
userId: number
} | {
action: 'uninstall'
npmName: string
userId: number
}
export type VideoFileImportPayload = {
videoUUID: string
filePath: string

View File

@ -29,6 +29,7 @@ export interface UserNotificationSetting {
newPeerTubeVersion: UserNotificationSettingValueType
newPluginVersion: UserNotificationSettingValueType
pluginManageFinished: UserNotificationSettingValueType
myVideoStudioEditionFinished: UserNotificationSettingValueType
}

View File

@ -36,7 +36,9 @@ export const UserNotificationType = {
NEW_USER_REGISTRATION_REQUEST: 20,
NEW_LIVE_FROM_SUBSCRIPTION: 21
NEW_LIVE_FROM_SUBSCRIPTION: 21,
PLUGIN_MANAGE_FINISHED: 22
} as const
export type UserNotificationType_Type = typeof UserNotificationType[keyof typeof UserNotificationType]
@ -67,6 +69,7 @@ export interface UserNotification {
id: number
type: UserNotificationType_Type
read: boolean
hasOperationFailed: boolean
video?: VideoInfo & {
channel: ActorInfo

View File

@ -170,7 +170,7 @@ export class PluginsCommand extends AbstractCommand {
path: apiPath,
fields: { npmName, path, pluginVersion },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
defaultExpectedStatus: HttpStatusCode.CREATED_201
})
}
@ -187,7 +187,7 @@ export class PluginsCommand extends AbstractCommand {
path: apiPath,
fields: { npmName, path },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
defaultExpectedStatus: HttpStatusCode.CREATED_201
})
}
@ -203,7 +203,7 @@ export class PluginsCommand extends AbstractCommand {
path: apiPath,
fields: { npmName },
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
defaultExpectedStatus: HttpStatusCode.CREATED_201
})
}

View File

@ -9,7 +9,8 @@ import {
makePostBodyRequest,
makePutBodyRequest,
PeerTubeServer,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test server plugins API validators', function () {
@ -42,14 +43,16 @@ describe('Test server plugins API validators', function () {
userAccessToken = await server.login.getAccessToken(user)
{
const res = await server.plugins.install({ npmName: npmPlugin })
const plugin = res.body as PeerTubePlugin
await server.plugins.install({ npmName: npmPlugin })
await waitJobs(server)
const plugin = await server.plugins.get({ npmName: npmPlugin })
npmVersion = plugin.version
}
{
const res = await server.plugins.install({ npmName: themePlugin })
const plugin = res.body as PeerTubePlugin
await server.plugins.install({ npmName: themePlugin })
await waitJobs(server)
const plugin = await server.plugins.get({ npmName: themePlugin }) as PeerTubePlugin
themeVersion = plugin.version
}
})

View File

@ -171,7 +171,8 @@ describe('Test user notifications API validators', function () {
abuseStateChange: UserNotificationSettingValue.WEB,
newPeerTubeVersion: UserNotificationSettingValue.WEB,
myVideoStudioEditionFinished: UserNotificationSettingValue.WEB,
newPluginVersion: UserNotificationSettingValue.WEB
newPluginVersion: UserNotificationSettingValue.WEB,
pluginManageFinished: UserNotificationSettingValue.WEB
}
it('Should fail with missing fields', async function () {

View File

@ -3,7 +3,7 @@
import { expect } from 'chai'
import { wait } from '@peertube/peertube-core-utils'
import { PluginType, UserNotification, UserNotificationType } from '@peertube/peertube-models'
import { cleanupTests, PeerTubeServer } from '@peertube/peertube-server-commands'
import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands'
import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js'
import { MockJoinPeerTubeVersions } from '@tests/shared/mock-servers/mock-joinpeertube-versions.js'
import { CheckerBaseParams, prepareNotificationsTest, checkNewPeerTubeVersion, checkNewPluginVersion } from '@tests/shared/notifications.js'
@ -55,6 +55,7 @@ describe('Test admin notifications', function () {
await server.plugins.install({ npmName: 'peertube-plugin-hello-world' })
await server.plugins.install({ npmName: 'peertube-theme-background-red' })
await waitJobs(server)
sqlCommand = new SQLCommand(server)
})

View File

@ -12,7 +12,8 @@ import {
makeGetRequest,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { SQLCommand } from '@tests/shared/sql-command.js'
import { testHelloWorldRegisteredSettings } from '@tests/shared/plugins.js'
@ -96,6 +97,7 @@ describe('Test plugins', function () {
await command.install({ npmName: 'peertube-plugin-hello-world' })
await command.install({ npmName: 'peertube-theme-background-red' })
await waitJobs(server)
})
it('Should have the plugin loaded in the configuration', async function () {
@ -274,6 +276,7 @@ describe('Test plugins', function () {
{
await command.update({ npmName: `peertube-${type}-${name}` })
await waitJobs(server)
const plugin = await getPluginFromAPI()
expect(plugin.version).to.equal(oldVersion)
@ -291,6 +294,7 @@ describe('Test plugins', function () {
it('Should uninstall the plugin', async function () {
await command.uninstall({ npmName: 'peertube-plugin-hello-world' })
await waitJobs(server)
const body = await command.list({ pluginType: PluginType.PLUGIN })
expect(body.total).to.equal(0)
@ -310,6 +314,7 @@ describe('Test plugins', function () {
it('Should uninstall the theme', async function () {
await command.uninstall({ npmName: 'peertube-theme-background-red' })
await waitJobs(server)
})
it('Should have updated the configuration', async function () {
@ -342,6 +347,7 @@ describe('Test plugins', function () {
path: PluginsCommand.getPluginTestPath('-broken'),
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
await waitJobs(server)
await check()
@ -360,6 +366,7 @@ describe('Test plugins', function () {
}
await command.install({ path: PluginsCommand.getPluginTestPath('-native') })
await waitJobs(server)
await makeGetRequest({
url: server.url,

View File

@ -6,7 +6,8 @@ import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType
import {
cleanupTests,
createSingleServer, PeerTubeServer,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test users', function () {
@ -34,6 +35,7 @@ describe('Test users', function () {
await setAccessTokensToServers([ server ])
await server.plugins.install({ npmName: 'peertube-theme-background-red' })
await waitJobs(server)
})
describe('Creating a user', function () {

View File

@ -24,6 +24,7 @@ describe('Official plugin Akismet', function () {
await servers[0].plugins.install({
npmName: 'peertube-plugin-akismet'
})
await waitJobs(servers[0])
if (!process.env.AKISMET_KEY) throw new Error('Missing AKISMET_KEY from env')

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands'
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands'
import { HttpStatusCode } from '@peertube/peertube-models'
describe('Official plugin auth-ldap', function () {
@ -16,6 +16,7 @@ describe('Official plugin auth-ldap', function () {
await setAccessTokensToServers([ server ])
await server.plugins.install({ npmName: 'peertube-plugin-auth-ldap' })
await waitJobs(server)
})
it('Should not login with without LDAP settings', async function () {
@ -104,6 +105,7 @@ describe('Official plugin auth-ldap', function () {
it('Should not login if the plugin is uninstalled', async function () {
await server.plugins.uninstall({ npmName: 'peertube-plugin-auth-ldap' })
await waitJobs(server)
await server.login.login({
user: { username: 'fry@planetexpress.com', password: 'fry' },

View File

@ -9,7 +9,8 @@ import {
doubleFollow,
killallServers,
PeerTubeServer,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { MockBlocklist } from '../shared/mock-servers/index.js'
@ -38,6 +39,7 @@ describe('Official plugin auto-block videos', function () {
for (const server of servers) {
await server.plugins.install({ npmName: 'peertube-plugin-auto-block-videos' })
}
await waitJobs(servers)
blocklistServer = new MockBlocklist()
port = await blocklistServer.initialize()

View File

@ -10,7 +10,8 @@ import {
killallServers,
makeGetRequest,
PeerTubeServer,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { MockBlocklist } from '../shared/mock-servers/index.js'
@ -29,6 +30,7 @@ describe('Official plugin auto-mute', function () {
for (const server of servers) {
await server.plugins.install({ npmName: 'peertube-plugin-auto-mute' })
}
await waitJobs(servers)
blocklistServer = new MockBlocklist()
port = await blocklistServer.initialize()

View File

@ -24,6 +24,7 @@ describe('Official plugin Privacy Remover', function () {
await servers[0].plugins.install({
npmName: 'peertube-plugin-privacy-remover'
})
await waitJobs(servers[0])
await doubleFollow(servers[0], servers[1])
})

View File

@ -112,6 +112,7 @@ describe('Test syndication feeds', () => {
await waitJobs([ ...servers, serverHLSOnly ])
await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-podcast-custom-tags') })
await waitJobs(servers[0])
})
describe('All feed', function () {
@ -792,6 +793,7 @@ describe('Test syndication feeds', () => {
after(async function () {
await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' })
await waitJobs(servers[0])
await cleanupTests([ ...servers, serverHLSOnly ])
})

View File

@ -31,6 +31,7 @@ describe('Test plugin action hooks', function () {
await setDefaultVideoChannel(servers)
await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() })
await waitJobs(servers[0])
await killallServers([ servers[0] ])

View File

@ -9,7 +9,8 @@ import {
decodeQueryString,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
async function loginExternal (options: {
@ -71,6 +72,8 @@ describe('Test external auth plugins', function () {
for (const suffix of [ 'one', 'two', 'three' ]) {
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-external-auth-' + suffix) })
}
await waitJobs(server)
})
it('Should display the correct configuration', async function () {
@ -327,6 +330,7 @@ describe('Test external auth plugins', function () {
it('Should uninstall the plugin one and do not login Cyan', async function () {
await server.plugins.uninstall({ npmName: 'peertube-plugin-test-external-auth-one' })
await waitJobs(server)
await loginExternal({
server,

View File

@ -44,6 +44,9 @@ describe('Test plugin filter hooks', function () {
await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath() })
await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') })
await waitJobs(servers[0])
{
({ uuid: videoPlaylistUUID } = await servers[0].playlists.create({
attributes: {

View File

@ -7,7 +7,8 @@ import {
makeHTMLRequest,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test plugins HTML injection', function () {
@ -39,6 +40,7 @@ describe('Test plugins HTML injection', function () {
this.timeout(30000)
await command.install({ npmName: 'peertube-plugin-hello-world' })
await waitJobs(server)
})
it('Should have the correct global css', async function () {
@ -55,6 +57,7 @@ describe('Test plugins HTML injection', function () {
it('Should have an empty global css on uninstall', async function () {
await command.uninstall({ npmName: 'peertube-plugin-hello-world' })
await waitJobs(server)
{
const text = await command.getCSS()

View File

@ -8,7 +8,8 @@ import {
createSingleServer,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test id and pass auth plugins', function () {
@ -30,6 +31,7 @@ describe('Test id and pass auth plugins', function () {
for (const suffix of [ 'one', 'two', 'three' ]) {
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-id-pass-auth-' + suffix) })
}
await waitJobs(server)
})
it('Should display the correct configuration', async function () {
@ -213,6 +215,7 @@ describe('Test id and pass auth plugins', function () {
it('Should uninstall the plugin one and do not login existing Crash', async function () {
await server.plugins.uninstall({ npmName: 'peertube-plugin-test-id-pass-auth-one' })
await waitJobs(server)
await server.login.login({
user: { username: 'crash', password: 'crash password' },

View File

@ -41,6 +41,7 @@ describe('Test plugin helpers', function () {
await doubleFollow(servers[0], servers[1])
await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-four') })
await waitJobs(servers[0])
})
describe('Logger', function () {

View File

@ -8,7 +8,8 @@ import {
makePostBodyRequest,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { HttpStatusCode } from '@peertube/peertube-models'
@ -26,6 +27,7 @@ describe('Test plugin helpers', function () {
await setAccessTokensToServers([ server ])
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-five') })
await waitJobs(server)
})
it('Should answer "pong"', async function () {
@ -100,6 +102,7 @@ describe('Test plugin helpers', function () {
it('Should remove the plugin and remove the routes', async function () {
await server.plugins.uninstall({ npmName: 'peertube-plugin-test-five' })
await waitJobs(server)
for (const path of basePaths) {
await makeGetRequest({

View File

@ -6,7 +6,8 @@ import {
createSingleServer,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test plugin settings', function () {
@ -24,6 +25,7 @@ describe('Test plugin settings', function () {
await command.install({
path: PluginsCommand.getPluginTestPath()
})
await waitJobs(server)
})
it('Should not have duplicate settings', async function () {

View File

@ -11,7 +11,8 @@ import {
makeGetRequest,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test plugin storage', function () {
@ -24,6 +25,7 @@ describe('Test plugin storage', function () {
await setAccessTokensToServers([ server ])
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-six') })
await waitJobs(server)
})
describe('DB storage', function () {
@ -76,6 +78,7 @@ describe('Test plugin storage', function () {
it('Should still have the file after an uninstallation', async function () {
await server.plugins.uninstall({ npmName: 'peertube-plugin-test-six' })
await waitJobs(server)
const content = await getFileContent()
expect(content).to.equal('Prince Ali')
@ -83,6 +86,7 @@ describe('Test plugin storage', function () {
it('Should still have the file after the reinstallation', async function () {
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-six') })
await waitJobs(server)
const content = await getFileContent()
expect(content).to.equal('Prince Ali')

View File

@ -105,6 +105,7 @@ describe('Test transcoding plugins', function () {
before(async function () {
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-transcoding-one') })
await waitJobs(server)
})
it('Should have the appropriate available profiles', async function () {
@ -219,6 +220,7 @@ describe('Test transcoding plugins', function () {
this.timeout(240000)
await server.plugins.uninstall({ npmName: 'peertube-plugin-test-transcoding-one' })
await waitJobs(server)
const config = await server.config.getConfig()
@ -238,6 +240,7 @@ describe('Test transcoding plugins', function () {
before(async function () {
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-transcoding-two') })
await waitJobs(server)
await updateConf(server, 'test-vod-profile', 'test-live-profile')
})

View File

@ -7,7 +7,8 @@ import {
makeGetRequest,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { HttpStatusCode } from '@peertube/peertube-models'
@ -23,6 +24,7 @@ describe('Test plugins module unloading', function () {
await setAccessTokensToServers([ server ])
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-unloading') })
await waitJobs(server)
})
it('Should return a numeric value', async function () {
@ -48,6 +50,7 @@ describe('Test plugins module unloading', function () {
it('Should uninstall the plugin and free the route', async function () {
await server.plugins.uninstall({ npmName: 'peertube-plugin-test-unloading' })
await waitJobs(server)
await makeGetRequest({
url: server.url,
@ -58,6 +61,7 @@ describe('Test plugins module unloading', function () {
it('Should return a different numeric value', async function () {
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-unloading') })
await waitJobs(server)
const res = await makeGetRequest({
url: server.url,

View File

@ -6,7 +6,8 @@ import {
createSingleServer,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
function buildWebSocket (server: PeerTubeServer, path: string) {
@ -42,6 +43,7 @@ describe('Test plugin websocket', function () {
await setAccessTokensToServers([ server ])
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-websocket') })
await waitJobs(server)
})
it('Should not connect to the websocket without the appropriate path', async function () {

View File

@ -6,7 +6,8 @@ import {
createSingleServer,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
describe('Test plugin translations', function () {
@ -23,6 +24,7 @@ describe('Test plugin translations', function () {
await command.install({ path: PluginsCommand.getPluginTestPath() })
await command.install({ path: PluginsCommand.getPluginTestPath('-filter-translations') })
await waitJobs(server)
})
it('Should not have translations for locale pt', async function () {
@ -56,6 +58,7 @@ describe('Test plugin translations', function () {
it('Should remove the plugin and remove the locales', async function () {
await command.uninstall({ npmName: 'peertube-plugin-test-filter-translations' })
await waitJobs(server)
{
const body = await command.getTranslations({ locale: 'fr-FR' })

View File

@ -7,7 +7,8 @@ import {
makeGetRequest,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { HttpStatusCode, VideoPlaylistPrivacy, VideoPrivacy } from '@peertube/peertube-models'
@ -21,6 +22,7 @@ describe('Test plugin altering video constants', function () {
await setAccessTokensToServers([ server ])
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-video-constants') })
await waitJobs(server)
})
it('Should have updated languages', async function () {
@ -93,6 +95,7 @@ describe('Test plugin altering video constants', function () {
it('Should uninstall the plugin and reset languages, categories, licences and privacies', async function () {
await server.plugins.uninstall({ npmName: 'peertube-plugin-test-video-constants' })
await waitJobs(server)
{
const languages = await server.videos.getLanguages()
@ -145,6 +148,7 @@ describe('Test plugin altering video constants', function () {
it('Should be able to reset categories', async function () {
await server.plugins.install({ path: PluginsCommand.getPluginTestPath('-video-constants') })
await waitJobs(server)
{
const categories = await server.videos.getCategories()

View File

@ -54,7 +54,8 @@ function getAllNotificationsSettings (): UserNotificationSetting {
autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
myVideoStudioEditionFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
pluginManageFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
}
}

View File

@ -0,0 +1,8 @@
extends ../common/greetings
block title
| Failed to #{action} #{pluginType} #{pluginName}
block content
p
| PeerTube failed to #{action} #{pluginType} #{pluginName}. To find more information about the issue you can visit #[a(href=jobsUrl) the PeerTube admin interface].

View File

@ -0,0 +1,8 @@
extends ../common/greetings
block title
| The #{pluginType} #{pluginName} has been #{actionPerfect}
block content
p
| To see all installed plugin and themes you can visit #[a(href=pluginUrl) the PeerTube admin interface].

View File

@ -32,7 +32,7 @@ jobsRouter.post('/resume',
resumeJobQueue
)
jobsRouter.get('/:state?',
jobsRouter.get('/',
openapiOperationDoc({ operationId: 'getJobs' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_JOBS),
@ -65,31 +65,31 @@ function resumeJobQueue (req: express.Request, res: express.Response) {
}
async function listJobs (req: express.Request, res: express.Response) {
const state = req.params.state as JobState
const states = (req.query.states || '').split(',').filter(s => !!s) as JobState[]
const asc = req.query.sort === 'createdAt'
const jobType = req.query.jobType
const jobs = await JobQueue.Instance.listForApi({
state,
states,
start: req.query.start,
count: req.query.count,
asc,
jobType
})
const total = await JobQueue.Instance.count(state, jobType)
const total = await JobQueue.Instance.count(states, jobType)
const result: ResultList<Job> = {
total,
data: await Promise.all(jobs.map(j => formatJob(j, state)))
data: await Promise.all(jobs.map(j => formatJob(j)))
}
return res.json(result)
}
async function formatJob (job: BullJob, state?: JobState): Promise<Job> {
async function formatJob (job: BullJob): Promise<Job> {
return {
id: job.id,
state: state || await job.getState(),
state: await job.getState(),
type: job.queueName as JobType,
data: job.data,
parent: job.parent

View File

@ -33,6 +33,8 @@ import {
RegisteredServerSettings,
UserRight
} from '@peertube/peertube-models'
import { CreateJobArgument, JobQueue } from '@server/lib/job-queue/job-queue.js'
import { basename } from 'path'
const pluginRouter = express.Router()
@ -144,45 +146,74 @@ function getPlugin (req: express.Request, res: express.Response) {
async function installPlugin (req: express.Request, res: express.Response) {
const body: InstallOrUpdatePlugin = req.body
const fromDisk = !!body.path
const toInstall = body.npmName || body.path
const npmName = body.npmName ?? basename(body.path)
const pluginVersion = body.pluginVersion && body.npmName
? body.pluginVersion
: undefined
const options = {
type: 'plugin-manage',
payload: {
action: 'install',
npmName,
path: body.path,
version: pluginVersion,
userId: res.locals.oauth.token.user.id
}
} as CreateJobArgument
try {
const plugin = await PluginManager.Instance.install({ toInstall, version: pluginVersion, fromDisk })
await JobQueue.Instance.createJob(options)
return res.json(plugin.toFormattedJSON())
return res.status(HttpStatusCode.CREATED_201).end()
} catch (err) {
logger.warn('Cannot install plugin %s.', toInstall, { err })
return res.fail({ message: 'Cannot install plugin ' + toInstall })
logger.error('Cannot create plugin-install job.', { err, options })
return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
}
}
async function updatePlugin (req: express.Request, res: express.Response) {
const body: InstallOrUpdatePlugin = req.body
const npmName = body.npmName ?? basename(body.path)
const options = {
type: 'plugin-manage',
payload: {
action: 'update',
npmName,
path: body.path,
userId: res.locals.oauth.token.user.id
}
} as CreateJobArgument
const fromDisk = !!body.path
const toUpdate = body.npmName || body.path
try {
const plugin = await PluginManager.Instance.update(toUpdate, fromDisk)
await JobQueue.Instance.createJob(options)
return res.json(plugin.toFormattedJSON())
return res.status(HttpStatusCode.CREATED_201).end()
} catch (err) {
logger.warn('Cannot update plugin %s.', toUpdate, { err })
return res.fail({ message: 'Cannot update plugin ' + toUpdate })
logger.error('Cannot create plugin-install job.', { err, options })
return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
}
}
async function uninstallPlugin (req: express.Request, res: express.Response) {
const body: ManagePlugin = req.body
await PluginManager.Instance.uninstall({ npmName: body.npmName })
try {
await JobQueue.Instance.createJob({
type: 'plugin-manage',
payload: {
action: 'uninstall',
npmName: body.npmName,
userId: res.locals.oauth.token.user.id
}
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
return res.status(HttpStatusCode.CREATED_201).end()
} catch (err) {
logger.error('Cannot create plugin-uninstall job for %s.', body.npmName, { err })
return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
}
}
function getPublicPluginSettings (req: express.Request, res: express.Response) {

View File

@ -76,6 +76,7 @@ async function updateNotificationSettings (req: express.Request, res: express.Re
abuseStateChange: body.abuseStateChange,
newPeerTubeVersion: body.newPeerTubeVersion,
newPluginVersion: body.newPluginVersion,
pluginManageFinished: body.pluginManageFinished,
myVideoStudioEditionFinished: body.myVideoStudioEditionFinished
}

View File

@ -47,7 +47,7 @@ import { cpus } from 'os'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 850
const LAST_MIGRATION_VERSION = 860
// ---------------------------------------------------------------------------
@ -213,7 +213,8 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
'notify': 1,
'federate-video': 1,
'create-user-export': 1,
'import-user-archive': 1
'import-user-archive': 1,
'plugin-manage': 1
}
// Excluded keys are jobs that can be configured by admins
const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-import'>]: number } = {
@ -241,7 +242,8 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
'notify': 5,
'federate-video': 3,
'create-user-export': 1,
'import-user-archive': 1
'import-user-archive': 1,
'plugin-manage': 1
}
const JOB_TTL: { [id in JobType]: number } = {
'activitypub-http-broadcast': 60000 * 10, // 10 minutes
@ -270,7 +272,8 @@ const JOB_TTL: { [id in JobType]: number } = {
'notify': 60000 * 5, // 5 minutes
'federate-video': 60000 * 5, // 5 minutes,
'create-user-export': 60000 * 60 * 24, // 24 hours
'import-user-archive': 60000 * 60 * 24 // 24 hours
'import-user-archive': 60000 * 60 * 24, // 24 hours
'plugin-manage': 60000 * 10 // 10 minutes
}
const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = {
'videos-views-stats': {

View File

@ -0,0 +1,42 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
const { transaction } = utils
{
const data = {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: true
}
await utils.queryInterface.addColumn('userNotificationSetting', 'pluginManageFinished', data, { transaction })
}
{
const query = 'UPDATE "userNotificationSetting" SET "pluginManageFinished" = 1'
await utils.sequelize.query(query, { transaction })
}
{
const data = {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: false
}
await utils.queryInterface.changeColumn('userNotificationSetting', 'pluginManageFinished', data, { transaction })
}
}
function down () {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -0,0 +1,42 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
const { transaction } = utils
{
const data = {
type: Sequelize.BOOLEAN,
defaultValue: null,
allowNull: true
}
await utils.queryInterface.addColumn('userNotification', 'hasOperationFailed', data, { transaction })
}
{
const query = 'UPDATE "userNotification" SET "hasOperationFailed" = false'
await utils.sequelize.query(query, { transaction })
}
{
const data = {
type: Sequelize.BOOLEAN,
defaultValue: null,
allowNull: false
}
await utils.queryInterface.changeColumn('userNotification', 'hasOperationFailed', data, { transaction })
}
}
function down () {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -0,0 +1,64 @@
import { Job } from 'bullmq'
import { PluginManagePayload } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { PluginManager } from '@server/lib/plugins/plugin-manager.js'
import { Notifier } from '@server/lib/notifier/index.js'
import { PluginModel } from '@server/models/server/plugin.js'
async function processPluginManage (job: Job) {
const payload = job.data as PluginManagePayload
let hasError = false
let pluginId: number
logger.info('Processing plugin manage in job %s.', job.id)
switch (payload.action) {
case 'install': {
const toInstall = payload.path || payload.npmName
const fromDisk = !!payload.path
try {
const plugin = await PluginManager.Instance.install({
fromDisk,
toInstall,
version: payload.version
})
pluginId = plugin.id
} catch (err) {
hasError = true
logger.warn('Cannot install plugin %s.', toInstall, { err })
}
break
}
case 'update': {
const toUpdate = payload.path || payload.npmName
const fromDisk = !!payload.path
try {
const plugin = await PluginManager.Instance.update(toUpdate, fromDisk)
pluginId = plugin.id
} catch (err) {
hasError = true
logger.warn('Cannot update plugin %s.', toUpdate, { err })
}
break
}
case 'uninstall':
try {
const plugin = await PluginModel.loadByNpmName(payload.npmName)
pluginId = plugin.id
await PluginManager.Instance.uninstall(payload)
} catch (err) {
hasError = true
logger.warn('Cannot uninstall plugin %s.', payload.npmName, { err })
}
break
}
Notifier.Instance.notifyOfPluginManageFinished(payload, pluginId, hasError)
}
// ---------------------------------------------------------------------------
export {
processPluginManage
}

View File

@ -29,6 +29,7 @@ import {
ManageVideoTorrentPayload,
MoveStoragePayload,
NotifyPayload,
PluginManagePayload,
RefreshPayload,
TranscodingJobBuilderPayload,
VideoChannelImportPayload,
@ -75,6 +76,7 @@ import { processVideosViewsStats } from './handlers/video-views-stats.js'
import { onMoveToFileSystemFailure, processMoveToFileSystem } from './handlers/move-to-file-system.js'
import { processCreateUserExport } from './handlers/create-user-export.js'
import { processImportUserArchive } from './handlers/import-user-archive.js'
import { processPluginManage } from './handlers/plugin-manage.js'
export type CreateJobArgument =
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@ -104,7 +106,8 @@ export type CreateJobArgument =
{ type: 'federate-video', payload: FederateVideoPayload } |
{ type: 'create-user-export', payload: CreateUserExportPayload } |
{ type: 'generate-video-storyboard', payload: GenerateStoryboardPayload } |
{ type: 'import-user-archive', payload: ImportUserArchivePayload }
{ type: 'import-user-archive', payload: ImportUserArchivePayload } |
{ type: 'plugin-manage', payload: PluginManagePayload }
export type CreateJobOptions = {
delay?: number
@ -129,6 +132,7 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
'move-to-object-storage': processMoveToObjectStorage,
'move-to-file-system': processMoveToFileSystem,
'notify': processNotify,
'plugin-manage': processPluginManage,
'video-channel-import': processVideoChannelImport,
'video-file-import': processVideoFileImport,
'video-import': processVideoImport,
@ -164,6 +168,7 @@ const jobTypes: JobType[] = [
'move-to-object-storage',
'move-to-file-system',
'notify',
'plugin-manage',
'transcoding-job-builder',
'video-channel-import',
'video-file-import',
@ -413,15 +418,15 @@ class JobQueue {
// ---------------------------------------------------------------------------
async listForApi (options: {
state?: JobState
states: JobState[]
start: number
count: number
asc?: boolean
jobType: JobType
}): Promise<Job[]> {
const { state, start, count, asc, jobType } = options
const { states, start, count, asc, jobType } = options
const states = this.buildStateFilter(state)
const totalStates = this.buildStateFilter(states)
const filteredJobTypes = this.buildTypeFilter(jobType)
let results: Job[] = []
@ -434,7 +439,7 @@ class JobQueue {
continue
}
let jobs = await queue.getJobs(states, 0, start + count, asc)
let jobs = await queue.getJobs(totalStates, 0, start + count, asc)
// FIXME: we have sometimes undefined values https://github.com/taskforcesh/bullmq/issues/248
jobs = jobs.filter(j => !!j)
@ -454,8 +459,8 @@ class JobQueue {
return results.slice(start, start + count)
}
async count (state: JobState, jobType?: JobType): Promise<number> {
const states = this.buildStateFilter(state)
async count (states: JobState[], jobType?: JobType): Promise<number> {
const totalStates = this.buildStateFilter(states)
const filteredJobTypes = this.buildTypeFilter(jobType)
let total = 0
@ -469,7 +474,7 @@ class JobQueue {
const counts = await queue.getJobCounts()
for (const s of states) {
for (const s of totalStates) {
total += counts[s]
}
}
@ -477,18 +482,18 @@ class JobQueue {
return total
}
private buildStateFilter (state?: JobState) {
if (!state) return Array.from(jobStates)
private buildStateFilter (states: JobState[]) {
if (states.length === 0) return Array.from(jobStates)
const states = [ state ]
const totalStates = states
// Include parent and prioritized if filtering on waiting
if (state === 'waiting') {
states.push('waiting-children')
states.push('prioritized')
if (states.includes('waiting')) {
totalStates.push('waiting-children')
totalStates.push('prioritized')
}
return states
return totalStates
}
private buildTypeFilter (jobType?: JobType) {

View File

@ -1,4 +1,4 @@
import { UserNotificationSettingValue, UserNotificationSettingValueType } from '@peertube/peertube-models'
import { PluginManagePayload, UserNotificationSettingValue, UserNotificationSettingValueType } from '@peertube/peertube-models'
import { MRegistration, MUser, MUserDefault } from '@server/types/models/user/index.js'
import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist.js'
import { logger, loggerTagsFactory } from '../../helpers/logger.js'
@ -43,6 +43,7 @@ import {
StudioEditionFinishedForOwner,
UnblacklistForOwner
} from './shared/index.js'
import { PluginManageFinished } from './shared/plugin-manage/plugin-manage-finished.js'
const lTags = loggerTagsFactory('notifier')
@ -69,7 +70,8 @@ class Notifier {
newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ],
newPeertubeVersion: [ NewPeerTubeVersionForAdmins ],
newPluginVersion: [ NewPluginVersionForAdmins ],
videoStudioEditionFinished: [ StudioEditionFinishedForOwner ]
videoStudioEditionFinished: [ StudioEditionFinishedForOwner ],
pluginManageFinished: [ PluginManageFinished ]
}
private static instance: Notifier
@ -266,6 +268,15 @@ class Notifier {
.catch(err => logger.error('Cannot notify on new plugin version %s.', plugin.name, { err }))
}
notifyOfPluginManageFinished (pluginManagePayload: PluginManagePayload, pluginId: number, hasError: boolean) {
const models = this.notificationModels.pluginManageFinished
logger.debug('Notify on new plugin manage finished', { ...pluginManagePayload, ...lTags() })
this.sendNotifications(models, { hasError, pluginManagePayload, pluginId })
.catch(err => logger.error('Cannot notify on plugin manage finished.', { err }))
}
notifyOfFinishedVideoStudioEdition (video: MVideoFullLight) {
const models = this.notificationModels.videoStudioEditionFinished
@ -297,7 +308,7 @@ class Notifier {
if (webNotificationEnabled) {
await notification.save()
PeerTubeSocket.Instance.sendNotification(user.id, notification)
await PeerTubeSocket.Instance.sendNotification(user.id, notification)
}
if (emailNotificationEnabled) {
@ -307,9 +318,11 @@ class Notifier {
Hooks.runAction('action:notifier.notification.created', { webNotificationEnabled, emailNotificationEnabled, user, notification })
}
for (const to of toEmails) {
const payload = await object.createEmail(to)
JobQueue.Instance.createJobAsync({ type: 'email', payload })
if (object.createEmail) {
for (const to of toEmails) {
const payload = await object.createEmail(to)
JobQueue.Instance.createJobAsync({ type: 'email', payload })
}
}
}

View File

@ -0,0 +1,83 @@
import { logger } from '@server/helpers/logger.js'
import { UserModel } from '@server/models/user/user.js'
import { UserNotificationModel } from '@server/models/user/user-notification.js'
import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models/index.js'
import { PluginManagePayload, PluginType, UserNotificationType } from '@peertube/peertube-models'
import { AbstractNotification } from '../common/abstract-notification.js'
import { PluginModel } from '@server/models/server/plugin.js'
import { WEBSERVER } from '@server/initializers/constants.js'
export type PluginManageFinishedPayload = {
pluginManagePayload: PluginManagePayload
hasError: boolean
pluginId: number
}
export class PluginManageFinished extends AbstractNotification <PluginManageFinishedPayload> {
private user: MUserDefault
async prepare () {
this.user = await UserModel.loadByIdFull(this.payload.pluginManagePayload.userId)
}
log () {
logger.info('Notifying user %s its plugin %s is finished.', this.user.username, this.payload.pluginManagePayload.action)
}
getSetting (user: MUserWithNotificationSetting) {
return user.NotificationSetting.pluginManageFinished
}
getTargetUsers () {
if (!this.user) return []
return [ this.user ]
}
createNotification (user: MUserWithNotificationSetting) {
const notification = UserNotificationModel.build<UserNotificationModelForApi>({
type: UserNotificationType.PLUGIN_MANAGE_FINISHED,
pluginId: this.payload.pluginId,
userId: user.id,
hasOperationFailed: this.payload.hasError
})
return notification
}
createEmail (to: string) {
const { hasError, pluginManagePayload } = this.payload
const { action, npmName } = pluginManagePayload
const pluginType = PluginModel.getTypeFromNpmName(npmName) === PluginType.PLUGIN ? 'plugin' : 'theme'
const pluginName = PluginModel.normalizePluginName(npmName)
const jobsUrl = WEBSERVER.URL + '/admin/system/jobs'
if (hasError) {
return {
template: 'plugin-manage-failed',
to,
subject: `Failed to ${action} ${pluginType} ${pluginName}`,
locals: {
action,
pluginName,
pluginType,
jobsUrl
}
}
}
const pluginUrl = WEBSERVER.URL + '/admin/plugins/list-installed?pluginType=' + pluginType
const actionPerfect = action === 'update' ? 'updated' : action + 'ed'
return {
template: 'plugin-manage',
to,
subject: `The ${pluginType} ${pluginName} has been ${actionPerfect}`,
locals: {
actionPerfect,
pluginName,
pluginType,
pluginUrl
}
}
}
}

View File

@ -8,6 +8,7 @@ import { UserNotificationModelForApi } from '@server/types/models/user/index.js'
import { LiveVideoEventPayload, LiveVideoEventType } from '@peertube/peertube-models'
import { logger } from '../helpers/logger.js'
import { authenticateRunnerSocket, authenticateSocket } from '../middlewares/index.js'
import { PluginModel } from '@server/models/server/plugin.js'
class PeerTubeSocket {
@ -76,12 +77,16 @@ class PeerTubeSocket {
})
}
sendNotification (userId: number, notification: UserNotificationModelForApi) {
async sendNotification (userId: number, notification: UserNotificationModelForApi) {
const sockets = this.userNotificationSockets[userId]
if (!sockets) return
logger.debug('Sending user notification to user %d.', userId)
await notification.reload({
include: [ PluginModel ]
})
const notificationMessage = notification.toFormattedJSON()
for (const socket of sockets) {
socket.emit('new-notification', notificationMessage)

View File

@ -1,7 +1,7 @@
import express from 'express'
import { Server } from 'http'
import { join } from 'path'
import { buildLogger } from '@server/helpers/logger.js'
import { buildLogger, logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
@ -254,6 +254,7 @@ function buildSocketHelpers () {
return {
sendNotification: (userId: number, notification: UserNotificationModelForApi) => {
PeerTubeSocket.Instance.sendNotification(userId, notification)
.catch(err => logger.error('Failed to send notification on behalf of plugin.', { err }))
},
sendVideoLiveNewState: (video: MVideo) => {
PeerTubeSocket.Instance.sendVideoLiveNewState(video)

View File

@ -93,7 +93,8 @@ export class UserSettingsImporter extends AbstractUserImporter <UserSettingsExpo
autoInstanceFollowing: settingsImportData.autoInstanceFollowing,
newPeerTubeVersion: settingsImportData.newPeerTubeVersion,
newPluginVersion: settingsImportData.newPluginVersion,
myVideoStudioEditionFinished: settingsImportData.myVideoStudioEditionFinished
myVideoStudioEditionFinished: settingsImportData.myVideoStudioEditionFinished,
pluginManageFinished: settingsImportData.pluginManageFinished
}
await UserNotificationSettingModel.updateUserSettings(values, this.user.id)

View File

@ -274,7 +274,8 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
autoInstanceFollowing: UserNotificationSettingValue.WEB,
newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
newPluginVersion: UserNotificationSettingValue.WEB,
myVideoStudioEditionFinished: UserNotificationSettingValue.WEB
myVideoStudioEditionFinished: UserNotificationSettingValue.WEB,
pluginManageFinished: UserNotificationSettingValue.WEB
}
return UserNotificationSettingModel.create(values, { transaction: t })

View File

@ -75,6 +75,7 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
"UserNotificationModel"."id",
"UserNotificationModel"."type",
"UserNotificationModel"."read",
"UserNotificationModel"."hasOperationFailed",
"UserNotificationModel"."createdAt",
"UserNotificationModel"."updatedAt",
"Video"."id" AS "Video.id",

View File

@ -181,6 +181,15 @@ export class UserNotificationSettingModel extends SequelizeModel<UserNotificatio
@Column
myVideoStudioEditionFinished: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingPluginManageFinished',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'pluginManageFinished')
)
@Column
pluginManageFinished: UserNotificationSettingValueType
@ForeignKey(() => UserModel)
@Column
userId: number
@ -233,7 +242,8 @@ export class UserNotificationSettingModel extends SequelizeModel<UserNotificatio
abuseStateChange: this.abuseStateChange,
newPeerTubeVersion: this.newPeerTubeVersion,
myVideoStudioEditionFinished: this.myVideoStudioEditionFinished,
newPluginVersion: this.newPluginVersion
newPluginVersion: this.newPluginVersion,
pluginManageFinished: this.pluginManageFinished
}
}
}

View File

@ -260,6 +260,12 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
})
UserRegistration: Awaited<UserRegistrationModel>
@AllowNull(false)
@Default(false)
@Is('HasOperationFailed', value => throwIfNotValid(value, isBooleanValid, 'hasOperationFailed'))
@Column
hasOperationFailed: boolean
static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
const where = { userId }
@ -445,6 +451,7 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
id: this.id,
type: this.type,
read: this.read,
hasOperationFailed: this.hasOperationFailed,
video,
videoImport,
comment,