From b46cf4b920984492df598c1b61179acfc7f6f22e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 17 Nov 2021 16:04:53 +0100 Subject: [PATCH] Add ability to remove hls/webtorrent files --- .../overview/videos/video-list.component.html | 4 +- .../overview/videos/video-list.component.ts | 55 ++++++--- .../shared/shared-main/video/video.model.ts | 11 +- .../shared/shared-main/video/video.service.ts | 9 ++ .../video-actions-dropdown.component.ts | 39 ++++++- server/controllers/api/videos/files.ts | 79 +++++++++++++ server/controllers/api/videos/index.ts | 15 +-- server/controllers/api/videos/update.ts | 2 +- .../job-queue/handlers/video-file-import.ts | 2 +- .../job-queue/handlers/video-transcoding.ts | 2 +- server/middlewares/validators/videos/index.ts | 1 + .../validators/videos/video-files.ts | 104 ++++++++++++++++++ server/models/redundancy/video-redundancy.ts | 2 +- server/models/video/video.ts | 4 +- server/tests/api/check-params/index.ts | 1 + server/tests/api/check-params/video-files.ts | 99 +++++++++++++++++ server/tests/api/videos/index.ts | 1 + server/tests/api/videos/video-files.ts | 70 ++++++++++++ shared/extra-utils/videos/videos-command.ts | 30 +++++ shared/models/users/user-right.enum.ts | 4 +- 20 files changed, 497 insertions(+), 37 deletions(-) create mode 100644 server/controllers/api/videos/files.ts create mode 100644 server/middlewares/validators/videos/video-files.ts create mode 100644 server/tests/api/check-params/video-files.ts create mode 100644 server/tests/api/videos/video-files.ts diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html index 9b536ec11..6e4fb4c6f 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.html +++ b/client/src/app/+admin/overview/videos/video-list.component.html @@ -57,7 +57,7 @@ @@ -127,4 +127,4 @@ - + diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts index 7f268bb23..3c21adb44 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.ts +++ b/client/src/app/+admin/overview/videos/video-list.component.ts @@ -39,7 +39,8 @@ export class VideoListComponent extends RestTable implements OnInit { report: false, duplicate: true, mute: true, - liveInfo: false + liveInfo: false, + removeFiles: true } loading = true @@ -71,17 +72,34 @@ export class VideoListComponent extends RestTable implements OnInit { { label: $localize`Delete`, handler: videos => this.removeVideos(videos), - isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO) + isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO), + iconName: 'delete' }, { label: $localize`Block`, handler: videos => this.videoBlockModal.show(videos), - isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => !v.blacklisted) + isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => !v.blacklisted), + iconName: 'no' }, { label: $localize`Unblock`, handler: videos => this.unblockVideos(videos), - isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => v.blacklisted) + isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => v.blacklisted), + iconName: 'undo' + } + ], + [ + { + label: $localize`Delete HLS files`, + handler: videos => this.removeVideoFiles(videos, 'hls'), + isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_FILES) && videos.every(v => v.hasHLS() && v.hasWebTorrent()), + iconName: 'delete' + }, + { + label: $localize`Delete WebTorrent files`, + handler: videos => this.removeVideoFiles(videos, 'webtorrent'), + isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_FILES) && videos.every(v => v.hasHLS() && v.hasWebTorrent()), + iconName: 'delete' } ] ] @@ -95,10 +113,6 @@ export class VideoListComponent extends RestTable implements OnInit { return this.selectedVideos.length !== 0 } - onVideoRemoved () { - this.reloadData() - } - getPrivacyBadgeClass (video: Video) { if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green' @@ -146,11 +160,7 @@ export class VideoListComponent extends RestTable implements OnInit { return files.reduce((p, f) => p += f.size, 0) } - onVideoBlocked () { - this.reloadData() - } - - protected reloadData () { + reloadData () { this.selectedVideos = [] this.loading = true @@ -197,4 +207,23 @@ export class VideoListComponent extends RestTable implements OnInit { error: err => this.notifier.error(err.message) }) } + + private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') { + const message = type === 'hls' + ? $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?` + : $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?` + + const res = await this.confirmService.confirm(message, $localize`Delete`) + if (res === false) return + + this.videoService.removeVideoFiles(videos.map(v => v.id), type) + .subscribe({ + next: () => { + this.notifier.success($localize`Files were removed.`) + this.reloadData() + }, + + error: err => this.notifier.error(err.message) + }) + } } diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index 472a8c810..4203ff1c0 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -14,7 +14,8 @@ import { VideoPrivacy, VideoScheduleUpdate, VideoState, - VideoStreamingPlaylist + VideoStreamingPlaylist, + VideoStreamingPlaylistType } from '@shared/models' export class Video implements VideoServerModel { @@ -219,6 +220,14 @@ export class Video implements VideoServerModel { return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) } + hasHLS () { + return this.streamingPlaylists?.some(p => p.type === VideoStreamingPlaylistType.HLS) + } + + hasWebTorrent () { + return this.files && this.files.length !== 0 + } + isLiveInfoAvailableBy (user: AuthUser) { return this.isLive && user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.GET_ANY_LIVE)) diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 570e8e3be..d135a27dc 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -299,6 +299,15 @@ export class VideoService { ) } + removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'webtorrent') { + return from(videoIds) + .pipe( + concatMap(id => this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + id + '/' + type)), + toArray(), + catchError(err => this.restExtractor.handleError(err)) + ) + } + loadCompleteDescription (descriptionPath: string) { return this.authHttp .get<{ description: string }>(environment.apiUrl + descriptionPath) diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts index eff56b40e..82c084791 100644 --- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts @@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@a import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' -import { VideoCaption } from '@shared/models' +import { UserRight, VideoCaption } from '@shared/models' import { Actor, DropdownAction, @@ -27,6 +27,7 @@ export type VideoActionsDisplayType = { duplicate?: boolean mute?: boolean liveInfo?: boolean + removeFiles?: boolean } @Component({ @@ -65,6 +66,7 @@ export class VideoActionsDropdownComponent implements OnChanges { @Input() buttonSize: DropdownButtonSize = 'normal' @Input() buttonDirection: DropdownDirection = 'vertical' + @Output() videoFilesRemoved = new EventEmitter() @Output() videoRemoved = new EventEmitter() @Output() videoUnblocked = new EventEmitter() @Output() videoBlocked = new EventEmitter() @@ -174,6 +176,10 @@ export class VideoActionsDropdownComponent implements OnChanges { return this.video.account.id !== this.user.account.id } + canRemoveVideoFiles () { + return this.user.hasRight(UserRight.MANAGE_VIDEO_FILES) && this.video.hasHLS() && this.video.hasWebTorrent() + } + /* Action handlers */ async unblockVideo () { @@ -245,6 +251,23 @@ export class VideoActionsDropdownComponent implements OnChanges { }) } + async removeVideoFiles (video: Video, type: 'hls' | 'webtorrent') { + const confirmMessage = $localize`Do you really want to remove "${this.video.name}" files?` + + const res = await this.confirmService.confirm(confirmMessage, $localize`Remove "${this.video.name}" files`) + if (res === false) return + + this.videoService.removeVideoFiles([ video.id ], type) + .subscribe({ + next: () => { + this.notifier.success($localize`Removed files of ${video.name}.`) + this.videoFilesRemoved.emit() + }, + + error: err => this.notifier.error(err.message) + }) + } + onVideoBlocked () { this.videoBlocked.emit() } @@ -317,6 +340,20 @@ export class VideoActionsDropdownComponent implements OnChanges { iconName: 'flag' } ], + [ + { + label: $localize`Delete HLS files`, + handler: ({ video }) => this.removeVideoFiles(video, 'hls'), + isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(), + iconName: 'delete' + }, + { + label: $localize`Delete WebTorrent files`, + handler: ({ video }) => this.removeVideoFiles(video, 'webtorrent'), + isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(), + iconName: 'delete' + } + ], [ // actions regarding the account/its server { label: $localize`Mute account`, diff --git a/server/controllers/api/videos/files.ts b/server/controllers/api/videos/files.ts new file mode 100644 index 000000000..2fe4b5a3f --- /dev/null +++ b/server/controllers/api/videos/files.ts @@ -0,0 +1,79 @@ +import express from 'express' +import toInt from 'validator/lib/toInt' +import { logger, loggerTagsFactory } from '@server/helpers/logger' +import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' +import { VideoFileModel } from '@server/models/video/video-file' +import { HttpStatusCode } from '@shared/models' +import { + asyncMiddleware, + authenticate, + videoFileMetadataGetValidator, + videoFilesDeleteHLSValidator, + videoFilesDeleteWebTorrentValidator +} from '../../../middlewares' + +const lTags = loggerTagsFactory('api', 'video') +const filesRouter = express.Router() + +filesRouter.get('/:id/metadata/:videoFileId', + asyncMiddleware(videoFileMetadataGetValidator), + asyncMiddleware(getVideoFileMetadata) +) + +filesRouter.delete('/:id/hls', + authenticate, + asyncMiddleware(videoFilesDeleteHLSValidator), + asyncMiddleware(removeHLSPlaylist) +) + +filesRouter.delete('/:id/webtorrent', + authenticate, + asyncMiddleware(videoFilesDeleteWebTorrentValidator), + asyncMiddleware(removeWebTorrentFiles) +) + +// --------------------------------------------------------------------------- + +export { + filesRouter +} + +// --------------------------------------------------------------------------- + +async function getVideoFileMetadata (req: express.Request, res: express.Response) { + const videoFile = await VideoFileModel.loadWithMetadata(toInt(req.params.videoFileId)) + + return res.json(videoFile.metadata) +} + +async function removeHLSPlaylist (req: express.Request, res: express.Response) { + const video = res.locals.videoAll + + logger.info('Deleting HLS playlist of %s.', video.url, lTags(video.uuid)) + + const hls = video.getHLSPlaylist() + await video.removeStreamingPlaylistFiles(hls) + await hls.destroy() + + video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id) + + await federateVideoIfNeeded(video, false, undefined) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function removeWebTorrentFiles (req: express.Request, res: express.Response) { + const video = res.locals.videoAll + + logger.info('Deleting WebTorrent files of %s.', video.url, lTags(video.uuid)) + + for (const file of video.VideoFiles) { + await video.removeWebTorrentFileAndTorrent(file) + await file.destroy() + } + + video.VideoFiles = [] + await federateVideoIfNeeded(video, false, undefined) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 72b382595..2d088a73e 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -1,5 +1,4 @@ import express from 'express' -import toInt from 'validator/lib/toInt' import { pickCommonVideoQuery } from '@server/helpers/query' import { doJSONRequest } from '@server/helpers/requests' import { VideoViews } from '@server/lib/video-views' @@ -27,17 +26,16 @@ import { paginationValidator, setDefaultPagination, setDefaultVideosSort, - videoFileMetadataGetValidator, videosCustomGetValidator, videosGetValidator, videosRemoveValidator, videosSortValidator } from '../../../middlewares' import { VideoModel } from '../../../models/video/video' -import { VideoFileModel } from '../../../models/video/video-file' import { blacklistRouter } from './blacklist' import { videoCaptionsRouter } from './captions' import { videoCommentRouter } from './comment' +import { filesRouter } from './files' import { videoImportsRouter } from './import' import { liveRouter } from './live' import { ownershipVideoRouter } from './ownership' @@ -59,6 +57,7 @@ videosRouter.use('/', watchingRouter) videosRouter.use('/', liveRouter) videosRouter.use('/', uploadRouter) videosRouter.use('/', updateRouter) +videosRouter.use('/', filesRouter) videosRouter.get('/categories', openapiOperationDoc({ operationId: 'getCategories' }), @@ -93,10 +92,6 @@ videosRouter.get('/:id/description', asyncMiddleware(videosGetValidator), asyncMiddleware(getVideoDescription) ) -videosRouter.get('/:id/metadata/:videoFileId', - asyncMiddleware(videoFileMetadataGetValidator), - asyncMiddleware(getVideoFileMetadata) -) videosRouter.get('/:id', openapiOperationDoc({ operationId: 'getVideo' }), optionalAuthenticate, @@ -177,12 +172,6 @@ async function getVideoDescription (req: express.Request, res: express.Response) return res.json({ description }) } -async function getVideoFileMetadata (req: express.Request, res: express.Response) { - const videoFile = await VideoFileModel.loadWithMetadata(toInt(req.params.videoFileId)) - - return res.json(videoFile.metadata) -} - async function listVideos (req: express.Request, res: express.Response) { const serverActor = await getServerActor() diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts index a0aa13d71..de5d94d55 100644 --- a/server/controllers/api/videos/update.ts +++ b/server/controllers/api/videos/update.ts @@ -51,7 +51,7 @@ export { // --------------------------------------------------------------------------- -export async function updateVideo (req: express.Request, res: express.Response) { +async function updateVideo (req: express.Request, res: express.Response) { const videoFromReq = res.locals.videoAll const videoFieldsSave = videoFromReq.toJSON() const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON()) diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts index 47ae10a66..a91c2ef80 100644 --- a/server/lib/job-queue/handlers/video-file-import.ts +++ b/server/lib/job-queue/handlers/video-file-import.ts @@ -55,7 +55,7 @@ async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { if (currentVideoFile) { // Remove old file and old torrent - await video.removeFileAndTorrent(currentVideoFile) + await video.removeWebTorrentFileAndTorrent(currentVideoFile) // Remove the old video file from the array video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile) diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index 0143cd02a..904ef2e3c 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -138,7 +138,7 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay if (payload.isMaxQuality && CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) { // Remove webtorrent files if not enabled for (const file of video.VideoFiles) { - await video.removeFileAndTorrent(file) + await video.removeWebTorrentFileAndTorrent(file) await file.destroy() } diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts index 369c2c9b6..fd1d58093 100644 --- a/server/middlewares/validators/videos/index.ts +++ b/server/middlewares/validators/videos/index.ts @@ -2,6 +2,7 @@ export * from './video-blacklist' export * from './video-captions' export * from './video-channels' export * from './video-comments' +export * from './video-files' export * from './video-imports' export * from './video-live' export * from './video-ownership-changes' diff --git a/server/middlewares/validators/videos/video-files.ts b/server/middlewares/validators/videos/video-files.ts new file mode 100644 index 000000000..282594ab6 --- /dev/null +++ b/server/middlewares/validators/videos/video-files.ts @@ -0,0 +1,104 @@ +import express from 'express' +import { MUser, MVideo } from '@server/types/models' +import { HttpStatusCode, UserRight } from '../../../../shared' +import { logger } from '../../../helpers/logger' +import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared' + +const videoFilesDeleteWebTorrentValidator = [ + isValidVideoIdParam('id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoFilesDeleteWebTorrent parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.id, res)) return + + const video = res.locals.videoAll + const user = res.locals.oauth.token.User + + if (!checkUserCanDeleteFiles(user, res)) return + if (!checkLocalVideo(video, res)) return + + if (!video.hasWebTorrentFiles()) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'This video does not have WebTorrent files' + }) + } + + if (!video.getHLSPlaylist()) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'Cannot delete WebTorrent files since this video does not have HLS playlist' + }) + } + + return next() + } +] + +const videoFilesDeleteHLSValidator = [ + isValidVideoIdParam('id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoFilesDeleteHLS parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await doesVideoExist(req.params.id, res)) return + + const video = res.locals.videoAll + const user = res.locals.oauth.token.User + + if (!checkUserCanDeleteFiles(user, res)) return + if (!checkLocalVideo(video, res)) return + + if (!video.getHLSPlaylist()) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'This video does not have HLS files' + }) + } + + if (!video.hasWebTorrentFiles()) { + return res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'Cannot delete HLS playlist since this video does not have WebTorrent files' + }) + } + + return next() + } +] + +export { + videoFilesDeleteWebTorrentValidator, + videoFilesDeleteHLSValidator +} + +// --------------------------------------------------------------------------- + +function checkLocalVideo (video: MVideo, res: express.Response) { + if (video.remote) { + res.fail({ + status: HttpStatusCode.BAD_REQUEST_400, + message: 'Cannot delete files of remote video' + }) + + return false + } + + return true +} + +function checkUserCanDeleteFiles (user: MUser, res: express.Response) { + if (user.hasRight(UserRight.MANAGE_VIDEO_FILES) !== true) { + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: 'User cannot update video files' + }) + + return false + } + + return true +} diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts index 529977924..e8d79a3ab 100644 --- a/server/models/redundancy/video-redundancy.ts +++ b/server/models/redundancy/video-redundancy.ts @@ -160,7 +160,7 @@ export class VideoRedundancyModel extends Model logger.error('Cannot delete %s files.', logIdentifier, { err })) } diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 69d009e04..6eeb6b312 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -746,7 +746,7 @@ export class VideoModel extends Model>> { // Remove physical files and torrents instance.VideoFiles.forEach(file => { - tasks.push(instance.removeFileAndTorrent(file)) + tasks.push(instance.removeWebTorrentFileAndTorrent(file)) }) // Remove playlists file @@ -1706,7 +1706,7 @@ export class VideoModel extends Model>> { .concat(toAdd) } - removeFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) { + removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) { const filePath = isRedundancy ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile) : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile) diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 0882f8176..ff7dc4abb 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -28,5 +28,6 @@ import './video-imports' import './video-playlists' import './videos' import './videos-common-filters' +import './video-files' import './videos-history' import './videos-overviews' diff --git a/server/tests/api/check-params/video-files.ts b/server/tests/api/check-params/video-files.ts new file mode 100644 index 000000000..48b10d2b5 --- /dev/null +++ b/server/tests/api/check-params/video-files.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/extra-utils' +import { HttpStatusCode, UserRole } from '@shared/models' + +describe('Test videos files', function () { + let servers: PeerTubeServer[] + let webtorrentId: string + let hlsId: string + let remoteId: string + let userToken: string + let moderatorToken: string + let validId1: string + let validId2: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(150_000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + userToken = await servers[0].users.generateUserAndToken('user', UserRole.USER) + moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR) + + { + await servers[0].config.enableTranscoding(true, true) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) + validId1 = uuid + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' }) + validId2 = uuid + } + } + + await waitJobs(servers) + + { + await servers[0].config.enableTranscoding(false, true) + const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) + hlsId = uuid + } + + await waitJobs(servers) + + { + await servers[0].config.enableTranscoding(false, true) + const { uuid } = await servers[0].videos.quickUpload({ name: 'webtorrent' }) + webtorrentId = uuid + } + + await waitJobs(servers) + }) + + it('Should not delete files of a remote video', async function () { + await servers[0].videos.removeHLSFiles({ videoId: remoteId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeWebTorrentFiles({ videoId: remoteId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not delete files by a non admin user', async function () { + const expectedStatus = HttpStatusCode.FORBIDDEN_403 + + await servers[0].videos.removeHLSFiles({ videoId: validId1, token: userToken, expectedStatus }) + await servers[0].videos.removeHLSFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) + + await servers[0].videos.removeWebTorrentFiles({ videoId: validId1, token: userToken, expectedStatus }) + await servers[0].videos.removeWebTorrentFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) + }) + + it('Should not delete files if the files are not available', async function () { + await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not delete files if no both versions are available', async function () { + await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should not delete files if no both versions are available', async function () { + await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should delete files if both versions are available', async function () { + await servers[0].videos.removeHLSFiles({ videoId: validId1 }) + await servers[0].videos.removeWebTorrentFiles({ videoId: validId2 }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index c9c678e9d..f92e339e7 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -7,6 +7,7 @@ import './video-change-ownership' import './video-channels' import './video-comments' import './video-description' +import './video-files' import './video-hls' import './video-imports' import './video-nsfw' diff --git a/server/tests/api/videos/video-files.ts b/server/tests/api/videos/video-files.ts new file mode 100644 index 000000000..fcb2ca2e4 --- /dev/null +++ b/server/tests/api/videos/video-files.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { expect } from 'chai' +import { cleanupTests, createMultipleServers, doubleFollow, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/extra-utils' + +describe('Test videos files', function () { + let servers: PeerTubeServer[] + let validId1: string + let validId2: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(150_000) + + servers = await createMultipleServers(2) + await setAccessTokensToServers(servers) + + await doubleFollow(servers[0], servers[1]) + + await servers[0].config.enableTranscoding(true, true) + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' }) + validId1 = uuid + } + + { + const { uuid } = await servers[0].videos.quickUpload({ name: 'video 2' }) + validId2 = uuid + } + + await waitJobs(servers) + }) + + it('Should delete webtorrent files', async function () { + this.timeout(30_000) + + await servers[0].videos.removeWebTorrentFiles({ videoId: validId1 }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: validId1 }) + + expect(video.files).to.have.lengthOf(0) + expect(video.streamingPlaylists).to.have.lengthOf(1) + } + }) + + it('Should delete HLS files', async function () { + this.timeout(30_000) + + await servers[0].videos.removeHLSFiles({ videoId: validId2 }) + + await waitJobs(servers) + + for (const server of servers) { + const video = await server.videos.get({ id: validId2 }) + + expect(video.files).to.have.length.above(0) + expect(video.streamingPlaylists).to.have.lengthOf(0) + } + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/shared/extra-utils/videos/videos-command.ts b/shared/extra-utils/videos/videos-command.ts index 167fae22d..13a7d0e1c 100644 --- a/shared/extra-utils/videos/videos-command.ts +++ b/shared/extra-utils/videos/videos-command.ts @@ -602,6 +602,36 @@ export class VideosCommand extends AbstractCommand { // --------------------------------------------------------------------------- + removeHLSFiles (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/hls' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + removeWebTorrentFiles (options: OverrideCommandOptions & { + videoId: number | string + }) { + const path = '/api/v1/videos/' + options.videoId + '/webtorrent' + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + // --------------------------------------------------------------------------- + private buildListQuery (options: VideosCommonQuery) { return pick(options, [ 'start', diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 950b22bad..96bccaf2f 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts @@ -38,5 +38,7 @@ export const enum UserRight { MANAGE_PLUGINS, - MANAGE_VIDEOS_REDUNDANCIES + MANAGE_VIDEOS_REDUNDANCIES, + + MANAGE_VIDEO_FILES }