aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+my-library/my-video-imports/my-video-imports.component.html7
-rw-r--r--client/src/app/+my-library/my-video-imports/my-video-imports.component.ts24
-rw-r--r--client/src/app/shared/shared-main/buttons/button.component.html4
-rw-r--r--client/src/app/shared/shared-main/buttons/button.component.ts4
-rw-r--r--client/src/app/shared/shared-main/buttons/delete-button.component.ts4
-rw-r--r--client/src/app/shared/shared-main/video/video-import.service.ts10
-rw-r--r--client/src/sass/include/_mixins.scss6
-rw-r--r--server/controllers/api/jobs.ts26
-rw-r--r--server/controllers/api/videos/import.ts48
-rw-r--r--server/initializers/constants.ts4
-rw-r--r--server/lib/job-queue/handlers/video-import.ts29
-rw-r--r--server/lib/job-queue/job-queue.ts12
-rw-r--r--server/middlewares/validators/videos/video-imports.ts71
-rw-r--r--server/tests/api/check-params/jobs.ts43
-rw-r--r--server/tests/api/check-params/video-imports.ts66
-rw-r--r--server/tests/api/server/jobs.ts25
-rw-r--r--server/tests/api/videos/video-imports.ts81
-rw-r--r--shared/models/users/user-right.enum.ts4
-rw-r--r--shared/models/videos/import/video-import-state.enum.ts4
-rw-r--r--shared/server-commands/server/jobs-command.ts24
-rw-r--r--shared/server-commands/videos/imports-command.ts28
-rw-r--r--support/doc/api/openapi.yaml59
22 files changed, 539 insertions, 44 deletions
diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.html b/client/src/app/+my-library/my-video-imports/my-video-imports.component.html
index bd29b11c8..e0d4e8f14 100644
--- a/client/src/app/+my-library/my-video-imports/my-video-imports.component.html
+++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.html
@@ -13,7 +13,7 @@
13 <ng-template pTemplate="header"> 13 <ng-template pTemplate="header">
14 <tr> 14 <tr>
15 <th style="width: 40px;"></th> 15 <th style="width: 40px;"></th>
16 <th style="width: 70px">Action</th> 16 <th style="width: 200px">Action</th>
17 <th style="width: 45%" i18n>Target</th> 17 <th style="width: 45%" i18n>Target</th>
18 <th style="width: 55%" i18n>Video</th> 18 <th style="width: 55%" i18n>Video</th>
19 <th style="width: 150px" i18n>State</th> 19 <th style="width: 150px" i18n>State</th>
@@ -28,8 +28,9 @@
28 </td> 28 </td>
29 29
30 <td class="action-cell"> 30 <td class="action-cell">
31 <my-edit-button *ngIf="isVideoImportSuccess(videoImport) && videoImport.video" 31 <my-button *ngIf="isVideoImportPending(videoImport)" i18n-label label="Cancel" icon="no" (click)="cancelImport(videoImport)"></my-button>
32 [routerLink]="getEditVideoUrl(videoImport.video)"></my-edit-button> 32 <my-delete-button *ngIf="isVideoImportFailed(videoImport) || isVideoImportCancelled(videoImport) || !videoImport.video" (click)="deleteImport(videoImport)"></my-delete-button>
33 <my-edit-button *ngIf="isVideoImportSuccess(videoImport) && videoImport.video" [routerLink]="getEditVideoUrl(videoImport.video)"></my-edit-button>
33 </td> 34 </td>
34 35
35 <td> 36 <td>
diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
index 914785bf7..f01558061 100644
--- a/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
+++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
@@ -37,6 +37,8 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
37 return 'badge-banned' 37 return 'badge-banned'
38 case VideoImportState.PENDING: 38 case VideoImportState.PENDING:
39 return 'badge-yellow' 39 return 'badge-yellow'
40 case VideoImportState.PROCESSING:
41 return 'badge-blue'
40 default: 42 default:
41 return 'badge-green' 43 return 'badge-green'
42 } 44 }
@@ -54,6 +56,10 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
54 return videoImport.state.id === VideoImportState.FAILED 56 return videoImport.state.id === VideoImportState.FAILED
55 } 57 }
56 58
59 isVideoImportCancelled (videoImport: VideoImport) {
60 return videoImport.state.id === VideoImportState.CANCELLED
61 }
62
57 getVideoUrl (video: { uuid: string }) { 63 getVideoUrl (video: { uuid: string }) {
58 return Video.buildWatchUrl(video) 64 return Video.buildWatchUrl(video)
59 } 65 }
@@ -62,6 +68,24 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
62 return Video.buildUpdateUrl(video) 68 return Video.buildUpdateUrl(video)
63 } 69 }
64 70
71 deleteImport (videoImport: VideoImport) {
72 this.videoImportService.deleteVideoImport(videoImport)
73 .subscribe({
74 next: () => this.reloadData(),
75
76 error: err => this.notifier.error(err.message)
77 })
78 }
79
80 cancelImport (videoImport: VideoImport) {
81 this.videoImportService.cancelVideoImport(videoImport)
82 .subscribe({
83 next: () => this.reloadData(),
84
85 error: err => this.notifier.error(err.message)
86 })
87 }
88
65 protected reloadData () { 89 protected reloadData () {
66 this.videoImportService.getMyVideoImports(this.pagination, this.sort) 90 this.videoImportService.getMyVideoImports(this.pagination, this.sort)
67 .subscribe({ 91 .subscribe({
diff --git a/client/src/app/shared/shared-main/buttons/button.component.html b/client/src/app/shared/shared-main/buttons/button.component.html
index 65e06f7a4..11c8ffedd 100644
--- a/client/src/app/shared/shared-main/buttons/button.component.html
+++ b/client/src/app/shared/shared-main/buttons/button.component.html
@@ -1,5 +1,5 @@
1<span class="action-button" [ngClass]="getClasses()" [ngbTooltip]="getTitle()" tabindex="0"> 1<span class="action-button" [ngClass]="getClasses()" [ngbTooltip]="title" tabindex="0">
2 <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon> 2 <my-global-icon *ngIf="icon && !loading" [iconName]="icon"></my-global-icon>
3 <my-small-loader [loading]="loading"></my-small-loader> 3 <my-small-loader [loading]="loading"></my-small-loader>
4 4
5 <span *ngIf="label" class="button-label">{{ label }}</span> 5 <span *ngIf="label" class="button-label">{{ label }}</span>
diff --git a/client/src/app/shared/shared-main/buttons/button.component.ts b/client/src/app/shared/shared-main/buttons/button.component.ts
index ee74b3d12..b97012d9a 100644
--- a/client/src/app/shared/shared-main/buttons/button.component.ts
+++ b/client/src/app/shared/shared-main/buttons/button.component.ts
@@ -16,10 +16,6 @@ export class ButtonComponent {
16 @Input() disabled = false 16 @Input() disabled = false
17 @Input() responsiveLabel = false 17 @Input() responsiveLabel = false
18 18
19 getTitle () {
20 return this.title || this.label
21 }
22
23 getClasses () { 19 getClasses () {
24 return { 20 return {
25 [this.className]: true, 21 [this.className]: true,
diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.ts b/client/src/app/shared/shared-main/buttons/delete-button.component.ts
index c091f5309..90735852c 100644
--- a/client/src/app/shared/shared-main/buttons/delete-button.component.ts
+++ b/client/src/app/shared/shared-main/buttons/delete-button.component.ts
@@ -20,10 +20,6 @@ export class DeleteButtonComponent implements OnInit {
20 // <my-delete-button label /> Use default label 20 // <my-delete-button label /> Use default label
21 if (this.label === '') { 21 if (this.label === '') {
22 this.label = $localize`Delete` 22 this.label = $localize`Delete`
23
24 if (!this.title) {
25 this.title = this.label
26 }
27 } 23 }
28 } 24 }
29} 25}
diff --git a/client/src/app/shared/shared-main/video/video-import.service.ts b/client/src/app/shared/shared-main/video/video-import.service.ts
index 99df78e3a..0a610ab1f 100644
--- a/client/src/app/shared/shared-main/video/video-import.service.ts
+++ b/client/src/app/shared/shared-main/video/video-import.service.ts
@@ -56,6 +56,16 @@ export class VideoImportService {
56 ) 56 )
57 } 57 }
58 58
59 deleteVideoImport (videoImport: VideoImport) {
60 return this.authHttp.delete(VideoImportService.BASE_VIDEO_IMPORT_URL + videoImport.id)
61 .pipe(catchError(err => this.restExtractor.handleError(err)))
62 }
63
64 cancelVideoImport (videoImport: VideoImport) {
65 return this.authHttp.post(VideoImportService.BASE_VIDEO_IMPORT_URL + videoImport.id + '/cancel', {})
66 .pipe(catchError(err => this.restExtractor.handleError(err)))
67 }
68
59 private buildImportVideoObject (video: VideoUpdate): VideoImportCreate { 69 private buildImportVideoObject (video: VideoUpdate): VideoImportCreate {
60 const language = video.language || null 70 const language = video.language || null
61 const licence = video.licence || null 71 const licence = video.licence || null
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index a7c4c99c2..c8ec3b4d1 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -257,7 +257,7 @@
257} 257}
258 258
259@mixin peertube-button { 259@mixin peertube-button {
260 @include padding(0, 17px, 0, 13px); 260 padding: 0 13px;
261 261
262 border: 0; 262 border: 0;
263 font-weight: $font-semibold; 263 font-weight: $font-semibold;
@@ -270,6 +270,10 @@
270 270
271 text-align: center; 271 text-align: center;
272 cursor: pointer; 272 cursor: pointer;
273
274 my-global-icon + * {
275 @include margin-right(4px);
276 }
273} 277}
274 278
275@mixin peertube-button-link { 279@mixin peertube-button-link {
diff --git a/server/controllers/api/jobs.ts b/server/controllers/api/jobs.ts
index eebd195b0..c61b7362f 100644
--- a/server/controllers/api/jobs.ts
+++ b/server/controllers/api/jobs.ts
@@ -1,5 +1,5 @@
1import express from 'express' 1import express from 'express'
2import { Job, JobState, JobType, ResultList, UserRight } from '@shared/models' 2import { HttpStatusCode, Job, JobState, JobType, ResultList, UserRight } from '@shared/models'
3import { isArray } from '../../helpers/custom-validators/misc' 3import { isArray } from '../../helpers/custom-validators/misc'
4import { JobQueue } from '../../lib/job-queue' 4import { JobQueue } from '../../lib/job-queue'
5import { 5import {
@@ -16,6 +16,18 @@ import { listJobsValidator } from '../../middlewares/validators/jobs'
16 16
17const jobsRouter = express.Router() 17const jobsRouter = express.Router()
18 18
19jobsRouter.post('/pause',
20 authenticate,
21 ensureUserHasRight(UserRight.MANAGE_JOBS),
22 asyncMiddleware(pauseJobQueue)
23)
24
25jobsRouter.post('/resume',
26 authenticate,
27 ensureUserHasRight(UserRight.MANAGE_JOBS),
28 asyncMiddleware(resumeJobQueue)
29)
30
19jobsRouter.get('/:state?', 31jobsRouter.get('/:state?',
20 openapiOperationDoc({ operationId: 'getJobs' }), 32 openapiOperationDoc({ operationId: 'getJobs' }),
21 authenticate, 33 authenticate,
@@ -36,6 +48,18 @@ export {
36 48
37// --------------------------------------------------------------------------- 49// ---------------------------------------------------------------------------
38 50
51async function pauseJobQueue (req: express.Request, res: express.Response) {
52 await JobQueue.Instance.pause()
53
54 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
55}
56
57async function resumeJobQueue (req: express.Request, res: express.Response) {
58 await JobQueue.Instance.resume()
59
60 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
61}
62
39async function listJobs (req: express.Request, res: express.Response) { 63async function listJobs (req: express.Request, res: express.Response) {
40 const state = req.params.state as JobState 64 const state = req.params.state as JobState
41 const asc = req.query.sort === 'createdAt' 65 const asc = req.query.sort === 'createdAt'
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 08d69827b..8cbfd3286 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -19,7 +19,15 @@ import {
19 MVideoWithBlacklistLight 19 MVideoWithBlacklistLight
20} from '@server/types/models' 20} from '@server/types/models'
21import { MVideoImportFormattable } from '@server/types/models/video/video-import' 21import { MVideoImportFormattable } from '@server/types/models/video/video-import'
22import { ServerErrorCode, ThumbnailType, VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' 22import {
23 HttpStatusCode,
24 ServerErrorCode,
25 ThumbnailType,
26 VideoImportCreate,
27 VideoImportState,
28 VideoPrivacy,
29 VideoState
30} from '@shared/models'
23import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' 31import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
24import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' 32import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
25import { isArray } from '../../../helpers/custom-validators/misc' 33import { isArray } from '../../../helpers/custom-validators/misc'
@@ -34,7 +42,14 @@ import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url'
34import { JobQueue } from '../../../lib/job-queue/job-queue' 42import { JobQueue } from '../../../lib/job-queue/job-queue'
35import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail' 43import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail'
36import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 44import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
37import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' 45import {
46 asyncMiddleware,
47 asyncRetryTransactionMiddleware,
48 authenticate,
49 videoImportAddValidator,
50 videoImportCancelValidator,
51 videoImportDeleteValidator
52} from '../../../middlewares'
38import { VideoModel } from '../../../models/video/video' 53import { VideoModel } from '../../../models/video/video'
39import { VideoCaptionModel } from '../../../models/video/video-caption' 54import { VideoCaptionModel } from '../../../models/video/video-caption'
40import { VideoImportModel } from '../../../models/video/video-import' 55import { VideoImportModel } from '../../../models/video/video-import'
@@ -59,6 +74,18 @@ videoImportsRouter.post('/imports',
59 asyncRetryTransactionMiddleware(addVideoImport) 74 asyncRetryTransactionMiddleware(addVideoImport)
60) 75)
61 76
77videoImportsRouter.post('/imports/:id/cancel',
78 authenticate,
79 asyncMiddleware(videoImportCancelValidator),
80 asyncRetryTransactionMiddleware(cancelVideoImport)
81)
82
83videoImportsRouter.delete('/imports/:id',
84 authenticate,
85 asyncMiddleware(videoImportDeleteValidator),
86 asyncRetryTransactionMiddleware(deleteVideoImport)
87)
88
62// --------------------------------------------------------------------------- 89// ---------------------------------------------------------------------------
63 90
64export { 91export {
@@ -67,6 +94,23 @@ export {
67 94
68// --------------------------------------------------------------------------- 95// ---------------------------------------------------------------------------
69 96
97async function deleteVideoImport (req: express.Request, res: express.Response) {
98 const videoImport = res.locals.videoImport
99
100 await videoImport.destroy()
101
102 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
103}
104
105async function cancelVideoImport (req: express.Request, res: express.Response) {
106 const videoImport = res.locals.videoImport
107
108 videoImport.state = VideoImportState.CANCELLED
109 await videoImport.save()
110
111 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
112}
113
70function addVideoImport (req: express.Request, res: express.Response) { 114function addVideoImport (req: express.Request, res: express.Response) {
71 if (req.body.targetUrl) return addYoutubeDLImport(req, res) 115 if (req.body.targetUrl) return addYoutubeDLImport(req, res)
72 116
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index b2f511152..6a59bf805 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -441,7 +441,9 @@ const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
441 [VideoImportState.FAILED]: 'Failed', 441 [VideoImportState.FAILED]: 'Failed',
442 [VideoImportState.PENDING]: 'Pending', 442 [VideoImportState.PENDING]: 'Pending',
443 [VideoImportState.SUCCESS]: 'Success', 443 [VideoImportState.SUCCESS]: 'Success',
444 [VideoImportState.REJECTED]: 'Rejected' 444 [VideoImportState.REJECTED]: 'Rejected',
445 [VideoImportState.CANCELLED]: 'Cancelled',
446 [VideoImportState.PROCESSING]: 'Processing'
445} 447}
446 448
447const ABUSE_STATES: { [ id in AbuseState ]: string } = { 449const ABUSE_STATES: { [ id in AbuseState ]: string } = {
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index 2f74e9fbd..cb79725aa 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -42,8 +42,17 @@ import { generateVideoMiniature } from '../../thumbnail'
42async function processVideoImport (job: Job) { 42async function processVideoImport (job: Job) {
43 const payload = job.data as VideoImportPayload 43 const payload = job.data as VideoImportPayload
44 44
45 if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, payload) 45 const videoImport = await getVideoImportOrDie(payload.videoImportId)
46 if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') return processTorrentImport(job, payload) 46 if (videoImport.state === VideoImportState.CANCELLED) {
47 logger.info('Do not process import since it has been cancelled', { payload })
48 return
49 }
50
51 videoImport.state = VideoImportState.PROCESSING
52 await videoImport.save()
53
54 if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, videoImport, payload)
55 if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') return processTorrentImport(job, videoImport, payload)
47} 56}
48 57
49// --------------------------------------------------------------------------- 58// ---------------------------------------------------------------------------
@@ -54,15 +63,11 @@ export {
54 63
55// --------------------------------------------------------------------------- 64// ---------------------------------------------------------------------------
56 65
57async function processTorrentImport (job: Job, payload: VideoImportTorrentPayload) { 66async function processTorrentImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportTorrentPayload) {
58 logger.info('Processing torrent video import in job %d.', job.id) 67 logger.info('Processing torrent video import in job %d.', job.id)
59 68
60 const videoImport = await getVideoImportOrDie(payload.videoImportId) 69 const options = { type: payload.type, videoImportId: payload.videoImportId }
61 70
62 const options = {
63 type: payload.type,
64 videoImportId: payload.videoImportId
65 }
66 const target = { 71 const target = {
67 torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined, 72 torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined,
68 uri: videoImport.magnetUri 73 uri: videoImport.magnetUri
@@ -70,14 +75,10 @@ async function processTorrentImport (job: Job, payload: VideoImportTorrentPayloa
70 return processFile(() => downloadWebTorrentVideo(target, VIDEO_IMPORT_TIMEOUT), videoImport, options) 75 return processFile(() => downloadWebTorrentVideo(target, VIDEO_IMPORT_TIMEOUT), videoImport, options)
71} 76}
72 77
73async function processYoutubeDLImport (job: Job, payload: VideoImportYoutubeDLPayload) { 78async function processYoutubeDLImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportYoutubeDLPayload) {
74 logger.info('Processing youtubeDL video import in job %d.', job.id) 79 logger.info('Processing youtubeDL video import in job %d.', job.id)
75 80
76 const videoImport = await getVideoImportOrDie(payload.videoImportId) 81 const options = { type: payload.type, videoImportId: videoImport.id }
77 const options = {
78 type: payload.type,
79 videoImportId: videoImport.id
80 }
81 82
82 const youtubeDL = new YoutubeDLWrapper(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod')) 83 const youtubeDL = new YoutubeDLWrapper(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
83 84
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index fbc599f12..22bd1f5d2 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -162,6 +162,18 @@ class JobQueue {
162 } 162 }
163 } 163 }
164 164
165 async pause () {
166 for (const handler of Object.keys(this.queues)) {
167 await this.queues[handler].pause(true)
168 }
169 }
170
171 async resume () {
172 for (const handler of Object.keys(this.queues)) {
173 await this.queues[handler].resume(true)
174 }
175 }
176
165 createJob (obj: CreateJobArgument, options: CreateJobOptions = {}): void { 177 createJob (obj: CreateJobArgument, options: CreateJobOptions = {}): void {
166 this.createJobWithPromise(obj, options) 178 this.createJobWithPromise(obj, options)
167 .catch(err => logger.error('Cannot create job.', { err, obj })) 179 .catch(err => logger.error('Cannot create job.', { err, obj }))
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts
index e4b54283f..a3a5cc531 100644
--- a/server/middlewares/validators/videos/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -1,8 +1,10 @@
1import express from 'express' 1import express from 'express'
2import { body } from 'express-validator' 2import { body, param } from 'express-validator'
3import { isValid as isIPValid, parse as parseIP } from 'ipaddr.js'
3import { isPreImportVideoAccepted } from '@server/lib/moderation' 4import { isPreImportVideoAccepted } from '@server/lib/moderation'
4import { Hooks } from '@server/lib/plugins/hooks' 5import { Hooks } from '@server/lib/plugins/hooks'
5import { HttpStatusCode } from '@shared/models' 6import { MUserAccountId, MVideoImport } from '@server/types/models'
7import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models'
6import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' 8import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
7import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' 9import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
8import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 10import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
@@ -11,9 +13,8 @@ import { cleanUpReqFiles } from '../../../helpers/express-utils'
11import { logger } from '../../../helpers/logger' 13import { logger } from '../../../helpers/logger'
12import { CONFIG } from '../../../initializers/config' 14import { CONFIG } from '../../../initializers/config'
13import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' 15import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
14import { areValidationErrors, doesVideoChannelOfAccountExist } from '../shared' 16import { areValidationErrors, doesVideoChannelOfAccountExist, doesVideoImportExist } from '../shared'
15import { getCommonVideoEditAttributes } from './videos' 17import { getCommonVideoEditAttributes } from './videos'
16import { isValid as isIPValid, parse as parseIP } from 'ipaddr.js'
17 18
18const videoImportAddValidator = getCommonVideoEditAttributes().concat([ 19const videoImportAddValidator = getCommonVideoEditAttributes().concat([
19 body('channelId') 20 body('channelId')
@@ -95,10 +96,58 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
95 } 96 }
96]) 97])
97 98
99const videoImportDeleteValidator = [
100 param('id')
101 .custom(isIdValid).withMessage('Should have correct import id'),
102
103 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
104 logger.debug('Checking videoImportDeleteValidator parameters', { parameters: req.params })
105
106 if (areValidationErrors(req, res)) return
107
108 if (!await doesVideoImportExist(parseInt(req.params.id), res)) return
109 if (!checkUserCanManageImport(res.locals.oauth.token.user, res.locals.videoImport, res)) return
110
111 if (res.locals.videoImport.state === VideoImportState.PENDING) {
112 return res.fail({
113 status: HttpStatusCode.CONFLICT_409,
114 message: 'Cannot delete a pending video import. Cancel it or wait for the end of the import first.'
115 })
116 }
117
118 return next()
119 }
120]
121
122const videoImportCancelValidator = [
123 param('id')
124 .custom(isIdValid).withMessage('Should have correct import id'),
125
126 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
127 logger.debug('Checking videoImportCancelValidator parameters', { parameters: req.params })
128
129 if (areValidationErrors(req, res)) return
130
131 if (!await doesVideoImportExist(parseInt(req.params.id), res)) return
132 if (!checkUserCanManageImport(res.locals.oauth.token.user, res.locals.videoImport, res)) return
133
134 if (res.locals.videoImport.state !== VideoImportState.PENDING) {
135 return res.fail({
136 status: HttpStatusCode.CONFLICT_409,
137 message: 'Cannot cancel a non pending video import.'
138 })
139 }
140
141 return next()
142 }
143]
144
98// --------------------------------------------------------------------------- 145// ---------------------------------------------------------------------------
99 146
100export { 147export {
101 videoImportAddValidator 148 videoImportAddValidator,
149 videoImportCancelValidator,
150 videoImportDeleteValidator
102} 151}
103 152
104// --------------------------------------------------------------------------- 153// ---------------------------------------------------------------------------
@@ -132,3 +181,15 @@ async function isImportAccepted (req: express.Request, res: express.Response) {
132 181
133 return true 182 return true
134} 183}
184
185function checkUserCanManageImport (user: MUserAccountId, videoImport: MVideoImport, res: express.Response) {
186 if (user.hasRight(UserRight.MANAGE_VIDEO_IMPORTS) === false && videoImport.userId !== user.id) {
187 res.fail({
188 status: HttpStatusCode.FORBIDDEN_403,
189 message: 'Cannot manage video import of another user'
190 })
191 return false
192 }
193
194 return true
195}
diff --git a/server/tests/api/check-params/jobs.ts b/server/tests/api/check-params/jobs.ts
index d85961d62..801b13d1e 100644
--- a/server/tests/api/check-params/jobs.ts
+++ b/server/tests/api/check-params/jobs.ts
@@ -3,7 +3,14 @@
3import 'mocha' 3import 'mocha'
4import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared' 4import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
5import { HttpStatusCode } from '@shared/models' 5import { HttpStatusCode } from '@shared/models'
6import { cleanupTests, createSingleServer, makeGetRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' 6import {
7 cleanupTests,
8 createSingleServer,
9 makeGetRequest,
10 makePostBodyRequest,
11 PeerTubeServer,
12 setAccessTokensToServers
13} from '@shared/server-commands'
7 14
8describe('Test jobs API validators', function () { 15describe('Test jobs API validators', function () {
9 const path = '/api/v1/jobs/failed' 16 const path = '/api/v1/jobs/failed'
@@ -76,7 +83,41 @@ describe('Test jobs API validators', function () {
76 expectedStatus: HttpStatusCode.FORBIDDEN_403 83 expectedStatus: HttpStatusCode.FORBIDDEN_403
77 }) 84 })
78 }) 85 })
86 })
87
88 describe('When pausing/resuming the job queue', async function () {
89 const commands = [ 'pause', 'resume' ]
90
91 it('Should fail with a non authenticated user', async function () {
92 for (const command of commands) {
93 await makePostBodyRequest({
94 url: server.url,
95 path: '/api/v1/jobs/' + command,
96 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
97 })
98 }
99 })
79 100
101 it('Should fail with a non admin user', async function () {
102 for (const command of commands) {
103 await makePostBodyRequest({
104 url: server.url,
105 path: '/api/v1/jobs/' + command,
106 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
107 })
108 }
109 })
110
111 it('Should succeed with the correct params', async function () {
112 for (const command of commands) {
113 await makePostBodyRequest({
114 url: server.url,
115 path: '/api/v1/jobs/' + command,
116 token: server.accessToken,
117 expectedStatus: HttpStatusCode.NO_CONTENT_204
118 })
119 }
120 })
80 }) 121 })
81 122
82 after(async function () { 123 after(async function () {
diff --git a/server/tests/api/check-params/video-imports.ts b/server/tests/api/check-params/video-imports.ts
index da05793a0..156a612ee 100644
--- a/server/tests/api/check-params/video-imports.ts
+++ b/server/tests/api/check-params/video-imports.ts
@@ -12,7 +12,9 @@ import {
12 makePostBodyRequest, 12 makePostBodyRequest,
13 makeUploadRequest, 13 makeUploadRequest,
14 PeerTubeServer, 14 PeerTubeServer,
15 setAccessTokensToServers 15 setAccessTokensToServers,
16 setDefaultVideoChannel,
17 waitJobs
16} from '@shared/server-commands' 18} from '@shared/server-commands'
17 19
18describe('Test video imports API validator', function () { 20describe('Test video imports API validator', function () {
@@ -29,6 +31,7 @@ describe('Test video imports API validator', function () {
29 server = await createSingleServer(1) 31 server = await createSingleServer(1)
30 32
31 await setAccessTokensToServers([ server ]) 33 await setAccessTokensToServers([ server ])
34 await setDefaultVideoChannel([ server ])
32 35
33 const username = 'user1' 36 const username = 'user1'
34 const password = 'my super password' 37 const password = 'my super password'
@@ -347,6 +350,67 @@ describe('Test video imports API validator', function () {
347 }) 350 })
348 }) 351 })
349 352
353 describe('Deleting/cancelling a video import', function () {
354 let importId: number
355
356 async function importVideo () {
357 const attributes = { channelId: server.store.channel.id, targetUrl: FIXTURE_URLS.goodVideo }
358 const res = await server.imports.importVideo({ attributes })
359
360 return res.id
361 }
362
363 before(async function () {
364 importId = await importVideo()
365 })
366
367 it('Should fail with an invalid import id', async function () {
368 await server.imports.cancel({ importId: 'artyom' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
369 await server.imports.delete({ importId: 'artyom' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
370 })
371
372 it('Should fail with an unknown import id', async function () {
373 await server.imports.cancel({ importId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
374 await server.imports.delete({ importId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
375 })
376
377 it('Should fail without token', async function () {
378 await server.imports.cancel({ importId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
379 await server.imports.delete({ importId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
380 })
381
382 it('Should fail with another user token', async function () {
383 await server.imports.cancel({ importId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
384 await server.imports.delete({ importId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
385 })
386
387 it('Should fail to cancel non pending import', async function () {
388 this.timeout(60000)
389
390 await waitJobs([ server ])
391
392 await server.imports.cancel({ importId, expectedStatus: HttpStatusCode.CONFLICT_409 })
393 })
394
395 it('Should succeed to delete an import', async function () {
396 await server.imports.delete({ importId })
397 })
398
399 it('Should fail to delete a pending import', async function () {
400 await server.jobs.pauseJobQueue()
401
402 importId = await importVideo()
403
404 await server.imports.delete({ importId, expectedStatus: HttpStatusCode.CONFLICT_409 })
405 })
406
407 it('Should succeed to cancel an import', async function () {
408 importId = await importVideo()
409
410 await server.imports.cancel({ importId })
411 })
412 })
413
350 after(async function () { 414 after(async function () {
351 await cleanupTests([ server ]) 415 await cleanupTests([ server ])
352 }) 416 })
diff --git a/server/tests/api/server/jobs.ts b/server/tests/api/server/jobs.ts
index 4294e1fd5..bd8ffe188 100644
--- a/server/tests/api/server/jobs.ts
+++ b/server/tests/api/server/jobs.ts
@@ -11,6 +11,7 @@ import {
11 setAccessTokensToServers, 11 setAccessTokensToServers,
12 waitJobs 12 waitJobs
13} from '@shared/server-commands' 13} from '@shared/server-commands'
14import { wait } from '@shared/core-utils'
14 15
15const expect = chai.expect 16const expect = chai.expect
16 17
@@ -91,6 +92,30 @@ describe('Test jobs', function () {
91 expect(jobs.find(j => j.state === 'completed')).to.not.be.undefined 92 expect(jobs.find(j => j.state === 'completed')).to.not.be.undefined
92 }) 93 })
93 94
95 it('Should pause the job queue', async function () {
96 this.timeout(120000)
97
98 await servers[1].jobs.pauseJobQueue()
99
100 await servers[1].videos.upload({ attributes: { name: 'video2' } })
101
102 await wait(5000)
103
104 const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
105 expect(body.data).to.have.lengthOf(1)
106 })
107
108 it('Should resume the job queue', async function () {
109 this.timeout(120000)
110
111 await servers[1].jobs.resumeJobQueue()
112
113 await waitJobs(servers)
114
115 const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
116 expect(body.data).to.have.lengthOf(0)
117 })
118
94 after(async function () { 119 after(async function () {
95 await cleanupTests(servers) 120 await cleanupTests(servers)
96 }) 121 })
diff --git a/server/tests/api/videos/video-imports.ts b/server/tests/api/videos/video-imports.ts
index e8e0f01f1..ba21ab17a 100644
--- a/server/tests/api/videos/video-imports.ts
+++ b/server/tests/api/videos/video-imports.ts
@@ -6,7 +6,7 @@ import { pathExists, readdir, remove } from 'fs-extra'
6import { join } from 'path' 6import { join } from 'path'
7import { FIXTURE_URLS, testCaptionFile, testImage } from '@server/tests/shared' 7import { FIXTURE_URLS, testCaptionFile, testImage } from '@server/tests/shared'
8import { areHttpImportTestsDisabled } from '@shared/core-utils' 8import { areHttpImportTestsDisabled } from '@shared/core-utils'
9import { VideoPrivacy, VideoResolution } from '@shared/models' 9import { HttpStatusCode, Video, VideoImportState, VideoPrivacy, VideoResolution, VideoState } from '@shared/models'
10import { 10import {
11 cleanupTests, 11 cleanupTests,
12 createMultipleServers, 12 createMultipleServers,
@@ -382,6 +382,85 @@ describe('Test video imports', function () {
382 382
383 runSuite('yt-dlp') 383 runSuite('yt-dlp')
384 384
385 describe('Delete/cancel an import', function () {
386 let server: PeerTubeServer
387
388 let finishedImportId: number
389 let finishedVideo: Video
390 let pendingImportId: number
391
392 async function importVideo (name: string) {
393 const attributes = { name, channelId: server.store.channel.id, targetUrl: FIXTURE_URLS.goodVideo }
394 const res = await server.imports.importVideo({ attributes })
395
396 return res.id
397 }
398
399 before(async function () {
400 this.timeout(120_000)
401
402 server = await createSingleServer(1)
403
404 await setAccessTokensToServers([ server ])
405 await setDefaultVideoChannel([ server ])
406
407 finishedImportId = await importVideo('finished')
408 await waitJobs([ server ])
409
410 await server.jobs.pauseJobQueue()
411 pendingImportId = await importVideo('pending')
412
413 const { data } = await server.imports.getMyVideoImports()
414 expect(data).to.have.lengthOf(2)
415
416 finishedVideo = data.find(i => i.id === finishedImportId).video
417 })
418
419 it('Should delete a video import', async function () {
420 await server.imports.delete({ importId: finishedImportId })
421
422 const { data } = await server.imports.getMyVideoImports()
423 expect(data).to.have.lengthOf(1)
424 expect(data[0].id).to.equal(pendingImportId)
425 expect(data[0].state.id).to.equal(VideoImportState.PENDING)
426 })
427
428 it('Should not have deleted the associated video', async function () {
429 const video = await server.videos.get({ id: finishedVideo.id, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
430 expect(video.name).to.equal('finished')
431 expect(video.state.id).to.equal(VideoState.PUBLISHED)
432 })
433
434 it('Should cancel a video import', async function () {
435 await server.imports.cancel({ importId: pendingImportId })
436
437 const { data } = await server.imports.getMyVideoImports()
438 expect(data).to.have.lengthOf(1)
439 expect(data[0].id).to.equal(pendingImportId)
440 expect(data[0].state.id).to.equal(VideoImportState.CANCELLED)
441 })
442
443 it('Should not have processed the cancelled video import', async function () {
444 this.timeout(60_000)
445
446 await server.jobs.resumeJobQueue()
447
448 await waitJobs([ server ])
449
450 const { data } = await server.imports.getMyVideoImports()
451 expect(data).to.have.lengthOf(1)
452 expect(data[0].id).to.equal(pendingImportId)
453 expect(data[0].state.id).to.equal(VideoImportState.CANCELLED)
454 expect(data[0].video.state.id).to.equal(VideoState.TO_IMPORT)
455 })
456
457 it('Should delete the cancelled video import', async function () {
458 await server.imports.delete({ importId: pendingImportId })
459 const { data } = await server.imports.getMyVideoImports()
460 expect(data).to.have.lengthOf(0)
461 })
462 })
463
385 describe('Auto update', function () { 464 describe('Auto update', function () {
386 let server: PeerTubeServer 465 let server: PeerTubeServer
387 466
diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts
index 668535f4e..d3f793d8b 100644
--- a/shared/models/users/user-right.enum.ts
+++ b/shared/models/users/user-right.enum.ts
@@ -41,5 +41,7 @@ export const enum UserRight {
41 MANAGE_VIDEOS_REDUNDANCIES, 41 MANAGE_VIDEOS_REDUNDANCIES,
42 42
43 MANAGE_VIDEO_FILES, 43 MANAGE_VIDEO_FILES,
44 RUN_VIDEO_TRANSCODING 44 RUN_VIDEO_TRANSCODING,
45
46 MANAGE_VIDEO_IMPORTS
45} 47}
diff --git a/shared/models/videos/import/video-import-state.enum.ts b/shared/models/videos/import/video-import-state.enum.ts
index 33dd83f88..ff5c6beff 100644
--- a/shared/models/videos/import/video-import-state.enum.ts
+++ b/shared/models/videos/import/video-import-state.enum.ts
@@ -2,5 +2,7 @@ export const enum VideoImportState {
2 PENDING = 1, 2 PENDING = 1,
3 SUCCESS = 2, 3 SUCCESS = 2,
4 FAILED = 3, 4 FAILED = 3,
5 REJECTED = 4 5 REJECTED = 4,
6 CANCELLED = 5,
7 PROCESSING = 6
6} 8}
diff --git a/shared/server-commands/server/jobs-command.ts b/shared/server-commands/server/jobs-command.ts
index ac62157d1..b8790ea00 100644
--- a/shared/server-commands/server/jobs-command.ts
+++ b/shared/server-commands/server/jobs-command.ts
@@ -14,6 +14,30 @@ export class JobsCommand extends AbstractCommand {
14 return data[0] 14 return data[0]
15 } 15 }
16 16
17 pauseJobQueue (options: OverrideCommandOptions = {}) {
18 const path = '/api/v1/jobs/pause'
19
20 return this.postBodyRequest({
21 ...options,
22
23 path,
24 implicitToken: true,
25 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
26 })
27 }
28
29 resumeJobQueue (options: OverrideCommandOptions = {}) {
30 const path = '/api/v1/jobs/resume'
31
32 return this.postBodyRequest({
33 ...options,
34
35 path,
36 implicitToken: true,
37 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
38 })
39 }
40
17 list (options: OverrideCommandOptions & { 41 list (options: OverrideCommandOptions & {
18 state?: JobState 42 state?: JobState
19 jobType?: JobType 43 jobType?: JobType
diff --git a/shared/server-commands/videos/imports-command.ts b/shared/server-commands/videos/imports-command.ts
index e4944694d..f63ed5d4b 100644
--- a/shared/server-commands/videos/imports-command.ts
+++ b/shared/server-commands/videos/imports-command.ts
@@ -26,6 +26,34 @@ export class ImportsCommand extends AbstractCommand {
26 })) 26 }))
27 } 27 }
28 28
29 delete (options: OverrideCommandOptions & {
30 importId: number
31 }) {
32 const path = '/api/v1/videos/imports/' + options.importId
33
34 return this.deleteRequest({
35 ...options,
36
37 path,
38 implicitToken: true,
39 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
40 })
41 }
42
43 cancel (options: OverrideCommandOptions & {
44 importId: number
45 }) {
46 const path = '/api/v1/videos/imports/' + options.importId + '/cancel'
47
48 return this.postBodyRequest({
49 ...options,
50
51 path,
52 implicitToken: true,
53 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
54 })
55 }
56
29 getMyVideoImports (options: OverrideCommandOptions & { 57 getMyVideoImports (options: OverrideCommandOptions & {
30 sort?: string 58 sort?: string
31 } = {}) { 59 } = {}) {
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 5bf3f13cc..9e721be4b 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -252,6 +252,8 @@ tags:
252 252
253 The import function is practical when the desired video/audio is available online. It makes PeerTube 253 The import function is practical when the desired video/audio is available online. It makes PeerTube
254 download it for you, saving you as much bandwidth and avoiding any instability or limitation your network might have. 254 download it for you, saving you as much bandwidth and avoiding any instability or limitation your network might have.
255 - name: Video Imports
256 description: Operations dealing with listing, adding and removing video imports.
255 - name: Video Captions 257 - name: Video Captions
256 description: Operations dealing with listing, adding and removing closed captions of a video. 258 description: Operations dealing with listing, adding and removing closed captions of a video.
257 - name: Video Channels 259 - name: Video Channels
@@ -306,6 +308,7 @@ x-tagGroups:
306 tags: 308 tags:
307 - Video 309 - Video
308 - Video Upload 310 - Video Upload
311 - Video Imports
309 - Video Captions 312 - Video Captions
310 - Video Channels 313 - Video Channels
311 - Video Comments 314 - Video Comments
@@ -587,6 +590,30 @@ paths:
587 '204': 590 '204':
588 description: successful operation 591 description: successful operation
589 592
593 /jobs/pause:
594 post:
595 summary: Pause job queue
596 security:
597 - OAuth2:
598 - admin
599 tags:
600 - Job
601 responses:
602 '204':
603 description: successful operation
604
605 /jobs/resume:
606 post:
607 summary: Resume job queue
608 security:
609 - OAuth2:
610 - admin
611 tags:
612 - Job
613 responses:
614 '204':
615 description: successful operation
616
590 /jobs/{state}: 617 /jobs/{state}:
591 get: 618 get:
592 summary: List instance jobs 619 summary: List instance jobs
@@ -2166,7 +2193,7 @@ paths:
2166 security: 2193 security:
2167 - OAuth2: [] 2194 - OAuth2: []
2168 tags: 2195 tags:
2169 - Video 2196 - Video Imports
2170 - Video Upload 2197 - Video Upload
2171 requestBody: 2198 requestBody:
2172 content: 2199 content:
@@ -2194,6 +2221,34 @@ paths:
2194 '409': 2221 '409':
2195 description: HTTP or Torrent/magnetURI import not enabled 2222 description: HTTP or Torrent/magnetURI import not enabled
2196 2223
2224 /videos/imports/{id}/cancel:
2225 post:
2226 summary: Cancel video import
2227 description: Cancel a pending video import
2228 security:
2229 - OAuth2: []
2230 tags:
2231 - Video Imports
2232 parameters:
2233 - $ref: '#/components/parameters/id'
2234 responses:
2235 '204':
2236 description: successful operation
2237
2238 /videos/imports/{id}:
2239 delete:
2240 summary: Delete video import
2241 description: Delete ended video import
2242 security:
2243 - OAuth2: []
2244 tags:
2245 - Video Imports
2246 parameters:
2247 - $ref: '#/components/parameters/id'
2248 responses:
2249 '204':
2250 description: successful operation
2251
2197 /videos/live: 2252 /videos/live:
2198 post: 2253 post:
2199 summary: Create a live 2254 summary: Create a live
@@ -4767,7 +4822,7 @@ components:
4767 name: id 4822 name: id
4768 in: path 4823 in: path
4769 required: true 4824 required: true
4770 description: The user id 4825 description: Entity id
4771 schema: 4826 schema:
4772 $ref: '#/components/schemas/id' 4827 $ref: '#/components/schemas/id'
4773 idOrUUID: 4828 idOrUUID: