]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability to cancel & delete video imports
authorChocobozzz <me@florianbigard.com>
Wed, 19 Jan 2022 13:23:00 +0000 (14:23 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 19 Jan 2022 13:31:05 +0000 (14:31 +0100)
22 files changed:
client/src/app/+my-library/my-video-imports/my-video-imports.component.html
client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
client/src/app/shared/shared-main/buttons/button.component.html
client/src/app/shared/shared-main/buttons/button.component.ts
client/src/app/shared/shared-main/buttons/delete-button.component.ts
client/src/app/shared/shared-main/video/video-import.service.ts
client/src/sass/include/_mixins.scss
server/controllers/api/jobs.ts
server/controllers/api/videos/import.ts
server/initializers/constants.ts
server/lib/job-queue/handlers/video-import.ts
server/lib/job-queue/job-queue.ts
server/middlewares/validators/videos/video-imports.ts
server/tests/api/check-params/jobs.ts
server/tests/api/check-params/video-imports.ts
server/tests/api/server/jobs.ts
server/tests/api/videos/video-imports.ts
shared/models/users/user-right.enum.ts
shared/models/videos/import/video-import-state.enum.ts
shared/server-commands/server/jobs-command.ts
shared/server-commands/videos/imports-command.ts
support/doc/api/openapi.yaml

index bd29b11c8e4bf2aa4901aca757be98e0d84ec1b1..e0d4e8f149c62457e21ad5ae491643b927f32378 100644 (file)
@@ -13,7 +13,7 @@
   <ng-template pTemplate="header">
     <tr>
       <th style="width: 40px;"></th>
-      <th style="width: 70px">Action</th>
+      <th style="width: 200px">Action</th>
       <th style="width: 45%" i18n>Target</th>
       <th style="width: 55%" i18n>Video</th>
       <th style="width: 150px" i18n>State</th>
@@ -28,8 +28,9 @@
       </td>
 
       <td class="action-cell">
-        <my-edit-button *ngIf="isVideoImportSuccess(videoImport) && videoImport.video"
-          [routerLink]="getEditVideoUrl(videoImport.video)"></my-edit-button>
+        <my-button *ngIf="isVideoImportPending(videoImport)" i18n-label label="Cancel" icon="no" (click)="cancelImport(videoImport)"></my-button>
+        <my-delete-button *ngIf="isVideoImportFailed(videoImport) || isVideoImportCancelled(videoImport) || !videoImport.video" (click)="deleteImport(videoImport)"></my-delete-button>
+        <my-edit-button *ngIf="isVideoImportSuccess(videoImport) && videoImport.video" [routerLink]="getEditVideoUrl(videoImport.video)"></my-edit-button>
       </td>
 
       <td>
index 914785bf7448b4fa8ebf297e0e9afdc9f67285ef..f01558061f786176962fde6c2af054d51d86e203 100644 (file)
@@ -37,6 +37,8 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
         return 'badge-banned'
       case VideoImportState.PENDING:
         return 'badge-yellow'
+      case VideoImportState.PROCESSING:
+        return 'badge-blue'
       default:
         return 'badge-green'
     }
@@ -54,6 +56,10 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
     return videoImport.state.id === VideoImportState.FAILED
   }
 
+  isVideoImportCancelled (videoImport: VideoImport) {
+    return videoImport.state.id === VideoImportState.CANCELLED
+  }
+
   getVideoUrl (video: { uuid: string }) {
     return Video.buildWatchUrl(video)
   }
@@ -62,6 +68,24 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
     return Video.buildUpdateUrl(video)
   }
 
+  deleteImport (videoImport: VideoImport) {
+    this.videoImportService.deleteVideoImport(videoImport)
+      .subscribe({
+        next: () => this.reloadData(),
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
+  cancelImport (videoImport: VideoImport) {
+    this.videoImportService.cancelVideoImport(videoImport)
+      .subscribe({
+        next: () => this.reloadData(),
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
   protected reloadData () {
     this.videoImportService.getMyVideoImports(this.pagination, this.sort)
         .subscribe({
index 65e06f7a4ddeb22ddd15260e0828760f0d426492..11c8ffeddd308fe9464a7c864b9c326061f1cda5 100644 (file)
@@ -1,5 +1,5 @@
-<span class="action-button" [ngClass]="getClasses()" [ngbTooltip]="getTitle()" tabindex="0">
-  <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
+<span class="action-button" [ngClass]="getClasses()" [ngbTooltip]="title" tabindex="0">
+  <my-global-icon *ngIf="icon && !loading" [iconName]="icon"></my-global-icon>
   <my-small-loader [loading]="loading"></my-small-loader>
 
   <span *ngIf="label" class="button-label">{{ label }}</span>
index ee74b3d128cfbb54281fd3badd93e8eea3733863..b97012d9a0a725b0bfe7a3a0363e371f8881adfa 100644 (file)
@@ -16,10 +16,6 @@ export class ButtonComponent {
   @Input() disabled = false
   @Input() responsiveLabel = false
 
-  getTitle () {
-    return this.title || this.label
-  }
-
   getClasses () {
     return {
       [this.className]: true,
index c091f5309e40801c62a38354bb9346f33048b09f..90735852c31c52981ac3ca6284f237848b507dde 100644 (file)
@@ -20,10 +20,6 @@ export class DeleteButtonComponent implements OnInit {
     // <my-delete-button label /> Use default label
     if (this.label === '') {
       this.label = $localize`Delete`
-
-      if (!this.title) {
-        this.title = this.label
-      }
     }
   }
 }
index 99df78e3a0e2d1834bbc65451dcd6403b7d50f60..0a610ab1f530ef40c7efd17cb64f631a8bab57fc 100644 (file)
@@ -56,6 +56,16 @@ export class VideoImportService {
                )
   }
 
+  deleteVideoImport (videoImport: VideoImport) {
+    return this.authHttp.delete(VideoImportService.BASE_VIDEO_IMPORT_URL + videoImport.id)
+                        .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  cancelVideoImport (videoImport: VideoImport) {
+    return this.authHttp.post(VideoImportService.BASE_VIDEO_IMPORT_URL + videoImport.id + '/cancel', {})
+                        .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
   private buildImportVideoObject (video: VideoUpdate): VideoImportCreate {
     const language = video.language || null
     const licence = video.licence || null
index a7c4c99c278edb20587996e0c14ebc2bc2df2c28..c8ec3b4d187e9dfd3d5af086c5fb5d3c667d4bca 100644 (file)
 }
 
 @mixin peertube-button {
-  @include padding(0, 17px, 0, 13px);
+  padding: 0 13px;
 
   border: 0;
   font-weight: $font-semibold;
 
   text-align: center;
   cursor: pointer;
+
+  my-global-icon + * {
+    @include margin-right(4px);
+  }
 }
 
 @mixin peertube-button-link {
index eebd195b0e0a603857a933d8e780159fd42af724..c61b7362f22a8277bbf5cd49161c5056d836f9ec 100644 (file)
@@ -1,5 +1,5 @@
 import express from 'express'
-import { Job, JobState, JobType, ResultList, UserRight } from '@shared/models'
+import { HttpStatusCode, Job, JobState, JobType, ResultList, UserRight } from '@shared/models'
 import { isArray } from '../../helpers/custom-validators/misc'
 import { JobQueue } from '../../lib/job-queue'
 import {
@@ -16,6 +16,18 @@ import { listJobsValidator } from '../../middlewares/validators/jobs'
 
 const jobsRouter = express.Router()
 
+jobsRouter.post('/pause',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_JOBS),
+  asyncMiddleware(pauseJobQueue)
+)
+
+jobsRouter.post('/resume',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_JOBS),
+  asyncMiddleware(resumeJobQueue)
+)
+
 jobsRouter.get('/:state?',
   openapiOperationDoc({ operationId: 'getJobs' }),
   authenticate,
@@ -36,6 +48,18 @@ export {
 
 // ---------------------------------------------------------------------------
 
+async function pauseJobQueue (req: express.Request, res: express.Response) {
+  await JobQueue.Instance.pause()
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function resumeJobQueue (req: express.Request, res: express.Response) {
+  await JobQueue.Instance.resume()
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
 async function listJobs (req: express.Request, res: express.Response) {
   const state = req.params.state as JobState
   const asc = req.query.sort === 'createdAt'
index 08d69827b371ada9f63d98f9b5224c1e11f34ec8..8cbfd3286458fa61e8fc6c054191dcad7a2e4841 100644 (file)
@@ -19,7 +19,15 @@ import {
   MVideoWithBlacklistLight
 } from '@server/types/models'
 import { MVideoImportFormattable } from '@server/types/models/video/video-import'
-import { ServerErrorCode, ThumbnailType, VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
+import {
+  HttpStatusCode,
+  ServerErrorCode,
+  ThumbnailType,
+  VideoImportCreate,
+  VideoImportState,
+  VideoPrivacy,
+  VideoState
+} from '@shared/models'
 import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
 import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
 import { isArray } from '../../../helpers/custom-validators/misc'
@@ -34,7 +42,14 @@ import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url'
 import { JobQueue } from '../../../lib/job-queue/job-queue'
 import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail'
 import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
-import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
+import {
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
+  authenticate,
+  videoImportAddValidator,
+  videoImportCancelValidator,
+  videoImportDeleteValidator
+} from '../../../middlewares'
 import { VideoModel } from '../../../models/video/video'
 import { VideoCaptionModel } from '../../../models/video/video-caption'
 import { VideoImportModel } from '../../../models/video/video-import'
@@ -59,6 +74,18 @@ videoImportsRouter.post('/imports',
   asyncRetryTransactionMiddleware(addVideoImport)
 )
 
+videoImportsRouter.post('/imports/:id/cancel',
+  authenticate,
+  asyncMiddleware(videoImportCancelValidator),
+  asyncRetryTransactionMiddleware(cancelVideoImport)
+)
+
+videoImportsRouter.delete('/imports/:id',
+  authenticate,
+  asyncMiddleware(videoImportDeleteValidator),
+  asyncRetryTransactionMiddleware(deleteVideoImport)
+)
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -67,6 +94,23 @@ export {
 
 // ---------------------------------------------------------------------------
 
+async function deleteVideoImport (req: express.Request, res: express.Response) {
+  const videoImport = res.locals.videoImport
+
+  await videoImport.destroy()
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function cancelVideoImport (req: express.Request, res: express.Response) {
+  const videoImport = res.locals.videoImport
+
+  videoImport.state = VideoImportState.CANCELLED
+  await videoImport.save()
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
 function addVideoImport (req: express.Request, res: express.Response) {
   if (req.body.targetUrl) return addYoutubeDLImport(req, res)
 
index b2f511152649693c3b38ac3a6b46b5024f47c0e3..6a59bf805eaa3aab3d6c3773075fc1a77bdfaf8b 100644 (file)
@@ -441,7 +441,9 @@ const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
   [VideoImportState.FAILED]: 'Failed',
   [VideoImportState.PENDING]: 'Pending',
   [VideoImportState.SUCCESS]: 'Success',
-  [VideoImportState.REJECTED]: 'Rejected'
+  [VideoImportState.REJECTED]: 'Rejected',
+  [VideoImportState.CANCELLED]: 'Cancelled',
+  [VideoImportState.PROCESSING]: 'Processing'
 }
 
 const ABUSE_STATES: { [ id in AbuseState ]: string } = {
index 2f74e9fbd6c9548142f24582088f2c7870194a14..cb79725aa06f6600966825e336a0349a5612ce00 100644 (file)
@@ -42,8 +42,17 @@ import { generateVideoMiniature } from '../../thumbnail'
 async function processVideoImport (job: Job) {
   const payload = job.data as VideoImportPayload
 
-  if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, payload)
-  if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') return processTorrentImport(job, payload)
+  const videoImport = await getVideoImportOrDie(payload.videoImportId)
+  if (videoImport.state === VideoImportState.CANCELLED) {
+    logger.info('Do not process import since it has been cancelled', { payload })
+    return
+  }
+
+  videoImport.state = VideoImportState.PROCESSING
+  await videoImport.save()
+
+  if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, videoImport, payload)
+  if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') return processTorrentImport(job, videoImport, payload)
 }
 
 // ---------------------------------------------------------------------------
@@ -54,15 +63,11 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function processTorrentImport (job: Job, payload: VideoImportTorrentPayload) {
+async function processTorrentImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportTorrentPayload) {
   logger.info('Processing torrent video import in job %d.', job.id)
 
-  const videoImport = await getVideoImportOrDie(payload.videoImportId)
+  const options = { type: payload.type, videoImportId: payload.videoImportId }
 
-  const options = {
-    type: payload.type,
-    videoImportId: payload.videoImportId
-  }
   const target = {
     torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined,
     uri: videoImport.magnetUri
@@ -70,14 +75,10 @@ async function processTorrentImport (job: Job, payload: VideoImportTorrentPayloa
   return processFile(() => downloadWebTorrentVideo(target, VIDEO_IMPORT_TIMEOUT), videoImport, options)
 }
 
-async function processYoutubeDLImport (job: Job, payload: VideoImportYoutubeDLPayload) {
+async function processYoutubeDLImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportYoutubeDLPayload) {
   logger.info('Processing youtubeDL video import in job %d.', job.id)
 
-  const videoImport = await getVideoImportOrDie(payload.videoImportId)
-  const options = {
-    type: payload.type,
-    videoImportId: videoImport.id
-  }
+  const options = { type: payload.type, videoImportId: videoImport.id }
 
   const youtubeDL = new YoutubeDLWrapper(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
 
index fbc599f127a865d8b41773e0e0b9935628574b7d..22bd1f5d2c70b3d8a8228aef440f4c70d7f7d552 100644 (file)
@@ -162,6 +162,18 @@ class JobQueue {
     }
   }
 
+  async pause () {
+    for (const handler of Object.keys(this.queues)) {
+      await this.queues[handler].pause(true)
+    }
+  }
+
+  async resume () {
+    for (const handler of Object.keys(this.queues)) {
+      await this.queues[handler].resume(true)
+    }
+  }
+
   createJob (obj: CreateJobArgument, options: CreateJobOptions = {}): void {
     this.createJobWithPromise(obj, options)
         .catch(err => logger.error('Cannot create job.', { err, obj }))
index e4b54283f6349f46d7156cc17f74947f2a23afc1..a3a5cc5315e5414de3da9b4e3b3c0cf00d6514dd 100644 (file)
@@ -1,8 +1,10 @@
 import express from 'express'
-import { body } from 'express-validator'
+import { body, param } from 'express-validator'
+import { isValid as isIPValid, parse as parseIP } from 'ipaddr.js'
 import { isPreImportVideoAccepted } from '@server/lib/moderation'
 import { Hooks } from '@server/lib/plugins/hooks'
-import { HttpStatusCode } from '@shared/models'
+import { MUserAccountId, MVideoImport } from '@server/types/models'
+import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models'
 import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
 import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
 import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
@@ -11,9 +13,8 @@ import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
 import { CONFIG } from '../../../initializers/config'
 import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
-import { areValidationErrors, doesVideoChannelOfAccountExist } from '../shared'
+import { areValidationErrors, doesVideoChannelOfAccountExist, doesVideoImportExist } from '../shared'
 import { getCommonVideoEditAttributes } from './videos'
-import { isValid as isIPValid, parse as parseIP } from 'ipaddr.js'
 
 const videoImportAddValidator = getCommonVideoEditAttributes().concat([
   body('channelId')
@@ -95,10 +96,58 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
   }
 ])
 
+const videoImportDeleteValidator = [
+  param('id')
+    .custom(isIdValid).withMessage('Should have correct import id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoImportDeleteValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await doesVideoImportExist(parseInt(req.params.id), res)) return
+    if (!checkUserCanManageImport(res.locals.oauth.token.user, res.locals.videoImport, res)) return
+
+    if (res.locals.videoImport.state === VideoImportState.PENDING) {
+      return res.fail({
+        status: HttpStatusCode.CONFLICT_409,
+        message: 'Cannot delete a pending video import. Cancel it or wait for the end of the import first.'
+      })
+    }
+
+    return next()
+  }
+]
+
+const videoImportCancelValidator = [
+  param('id')
+    .custom(isIdValid).withMessage('Should have correct import id'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoImportCancelValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    if (!await doesVideoImportExist(parseInt(req.params.id), res)) return
+    if (!checkUserCanManageImport(res.locals.oauth.token.user, res.locals.videoImport, res)) return
+
+    if (res.locals.videoImport.state !== VideoImportState.PENDING) {
+      return res.fail({
+        status: HttpStatusCode.CONFLICT_409,
+        message: 'Cannot cancel a non pending video import.'
+      })
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
-  videoImportAddValidator
+  videoImportAddValidator,
+  videoImportCancelValidator,
+  videoImportDeleteValidator
 }
 
 // ---------------------------------------------------------------------------
@@ -132,3 +181,15 @@ async function isImportAccepted (req: express.Request, res: express.Response) {
 
   return true
 }
+
+function checkUserCanManageImport (user: MUserAccountId, videoImport: MVideoImport, res: express.Response) {
+  if (user.hasRight(UserRight.MANAGE_VIDEO_IMPORTS) === false && videoImport.userId !== user.id) {
+    res.fail({
+      status: HttpStatusCode.FORBIDDEN_403,
+      message: 'Cannot manage video import of another user'
+    })
+    return false
+  }
+
+  return true
+}
index d85961d62679a5d095d04f166f13aee1ebff4fb2..801b13d1e8547e507929507e83efce1c6dc51d80 100644 (file)
@@ -3,7 +3,14 @@
 import 'mocha'
 import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
 import { HttpStatusCode } from '@shared/models'
-import { cleanupTests, createSingleServer, makeGetRequest, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
+import {
+  cleanupTests,
+  createSingleServer,
+  makeGetRequest,
+  makePostBodyRequest,
+  PeerTubeServer,
+  setAccessTokensToServers
+} from '@shared/server-commands'
 
 describe('Test jobs API validators', function () {
   const path = '/api/v1/jobs/failed'
@@ -76,7 +83,41 @@ describe('Test jobs API validators', function () {
         expectedStatus: HttpStatusCode.FORBIDDEN_403
       })
     })
+  })
+
+  describe('When pausing/resuming the job queue', async function () {
+    const commands = [ 'pause', 'resume' ]
+
+    it('Should fail with a non authenticated user', async function () {
+      for (const command of commands) {
+        await makePostBodyRequest({
+          url: server.url,
+          path: '/api/v1/jobs/' + command,
+          expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+        })
+      }
+    })
 
+    it('Should fail with a non admin user', async function () {
+      for (const command of commands) {
+        await makePostBodyRequest({
+          url: server.url,
+          path: '/api/v1/jobs/' + command,
+          expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+        })
+      }
+    })
+
+    it('Should succeed with the correct params', async function () {
+      for (const command of commands) {
+        await makePostBodyRequest({
+          url: server.url,
+          path: '/api/v1/jobs/' + command,
+          token: server.accessToken,
+          expectedStatus: HttpStatusCode.NO_CONTENT_204
+        })
+      }
+    })
   })
 
   after(async function () {
index da05793a016f961173a98bc0ba75b5360088c1ed..156a612ee7ab1cd9d861c3990c3f2a4c7d9e1a6b 100644 (file)
@@ -12,7 +12,9 @@ import {
   makePostBodyRequest,
   makeUploadRequest,
   PeerTubeServer,
-  setAccessTokensToServers
+  setAccessTokensToServers,
+  setDefaultVideoChannel,
+  waitJobs
 } from '@shared/server-commands'
 
 describe('Test video imports API validator', function () {
@@ -29,6 +31,7 @@ describe('Test video imports API validator', function () {
     server = await createSingleServer(1)
 
     await setAccessTokensToServers([ server ])
+    await setDefaultVideoChannel([ server ])
 
     const username = 'user1'
     const password = 'my super password'
@@ -347,6 +350,67 @@ describe('Test video imports API validator', function () {
     })
   })
 
+  describe('Deleting/cancelling a video import', function () {
+    let importId: number
+
+    async function importVideo () {
+      const attributes = { channelId: server.store.channel.id, targetUrl: FIXTURE_URLS.goodVideo }
+      const res = await server.imports.importVideo({ attributes })
+
+      return res.id
+    }
+
+    before(async function () {
+      importId = await importVideo()
+    })
+
+    it('Should fail with an invalid import id', async function () {
+      await server.imports.cancel({ importId: 'artyom' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+      await server.imports.delete({ importId: 'artyom' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
+
+    it('Should fail with an unknown import id', async function () {
+      await server.imports.cancel({ importId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+      await server.imports.delete({ importId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should fail without token', async function () {
+      await server.imports.cancel({ importId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+      await server.imports.delete({ importId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should fail with another user token', async function () {
+      await server.imports.cancel({ importId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+      await server.imports.delete({ importId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should fail to cancel non pending import', async function () {
+      this.timeout(60000)
+
+      await waitJobs([ server ])
+
+      await server.imports.cancel({ importId, expectedStatus: HttpStatusCode.CONFLICT_409 })
+    })
+
+    it('Should succeed to delete an import', async function () {
+      await server.imports.delete({ importId })
+    })
+
+    it('Should fail to delete a pending import', async function () {
+      await server.jobs.pauseJobQueue()
+
+      importId = await importVideo()
+
+      await server.imports.delete({ importId, expectedStatus: HttpStatusCode.CONFLICT_409 })
+    })
+
+    it('Should succeed to cancel an import', async function () {
+      importId = await importVideo()
+
+      await server.imports.cancel({ importId })
+    })
+  })
+
   after(async function () {
     await cleanupTests([ server ])
   })
index 4294e1fd52af0cf93ae4172ee5f8e58438230d34..bd8ffe18871235b15645eccbe8a9109d0d7d7852 100644 (file)
@@ -11,6 +11,7 @@ import {
   setAccessTokensToServers,
   waitJobs
 } from '@shared/server-commands'
+import { wait } from '@shared/core-utils'
 
 const expect = chai.expect
 
@@ -91,6 +92,30 @@ describe('Test jobs', function () {
     expect(jobs.find(j => j.state === 'completed')).to.not.be.undefined
   })
 
+  it('Should pause the job queue', async function () {
+    this.timeout(120000)
+
+    await servers[1].jobs.pauseJobQueue()
+
+    await servers[1].videos.upload({ attributes: { name: 'video2' } })
+
+    await wait(5000)
+
+    const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
+    expect(body.data).to.have.lengthOf(1)
+  })
+
+  it('Should resume the job queue', async function () {
+    this.timeout(120000)
+
+    await servers[1].jobs.resumeJobQueue()
+
+    await waitJobs(servers)
+
+    const body = await servers[1].jobs.list({ state: 'waiting', jobType: 'video-transcoding' })
+    expect(body.data).to.have.lengthOf(0)
+  })
+
   after(async function () {
     await cleanupTests(servers)
   })
index e8e0f01f1838088b7ea19ee986aa5a4fedcd4224..ba21ab17acd7d4d009ee819512bed85a3587a1d4 100644 (file)
@@ -6,7 +6,7 @@ import { pathExists, readdir, remove } from 'fs-extra'
 import { join } from 'path'
 import { FIXTURE_URLS, testCaptionFile, testImage } from '@server/tests/shared'
 import { areHttpImportTestsDisabled } from '@shared/core-utils'
-import { VideoPrivacy, VideoResolution } from '@shared/models'
+import { HttpStatusCode, Video, VideoImportState, VideoPrivacy, VideoResolution, VideoState } from '@shared/models'
 import {
   cleanupTests,
   createMultipleServers,
@@ -382,6 +382,85 @@ describe('Test video imports', function () {
 
   runSuite('yt-dlp')
 
+  describe('Delete/cancel an import', function () {
+    let server: PeerTubeServer
+
+    let finishedImportId: number
+    let finishedVideo: Video
+    let pendingImportId: number
+
+    async function importVideo (name: string) {
+      const attributes = { name, channelId: server.store.channel.id, targetUrl: FIXTURE_URLS.goodVideo }
+      const res = await server.imports.importVideo({ attributes })
+
+      return res.id
+    }
+
+    before(async function () {
+      this.timeout(120_000)
+
+      server = await createSingleServer(1)
+
+      await setAccessTokensToServers([ server ])
+      await setDefaultVideoChannel([ server ])
+
+      finishedImportId = await importVideo('finished')
+      await waitJobs([ server ])
+
+      await server.jobs.pauseJobQueue()
+      pendingImportId = await importVideo('pending')
+
+      const { data } = await server.imports.getMyVideoImports()
+      expect(data).to.have.lengthOf(2)
+
+      finishedVideo = data.find(i => i.id === finishedImportId).video
+    })
+
+    it('Should delete a video import', async function () {
+      await server.imports.delete({ importId: finishedImportId })
+
+      const { data } = await server.imports.getMyVideoImports()
+      expect(data).to.have.lengthOf(1)
+      expect(data[0].id).to.equal(pendingImportId)
+      expect(data[0].state.id).to.equal(VideoImportState.PENDING)
+    })
+
+    it('Should not have deleted the associated video', async function () {
+      const video = await server.videos.get({ id: finishedVideo.id, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
+      expect(video.name).to.equal('finished')
+      expect(video.state.id).to.equal(VideoState.PUBLISHED)
+    })
+
+    it('Should cancel a video import', async function () {
+      await server.imports.cancel({ importId: pendingImportId })
+
+      const { data } = await server.imports.getMyVideoImports()
+      expect(data).to.have.lengthOf(1)
+      expect(data[0].id).to.equal(pendingImportId)
+      expect(data[0].state.id).to.equal(VideoImportState.CANCELLED)
+    })
+
+    it('Should not have processed the cancelled video import', async function () {
+      this.timeout(60_000)
+
+      await server.jobs.resumeJobQueue()
+
+      await waitJobs([ server ])
+
+      const { data } = await server.imports.getMyVideoImports()
+      expect(data).to.have.lengthOf(1)
+      expect(data[0].id).to.equal(pendingImportId)
+      expect(data[0].state.id).to.equal(VideoImportState.CANCELLED)
+      expect(data[0].video.state.id).to.equal(VideoState.TO_IMPORT)
+    })
+
+    it('Should delete the cancelled video import', async function () {
+      await server.imports.delete({ importId: pendingImportId })
+      const { data } = await server.imports.getMyVideoImports()
+      expect(data).to.have.lengthOf(0)
+    })
+  })
+
   describe('Auto update', function () {
     let server: PeerTubeServer
 
index 668535f4eb86d12f74d344206ba8bf2bcbe016fe..d3f793d8b496fe72427707b8171befa35511a3fd 100644 (file)
@@ -41,5 +41,7 @@ export const enum UserRight {
   MANAGE_VIDEOS_REDUNDANCIES,
 
   MANAGE_VIDEO_FILES,
-  RUN_VIDEO_TRANSCODING
+  RUN_VIDEO_TRANSCODING,
+
+  MANAGE_VIDEO_IMPORTS
 }
index 33dd83f8882658cc0df29851c1c36f42c1740961..ff5c6beff7f6ff853d3bfbd6a57c6cce28e89663 100644 (file)
@@ -2,5 +2,7 @@ export const enum VideoImportState {
   PENDING = 1,
   SUCCESS = 2,
   FAILED = 3,
-  REJECTED = 4
+  REJECTED = 4,
+  CANCELLED = 5,
+  PROCESSING = 6
 }
index ac62157d1d4fa1410e0258f02d4e0c2e22814ac2..b8790ea00df78ebb8b9969f49ec75f047bc57974 100644 (file)
@@ -14,6 +14,30 @@ export class JobsCommand extends AbstractCommand {
     return data[0]
   }
 
+  pauseJobQueue (options: OverrideCommandOptions = {}) {
+    const path = '/api/v1/jobs/pause'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  resumeJobQueue (options: OverrideCommandOptions = {}) {
+    const path = '/api/v1/jobs/resume'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
   list (options: OverrideCommandOptions & {
     state?: JobState
     jobType?: JobType
index e4944694d9e95e2af2c6eacbeac9f9068a7f486a..f63ed5d4bc3db2abe5ccee524831eaf37b3ca605 100644 (file)
@@ -26,6 +26,34 @@ export class ImportsCommand extends AbstractCommand {
     }))
   }
 
+  delete (options: OverrideCommandOptions & {
+    importId: number
+  }) {
+    const path = '/api/v1/videos/imports/' + options.importId
+
+    return this.deleteRequest({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  cancel (options: OverrideCommandOptions & {
+    importId: number
+  }) {
+    const path = '/api/v1/videos/imports/' + options.importId + '/cancel'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
   getMyVideoImports (options: OverrideCommandOptions & {
     sort?: string
   } = {}) {
index 5bf3f13cc533dc725b71add01724dbfa7cf658c6..9e721be4b425febc5d6db06a8501aa308ef83b6f 100644 (file)
@@ -252,6 +252,8 @@ tags:
 
       The import function is practical when the desired video/audio is available online. It makes PeerTube
       download it for you, saving you as much bandwidth and avoiding any instability or limitation your network might have.
+  - name: Video Imports
+    description: Operations dealing with listing, adding and removing video imports.
   - name: Video Captions
     description: Operations dealing with listing, adding and removing closed captions of a video.
   - name: Video Channels
@@ -306,6 +308,7 @@ x-tagGroups:
     tags:
       - Video
       - Video Upload
+      - Video Imports
       - Video Captions
       - Video Channels
       - Video Comments
@@ -587,6 +590,30 @@ paths:
         '204':
           description: successful operation
 
+  /jobs/pause:
+    post:
+      summary: Pause job queue
+      security:
+        - OAuth2:
+          - admin
+      tags:
+        - Job
+      responses:
+        '204':
+          description: successful operation
+
+  /jobs/resume:
+    post:
+      summary: Resume job queue
+      security:
+        - OAuth2:
+          - admin
+      tags:
+        - Job
+      responses:
+        '204':
+          description: successful operation
+
   /jobs/{state}:
     get:
       summary: List instance jobs
@@ -2166,7 +2193,7 @@ paths:
       security:
         - OAuth2: []
       tags:
-        - Video
+        - Video Imports
         - Video Upload
       requestBody:
         content:
@@ -2194,6 +2221,34 @@ paths:
         '409':
           description: HTTP or Torrent/magnetURI import not enabled
 
+  /videos/imports/{id}/cancel:
+    post:
+      summary: Cancel video import
+      description: Cancel a pending video import
+      security:
+        - OAuth2: []
+      tags:
+        - Video Imports
+      parameters:
+        - $ref: '#/components/parameters/id'
+      responses:
+        '204':
+          description: successful operation
+
+  /videos/imports/{id}:
+    delete:
+      summary: Delete video import
+      description: Delete ended video import
+      security:
+        - OAuth2: []
+      tags:
+        - Video Imports
+      parameters:
+        - $ref: '#/components/parameters/id'
+      responses:
+        '204':
+          description: successful operation
+
   /videos/live:
     post:
       summary: Create a live
@@ -4767,7 +4822,7 @@ components:
       name: id
       in: path
       required: true
-      description: The user id
+      description: Entity id
       schema:
         $ref: '#/components/schemas/id'
     idOrUUID: