diff options
author | Chocobozzz <me@florianbigard.com> | 2021-11-17 16:04:53 +0100 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2021-11-18 09:04:30 +0100 |
commit | b46cf4b920984492df598c1b61179acfc7f6f22e (patch) | |
tree | 21fda049c85be48ab3d37b537aafa98e94649ad7 | |
parent | 3cfa817672657df18260ece5b354efa0f3b6e317 (diff) | |
download | PeerTube-b46cf4b920984492df598c1b61179acfc7f6f22e.tar.gz PeerTube-b46cf4b920984492df598c1b61179acfc7f6f22e.tar.zst PeerTube-b46cf4b920984492df598c1b61179acfc7f6f22e.zip |
Add ability to remove hls/webtorrent files
20 files changed, 497 insertions, 37 deletions
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 @@ | |||
57 | <td class="action-cell"> | 57 | <td class="action-cell"> |
58 | <my-video-actions-dropdown | 58 | <my-video-actions-dropdown |
59 | placement="bottom auto" buttonDirection="horizontal" [buttonStyled]="true" [video]="video" | 59 | placement="bottom auto" buttonDirection="horizontal" [buttonStyled]="true" [video]="video" |
60 | [displayOptions]="videoActionsOptions" (videoRemoved)="onVideoRemoved()" | 60 | [displayOptions]="videoActionsOptions" (videoRemoved)="reloadData()" (videoFilesRemoved)="reloadData()" |
61 | ></my-video-actions-dropdown> | 61 | ></my-video-actions-dropdown> |
62 | </td> | 62 | </td> |
63 | 63 | ||
@@ -127,4 +127,4 @@ | |||
127 | </ng-template> | 127 | </ng-template> |
128 | </p-table> | 128 | </p-table> |
129 | 129 | ||
130 | <my-video-block #videoBlockModal (videoBlocked)="onVideoBlocked()"></my-video-block> | 130 | <my-video-block #videoBlockModal (videoBlocked)="reloadData()"></my-video-block> |
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 { | |||
39 | report: false, | 39 | report: false, |
40 | duplicate: true, | 40 | duplicate: true, |
41 | mute: true, | 41 | mute: true, |
42 | liveInfo: false | 42 | liveInfo: false, |
43 | removeFiles: true | ||
43 | } | 44 | } |
44 | 45 | ||
45 | loading = true | 46 | loading = true |
@@ -71,17 +72,34 @@ export class VideoListComponent extends RestTable implements OnInit { | |||
71 | { | 72 | { |
72 | label: $localize`Delete`, | 73 | label: $localize`Delete`, |
73 | handler: videos => this.removeVideos(videos), | 74 | handler: videos => this.removeVideos(videos), |
74 | isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO) | 75 | isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO), |
76 | iconName: 'delete' | ||
75 | }, | 77 | }, |
76 | { | 78 | { |
77 | label: $localize`Block`, | 79 | label: $localize`Block`, |
78 | handler: videos => this.videoBlockModal.show(videos), | 80 | handler: videos => this.videoBlockModal.show(videos), |
79 | isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => !v.blacklisted) | 81 | isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => !v.blacklisted), |
82 | iconName: 'no' | ||
80 | }, | 83 | }, |
81 | { | 84 | { |
82 | label: $localize`Unblock`, | 85 | label: $localize`Unblock`, |
83 | handler: videos => this.unblockVideos(videos), | 86 | handler: videos => this.unblockVideos(videos), |
84 | isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => v.blacklisted) | 87 | isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) && videos.every(v => v.blacklisted), |
88 | iconName: 'undo' | ||
89 | } | ||
90 | ], | ||
91 | [ | ||
92 | { | ||
93 | label: $localize`Delete HLS files`, | ||
94 | handler: videos => this.removeVideoFiles(videos, 'hls'), | ||
95 | isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_FILES) && videos.every(v => v.hasHLS() && v.hasWebTorrent()), | ||
96 | iconName: 'delete' | ||
97 | }, | ||
98 | { | ||
99 | label: $localize`Delete WebTorrent files`, | ||
100 | handler: videos => this.removeVideoFiles(videos, 'webtorrent'), | ||
101 | isDisplayed: videos => this.authUser.hasRight(UserRight.MANAGE_VIDEO_FILES) && videos.every(v => v.hasHLS() && v.hasWebTorrent()), | ||
102 | iconName: 'delete' | ||
85 | } | 103 | } |
86 | ] | 104 | ] |
87 | ] | 105 | ] |
@@ -95,10 +113,6 @@ export class VideoListComponent extends RestTable implements OnInit { | |||
95 | return this.selectedVideos.length !== 0 | 113 | return this.selectedVideos.length !== 0 |
96 | } | 114 | } |
97 | 115 | ||
98 | onVideoRemoved () { | ||
99 | this.reloadData() | ||
100 | } | ||
101 | |||
102 | getPrivacyBadgeClass (video: Video) { | 116 | getPrivacyBadgeClass (video: Video) { |
103 | if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green' | 117 | if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green' |
104 | 118 | ||
@@ -146,11 +160,7 @@ export class VideoListComponent extends RestTable implements OnInit { | |||
146 | return files.reduce((p, f) => p += f.size, 0) | 160 | return files.reduce((p, f) => p += f.size, 0) |
147 | } | 161 | } |
148 | 162 | ||
149 | onVideoBlocked () { | 163 | reloadData () { |
150 | this.reloadData() | ||
151 | } | ||
152 | |||
153 | protected reloadData () { | ||
154 | this.selectedVideos = [] | 164 | this.selectedVideos = [] |
155 | 165 | ||
156 | this.loading = true | 166 | this.loading = true |
@@ -197,4 +207,23 @@ export class VideoListComponent extends RestTable implements OnInit { | |||
197 | error: err => this.notifier.error(err.message) | 207 | error: err => this.notifier.error(err.message) |
198 | }) | 208 | }) |
199 | } | 209 | } |
210 | |||
211 | private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') { | ||
212 | const message = type === 'hls' | ||
213 | ? $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?` | ||
214 | : $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?` | ||
215 | |||
216 | const res = await this.confirmService.confirm(message, $localize`Delete`) | ||
217 | if (res === false) return | ||
218 | |||
219 | this.videoService.removeVideoFiles(videos.map(v => v.id), type) | ||
220 | .subscribe({ | ||
221 | next: () => { | ||
222 | this.notifier.success($localize`Files were removed.`) | ||
223 | this.reloadData() | ||
224 | }, | ||
225 | |||
226 | error: err => this.notifier.error(err.message) | ||
227 | }) | ||
228 | } | ||
200 | } | 229 | } |
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 { | |||
14 | VideoPrivacy, | 14 | VideoPrivacy, |
15 | VideoScheduleUpdate, | 15 | VideoScheduleUpdate, |
16 | VideoState, | 16 | VideoState, |
17 | VideoStreamingPlaylist | 17 | VideoStreamingPlaylist, |
18 | VideoStreamingPlaylistType | ||
18 | } from '@shared/models' | 19 | } from '@shared/models' |
19 | 20 | ||
20 | export class Video implements VideoServerModel { | 21 | export class Video implements VideoServerModel { |
@@ -219,6 +220,14 @@ export class Video implements VideoServerModel { | |||
219 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) | 220 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) |
220 | } | 221 | } |
221 | 222 | ||
223 | hasHLS () { | ||
224 | return this.streamingPlaylists?.some(p => p.type === VideoStreamingPlaylistType.HLS) | ||
225 | } | ||
226 | |||
227 | hasWebTorrent () { | ||
228 | return this.files && this.files.length !== 0 | ||
229 | } | ||
230 | |||
222 | isLiveInfoAvailableBy (user: AuthUser) { | 231 | isLiveInfoAvailableBy (user: AuthUser) { |
223 | return this.isLive && | 232 | return this.isLive && |
224 | user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.GET_ANY_LIVE)) | 233 | 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 { | |||
299 | ) | 299 | ) |
300 | } | 300 | } |
301 | 301 | ||
302 | removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'webtorrent') { | ||
303 | return from(videoIds) | ||
304 | .pipe( | ||
305 | concatMap(id => this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + id + '/' + type)), | ||
306 | toArray(), | ||
307 | catchError(err => this.restExtractor.handleError(err)) | ||
308 | ) | ||
309 | } | ||
310 | |||
302 | loadCompleteDescription (descriptionPath: string) { | 311 | loadCompleteDescription (descriptionPath: string) { |
303 | return this.authHttp | 312 | return this.authHttp |
304 | .get<{ description: string }>(environment.apiUrl + descriptionPath) | 313 | .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 | |||
2 | import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' | 2 | import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' |
3 | import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' | 3 | import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' |
4 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | 4 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' |
5 | import { VideoCaption } from '@shared/models' | 5 | import { UserRight, VideoCaption } from '@shared/models' |
6 | import { | 6 | import { |
7 | Actor, | 7 | Actor, |
8 | DropdownAction, | 8 | DropdownAction, |
@@ -27,6 +27,7 @@ export type VideoActionsDisplayType = { | |||
27 | duplicate?: boolean | 27 | duplicate?: boolean |
28 | mute?: boolean | 28 | mute?: boolean |
29 | liveInfo?: boolean | 29 | liveInfo?: boolean |
30 | removeFiles?: boolean | ||
30 | } | 31 | } |
31 | 32 | ||
32 | @Component({ | 33 | @Component({ |
@@ -65,6 +66,7 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
65 | @Input() buttonSize: DropdownButtonSize = 'normal' | 66 | @Input() buttonSize: DropdownButtonSize = 'normal' |
66 | @Input() buttonDirection: DropdownDirection = 'vertical' | 67 | @Input() buttonDirection: DropdownDirection = 'vertical' |
67 | 68 | ||
69 | @Output() videoFilesRemoved = new EventEmitter() | ||
68 | @Output() videoRemoved = new EventEmitter() | 70 | @Output() videoRemoved = new EventEmitter() |
69 | @Output() videoUnblocked = new EventEmitter() | 71 | @Output() videoUnblocked = new EventEmitter() |
70 | @Output() videoBlocked = new EventEmitter() | 72 | @Output() videoBlocked = new EventEmitter() |
@@ -174,6 +176,10 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
174 | return this.video.account.id !== this.user.account.id | 176 | return this.video.account.id !== this.user.account.id |
175 | } | 177 | } |
176 | 178 | ||
179 | canRemoveVideoFiles () { | ||
180 | return this.user.hasRight(UserRight.MANAGE_VIDEO_FILES) && this.video.hasHLS() && this.video.hasWebTorrent() | ||
181 | } | ||
182 | |||
177 | /* Action handlers */ | 183 | /* Action handlers */ |
178 | 184 | ||
179 | async unblockVideo () { | 185 | async unblockVideo () { |
@@ -245,6 +251,23 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
245 | }) | 251 | }) |
246 | } | 252 | } |
247 | 253 | ||
254 | async removeVideoFiles (video: Video, type: 'hls' | 'webtorrent') { | ||
255 | const confirmMessage = $localize`Do you really want to remove "${this.video.name}" files?` | ||
256 | |||
257 | const res = await this.confirmService.confirm(confirmMessage, $localize`Remove "${this.video.name}" files`) | ||
258 | if (res === false) return | ||
259 | |||
260 | this.videoService.removeVideoFiles([ video.id ], type) | ||
261 | .subscribe({ | ||
262 | next: () => { | ||
263 | this.notifier.success($localize`Removed files of ${video.name}.`) | ||
264 | this.videoFilesRemoved.emit() | ||
265 | }, | ||
266 | |||
267 | error: err => this.notifier.error(err.message) | ||
268 | }) | ||
269 | } | ||
270 | |||
248 | onVideoBlocked () { | 271 | onVideoBlocked () { |
249 | this.videoBlocked.emit() | 272 | this.videoBlocked.emit() |
250 | } | 273 | } |
@@ -317,6 +340,20 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
317 | iconName: 'flag' | 340 | iconName: 'flag' |
318 | } | 341 | } |
319 | ], | 342 | ], |
343 | [ | ||
344 | { | ||
345 | label: $localize`Delete HLS files`, | ||
346 | handler: ({ video }) => this.removeVideoFiles(video, 'hls'), | ||
347 | isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(), | ||
348 | iconName: 'delete' | ||
349 | }, | ||
350 | { | ||
351 | label: $localize`Delete WebTorrent files`, | ||
352 | handler: ({ video }) => this.removeVideoFiles(video, 'webtorrent'), | ||
353 | isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(), | ||
354 | iconName: 'delete' | ||
355 | } | ||
356 | ], | ||
320 | [ // actions regarding the account/its server | 357 | [ // actions regarding the account/its server |
321 | { | 358 | { |
322 | label: $localize`Mute account`, | 359 | 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 @@ | |||
1 | import express from 'express' | ||
2 | import toInt from 'validator/lib/toInt' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
4 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | ||
5 | import { VideoFileModel } from '@server/models/video/video-file' | ||
6 | import { HttpStatusCode } from '@shared/models' | ||
7 | import { | ||
8 | asyncMiddleware, | ||
9 | authenticate, | ||
10 | videoFileMetadataGetValidator, | ||
11 | videoFilesDeleteHLSValidator, | ||
12 | videoFilesDeleteWebTorrentValidator | ||
13 | } from '../../../middlewares' | ||
14 | |||
15 | const lTags = loggerTagsFactory('api', 'video') | ||
16 | const filesRouter = express.Router() | ||
17 | |||
18 | filesRouter.get('/:id/metadata/:videoFileId', | ||
19 | asyncMiddleware(videoFileMetadataGetValidator), | ||
20 | asyncMiddleware(getVideoFileMetadata) | ||
21 | ) | ||
22 | |||
23 | filesRouter.delete('/:id/hls', | ||
24 | authenticate, | ||
25 | asyncMiddleware(videoFilesDeleteHLSValidator), | ||
26 | asyncMiddleware(removeHLSPlaylist) | ||
27 | ) | ||
28 | |||
29 | filesRouter.delete('/:id/webtorrent', | ||
30 | authenticate, | ||
31 | asyncMiddleware(videoFilesDeleteWebTorrentValidator), | ||
32 | asyncMiddleware(removeWebTorrentFiles) | ||
33 | ) | ||
34 | |||
35 | // --------------------------------------------------------------------------- | ||
36 | |||
37 | export { | ||
38 | filesRouter | ||
39 | } | ||
40 | |||
41 | // --------------------------------------------------------------------------- | ||
42 | |||
43 | async function getVideoFileMetadata (req: express.Request, res: express.Response) { | ||
44 | const videoFile = await VideoFileModel.loadWithMetadata(toInt(req.params.videoFileId)) | ||
45 | |||
46 | return res.json(videoFile.metadata) | ||
47 | } | ||
48 | |||
49 | async function removeHLSPlaylist (req: express.Request, res: express.Response) { | ||
50 | const video = res.locals.videoAll | ||
51 | |||
52 | logger.info('Deleting HLS playlist of %s.', video.url, lTags(video.uuid)) | ||
53 | |||
54 | const hls = video.getHLSPlaylist() | ||
55 | await video.removeStreamingPlaylistFiles(hls) | ||
56 | await hls.destroy() | ||
57 | |||
58 | video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id) | ||
59 | |||
60 | await federateVideoIfNeeded(video, false, undefined) | ||
61 | |||
62 | return res.sendStatus(HttpStatusCode.NO_CONTENT_204) | ||
63 | } | ||
64 | |||
65 | async function removeWebTorrentFiles (req: express.Request, res: express.Response) { | ||
66 | const video = res.locals.videoAll | ||
67 | |||
68 | logger.info('Deleting WebTorrent files of %s.', video.url, lTags(video.uuid)) | ||
69 | |||
70 | for (const file of video.VideoFiles) { | ||
71 | await video.removeWebTorrentFileAndTorrent(file) | ||
72 | await file.destroy() | ||
73 | } | ||
74 | |||
75 | video.VideoFiles = [] | ||
76 | await federateVideoIfNeeded(video, false, undefined) | ||
77 | |||
78 | return res.sendStatus(HttpStatusCode.NO_CONTENT_204) | ||
79 | } | ||
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 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import toInt from 'validator/lib/toInt' | ||
3 | import { pickCommonVideoQuery } from '@server/helpers/query' | 2 | import { pickCommonVideoQuery } from '@server/helpers/query' |
4 | import { doJSONRequest } from '@server/helpers/requests' | 3 | import { doJSONRequest } from '@server/helpers/requests' |
5 | import { VideoViews } from '@server/lib/video-views' | 4 | import { VideoViews } from '@server/lib/video-views' |
@@ -27,17 +26,16 @@ import { | |||
27 | paginationValidator, | 26 | paginationValidator, |
28 | setDefaultPagination, | 27 | setDefaultPagination, |
29 | setDefaultVideosSort, | 28 | setDefaultVideosSort, |
30 | videoFileMetadataGetValidator, | ||
31 | videosCustomGetValidator, | 29 | videosCustomGetValidator, |
32 | videosGetValidator, | 30 | videosGetValidator, |
33 | videosRemoveValidator, | 31 | videosRemoveValidator, |
34 | videosSortValidator | 32 | videosSortValidator |
35 | } from '../../../middlewares' | 33 | } from '../../../middlewares' |
36 | import { VideoModel } from '../../../models/video/video' | 34 | import { VideoModel } from '../../../models/video/video' |
37 | import { VideoFileModel } from '../../../models/video/video-file' | ||
38 | import { blacklistRouter } from './blacklist' | 35 | import { blacklistRouter } from './blacklist' |
39 | import { videoCaptionsRouter } from './captions' | 36 | import { videoCaptionsRouter } from './captions' |
40 | import { videoCommentRouter } from './comment' | 37 | import { videoCommentRouter } from './comment' |
38 | import { filesRouter } from './files' | ||
41 | import { videoImportsRouter } from './import' | 39 | import { videoImportsRouter } from './import' |
42 | import { liveRouter } from './live' | 40 | import { liveRouter } from './live' |
43 | import { ownershipVideoRouter } from './ownership' | 41 | import { ownershipVideoRouter } from './ownership' |
@@ -59,6 +57,7 @@ videosRouter.use('/', watchingRouter) | |||
59 | videosRouter.use('/', liveRouter) | 57 | videosRouter.use('/', liveRouter) |
60 | videosRouter.use('/', uploadRouter) | 58 | videosRouter.use('/', uploadRouter) |
61 | videosRouter.use('/', updateRouter) | 59 | videosRouter.use('/', updateRouter) |
60 | videosRouter.use('/', filesRouter) | ||
62 | 61 | ||
63 | videosRouter.get('/categories', | 62 | videosRouter.get('/categories', |
64 | openapiOperationDoc({ operationId: 'getCategories' }), | 63 | openapiOperationDoc({ operationId: 'getCategories' }), |
@@ -93,10 +92,6 @@ videosRouter.get('/:id/description', | |||
93 | asyncMiddleware(videosGetValidator), | 92 | asyncMiddleware(videosGetValidator), |
94 | asyncMiddleware(getVideoDescription) | 93 | asyncMiddleware(getVideoDescription) |
95 | ) | 94 | ) |
96 | videosRouter.get('/:id/metadata/:videoFileId', | ||
97 | asyncMiddleware(videoFileMetadataGetValidator), | ||
98 | asyncMiddleware(getVideoFileMetadata) | ||
99 | ) | ||
100 | videosRouter.get('/:id', | 95 | videosRouter.get('/:id', |
101 | openapiOperationDoc({ operationId: 'getVideo' }), | 96 | openapiOperationDoc({ operationId: 'getVideo' }), |
102 | optionalAuthenticate, | 97 | optionalAuthenticate, |
@@ -177,12 +172,6 @@ async function getVideoDescription (req: express.Request, res: express.Response) | |||
177 | return res.json({ description }) | 172 | return res.json({ description }) |
178 | } | 173 | } |
179 | 174 | ||
180 | async function getVideoFileMetadata (req: express.Request, res: express.Response) { | ||
181 | const videoFile = await VideoFileModel.loadWithMetadata(toInt(req.params.videoFileId)) | ||
182 | |||
183 | return res.json(videoFile.metadata) | ||
184 | } | ||
185 | |||
186 | async function listVideos (req: express.Request, res: express.Response) { | 175 | async function listVideos (req: express.Request, res: express.Response) { |
187 | const serverActor = await getServerActor() | 176 | const serverActor = await getServerActor() |
188 | 177 | ||
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 { | |||
51 | 51 | ||
52 | // --------------------------------------------------------------------------- | 52 | // --------------------------------------------------------------------------- |
53 | 53 | ||
54 | export async function updateVideo (req: express.Request, res: express.Response) { | 54 | async function updateVideo (req: express.Request, res: express.Response) { |
55 | const videoFromReq = res.locals.videoAll | 55 | const videoFromReq = res.locals.videoAll |
56 | const videoFieldsSave = videoFromReq.toJSON() | 56 | const videoFieldsSave = videoFromReq.toJSON() |
57 | const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON()) | 57 | 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) { | |||
55 | 55 | ||
56 | if (currentVideoFile) { | 56 | if (currentVideoFile) { |
57 | // Remove old file and old torrent | 57 | // Remove old file and old torrent |
58 | await video.removeFileAndTorrent(currentVideoFile) | 58 | await video.removeWebTorrentFileAndTorrent(currentVideoFile) |
59 | // Remove the old video file from the array | 59 | // Remove the old video file from the array |
60 | video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile) | 60 | video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile) |
61 | 61 | ||
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 | |||
138 | if (payload.isMaxQuality && CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) { | 138 | if (payload.isMaxQuality && CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) { |
139 | // Remove webtorrent files if not enabled | 139 | // Remove webtorrent files if not enabled |
140 | for (const file of video.VideoFiles) { | 140 | for (const file of video.VideoFiles) { |
141 | await video.removeFileAndTorrent(file) | 141 | await video.removeWebTorrentFileAndTorrent(file) |
142 | await file.destroy() | 142 | await file.destroy() |
143 | } | 143 | } |
144 | 144 | ||
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' | |||
2 | export * from './video-captions' | 2 | export * from './video-captions' |
3 | export * from './video-channels' | 3 | export * from './video-channels' |
4 | export * from './video-comments' | 4 | export * from './video-comments' |
5 | export * from './video-files' | ||
5 | export * from './video-imports' | 6 | export * from './video-imports' |
6 | export * from './video-live' | 7 | export * from './video-live' |
7 | export * from './video-ownership-changes' | 8 | 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 @@ | |||
1 | import express from 'express' | ||
2 | import { MUser, MVideo } from '@server/types/models' | ||
3 | import { HttpStatusCode, UserRight } from '../../../../shared' | ||
4 | import { logger } from '../../../helpers/logger' | ||
5 | import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared' | ||
6 | |||
7 | const videoFilesDeleteWebTorrentValidator = [ | ||
8 | isValidVideoIdParam('id'), | ||
9 | |||
10 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
11 | logger.debug('Checking videoFilesDeleteWebTorrent parameters', { parameters: req.params }) | ||
12 | |||
13 | if (areValidationErrors(req, res)) return | ||
14 | if (!await doesVideoExist(req.params.id, res)) return | ||
15 | |||
16 | const video = res.locals.videoAll | ||
17 | const user = res.locals.oauth.token.User | ||
18 | |||
19 | if (!checkUserCanDeleteFiles(user, res)) return | ||
20 | if (!checkLocalVideo(video, res)) return | ||
21 | |||
22 | if (!video.hasWebTorrentFiles()) { | ||
23 | return res.fail({ | ||
24 | status: HttpStatusCode.BAD_REQUEST_400, | ||
25 | message: 'This video does not have WebTorrent files' | ||
26 | }) | ||
27 | } | ||
28 | |||
29 | if (!video.getHLSPlaylist()) { | ||
30 | return res.fail({ | ||
31 | status: HttpStatusCode.BAD_REQUEST_400, | ||
32 | message: 'Cannot delete WebTorrent files since this video does not have HLS playlist' | ||
33 | }) | ||
34 | } | ||
35 | |||
36 | return next() | ||
37 | } | ||
38 | ] | ||
39 | |||
40 | const videoFilesDeleteHLSValidator = [ | ||
41 | isValidVideoIdParam('id'), | ||
42 | |||
43 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
44 | logger.debug('Checking videoFilesDeleteHLS parameters', { parameters: req.params }) | ||
45 | |||
46 | if (areValidationErrors(req, res)) return | ||
47 | if (!await doesVideoExist(req.params.id, res)) return | ||
48 | |||
49 | const video = res.locals.videoAll | ||
50 | const user = res.locals.oauth.token.User | ||
51 | |||
52 | if (!checkUserCanDeleteFiles(user, res)) return | ||
53 | if (!checkLocalVideo(video, res)) return | ||
54 | |||
55 | if (!video.getHLSPlaylist()) { | ||
56 | return res.fail({ | ||
57 | status: HttpStatusCode.BAD_REQUEST_400, | ||
58 | message: 'This video does not have HLS files' | ||
59 | }) | ||
60 | } | ||
61 | |||
62 | if (!video.hasWebTorrentFiles()) { | ||
63 | return res.fail({ | ||
64 | status: HttpStatusCode.BAD_REQUEST_400, | ||
65 | message: 'Cannot delete HLS playlist since this video does not have WebTorrent files' | ||
66 | }) | ||
67 | } | ||
68 | |||
69 | return next() | ||
70 | } | ||
71 | ] | ||
72 | |||
73 | export { | ||
74 | videoFilesDeleteWebTorrentValidator, | ||
75 | videoFilesDeleteHLSValidator | ||
76 | } | ||
77 | |||
78 | // --------------------------------------------------------------------------- | ||
79 | |||
80 | function checkLocalVideo (video: MVideo, res: express.Response) { | ||
81 | if (video.remote) { | ||
82 | res.fail({ | ||
83 | status: HttpStatusCode.BAD_REQUEST_400, | ||
84 | message: 'Cannot delete files of remote video' | ||
85 | }) | ||
86 | |||
87 | return false | ||
88 | } | ||
89 | |||
90 | return true | ||
91 | } | ||
92 | |||
93 | function checkUserCanDeleteFiles (user: MUser, res: express.Response) { | ||
94 | if (user.hasRight(UserRight.MANAGE_VIDEO_FILES) !== true) { | ||
95 | res.fail({ | ||
96 | status: HttpStatusCode.FORBIDDEN_403, | ||
97 | message: 'User cannot update video files' | ||
98 | }) | ||
99 | |||
100 | return false | ||
101 | } | ||
102 | |||
103 | return true | ||
104 | } | ||
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<Partial<AttributesOnly<VideoRedu | |||
160 | const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` | 160 | const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` |
161 | logger.info('Removing duplicated video file %s.', logIdentifier) | 161 | logger.info('Removing duplicated video file %s.', logIdentifier) |
162 | 162 | ||
163 | videoFile.Video.removeFileAndTorrent(videoFile, true) | 163 | videoFile.Video.removeWebTorrentFileAndTorrent(videoFile, true) |
164 | .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) | 164 | .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) |
165 | } | 165 | } |
166 | 166 | ||
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<Partial<AttributesOnly<VideoModel>>> { | |||
746 | 746 | ||
747 | // Remove physical files and torrents | 747 | // Remove physical files and torrents |
748 | instance.VideoFiles.forEach(file => { | 748 | instance.VideoFiles.forEach(file => { |
749 | tasks.push(instance.removeFileAndTorrent(file)) | 749 | tasks.push(instance.removeWebTorrentFileAndTorrent(file)) |
750 | }) | 750 | }) |
751 | 751 | ||
752 | // Remove playlists file | 752 | // Remove playlists file |
@@ -1706,7 +1706,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> { | |||
1706 | .concat(toAdd) | 1706 | .concat(toAdd) |
1707 | } | 1707 | } |
1708 | 1708 | ||
1709 | removeFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) { | 1709 | removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) { |
1710 | const filePath = isRedundancy | 1710 | const filePath = isRedundancy |
1711 | ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile) | 1711 | ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile) |
1712 | : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile) | 1712 | : 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' | |||
28 | import './video-playlists' | 28 | import './video-playlists' |
29 | import './videos' | 29 | import './videos' |
30 | import './videos-common-filters' | 30 | import './videos-common-filters' |
31 | import './video-files' | ||
31 | import './videos-history' | 32 | import './videos-history' |
32 | import './videos-overviews' | 33 | 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 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import 'mocha' | ||
4 | import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/extra-utils' | ||
5 | import { HttpStatusCode, UserRole } from '@shared/models' | ||
6 | |||
7 | describe('Test videos files', function () { | ||
8 | let servers: PeerTubeServer[] | ||
9 | let webtorrentId: string | ||
10 | let hlsId: string | ||
11 | let remoteId: string | ||
12 | let userToken: string | ||
13 | let moderatorToken: string | ||
14 | let validId1: string | ||
15 | let validId2: string | ||
16 | |||
17 | // --------------------------------------------------------------- | ||
18 | |||
19 | before(async function () { | ||
20 | this.timeout(150_000) | ||
21 | |||
22 | servers = await createMultipleServers(2) | ||
23 | await setAccessTokensToServers(servers) | ||
24 | |||
25 | userToken = await servers[0].users.generateUserAndToken('user', UserRole.USER) | ||
26 | moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR) | ||
27 | |||
28 | { | ||
29 | await servers[0].config.enableTranscoding(true, true) | ||
30 | |||
31 | { | ||
32 | const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) | ||
33 | validId1 = uuid | ||
34 | } | ||
35 | |||
36 | { | ||
37 | const { uuid } = await servers[0].videos.quickUpload({ name: 'both 2' }) | ||
38 | validId2 = uuid | ||
39 | } | ||
40 | } | ||
41 | |||
42 | await waitJobs(servers) | ||
43 | |||
44 | { | ||
45 | await servers[0].config.enableTranscoding(false, true) | ||
46 | const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' }) | ||
47 | hlsId = uuid | ||
48 | } | ||
49 | |||
50 | await waitJobs(servers) | ||
51 | |||
52 | { | ||
53 | await servers[0].config.enableTranscoding(false, true) | ||
54 | const { uuid } = await servers[0].videos.quickUpload({ name: 'webtorrent' }) | ||
55 | webtorrentId = uuid | ||
56 | } | ||
57 | |||
58 | await waitJobs(servers) | ||
59 | }) | ||
60 | |||
61 | it('Should not delete files of a remote video', async function () { | ||
62 | await servers[0].videos.removeHLSFiles({ videoId: remoteId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
63 | await servers[0].videos.removeWebTorrentFiles({ videoId: remoteId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
64 | }) | ||
65 | |||
66 | it('Should not delete files by a non admin user', async function () { | ||
67 | const expectedStatus = HttpStatusCode.FORBIDDEN_403 | ||
68 | |||
69 | await servers[0].videos.removeHLSFiles({ videoId: validId1, token: userToken, expectedStatus }) | ||
70 | await servers[0].videos.removeHLSFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) | ||
71 | |||
72 | await servers[0].videos.removeWebTorrentFiles({ videoId: validId1, token: userToken, expectedStatus }) | ||
73 | await servers[0].videos.removeWebTorrentFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) | ||
74 | }) | ||
75 | |||
76 | it('Should not delete files if the files are not available', async function () { | ||
77 | await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
78 | await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
79 | }) | ||
80 | |||
81 | it('Should not delete files if no both versions are available', async function () { | ||
82 | await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
83 | await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
84 | }) | ||
85 | |||
86 | it('Should not delete files if no both versions are available', async function () { | ||
87 | await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
88 | await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
89 | }) | ||
90 | |||
91 | it('Should delete files if both versions are available', async function () { | ||
92 | await servers[0].videos.removeHLSFiles({ videoId: validId1 }) | ||
93 | await servers[0].videos.removeWebTorrentFiles({ videoId: validId2 }) | ||
94 | }) | ||
95 | |||
96 | after(async function () { | ||
97 | await cleanupTests(servers) | ||
98 | }) | ||
99 | }) | ||
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' | |||
7 | import './video-channels' | 7 | import './video-channels' |
8 | import './video-comments' | 8 | import './video-comments' |
9 | import './video-description' | 9 | import './video-description' |
10 | import './video-files' | ||
10 | import './video-hls' | 11 | import './video-hls' |
11 | import './video-imports' | 12 | import './video-imports' |
12 | import './video-nsfw' | 13 | 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 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import 'mocha' | ||
4 | import { expect } from 'chai' | ||
5 | import { cleanupTests, createMultipleServers, doubleFollow, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/extra-utils' | ||
6 | |||
7 | describe('Test videos files', function () { | ||
8 | let servers: PeerTubeServer[] | ||
9 | let validId1: string | ||
10 | let validId2: string | ||
11 | |||
12 | // --------------------------------------------------------------- | ||
13 | |||
14 | before(async function () { | ||
15 | this.timeout(150_000) | ||
16 | |||
17 | servers = await createMultipleServers(2) | ||
18 | await setAccessTokensToServers(servers) | ||
19 | |||
20 | await doubleFollow(servers[0], servers[1]) | ||
21 | |||
22 | await servers[0].config.enableTranscoding(true, true) | ||
23 | |||
24 | { | ||
25 | const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' }) | ||
26 | validId1 = uuid | ||
27 | } | ||
28 | |||
29 | { | ||
30 | const { uuid } = await servers[0].videos.quickUpload({ name: 'video 2' }) | ||
31 | validId2 = uuid | ||
32 | } | ||
33 | |||
34 | await waitJobs(servers) | ||
35 | }) | ||
36 | |||
37 | it('Should delete webtorrent files', async function () { | ||
38 | this.timeout(30_000) | ||
39 | |||
40 | await servers[0].videos.removeWebTorrentFiles({ videoId: validId1 }) | ||
41 | |||
42 | await waitJobs(servers) | ||
43 | |||
44 | for (const server of servers) { | ||
45 | const video = await server.videos.get({ id: validId1 }) | ||
46 | |||
47 | expect(video.files).to.have.lengthOf(0) | ||
48 | expect(video.streamingPlaylists).to.have.lengthOf(1) | ||
49 | } | ||
50 | }) | ||
51 | |||
52 | it('Should delete HLS files', async function () { | ||
53 | this.timeout(30_000) | ||
54 | |||
55 | await servers[0].videos.removeHLSFiles({ videoId: validId2 }) | ||
56 | |||
57 | await waitJobs(servers) | ||
58 | |||
59 | for (const server of servers) { | ||
60 | const video = await server.videos.get({ id: validId2 }) | ||
61 | |||
62 | expect(video.files).to.have.length.above(0) | ||
63 | expect(video.streamingPlaylists).to.have.lengthOf(0) | ||
64 | } | ||
65 | }) | ||
66 | |||
67 | after(async function () { | ||
68 | await cleanupTests(servers) | ||
69 | }) | ||
70 | }) | ||
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 { | |||
602 | 602 | ||
603 | // --------------------------------------------------------------------------- | 603 | // --------------------------------------------------------------------------- |
604 | 604 | ||
605 | removeHLSFiles (options: OverrideCommandOptions & { | ||
606 | videoId: number | string | ||
607 | }) { | ||
608 | const path = '/api/v1/videos/' + options.videoId + '/hls' | ||
609 | |||
610 | return this.deleteRequest({ | ||
611 | ...options, | ||
612 | |||
613 | path, | ||
614 | implicitToken: true, | ||
615 | defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 | ||
616 | }) | ||
617 | } | ||
618 | |||
619 | removeWebTorrentFiles (options: OverrideCommandOptions & { | ||
620 | videoId: number | string | ||
621 | }) { | ||
622 | const path = '/api/v1/videos/' + options.videoId + '/webtorrent' | ||
623 | |||
624 | return this.deleteRequest({ | ||
625 | ...options, | ||
626 | |||
627 | path, | ||
628 | implicitToken: true, | ||
629 | defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 | ||
630 | }) | ||
631 | } | ||
632 | |||
633 | // --------------------------------------------------------------------------- | ||
634 | |||
605 | private buildListQuery (options: VideosCommonQuery) { | 635 | private buildListQuery (options: VideosCommonQuery) { |
606 | return pick(options, [ | 636 | return pick(options, [ |
607 | 'start', | 637 | '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 { | |||
38 | 38 | ||
39 | MANAGE_PLUGINS, | 39 | MANAGE_PLUGINS, |
40 | 40 | ||
41 | MANAGE_VIDEOS_REDUNDANCIES | 41 | MANAGE_VIDEOS_REDUNDANCIES, |
42 | |||
43 | MANAGE_VIDEO_FILES | ||
42 | } | 44 | } |