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
}