aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.html4
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts55
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts11
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts9
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts39
-rw-r--r--server/controllers/api/videos/files.ts79
-rw-r--r--server/controllers/api/videos/index.ts15
-rw-r--r--server/controllers/api/videos/update.ts2
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts2
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts2
-rw-r--r--server/middlewares/validators/videos/index.ts1
-rw-r--r--server/middlewares/validators/videos/video-files.ts104
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/video/video.ts4
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/video-files.ts99
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/video-files.ts70
-rw-r--r--shared/extra-utils/videos/videos-command.ts30
-rw-r--r--shared/models/users/user-right.enum.ts4
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
20export class Video implements VideoServerModel { 21export 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
2import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' 2import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core'
3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' 3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
5import { VideoCaption } from '@shared/models' 5import { UserRight, VideoCaption } from '@shared/models'
6import { 6import {
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 @@
1import express from 'express'
2import toInt from 'validator/lib/toInt'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
5import { VideoFileModel } from '@server/models/video/video-file'
6import { HttpStatusCode } from '@shared/models'
7import {
8 asyncMiddleware,
9 authenticate,
10 videoFileMetadataGetValidator,
11 videoFilesDeleteHLSValidator,
12 videoFilesDeleteWebTorrentValidator
13} from '../../../middlewares'
14
15const lTags = loggerTagsFactory('api', 'video')
16const filesRouter = express.Router()
17
18filesRouter.get('/:id/metadata/:videoFileId',
19 asyncMiddleware(videoFileMetadataGetValidator),
20 asyncMiddleware(getVideoFileMetadata)
21)
22
23filesRouter.delete('/:id/hls',
24 authenticate,
25 asyncMiddleware(videoFilesDeleteHLSValidator),
26 asyncMiddleware(removeHLSPlaylist)
27)
28
29filesRouter.delete('/:id/webtorrent',
30 authenticate,
31 asyncMiddleware(videoFilesDeleteWebTorrentValidator),
32 asyncMiddleware(removeWebTorrentFiles)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 filesRouter
39}
40
41// ---------------------------------------------------------------------------
42
43async 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
49async 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
65async 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 @@
1import express from 'express' 1import express from 'express'
2import toInt from 'validator/lib/toInt'
3import { pickCommonVideoQuery } from '@server/helpers/query' 2import { pickCommonVideoQuery } from '@server/helpers/query'
4import { doJSONRequest } from '@server/helpers/requests' 3import { doJSONRequest } from '@server/helpers/requests'
5import { VideoViews } from '@server/lib/video-views' 4import { 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'
36import { VideoModel } from '../../../models/video/video' 34import { VideoModel } from '../../../models/video/video'
37import { VideoFileModel } from '../../../models/video/video-file'
38import { blacklistRouter } from './blacklist' 35import { blacklistRouter } from './blacklist'
39import { videoCaptionsRouter } from './captions' 36import { videoCaptionsRouter } from './captions'
40import { videoCommentRouter } from './comment' 37import { videoCommentRouter } from './comment'
38import { filesRouter } from './files'
41import { videoImportsRouter } from './import' 39import { videoImportsRouter } from './import'
42import { liveRouter } from './live' 40import { liveRouter } from './live'
43import { ownershipVideoRouter } from './ownership' 41import { ownershipVideoRouter } from './ownership'
@@ -59,6 +57,7 @@ videosRouter.use('/', watchingRouter)
59videosRouter.use('/', liveRouter) 57videosRouter.use('/', liveRouter)
60videosRouter.use('/', uploadRouter) 58videosRouter.use('/', uploadRouter)
61videosRouter.use('/', updateRouter) 59videosRouter.use('/', updateRouter)
60videosRouter.use('/', filesRouter)
62 61
63videosRouter.get('/categories', 62videosRouter.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)
96videosRouter.get('/:id/metadata/:videoFileId',
97 asyncMiddleware(videoFileMetadataGetValidator),
98 asyncMiddleware(getVideoFileMetadata)
99)
100videosRouter.get('/:id', 95videosRouter.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
180async 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
186async function listVideos (req: express.Request, res: express.Response) { 175async 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
54export async function updateVideo (req: express.Request, res: express.Response) { 54async 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'
2export * from './video-captions' 2export * from './video-captions'
3export * from './video-channels' 3export * from './video-channels'
4export * from './video-comments' 4export * from './video-comments'
5export * from './video-files'
5export * from './video-imports' 6export * from './video-imports'
6export * from './video-live' 7export * from './video-live'
7export * from './video-ownership-changes' 8export * 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 @@
1import express from 'express'
2import { MUser, MVideo } from '@server/types/models'
3import { HttpStatusCode, UserRight } from '../../../../shared'
4import { logger } from '../../../helpers/logger'
5import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
6
7const 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
40const 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
73export {
74 videoFilesDeleteWebTorrentValidator,
75 videoFilesDeleteHLSValidator
76}
77
78// ---------------------------------------------------------------------------
79
80function 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
93function 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'
28import './video-playlists' 28import './video-playlists'
29import './videos' 29import './videos'
30import './videos-common-filters' 30import './videos-common-filters'
31import './video-files'
31import './videos-history' 32import './videos-history'
32import './videos-overviews' 33import './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
3import 'mocha'
4import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/extra-utils'
5import { HttpStatusCode, UserRole } from '@shared/models'
6
7describe('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'
7import './video-channels' 7import './video-channels'
8import './video-comments' 8import './video-comments'
9import './video-description' 9import './video-description'
10import './video-files'
10import './video-hls' 11import './video-hls'
11import './video-imports' 12import './video-imports'
12import './video-nsfw' 13import './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
3import 'mocha'
4import { expect } from 'chai'
5import { cleanupTests, createMultipleServers, doubleFollow, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/extra-utils'
6
7describe('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}