Add ability to cancel & delete video imports

This commit is contained in:
Chocobozzz 2022-01-19 14:23:00 +01:00
parent 52435e467a
commit 419b520ca4
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
22 changed files with 539 additions and 44 deletions

View File

@ -13,7 +13,7 @@
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr> <tr>
<th style="width: 40px;"></th> <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: 45%" i18n>Target</th>
<th style="width: 55%" i18n>Video</th> <th style="width: 55%" i18n>Video</th>
<th style="width: 150px" i18n>State</th> <th style="width: 150px" i18n>State</th>
@ -28,8 +28,9 @@
</td> </td>
<td class="action-cell"> <td class="action-cell">
<my-edit-button *ngIf="isVideoImportSuccess(videoImport) && videoImport.video" <my-button *ngIf="isVideoImportPending(videoImport)" i18n-label label="Cancel" icon="no" (click)="cancelImport(videoImport)"></my-button>
[routerLink]="getEditVideoUrl(videoImport.video)"></my-edit-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>
<td> <td>

View File

@ -37,6 +37,8 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
return 'badge-banned' return 'badge-banned'
case VideoImportState.PENDING: case VideoImportState.PENDING:
return 'badge-yellow' return 'badge-yellow'
case VideoImportState.PROCESSING:
return 'badge-blue'
default: default:
return 'badge-green' return 'badge-green'
} }
@ -54,6 +56,10 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
return videoImport.state.id === VideoImportState.FAILED return videoImport.state.id === VideoImportState.FAILED
} }
isVideoImportCancelled (videoImport: VideoImport) {
return videoImport.state.id === VideoImportState.CANCELLED
}
getVideoUrl (video: { uuid: string }) { getVideoUrl (video: { uuid: string }) {
return Video.buildWatchUrl(video) return Video.buildWatchUrl(video)
} }
@ -62,6 +68,24 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
return Video.buildUpdateUrl(video) 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 () { protected reloadData () {
this.videoImportService.getMyVideoImports(this.pagination, this.sort) this.videoImportService.getMyVideoImports(this.pagination, this.sort)
.subscribe({ .subscribe({

View File

@ -1,5 +1,5 @@
<span class="action-button" [ngClass]="getClasses()" [ngbTooltip]="getTitle()" tabindex="0"> <span class="action-button" [ngClass]="getClasses()" [ngbTooltip]="title" tabindex="0">
<my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon> <my-global-icon *ngIf="icon && !loading" [iconName]="icon"></my-global-icon>
<my-small-loader [loading]="loading"></my-small-loader> <my-small-loader [loading]="loading"></my-small-loader>
<span *ngIf="label" class="button-label">{{ label }}</span> <span *ngIf="label" class="button-label">{{ label }}</span>

View File

@ -16,10 +16,6 @@ export class ButtonComponent {
@Input() disabled = false @Input() disabled = false
@Input() responsiveLabel = false @Input() responsiveLabel = false
getTitle () {
return this.title || this.label
}
getClasses () { getClasses () {
return { return {
[this.className]: true, [this.className]: true,

View File

@ -20,10 +20,6 @@ export class DeleteButtonComponent implements OnInit {
// <my-delete-button label /> Use default label // <my-delete-button label /> Use default label
if (this.label === '') { if (this.label === '') {
this.label = $localize`Delete` this.label = $localize`Delete`
if (!this.title) {
this.title = this.label
}
} }
} }
} }

View 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 { private buildImportVideoObject (video: VideoUpdate): VideoImportCreate {
const language = video.language || null const language = video.language || null
const licence = video.licence || null const licence = video.licence || null

View File

@ -257,7 +257,7 @@
} }
@mixin peertube-button { @mixin peertube-button {
@include padding(0, 17px, 0, 13px); padding: 0 13px;
border: 0; border: 0;
font-weight: $font-semibold; font-weight: $font-semibold;
@ -270,6 +270,10 @@
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
my-global-icon + * {
@include margin-right(4px);
}
} }
@mixin peertube-button-link { @mixin peertube-button-link {

View File

@ -1,5 +1,5 @@
import express from 'express' 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 { isArray } from '../../helpers/custom-validators/misc'
import { JobQueue } from '../../lib/job-queue' import { JobQueue } from '../../lib/job-queue'
import { import {
@ -16,6 +16,18 @@ import { listJobsValidator } from '../../middlewares/validators/jobs'
const jobsRouter = express.Router() 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?', jobsRouter.get('/:state?',
openapiOperationDoc({ operationId: 'getJobs' }), openapiOperationDoc({ operationId: 'getJobs' }),
authenticate, 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) { async function listJobs (req: express.Request, res: express.Response) {
const state = req.params.state as JobState const state = req.params.state as JobState
const asc = req.query.sort === 'createdAt' const asc = req.query.sort === 'createdAt'

View File

@ -19,7 +19,15 @@ import {
MVideoWithBlacklistLight MVideoWithBlacklistLight
} from '@server/types/models' } from '@server/types/models'
import { MVideoImportFormattable } from '@server/types/models/video/video-import' 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 { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
import { isArray } from '../../../helpers/custom-validators/misc' 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 { JobQueue } from '../../../lib/job-queue/job-queue'
import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail' import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail'
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 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 { VideoModel } from '../../../models/video/video'
import { VideoCaptionModel } from '../../../models/video/video-caption' import { VideoCaptionModel } from '../../../models/video/video-caption'
import { VideoImportModel } from '../../../models/video/video-import' import { VideoImportModel } from '../../../models/video/video-import'
@ -59,6 +74,18 @@ videoImportsRouter.post('/imports',
asyncRetryTransactionMiddleware(addVideoImport) asyncRetryTransactionMiddleware(addVideoImport)
) )
videoImportsRouter.post('/imports/:id/cancel',
authenticate,
asyncMiddleware(videoImportCancelValidator),
asyncRetryTransactionMiddleware(cancelVideoImport)
)
videoImportsRouter.delete('/imports/:id',
authenticate,
asyncMiddleware(videoImportDeleteValidator),
asyncRetryTransactionMiddleware(deleteVideoImport)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { 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) { function addVideoImport (req: express.Request, res: express.Response) {
if (req.body.targetUrl) return addYoutubeDLImport(req, res) if (req.body.targetUrl) return addYoutubeDLImport(req, res)

View File

@ -441,7 +441,9 @@ const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
[VideoImportState.FAILED]: 'Failed', [VideoImportState.FAILED]: 'Failed',
[VideoImportState.PENDING]: 'Pending', [VideoImportState.PENDING]: 'Pending',
[VideoImportState.SUCCESS]: 'Success', [VideoImportState.SUCCESS]: 'Success',
[VideoImportState.REJECTED]: 'Rejected' [VideoImportState.REJECTED]: 'Rejected',
[VideoImportState.CANCELLED]: 'Cancelled',
[VideoImportState.PROCESSING]: 'Processing'
} }
const ABUSE_STATES: { [ id in AbuseState ]: string } = { const ABUSE_STATES: { [ id in AbuseState ]: string } = {

View File

@ -42,8 +42,17 @@ import { generateVideoMiniature } from '../../thumbnail'
async function processVideoImport (job: Job) { async function processVideoImport (job: Job) {
const payload = job.data as VideoImportPayload const payload = job.data as VideoImportPayload
if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, payload) const videoImport = await getVideoImportOrDie(payload.videoImportId)
if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') return processTorrentImport(job, payload) 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) 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 = { const target = {
torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined, torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined,
uri: videoImport.magnetUri uri: videoImport.magnetUri
@ -70,14 +75,10 @@ async function processTorrentImport (job: Job, payload: VideoImportTorrentPayloa
return processFile(() => downloadWebTorrentVideo(target, VIDEO_IMPORT_TIMEOUT), videoImport, options) 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) 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')) const youtubeDL = new YoutubeDLWrapper(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))

View 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 { createJob (obj: CreateJobArgument, options: CreateJobOptions = {}): void {
this.createJobWithPromise(obj, options) this.createJobWithPromise(obj, options)
.catch(err => logger.error('Cannot create job.', { err, obj })) .catch(err => logger.error('Cannot create job.', { err, obj }))

View File

@ -1,8 +1,10 @@
import express from 'express' 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 { isPreImportVideoAccepted } from '@server/lib/moderation'
import { Hooks } from '@server/lib/plugins/hooks' 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 { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 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 { logger } from '../../../helpers/logger'
import { CONFIG } from '../../../initializers/config' import { CONFIG } from '../../../initializers/config'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
import { areValidationErrors, doesVideoChannelOfAccountExist } from '../shared' import { areValidationErrors, doesVideoChannelOfAccountExist, doesVideoImportExist } from '../shared'
import { getCommonVideoEditAttributes } from './videos' import { getCommonVideoEditAttributes } from './videos'
import { isValid as isIPValid, parse as parseIP } from 'ipaddr.js'
const videoImportAddValidator = getCommonVideoEditAttributes().concat([ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
body('channelId') 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 { export {
videoImportAddValidator videoImportAddValidator,
videoImportCancelValidator,
videoImportDeleteValidator
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -132,3 +181,15 @@ async function isImportAccepted (req: express.Request, res: express.Response) {
return true 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
}

View File

@ -3,7 +3,14 @@
import 'mocha' import 'mocha'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
import { HttpStatusCode } from '@shared/models' 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 () { describe('Test jobs API validators', function () {
const path = '/api/v1/jobs/failed' const path = '/api/v1/jobs/failed'
@ -76,7 +83,41 @@ describe('Test jobs API validators', function () {
expectedStatus: HttpStatusCode.FORBIDDEN_403 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 () { after(async function () {

View File

@ -12,7 +12,9 @@ import {
makePostBodyRequest, makePostBodyRequest,
makeUploadRequest, makeUploadRequest,
PeerTubeServer, PeerTubeServer,
setAccessTokensToServers setAccessTokensToServers,
setDefaultVideoChannel,
waitJobs
} from '@shared/server-commands' } from '@shared/server-commands'
describe('Test video imports API validator', function () { describe('Test video imports API validator', function () {
@ -29,6 +31,7 @@ describe('Test video imports API validator', function () {
server = await createSingleServer(1) server = await createSingleServer(1)
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
await setDefaultVideoChannel([ server ])
const username = 'user1' const username = 'user1'
const password = 'my super password' 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 () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View File

@ -11,6 +11,7 @@ import {
setAccessTokensToServers, setAccessTokensToServers,
waitJobs waitJobs
} from '@shared/server-commands' } from '@shared/server-commands'
import { wait } from '@shared/core-utils'
const expect = chai.expect const expect = chai.expect
@ -91,6 +92,30 @@ describe('Test jobs', function () {
expect(jobs.find(j => j.state === 'completed')).to.not.be.undefined 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 () { after(async function () {
await cleanupTests(servers) await cleanupTests(servers)
}) })

View File

@ -6,7 +6,7 @@ import { pathExists, readdir, remove } from 'fs-extra'
import { join } from 'path' import { join } from 'path'
import { FIXTURE_URLS, testCaptionFile, testImage } from '@server/tests/shared' import { FIXTURE_URLS, testCaptionFile, testImage } from '@server/tests/shared'
import { areHttpImportTestsDisabled } from '@shared/core-utils' import { areHttpImportTestsDisabled } from '@shared/core-utils'
import { VideoPrivacy, VideoResolution } from '@shared/models' import { HttpStatusCode, Video, VideoImportState, VideoPrivacy, VideoResolution, VideoState } from '@shared/models'
import { import {
cleanupTests, cleanupTests,
createMultipleServers, createMultipleServers,
@ -382,6 +382,85 @@ describe('Test video imports', function () {
runSuite('yt-dlp') 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 () { describe('Auto update', function () {
let server: PeerTubeServer let server: PeerTubeServer

View File

@ -41,5 +41,7 @@ export const enum UserRight {
MANAGE_VIDEOS_REDUNDANCIES, MANAGE_VIDEOS_REDUNDANCIES,
MANAGE_VIDEO_FILES, MANAGE_VIDEO_FILES,
RUN_VIDEO_TRANSCODING RUN_VIDEO_TRANSCODING,
MANAGE_VIDEO_IMPORTS
} }

View File

@ -2,5 +2,7 @@ export const enum VideoImportState {
PENDING = 1, PENDING = 1,
SUCCESS = 2, SUCCESS = 2,
FAILED = 3, FAILED = 3,
REJECTED = 4 REJECTED = 4,
CANCELLED = 5,
PROCESSING = 6
} }

View File

@ -14,6 +14,30 @@ export class JobsCommand extends AbstractCommand {
return data[0] 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 & { list (options: OverrideCommandOptions & {
state?: JobState state?: JobState
jobType?: JobType jobType?: JobType

View 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 & { getMyVideoImports (options: OverrideCommandOptions & {
sort?: string sort?: string
} = {}) { } = {}) {

View File

@ -252,6 +252,8 @@ tags:
The import function is practical when the desired video/audio is available online. It makes PeerTube 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. 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 - name: Video Captions
description: Operations dealing with listing, adding and removing closed captions of a video. description: Operations dealing with listing, adding and removing closed captions of a video.
- name: Video Channels - name: Video Channels
@ -306,6 +308,7 @@ x-tagGroups:
tags: tags:
- Video - Video
- Video Upload - Video Upload
- Video Imports
- Video Captions - Video Captions
- Video Channels - Video Channels
- Video Comments - Video Comments
@ -587,6 +590,30 @@ paths:
'204': '204':
description: successful operation 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}: /jobs/{state}:
get: get:
summary: List instance jobs summary: List instance jobs
@ -2166,7 +2193,7 @@ paths:
security: security:
- OAuth2: [] - OAuth2: []
tags: tags:
- Video - Video Imports
- Video Upload - Video Upload
requestBody: requestBody:
content: content:
@ -2194,6 +2221,34 @@ paths:
'409': '409':
description: HTTP or Torrent/magnetURI import not enabled 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: /videos/live:
post: post:
summary: Create a live summary: Create a live
@ -4767,7 +4822,7 @@ components:
name: id name: id
in: path in: path
required: true required: true
description: The user id description: Entity id
schema: schema:
$ref: '#/components/schemas/id' $ref: '#/components/schemas/id'
idOrUUID: idOrUUID: