aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.html10
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.scss7
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts18
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts5
-rw-r--r--package.json1
-rw-r--r--server/controllers/api/videos/files.ts62
-rw-r--r--server/helpers/ffmpeg/ffprobe-utils.ts8
-rw-r--r--server/lib/activitypub/videos/shared/abstract-builder.ts25
-rw-r--r--server/lib/hls.ts147
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts2
-rw-r--r--server/lib/job-queue/handlers/video-studio-edition.ts18
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts2
-rw-r--r--server/lib/transcoding/transcoding.ts61
-rw-r--r--server/lib/video-file.ts69
-rw-r--r--server/middlewares/validators/videos/video-files.ts87
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/video/video-streaming-playlist.ts36
-rw-r--r--server/models/video/video.ts10
-rw-r--r--server/tests/api/check-params/video-files.ts80
-rw-r--r--server/tests/api/transcoding/create-transcoding.ts4
-rw-r--r--server/tests/api/videos/video-files.ts172
-rw-r--r--shared/server-commands/videos/videos-command.ts36
-rw-r--r--yarn.lock13
23 files changed, 672 insertions, 203 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 fdd682ee2..06b9ab347 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.html
+++ b/client/src/app/+admin/overview/videos/video-list.component.html
@@ -107,6 +107,11 @@
107 <ul> 107 <ul>
108 <li *ngFor="let file of video.files"> 108 <li *ngFor="let file of video.files">
109 {{ file.resolution.label }}: {{ file.size | bytes: 1 }} 109 {{ file.resolution.label }}: {{ file.size | bytes: 1 }}
110
111 <my-global-icon
112 i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button"
113 (click)="removeVideoFile(video, file, 'webtorrent')"
114 ></my-global-icon>
110 </li> 115 </li>
111 </ul> 116 </ul>
112 </div> 117 </div>
@@ -117,6 +122,11 @@
117 <ul> 122 <ul>
118 <li *ngFor="let file of video.streamingPlaylists[0].files"> 123 <li *ngFor="let file of video.streamingPlaylists[0].files">
119 {{ file.resolution.label }}: {{ file.size | bytes: 1 }} 124 {{ file.resolution.label }}: {{ file.size | bytes: 1 }}
125
126 <my-global-icon
127 i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button"
128 (click)="removeVideoFile(video, file, 'hls')"
129 ></my-global-icon>
120 </li> 130 </li>
121 </ul> 131 </ul>
122 </div> 132 </div>
diff --git a/client/src/app/+admin/overview/videos/video-list.component.scss b/client/src/app/+admin/overview/videos/video-list.component.scss
index dcd41a1b4..d538ca30a 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.scss
+++ b/client/src/app/+admin/overview/videos/video-list.component.scss
@@ -13,6 +13,13 @@ my-embed {
13 13
14.video-info > div { 14.video-info > div {
15 display: flex; 15 display: flex;
16
17 my-global-icon {
18 width: 16px;
19 margin-left: 3px;
20 position: relative;
21 top: -2px;
22 }
16} 23}
17 24
18.loading { 25.loading {
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 67e52d100..ed7ec54a1 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.ts
+++ b/client/src/app/+admin/overview/videos/video-list.component.ts
@@ -8,7 +8,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
9import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation' 9import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation'
10import { VideoActionsDisplayType } from '@app/shared/shared-video-miniature' 10import { VideoActionsDisplayType } from '@app/shared/shared-video-miniature'
11import { UserRight, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@shared/models' 11import { UserRight, VideoFile, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@shared/models'
12import { VideoAdminService } from './video-admin.service' 12import { VideoAdminService } from './video-admin.service'
13 13
14@Component({ 14@Component({
@@ -196,6 +196,22 @@ export class VideoListComponent extends RestTable implements OnInit {
196 }) 196 })
197 } 197 }
198 198
199 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
200 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
201 const res = await this.confirmService.confirm(message, $localize`Delete file`)
202 if (res === false) return
203
204 this.videoService.removeFile(video.uuid, file.id, type)
205 .subscribe({
206 next: () => {
207 this.notifier.success($localize`File removed.`)
208 this.reloadData()
209 },
210
211 error: err => this.notifier.error(err.message)
212 })
213 }
214
199 private async removeVideos (videos: Video[]) { 215 private async removeVideos (videos: Video[]) {
200 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)( 216 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)(
201 { count: videos.length }, 217 { count: videos.length },
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 f2bf02695..8c8b1e08f 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -305,6 +305,11 @@ export class VideoService {
305 ) 305 )
306 } 306 }
307 307
308 removeFile (videoId: number | string, fileId: number, type: 'hls' | 'webtorrent') {
309 return this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + videoId + '/' + type + '/' + fileId)
310 .pipe(catchError(err => this.restExtractor.handleError(err)))
311 }
312
308 runTranscoding (videoIds: (number | string)[], type: 'hls' | 'webtorrent') { 313 runTranscoding (videoIds: (number | string)[], type: 'hls' | 'webtorrent') {
309 const body: VideoTranscodingCreate = { transcodingType: type } 314 const body: VideoTranscodingCreate = { transcodingType: type }
310 315
diff --git a/package.json b/package.json
index db433bfc2..a527a1880 100644
--- a/package.json
+++ b/package.json
@@ -150,6 +150,7 @@
150 "node-media-server": "^2.1.4", 150 "node-media-server": "^2.1.4",
151 "nodemailer": "^6.0.0", 151 "nodemailer": "^6.0.0",
152 "opentelemetry-instrumentation-sequelize": "^0.29.0", 152 "opentelemetry-instrumentation-sequelize": "^0.29.0",
153 "p-queue": "^6",
153 "parse-torrent": "^9.1.0", 154 "parse-torrent": "^9.1.0",
154 "password-generator": "^2.0.2", 155 "password-generator": "^2.0.2",
155 "pg": "^8.2.1", 156 "pg": "^8.2.1",
diff --git a/server/controllers/api/videos/files.ts b/server/controllers/api/videos/files.ts
index 0fbda280e..6d9c0b843 100644
--- a/server/controllers/api/videos/files.ts
+++ b/server/controllers/api/videos/files.ts
@@ -2,6 +2,7 @@ import express from 'express'
2import toInt from 'validator/lib/toInt' 2import toInt from 'validator/lib/toInt'
3import { logger, loggerTagsFactory } from '@server/helpers/logger' 3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 4import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
5import { removeAllWebTorrentFiles, removeHLSFile, removeHLSPlaylist, removeWebTorrentFile } from '@server/lib/video-file'
5import { VideoFileModel } from '@server/models/video/video-file' 6import { VideoFileModel } from '@server/models/video/video-file'
6import { HttpStatusCode, UserRight } from '@shared/models' 7import { HttpStatusCode, UserRight } from '@shared/models'
7import { 8import {
@@ -9,10 +10,13 @@ import {
9 authenticate, 10 authenticate,
10 ensureUserHasRight, 11 ensureUserHasRight,
11 videoFileMetadataGetValidator, 12 videoFileMetadataGetValidator,
13 videoFilesDeleteHLSFileValidator,
12 videoFilesDeleteHLSValidator, 14 videoFilesDeleteHLSValidator,
15 videoFilesDeleteWebTorrentFileValidator,
13 videoFilesDeleteWebTorrentValidator, 16 videoFilesDeleteWebTorrentValidator,
14 videosGetValidator 17 videosGetValidator
15} from '../../../middlewares' 18} from '../../../middlewares'
19import { updatePlaylistAfterFileChange } from '@server/lib/hls'
16 20
17const lTags = loggerTagsFactory('api', 'video') 21const lTags = loggerTagsFactory('api', 'video')
18const filesRouter = express.Router() 22const filesRouter = express.Router()
@@ -27,14 +31,26 @@ filesRouter.delete('/:id/hls',
27 authenticate, 31 authenticate,
28 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES), 32 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
29 asyncMiddleware(videoFilesDeleteHLSValidator), 33 asyncMiddleware(videoFilesDeleteHLSValidator),
30 asyncMiddleware(removeHLSPlaylist) 34 asyncMiddleware(removeHLSPlaylistController)
35)
36filesRouter.delete('/:id/hls/:videoFileId',
37 authenticate,
38 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
39 asyncMiddleware(videoFilesDeleteHLSFileValidator),
40 asyncMiddleware(removeHLSFileController)
31) 41)
32 42
33filesRouter.delete('/:id/webtorrent', 43filesRouter.delete('/:id/webtorrent',
34 authenticate, 44 authenticate,
35 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES), 45 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
36 asyncMiddleware(videoFilesDeleteWebTorrentValidator), 46 asyncMiddleware(videoFilesDeleteWebTorrentValidator),
37 asyncMiddleware(removeWebTorrentFiles) 47 asyncMiddleware(removeAllWebTorrentFilesController)
48)
49filesRouter.delete('/:id/webtorrent/:videoFileId',
50 authenticate,
51 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
52 asyncMiddleware(videoFilesDeleteWebTorrentFileValidator),
53 asyncMiddleware(removeWebTorrentFileController)
38) 54)
39 55
40// --------------------------------------------------------------------------- 56// ---------------------------------------------------------------------------
@@ -51,33 +67,53 @@ async function getVideoFileMetadata (req: express.Request, res: express.Response
51 return res.json(videoFile.metadata) 67 return res.json(videoFile.metadata)
52} 68}
53 69
54async function removeHLSPlaylist (req: express.Request, res: express.Response) { 70// ---------------------------------------------------------------------------
71
72async function removeHLSPlaylistController (req: express.Request, res: express.Response) {
55 const video = res.locals.videoAll 73 const video = res.locals.videoAll
56 74
57 logger.info('Deleting HLS playlist of %s.', video.url, lTags(video.uuid)) 75 logger.info('Deleting HLS playlist of %s.', video.url, lTags(video.uuid))
76 await removeHLSPlaylist(video)
77
78 await federateVideoIfNeeded(video, false, undefined)
79
80 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
81}
82
83async function removeHLSFileController (req: express.Request, res: express.Response) {
84 const video = res.locals.videoAll
85 const videoFileId = +req.params.videoFileId
58 86
59 const hls = video.getHLSPlaylist() 87 logger.info('Deleting HLS file %d of %s.', videoFileId, video.url, lTags(video.uuid))
60 await video.removeStreamingPlaylistFiles(hls)
61 await hls.destroy()
62 88
63 video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id) 89 const playlist = await removeHLSFile(video, videoFileId)
90 if (playlist) await updatePlaylistAfterFileChange(video, playlist)
64 91
65 await federateVideoIfNeeded(video, false, undefined) 92 await federateVideoIfNeeded(video, false, undefined)
66 93
67 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 94 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
68} 95}
69 96
70async function removeWebTorrentFiles (req: express.Request, res: express.Response) { 97// ---------------------------------------------------------------------------
98
99async function removeAllWebTorrentFilesController (req: express.Request, res: express.Response) {
71 const video = res.locals.videoAll 100 const video = res.locals.videoAll
72 101
73 logger.info('Deleting WebTorrent files of %s.', video.url, lTags(video.uuid)) 102 logger.info('Deleting WebTorrent files of %s.', video.url, lTags(video.uuid))
74 103
75 for (const file of video.VideoFiles) { 104 await removeAllWebTorrentFiles(video)
76 await video.removeWebTorrentFileAndTorrent(file) 105 await federateVideoIfNeeded(video, false, undefined)
77 await file.destroy() 106
78 } 107 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
108}
109
110async function removeWebTorrentFileController (req: express.Request, res: express.Response) {
111 const video = res.locals.videoAll
112
113 const videoFileId = +req.params.videoFileId
114 logger.info('Deleting WebTorrent file %d of %s.', videoFileId, video.url, lTags(video.uuid))
79 115
80 video.VideoFiles = [] 116 await removeWebTorrentFile(video, videoFileId)
81 await federateVideoIfNeeded(video, false, undefined) 117 await federateVideoIfNeeded(video, false, undefined)
82 118
83 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 119 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
diff --git a/server/helpers/ffmpeg/ffprobe-utils.ts b/server/helpers/ffmpeg/ffprobe-utils.ts
index a9b4fb456..9529162eb 100644
--- a/server/helpers/ffmpeg/ffprobe-utils.ts
+++ b/server/helpers/ffmpeg/ffprobe-utils.ts
@@ -1,15 +1,15 @@
1import { FfprobeData } from 'fluent-ffmpeg' 1import { FfprobeData } from 'fluent-ffmpeg'
2import { getMaxBitrate } from '@shared/core-utils' 2import { getMaxBitrate } from '@shared/core-utils'
3import { 3import {
4 buildFileMetadata,
4 ffprobePromise, 5 ffprobePromise,
5 getAudioStream, 6 getAudioStream,
6 getVideoStreamDuration,
7 getMaxAudioBitrate, 7 getMaxAudioBitrate,
8 buildFileMetadata,
9 getVideoStreamBitrate,
10 getVideoStreamFPS,
11 getVideoStream, 8 getVideoStream,
9 getVideoStreamBitrate,
12 getVideoStreamDimensionsInfo, 10 getVideoStreamDimensionsInfo,
11 getVideoStreamDuration,
12 getVideoStreamFPS,
13 hasAudioStream 13 hasAudioStream
14} from '@shared/extra-utils/ffprobe' 14} from '@shared/extra-utils/ffprobe'
15import { VideoResolution, VideoTranscodingFPS } from '@shared/models' 15import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
diff --git a/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/lib/activitypub/videos/shared/abstract-builder.ts
index f299ba4fd..c0b92c93d 100644
--- a/server/lib/activitypub/videos/shared/abstract-builder.ts
+++ b/server/lib/activitypub/videos/shared/abstract-builder.ts
@@ -1,4 +1,4 @@
1import { Transaction } from 'sequelize/types' 1import { CreationAttributes, Transaction } from 'sequelize/types'
2import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils' 2import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils'
3import { logger, LoggerTagsFn } from '@server/helpers/logger' 3import { logger, LoggerTagsFn } from '@server/helpers/logger'
4import { updatePlaceholderThumbnail, updateVideoMiniatureFromUrl } from '@server/lib/thumbnail' 4import { updatePlaceholderThumbnail, updateVideoMiniatureFromUrl } from '@server/lib/thumbnail'
@@ -7,7 +7,15 @@ import { VideoCaptionModel } from '@server/models/video/video-caption'
7import { VideoFileModel } from '@server/models/video/video-file' 7import { VideoFileModel } from '@server/models/video/video-file'
8import { VideoLiveModel } from '@server/models/video/video-live' 8import { VideoLiveModel } from '@server/models/video/video-live'
9import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 9import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
10import { MStreamingPlaylistFilesVideo, MThumbnail, MVideoCaption, MVideoFile, MVideoFullLight, MVideoThumbnail } from '@server/types/models' 10import {
11 MStreamingPlaylistFiles,
12 MStreamingPlaylistFilesVideo,
13 MThumbnail,
14 MVideoCaption,
15 MVideoFile,
16 MVideoFullLight,
17 MVideoThumbnail
18} from '@server/types/models'
11import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models' 19import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models'
12import { getOrCreateAPActor } from '../../actors' 20import { getOrCreateAPActor } from '../../actors'
13import { checkUrlsSameHost } from '../../url' 21import { checkUrlsSameHost } from '../../url'
@@ -125,38 +133,39 @@ export abstract class APVideoAbstractBuilder {
125 // Remove video playlists that do not exist anymore 133 // Remove video playlists that do not exist anymore
126 await deleteAllModels(filterNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists), t) 134 await deleteAllModels(filterNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists), t)
127 135
136 const oldPlaylists = video.VideoStreamingPlaylists
128 video.VideoStreamingPlaylists = [] 137 video.VideoStreamingPlaylists = []
129 138
130 for (const playlistAttributes of streamingPlaylistAttributes) { 139 for (const playlistAttributes of streamingPlaylistAttributes) {
131 const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t) 140 const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t)
132 streamingPlaylistModel.Video = video 141 streamingPlaylistModel.Video = video
133 142
134 await this.setStreamingPlaylistFiles(video, streamingPlaylistModel, playlistAttributes.tagAPObject, t) 143 await this.setStreamingPlaylistFiles(oldPlaylists, streamingPlaylistModel, playlistAttributes.tagAPObject, t)
135 144
136 video.VideoStreamingPlaylists.push(streamingPlaylistModel) 145 video.VideoStreamingPlaylists.push(streamingPlaylistModel)
137 } 146 }
138 } 147 }
139 148
140 private async insertOrReplaceStreamingPlaylist (attributes: VideoStreamingPlaylistModel['_creationAttributes'], t: Transaction) { 149 private async insertOrReplaceStreamingPlaylist (attributes: CreationAttributes<VideoStreamingPlaylistModel>, t: Transaction) {
141 const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t }) 150 const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t })
142 151
143 return streamingPlaylist as MStreamingPlaylistFilesVideo 152 return streamingPlaylist as MStreamingPlaylistFilesVideo
144 } 153 }
145 154
146 private getStreamingPlaylistFiles (video: MVideoFullLight, type: VideoStreamingPlaylistType) { 155 private getStreamingPlaylistFiles (oldPlaylists: MStreamingPlaylistFiles[], type: VideoStreamingPlaylistType) {
147 const playlist = video.VideoStreamingPlaylists.find(s => s.type === type) 156 const playlist = oldPlaylists.find(s => s.type === type)
148 if (!playlist) return [] 157 if (!playlist) return []
149 158
150 return playlist.VideoFiles 159 return playlist.VideoFiles
151 } 160 }
152 161
153 private async setStreamingPlaylistFiles ( 162 private async setStreamingPlaylistFiles (
154 video: MVideoFullLight, 163 oldPlaylists: MStreamingPlaylistFiles[],
155 playlistModel: MStreamingPlaylistFilesVideo, 164 playlistModel: MStreamingPlaylistFilesVideo,
156 tagObjects: ActivityTagObject[], 165 tagObjects: ActivityTagObject[],
157 t: Transaction 166 t: Transaction
158 ) { 167 ) {
159 const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(video, playlistModel.type) 168 const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(oldPlaylists || [], playlistModel.type)
160 169
161 const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a)) 170 const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
162 171
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
index 43043315b..20754219f 100644
--- a/server/lib/hls.ts
+++ b/server/lib/hls.ts
@@ -1,7 +1,8 @@
1import { close, ensureDir, move, open, outputJSON, read, readFile, remove, stat, writeFile } from 'fs-extra' 1import { close, ensureDir, move, open, outputJSON, read, readFile, remove, stat, writeFile } from 'fs-extra'
2import { flatten, uniq } from 'lodash' 2import { flatten, uniq } from 'lodash'
3import PQueue from 'p-queue'
3import { basename, dirname, join } from 'path' 4import { basename, dirname, join } from 'path'
4import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models' 5import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models'
5import { sha256 } from '@shared/extra-utils' 6import { sha256 } from '@shared/extra-utils'
6import { VideoStorage } from '@shared/models' 7import { VideoStorage } from '@shared/models'
7import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg' 8import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg'
@@ -14,7 +15,7 @@ import { sequelizeTypescript } from '../initializers/database'
14import { VideoFileModel } from '../models/video/video-file' 15import { VideoFileModel } from '../models/video/video-file'
15import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 16import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
16import { storeHLSFile } from './object-storage' 17import { storeHLSFile } from './object-storage'
17import { getHlsResolutionPlaylistFilename } from './paths' 18import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths'
18import { VideoPathManager } from './video-path-manager' 19import { VideoPathManager } from './video-path-manager'
19 20
20async function updateStreamingPlaylistsInfohashesIfNeeded () { 21async function updateStreamingPlaylistsInfohashesIfNeeded () {
@@ -33,80 +34,123 @@ async function updateStreamingPlaylistsInfohashesIfNeeded () {
33 } 34 }
34} 35}
35 36
36async function updateMasterHLSPlaylist (video: MVideo, playlist: MStreamingPlaylistFilesVideo) { 37async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) {
37 const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ] 38 let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist)
39 playlistWithFiles = await updateSha256VODSegments(video, playlist)
38 40
39 for (const file of playlist.VideoFiles) { 41 // Refresh playlist, operations can take some time
40 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename) 42 playlistWithFiles = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlist.id)
43 playlistWithFiles.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles)
44 await playlistWithFiles.save()
41 45
42 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => { 46 video.setHLSPlaylist(playlistWithFiles)
43 const size = await getVideoStreamDimensionsInfo(videoFilePath) 47}
44 48
45 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file) 49// ---------------------------------------------------------------------------
46 const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
47 50
48 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}` 51// Avoid concurrency issues when updating streaming playlist files
49 if (file.fps) line += ',FRAME-RATE=' + file.fps 52const playlistFilesQueue = new PQueue({ concurrency: 1 })
50 53
51 const codecs = await Promise.all([ 54function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
52 getVideoStreamCodec(videoFilePath), 55 return playlistFilesQueue.add(async () => {
53 getAudioStreamCodec(videoFilePath) 56 const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
54 ])
55 57
56 line += `,CODECS="${codecs.filter(c => !!c).join(',')}"` 58 const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
57 59
58 masterPlaylists.push(line) 60 for (const file of playlist.VideoFiles) {
59 masterPlaylists.push(playlistFilename) 61 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
60 }) 62
61 } 63 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
64 const size = await getVideoStreamDimensionsInfo(videoFilePath)
65
66 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
67 const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
68
69 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
70 if (file.fps) line += ',FRAME-RATE=' + file.fps
71
72 const codecs = await Promise.all([
73 getVideoStreamCodec(videoFilePath),
74 getAudioStreamCodec(videoFilePath)
75 ])
62 76
63 await VideoPathManager.Instance.makeAvailablePlaylistFile(playlist, playlist.playlistFilename, async masterPlaylistPath => { 77 line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
78
79 masterPlaylists.push(line)
80 masterPlaylists.push(playlistFilename)
81 })
82 }
83
84 if (playlist.playlistFilename) {
85 await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
86 }
87 playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
88
89 const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
64 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n') 90 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
65 91
66 if (playlist.storage === VideoStorage.OBJECT_STORAGE) { 92 if (playlist.storage === VideoStorage.OBJECT_STORAGE) {
67 await storeHLSFile(playlist, playlist.playlistFilename, masterPlaylistPath) 93 playlist.playlistUrl = await storeHLSFile(playlist, playlist.playlistFilename)
94 await remove(masterPlaylistPath)
68 } 95 }
96
97 return playlist.save()
69 }) 98 })
70} 99}
71 100
72async function updateSha256VODSegments (video: MVideoUUID, playlist: MStreamingPlaylistFilesVideo) { 101// ---------------------------------------------------------------------------
73 const json: { [filename: string]: { [range: string]: string } } = {} 102
103async function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
104 return playlistFilesQueue.add(async () => {
105 const json: { [filename: string]: { [range: string]: string } } = {}
74 106
75 // For all the resolutions available for this video 107 const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
76 for (const file of playlist.VideoFiles) {
77 const rangeHashes: { [range: string]: string } = {}
78 const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
79 108
80 await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => { 109 // For all the resolutions available for this video
110 for (const file of playlist.VideoFiles) {
111 const rangeHashes: { [range: string]: string } = {}
112 const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
81 113
82 return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => { 114 await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
83 const playlistContent = await readFile(resolutionPlaylistPath)
84 const ranges = getRangesFromPlaylist(playlistContent.toString())
85 115
86 const fd = await open(videoPath, 'r') 116 return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
87 for (const range of ranges) { 117 const playlistContent = await readFile(resolutionPlaylistPath)
88 const buf = Buffer.alloc(range.length) 118 const ranges = getRangesFromPlaylist(playlistContent.toString())
89 await read(fd, buf, 0, range.length, range.offset)
90 119
91 rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf) 120 const fd = await open(videoPath, 'r')
92 } 121 for (const range of ranges) {
93 await close(fd) 122 const buf = Buffer.alloc(range.length)
123 await read(fd, buf, 0, range.length, range.offset)
94 124
95 const videoFilename = file.filename 125 rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
96 json[videoFilename] = rangeHashes 126 }
127 await close(fd)
128
129 const videoFilename = file.filename
130 json[videoFilename] = rangeHashes
131 })
97 }) 132 })
98 }) 133 }
99 }
100 134
101 const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename) 135 if (playlist.segmentsSha256Filename) {
102 await outputJSON(outputPath, json) 136 await video.removeStreamingPlaylistFile(playlist, playlist.segmentsSha256Filename)
137 }
138 playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
103 139
104 if (playlist.storage === VideoStorage.OBJECT_STORAGE) { 140 const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
105 await storeHLSFile(playlist, playlist.segmentsSha256Filename) 141 await outputJSON(outputPath, json)
106 await remove(outputPath) 142
107 } 143 if (playlist.storage === VideoStorage.OBJECT_STORAGE) {
144 playlist.segmentsSha256Url = await storeHLSFile(playlist, playlist.segmentsSha256Filename)
145 await remove(outputPath)
146 }
147
148 return playlist.save()
149 })
108} 150}
109 151
152// ---------------------------------------------------------------------------
153
110async function buildSha256Segment (segmentPath: string) { 154async function buildSha256Segment (segmentPath: string) {
111 const buf = await readFile(segmentPath) 155 const buf = await readFile(segmentPath)
112 return sha256(buf) 156 return sha256(buf)
@@ -190,7 +234,8 @@ export {
190 updateSha256VODSegments, 234 updateSha256VODSegments,
191 buildSha256Segment, 235 buildSha256Segment,
192 downloadPlaylistSegments, 236 downloadPlaylistSegments,
193 updateStreamingPlaylistsInfohashesIfNeeded 237 updateStreamingPlaylistsInfohashesIfNeeded,
238 updatePlaylistAfterFileChange
194} 239}
195 240
196// --------------------------------------------------------------------------- 241// ---------------------------------------------------------------------------
diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts
index 1c600e2a7..71c5444af 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.removeWebTorrentFileAndTorrent(currentVideoFile) 58 await video.removeWebTorrentFile(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-studio-edition.ts b/server/lib/job-queue/handlers/video-studio-edition.ts
index 434d0ffe8..735150d57 100644
--- a/server/lib/job-queue/handlers/video-studio-edition.ts
+++ b/server/lib/job-queue/handlers/video-studio-edition.ts
@@ -9,6 +9,7 @@ import { generateWebTorrentVideoFilename } from '@server/lib/paths'
9import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles' 9import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
10import { isAbleToUploadVideo } from '@server/lib/user' 10import { isAbleToUploadVideo } from '@server/lib/user'
11import { addOptimizeOrMergeAudioJob } from '@server/lib/video' 11import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
12import { removeHLSPlaylist, removeWebTorrentFile } from '@server/lib/video-file'
12import { VideoPathManager } from '@server/lib/video-path-manager' 13import { VideoPathManager } from '@server/lib/video-path-manager'
13import { approximateIntroOutroAdditionalSize } from '@server/lib/video-studio' 14import { approximateIntroOutroAdditionalSize } from '@server/lib/video-studio'
14import { UserModel } from '@server/models/user/user' 15import { UserModel } from '@server/models/user/user'
@@ -27,12 +28,12 @@ import {
27} from '@shared/extra-utils' 28} from '@shared/extra-utils'
28import { 29import {
29 VideoStudioEditionPayload, 30 VideoStudioEditionPayload,
30 VideoStudioTaskPayload, 31 VideoStudioTask,
31 VideoStudioTaskCutPayload, 32 VideoStudioTaskCutPayload,
32 VideoStudioTaskIntroPayload, 33 VideoStudioTaskIntroPayload,
33 VideoStudioTaskOutroPayload, 34 VideoStudioTaskOutroPayload,
34 VideoStudioTaskWatermarkPayload, 35 VideoStudioTaskPayload,
35 VideoStudioTask 36 VideoStudioTaskWatermarkPayload
36} from '@shared/models' 37} from '@shared/models'
37import { logger, loggerTagsFactory } from '../../../helpers/logger' 38import { logger, loggerTagsFactory } from '../../../helpers/logger'
38 39
@@ -89,7 +90,6 @@ async function processVideoStudioEdition (job: Job) {
89 await move(editionResultPath, outputPath) 90 await move(editionResultPath, outputPath)
90 91
91 await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath) 92 await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
92
93 await removeAllFiles(video, newFile) 93 await removeAllFiles(video, newFile)
94 94
95 await newFile.save() 95 await newFile.save()
@@ -197,18 +197,12 @@ async function buildNewFile (video: MVideoId, path: string) {
197} 197}
198 198
199async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) { 199async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
200 const hls = video.getHLSPlaylist() 200 await removeHLSPlaylist(video)
201
202 if (hls) {
203 await video.removeStreamingPlaylistFiles(hls)
204 await hls.destroy()
205 }
206 201
207 for (const file of video.VideoFiles) { 202 for (const file of video.VideoFiles) {
208 if (file.id === webTorrentFileException.id) continue 203 if (file.id === webTorrentFileException.id) continue
209 204
210 await video.removeWebTorrentFileAndTorrent(file) 205 await removeWebTorrentFile(video, file.id)
211 await file.destroy()
212 } 206 }
213} 207}
214 208
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
index 5afca65ca..1b34ced14 100644
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ b/server/lib/job-queue/handlers/video-transcoding.ts
@@ -149,7 +149,7 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
149 if (payload.isMaxQuality && payload.autoDeleteWebTorrentIfNeeded && CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) { 149 if (payload.isMaxQuality && payload.autoDeleteWebTorrentIfNeeded && CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false) {
150 // Remove webtorrent files if not enabled 150 // Remove webtorrent files if not enabled
151 for (const file of video.VideoFiles) { 151 for (const file of video.VideoFiles) {
152 await video.removeWebTorrentFileAndTorrent(file) 152 await video.removeWebTorrentFile(file)
153 await file.destroy() 153 await file.destroy()
154 } 154 }
155 155
diff --git a/server/lib/transcoding/transcoding.ts b/server/lib/transcoding/transcoding.ts
index 69a973fbd..924141d1c 100644
--- a/server/lib/transcoding/transcoding.ts
+++ b/server/lib/transcoding/transcoding.ts
@@ -5,9 +5,8 @@ import { toEven } from '@server/helpers/core-utils'
5import { retryTransactionWrapper } from '@server/helpers/database-utils' 5import { retryTransactionWrapper } from '@server/helpers/database-utils'
6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
7import { sequelizeTypescript } from '@server/initializers/database' 7import { sequelizeTypescript } from '@server/initializers/database'
8import { MStreamingPlaylistFilesVideo, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 8import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
9import { VideoResolution, VideoStorage } from '../../../shared/models/videos' 9import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
10import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
11import { 10import {
12 buildFileMetadata, 11 buildFileMetadata,
13 canDoQuickTranscode, 12 canDoQuickTranscode,
@@ -18,17 +17,10 @@ import {
18 TranscodeVODOptionsType 17 TranscodeVODOptionsType
19} from '../../helpers/ffmpeg' 18} from '../../helpers/ffmpeg'
20import { CONFIG } from '../../initializers/config' 19import { CONFIG } from '../../initializers/config'
21import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
22import { VideoFileModel } from '../../models/video/video-file' 20import { VideoFileModel } from '../../models/video/video-file'
23import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist' 21import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
24import { updateMasterHLSPlaylist, updateSha256VODSegments } from '../hls' 22import { updatePlaylistAfterFileChange } from '../hls'
25import { 23import { generateHLSVideoFilename, generateWebTorrentVideoFilename, getHlsResolutionPlaylistFilename } from '../paths'
26 generateHLSMasterPlaylistFilename,
27 generateHlsSha256SegmentsFilename,
28 generateHLSVideoFilename,
29 generateWebTorrentVideoFilename,
30 getHlsResolutionPlaylistFilename
31} from '../paths'
32import { VideoPathManager } from '../video-path-manager' 24import { VideoPathManager } from '../video-path-manager'
33import { VideoTranscodingProfilesManager } from './default-transcoding-profiles' 25import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
34 26
@@ -260,7 +252,7 @@ async function onWebTorrentVideoFileTranscoding (
260 await createTorrentAndSetInfoHash(video, videoFile) 252 await createTorrentAndSetInfoHash(video, videoFile)
261 253
262 const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution }) 254 const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
263 if (oldFile) await video.removeWebTorrentFileAndTorrent(oldFile) 255 if (oldFile) await video.removeWebTorrentFile(oldFile)
264 256
265 await VideoFileModel.customUpsert(videoFile, 'video', undefined) 257 await VideoFileModel.customUpsert(videoFile, 'video', undefined)
266 video.VideoFiles = await video.$get('VideoFiles') 258 video.VideoFiles = await video.$get('VideoFiles')
@@ -314,35 +306,15 @@ async function generateHlsPlaylistCommon (options: {
314 await transcodeVOD(transcodeOptions) 306 await transcodeVOD(transcodeOptions)
315 307
316 // Create or update the playlist 308 // Create or update the playlist
317 const { playlist, oldPlaylistFilename, oldSegmentsSha256Filename } = await retryTransactionWrapper(() => { 309 const playlist = await retryTransactionWrapper(() => {
318 return sequelizeTypescript.transaction(async transaction => { 310 return sequelizeTypescript.transaction(async transaction => {
319 const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video, transaction) 311 return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
320
321 const oldPlaylistFilename = playlist.playlistFilename
322 const oldSegmentsSha256Filename = playlist.segmentsSha256Filename
323
324 playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
325 playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
326
327 playlist.p2pMediaLoaderInfohashes = []
328 playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
329
330 playlist.type = VideoStreamingPlaylistType.HLS
331
332 await playlist.save({ transaction })
333
334 return { playlist, oldPlaylistFilename, oldSegmentsSha256Filename }
335 }) 312 })
336 }) 313 })
337 314
338 if (oldPlaylistFilename) await video.removeStreamingPlaylistFile(playlist, oldPlaylistFilename)
339 if (oldSegmentsSha256Filename) await video.removeStreamingPlaylistFile(playlist, oldSegmentsSha256Filename)
340
341 // Build the new playlist file
342 const extname = extnameUtil(videoFilename)
343 const newVideoFile = new VideoFileModel({ 315 const newVideoFile = new VideoFileModel({
344 resolution, 316 resolution,
345 extname, 317 extname: extnameUtil(videoFilename),
346 size: 0, 318 size: 0,
347 filename: videoFilename, 319 filename: videoFilename,
348 fps: -1, 320 fps: -1,
@@ -350,8 +322,6 @@ async function generateHlsPlaylistCommon (options: {
350 }) 322 })
351 323
352 const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile) 324 const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile)
353
354 // Move files from tmp transcoded directory to the appropriate place
355 await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video)) 325 await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video))
356 326
357 // Move playlist file 327 // Move playlist file
@@ -369,21 +339,14 @@ async function generateHlsPlaylistCommon (options: {
369 await createTorrentAndSetInfoHash(playlist, newVideoFile) 339 await createTorrentAndSetInfoHash(playlist, newVideoFile)
370 340
371 const oldFile = await VideoFileModel.loadHLSFile({ playlistId: playlist.id, fps: newVideoFile.fps, resolution: newVideoFile.resolution }) 341 const oldFile = await VideoFileModel.loadHLSFile({ playlistId: playlist.id, fps: newVideoFile.fps, resolution: newVideoFile.resolution })
372 if (oldFile) await video.removeStreamingPlaylistVideoFile(playlist, oldFile) 342 if (oldFile) {
343 await video.removeStreamingPlaylistVideoFile(playlist, oldFile)
344 await oldFile.destroy()
345 }
373 346
374 const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) 347 const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
375 348
376 const playlistWithFiles = playlist as MStreamingPlaylistFilesVideo 349 await updatePlaylistAfterFileChange(video, playlist)
377 playlistWithFiles.VideoFiles = await playlist.$get('VideoFiles')
378 playlist.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles)
379 playlist.storage = VideoStorage.FILE_SYSTEM
380
381 await playlist.save()
382
383 video.setHLSPlaylist(playlist)
384
385 await updateMasterHLSPlaylist(video, playlistWithFiles)
386 await updateSha256VODSegments(video, playlistWithFiles)
387 350
388 return { resolutionPlaylistPath, videoFile: savedVideoFile } 351 return { resolutionPlaylistPath, videoFile: savedVideoFile }
389} 352}
diff --git a/server/lib/video-file.ts b/server/lib/video-file.ts
new file mode 100644
index 000000000..2ab7190f1
--- /dev/null
+++ b/server/lib/video-file.ts
@@ -0,0 +1,69 @@
1import { logger } from '@server/helpers/logger'
2import { MVideoWithAllFiles } from '@server/types/models'
3import { lTags } from './object-storage/shared'
4
5async function removeHLSPlaylist (video: MVideoWithAllFiles) {
6 const hls = video.getHLSPlaylist()
7 if (!hls) return
8
9 await video.removeStreamingPlaylistFiles(hls)
10 await hls.destroy()
11
12 video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id)
13}
14
15async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
16 logger.info('Deleting HLS file %d of %s.', fileToDeleteId, video.url, lTags(video.uuid))
17
18 const hls = video.getHLSPlaylist()
19 const files = hls.VideoFiles
20
21 if (files.length === 1) {
22 await removeHLSPlaylist(video)
23 return undefined
24 }
25
26 const toDelete = files.find(f => f.id === fileToDeleteId)
27 await video.removeStreamingPlaylistVideoFile(video.getHLSPlaylist(), toDelete)
28 await toDelete.destroy()
29
30 hls.VideoFiles = hls.VideoFiles.filter(f => f.id !== toDelete.id)
31
32 return hls
33}
34
35// ---------------------------------------------------------------------------
36
37async function removeAllWebTorrentFiles (video: MVideoWithAllFiles) {
38 for (const file of video.VideoFiles) {
39 await video.removeWebTorrentFile(file)
40 await file.destroy()
41 }
42
43 video.VideoFiles = []
44
45 return video
46}
47
48async function removeWebTorrentFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
49 const files = video.VideoFiles
50
51 if (files.length === 1) {
52 return removeAllWebTorrentFiles(video)
53 }
54
55 const toDelete = files.find(f => f.id === fileToDeleteId)
56 await video.removeWebTorrentFile(toDelete)
57 await toDelete.destroy()
58
59 video.VideoFiles = files.filter(f => f.id !== toDelete.id)
60
61 return video
62}
63
64export {
65 removeHLSPlaylist,
66 removeHLSFile,
67 removeAllWebTorrentFiles,
68 removeWebTorrentFile
69}
diff --git a/server/middlewares/validators/videos/video-files.ts b/server/middlewares/validators/videos/video-files.ts
index 35b0ac757..b3db3f4f7 100644
--- a/server/middlewares/validators/videos/video-files.ts
+++ b/server/middlewares/validators/videos/video-files.ts
@@ -3,6 +3,8 @@ import { MVideo } from '@server/types/models'
3import { HttpStatusCode } from '@shared/models' 3import { HttpStatusCode } from '@shared/models'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared' 5import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
6import { isIdValid } from '@server/helpers/custom-validators/misc'
7import { param } from 'express-validator'
6 8
7const videoFilesDeleteWebTorrentValidator = [ 9const videoFilesDeleteWebTorrentValidator = [
8 isValidVideoIdParam('id'), 10 isValidVideoIdParam('id'),
@@ -35,6 +37,43 @@ const videoFilesDeleteWebTorrentValidator = [
35 } 37 }
36] 38]
37 39
40const videoFilesDeleteWebTorrentFileValidator = [
41 isValidVideoIdParam('id'),
42
43 param('videoFileId')
44 .custom(isIdValid).withMessage('Should have a valid file id'),
45
46 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
47 logger.debug('Checking videoFilesDeleteWebTorrentFile parameters', { parameters: req.params })
48
49 if (areValidationErrors(req, res)) return
50 if (!await doesVideoExist(req.params.id, res)) return
51
52 const video = res.locals.videoAll
53
54 if (!checkLocalVideo(video, res)) return
55
56 const files = video.VideoFiles
57 if (!files.find(f => f.id === +req.params.videoFileId)) {
58 return res.fail({
59 status: HttpStatusCode.NOT_FOUND_404,
60 message: 'This video does not have this WebTorrent file id'
61 })
62 }
63
64 if (files.length === 1 && !video.getHLSPlaylist()) {
65 return res.fail({
66 status: HttpStatusCode.BAD_REQUEST_400,
67 message: 'Cannot delete WebTorrent files since this video does not have HLS playlist'
68 })
69 }
70
71 return next()
72 }
73]
74
75// ---------------------------------------------------------------------------
76
38const videoFilesDeleteHLSValidator = [ 77const videoFilesDeleteHLSValidator = [
39 isValidVideoIdParam('id'), 78 isValidVideoIdParam('id'),
40 79
@@ -66,9 +105,55 @@ const videoFilesDeleteHLSValidator = [
66 } 105 }
67] 106]
68 107
108const videoFilesDeleteHLSFileValidator = [
109 isValidVideoIdParam('id'),
110
111 param('videoFileId')
112 .custom(isIdValid).withMessage('Should have a valid file id'),
113
114 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
115 logger.debug('Checking videoFilesDeleteHLSFile parameters', { parameters: req.params })
116
117 if (areValidationErrors(req, res)) return
118 if (!await doesVideoExist(req.params.id, res)) return
119
120 const video = res.locals.videoAll
121
122 if (!checkLocalVideo(video, res)) return
123
124 if (!video.getHLSPlaylist()) {
125 return res.fail({
126 status: HttpStatusCode.BAD_REQUEST_400,
127 message: 'This video does not have HLS files'
128 })
129 }
130
131 const hlsFiles = video.getHLSPlaylist().VideoFiles
132 if (!hlsFiles.find(f => f.id === +req.params.videoFileId)) {
133 return res.fail({
134 status: HttpStatusCode.NOT_FOUND_404,
135 message: 'This HLS playlist does not have this file id'
136 })
137 }
138
139 // Last file to delete
140 if (hlsFiles.length === 1 && !video.hasWebTorrentFiles()) {
141 return res.fail({
142 status: HttpStatusCode.BAD_REQUEST_400,
143 message: 'Cannot delete last HLS playlist file since this video does not have WebTorrent files'
144 })
145 }
146
147 return next()
148 }
149]
150
69export { 151export {
70 videoFilesDeleteWebTorrentValidator, 152 videoFilesDeleteWebTorrentValidator,
71 videoFilesDeleteHLSValidator 153 videoFilesDeleteWebTorrentFileValidator,
154
155 videoFilesDeleteHLSValidator,
156 videoFilesDeleteHLSFileValidator
72} 157}
73 158
74// --------------------------------------------------------------------------- 159// ---------------------------------------------------------------------------
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index b363afb28..15909d5f3 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -162,7 +162,7 @@ export class VideoRedundancyModel extends Model<Partial<AttributesOnly<VideoRedu
162 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 162 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
163 logger.info('Removing duplicated video file %s.', logIdentifier) 163 logger.info('Removing duplicated video file %s.', logIdentifier)
164 164
165 videoFile.Video.removeWebTorrentFileAndTorrent(videoFile, true) 165 videoFile.Video.removeWebTorrentFile(videoFile, true)
166 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 166 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
167 } 167 }
168 168
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index 2c4dbd8ec..f587989dc 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -16,8 +16,9 @@ import {
16 UpdatedAt 16 UpdatedAt
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { getHLSPublicFileUrl } from '@server/lib/object-storage' 18import { getHLSPublicFileUrl } from '@server/lib/object-storage'
19import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths'
19import { VideoFileModel } from '@server/models/video/video-file' 20import { VideoFileModel } from '@server/models/video/video-file'
20import { MStreamingPlaylist, MVideo } from '@server/types/models' 21import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models'
21import { sha1 } from '@shared/extra-utils' 22import { sha1 } from '@shared/extra-utils'
22import { VideoStorage } from '@shared/models' 23import { VideoStorage } from '@shared/models'
23import { AttributesOnly } from '@shared/typescript-utils' 24import { AttributesOnly } from '@shared/typescript-utils'
@@ -167,6 +168,22 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
167 return VideoStreamingPlaylistModel.findAll(query) 168 return VideoStreamingPlaylistModel.findAll(query)
168 } 169 }
169 170
171 static loadWithVideoAndFiles (id: number) {
172 const options = {
173 include: [
174 {
175 model: VideoModel.unscoped(),
176 required: true
177 },
178 {
179 model: VideoFileModel.unscoped()
180 }
181 ]
182 }
183
184 return VideoStreamingPlaylistModel.findByPk<MStreamingPlaylistFilesVideo>(id, options)
185 }
186
170 static loadWithVideo (id: number) { 187 static loadWithVideo (id: number) {
171 const options = { 188 const options = {
172 include: [ 189 include: [
@@ -194,9 +211,22 @@ export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<Vi
194 211
195 static async loadOrGenerate (video: MVideo, transaction?: Transaction) { 212 static async loadOrGenerate (video: MVideo, transaction?: Transaction) {
196 let playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id, transaction) 213 let playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id, transaction)
197 if (!playlist) playlist = new VideoStreamingPlaylistModel()
198 214
199 return Object.assign(playlist, { videoId: video.id, Video: video }) 215 if (!playlist) {
216 playlist = new VideoStreamingPlaylistModel({
217 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
218 type: VideoStreamingPlaylistType.HLS,
219 storage: VideoStorage.FILE_SYSTEM,
220 p2pMediaLoaderInfohashes: [],
221 playlistFilename: generateHLSMasterPlaylistFilename(video.isLive),
222 segmentsSha256Filename: generateHlsSha256SegmentsFilename(video.isLive),
223 videoId: video.id
224 })
225
226 await playlist.save({ transaction })
227 }
228
229 return Object.assign(playlist, { Video: video })
200 } 230 }
201 231
202 static doesOwnedHLSPlaylistExist (videoUUID: string) { 232 static doesOwnedHLSPlaylistExist (videoUUID: string) {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 7e9bb4db4..b8e383502 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -28,7 +28,7 @@ import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation
28import { LiveManager } from '@server/lib/live/live-manager' 28import { LiveManager } from '@server/lib/live/live-manager'
29import { removeHLSFileObjectStorage, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' 29import { removeHLSFileObjectStorage, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
30import { tracer } from '@server/lib/opentelemetry/tracing' 30import { tracer } from '@server/lib/opentelemetry/tracing'
31import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths' 31import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
32import { VideoPathManager } from '@server/lib/video-path-manager' 32import { VideoPathManager } from '@server/lib/video-path-manager'
33import { getServerActor } from '@server/models/application/application' 33import { getServerActor } from '@server/models/application/application'
34import { ModelCache } from '@server/models/model-cache' 34import { ModelCache } from '@server/models/model-cache'
@@ -769,7 +769,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
769 769
770 // Remove physical files and torrents 770 // Remove physical files and torrents
771 instance.VideoFiles.forEach(file => { 771 instance.VideoFiles.forEach(file => {
772 tasks.push(instance.removeWebTorrentFileAndTorrent(file)) 772 tasks.push(instance.removeWebTorrentFile(file))
773 }) 773 })
774 774
775 // Remove playlists file 775 // Remove playlists file
@@ -1783,7 +1783,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1783 .concat(toAdd) 1783 .concat(toAdd)
1784 } 1784 }
1785 1785
1786 removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) { 1786 removeWebTorrentFile (videoFile: MVideoFile, isRedundancy = false) {
1787 const filePath = isRedundancy 1787 const filePath = isRedundancy
1788 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile) 1788 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1789 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile) 1789 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
@@ -1829,8 +1829,12 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1829 await videoFile.removeTorrent() 1829 await videoFile.removeTorrent()
1830 await remove(filePath) 1830 await remove(filePath)
1831 1831
1832 const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
1833 await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
1834
1832 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) { 1835 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1833 await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), videoFile.filename) 1836 await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), videoFile.filename)
1837 await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), resolutionFilename)
1834 } 1838 }
1835 } 1839 }
1836 1840
diff --git a/server/tests/api/check-params/video-files.ts b/server/tests/api/check-params/video-files.ts
index 8c0795092..c698bea82 100644
--- a/server/tests/api/check-params/video-files.ts
+++ b/server/tests/api/check-params/video-files.ts
@@ -24,6 +24,12 @@ describe('Test videos files', function () {
24 let validId1: string 24 let validId1: string
25 let validId2: string 25 let validId2: string
26 26
27 let hlsFileId: number
28 let webtorrentFileId: number
29
30 let remoteHLSFileId: number
31 let remoteWebtorrentFileId: number
32
27 // --------------------------------------------------------------- 33 // ---------------------------------------------------------------
28 34
29 before(async function () { 35 before(async function () {
@@ -39,7 +45,12 @@ describe('Test videos files', function () {
39 45
40 { 46 {
41 const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' }) 47 const { uuid } = await servers[1].videos.quickUpload({ name: 'remote video' })
42 remoteId = uuid 48 await waitJobs(servers)
49
50 const video = await servers[1].videos.get({ id: uuid })
51 remoteId = video.uuid
52 remoteHLSFileId = video.streamingPlaylists[0].files[0].id
53 remoteWebtorrentFileId = video.files[0].id
43 } 54 }
44 55
45 { 56 {
@@ -47,7 +58,12 @@ describe('Test videos files', function () {
47 58
48 { 59 {
49 const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' }) 60 const { uuid } = await servers[0].videos.quickUpload({ name: 'both 1' })
50 validId1 = uuid 61 await waitJobs(servers)
62
63 const video = await servers[0].videos.get({ id: uuid })
64 validId1 = video.uuid
65 hlsFileId = video.streamingPlaylists[0].files[0].id
66 webtorrentFileId = video.files[0].id
51 } 67 }
52 68
53 { 69 {
@@ -76,43 +92,67 @@ describe('Test videos files', function () {
76 }) 92 })
77 93
78 it('Should not delete files of a unknown video', async function () { 94 it('Should not delete files of a unknown video', async function () {
79 await servers[0].videos.removeHLSFiles({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 95 const expectedStatus = HttpStatusCode.NOT_FOUND_404
80 await servers[0].videos.removeWebTorrentFiles({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 96
97 await servers[0].videos.removeHLSPlaylist({ videoId: 404, expectedStatus })
98 await servers[0].videos.removeAllWebTorrentFiles({ videoId: 404, expectedStatus })
99
100 await servers[0].videos.removeHLSFile({ videoId: 404, fileId: hlsFileId, expectedStatus })
101 await servers[0].videos.removeWebTorrentFile({ videoId: 404, fileId: webtorrentFileId, expectedStatus })
102 })
103
104 it('Should not delete unknown files', async function () {
105 const expectedStatus = HttpStatusCode.NOT_FOUND_404
106
107 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: webtorrentFileId, expectedStatus })
108 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: hlsFileId, expectedStatus })
81 }) 109 })
82 110
83 it('Should not delete files of a remote video', async function () { 111 it('Should not delete files of a remote video', async function () {
84 await servers[0].videos.removeHLSFiles({ videoId: remoteId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 112 const expectedStatus = HttpStatusCode.BAD_REQUEST_400
85 await servers[0].videos.removeWebTorrentFiles({ videoId: remoteId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 113
114 await servers[0].videos.removeHLSPlaylist({ videoId: remoteId, expectedStatus })
115 await servers[0].videos.removeAllWebTorrentFiles({ videoId: remoteId, expectedStatus })
116
117 await servers[0].videos.removeHLSFile({ videoId: remoteId, fileId: remoteHLSFileId, expectedStatus })
118 await servers[0].videos.removeWebTorrentFile({ videoId: remoteId, fileId: remoteWebtorrentFileId, expectedStatus })
86 }) 119 })
87 120
88 it('Should not delete files by a non admin user', async function () { 121 it('Should not delete files by a non admin user', async function () {
89 const expectedStatus = HttpStatusCode.FORBIDDEN_403 122 const expectedStatus = HttpStatusCode.FORBIDDEN_403
90 123
91 await servers[0].videos.removeHLSFiles({ videoId: validId1, token: userToken, expectedStatus }) 124 await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: userToken, expectedStatus })
92 await servers[0].videos.removeHLSFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) 125 await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: moderatorToken, expectedStatus })
126
127 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: userToken, expectedStatus })
128 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: moderatorToken, expectedStatus })
93 129
94 await servers[0].videos.removeWebTorrentFiles({ videoId: validId1, token: userToken, expectedStatus }) 130 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: userToken, expectedStatus })
95 await servers[0].videos.removeWebTorrentFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) 131 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: moderatorToken, expectedStatus })
132
133 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: userToken, expectedStatus })
134 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: moderatorToken, expectedStatus })
96 }) 135 })
97 136
98 it('Should not delete files if the files are not available', async function () { 137 it('Should not delete files if the files are not available', async function () {
99 await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 138 await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
100 await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 139 await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
101 })
102 140
103 it('Should not delete files if no both versions are available', async function () { 141 await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
104 await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 142 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
105 await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
106 }) 143 })
107 144
108 it('Should not delete files if no both versions are available', async function () { 145 it('Should not delete files if no both versions are available', async function () {
109 await servers[0].videos.removeHLSFiles({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 146 await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
110 await servers[0].videos.removeWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 147 await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
111 }) 148 })
112 149
113 it('Should delete files if both versions are available', async function () { 150 it('Should delete files if both versions are available', async function () {
114 await servers[0].videos.removeHLSFiles({ videoId: validId1 }) 151 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId })
115 await servers[0].videos.removeWebTorrentFiles({ videoId: validId2 }) 152 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId })
153
154 await servers[0].videos.removeHLSPlaylist({ videoId: validId1 })
155 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId2 })
116 }) 156 })
117 157
118 after(async function () { 158 after(async function () {
diff --git a/server/tests/api/transcoding/create-transcoding.ts b/server/tests/api/transcoding/create-transcoding.ts
index e3867fdad..b59bef772 100644
--- a/server/tests/api/transcoding/create-transcoding.ts
+++ b/server/tests/api/transcoding/create-transcoding.ts
@@ -122,7 +122,7 @@ function runTests (objectStorage: boolean) {
122 it('Should generate WebTorrent from HLS only video', async function () { 122 it('Should generate WebTorrent from HLS only video', async function () {
123 this.timeout(60000) 123 this.timeout(60000)
124 124
125 await servers[0].videos.removeWebTorrentFiles({ videoId: videoUUID }) 125 await servers[0].videos.removeAllWebTorrentFiles({ videoId: videoUUID })
126 await waitJobs(servers) 126 await waitJobs(servers)
127 127
128 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'webtorrent' }) 128 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'webtorrent' })
@@ -142,7 +142,7 @@ function runTests (objectStorage: boolean) {
142 it('Should only generate WebTorrent', async function () { 142 it('Should only generate WebTorrent', async function () {
143 this.timeout(60000) 143 this.timeout(60000)
144 144
145 await servers[0].videos.removeHLSFiles({ videoId: videoUUID }) 145 await servers[0].videos.removeHLSPlaylist({ videoId: videoUUID })
146 await waitJobs(servers) 146 await waitJobs(servers)
147 147
148 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'webtorrent' }) 148 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'webtorrent' })
diff --git a/server/tests/api/videos/video-files.ts b/server/tests/api/videos/video-files.ts
index b0ef4a2e9..313f020e9 100644
--- a/server/tests/api/videos/video-files.ts
+++ b/server/tests/api/videos/video-files.ts
@@ -2,10 +2,12 @@
2 2
3import 'mocha' 3import 'mocha'
4import { expect } from 'chai' 4import { expect } from 'chai'
5import { HttpStatusCode } from '@shared/models'
5import { 6import {
6 cleanupTests, 7 cleanupTests,
7 createMultipleServers, 8 createMultipleServers,
8 doubleFollow, 9 doubleFollow,
10 makeRawRequest,
9 PeerTubeServer, 11 PeerTubeServer,
10 setAccessTokensToServers, 12 setAccessTokensToServers,
11 waitJobs 13 waitJobs
@@ -13,8 +15,6 @@ import {
13 15
14describe('Test videos files', function () { 16describe('Test videos files', function () {
15 let servers: PeerTubeServer[] 17 let servers: PeerTubeServer[]
16 let validId1: string
17 let validId2: string
18 18
19 // --------------------------------------------------------------- 19 // ---------------------------------------------------------------
20 20
@@ -27,48 +27,160 @@ describe('Test videos files', function () {
27 await doubleFollow(servers[0], servers[1]) 27 await doubleFollow(servers[0], servers[1])
28 28
29 await servers[0].config.enableTranscoding(true, true) 29 await servers[0].config.enableTranscoding(true, true)
30 })
30 31
31 { 32 describe('When deleting all files', function () {
32 const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' }) 33 let validId1: string
33 validId1 = uuid 34 let validId2: string
34 }
35 35
36 { 36 before(async function () {
37 const { uuid } = await servers[0].videos.quickUpload({ name: 'video 2' }) 37 {
38 validId2 = uuid 38 const { uuid } = await servers[0].videos.quickUpload({ name: 'video 1' })
39 } 39 validId1 = uuid
40 }
40 41
41 await waitJobs(servers) 42 {
42 }) 43 const { uuid } = await servers[0].videos.quickUpload({ name: 'video 2' })
44 validId2 = uuid
45 }
46
47 await waitJobs(servers)
48 })
49
50 it('Should delete webtorrent files', async function () {
51 this.timeout(30_000)
52
53 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1 })
54
55 await waitJobs(servers)
56
57 for (const server of servers) {
58 const video = await server.videos.get({ id: validId1 })
59
60 expect(video.files).to.have.lengthOf(0)
61 expect(video.streamingPlaylists).to.have.lengthOf(1)
62 }
63 })
43 64
44 it('Should delete webtorrent files', async function () { 65 it('Should delete HLS files', async function () {
45 this.timeout(30_000) 66 this.timeout(30_000)
46 67
47 await servers[0].videos.removeWebTorrentFiles({ videoId: validId1 }) 68 await servers[0].videos.removeHLSPlaylist({ videoId: validId2 })
48 69
49 await waitJobs(servers) 70 await waitJobs(servers)
50 71
51 for (const server of servers) { 72 for (const server of servers) {
52 const video = await server.videos.get({ id: validId1 }) 73 const video = await server.videos.get({ id: validId2 })
53 74
54 expect(video.files).to.have.lengthOf(0) 75 expect(video.files).to.have.length.above(0)
55 expect(video.streamingPlaylists).to.have.lengthOf(1) 76 expect(video.streamingPlaylists).to.have.lengthOf(0)
56 } 77 }
78 })
57 }) 79 })
58 80
59 it('Should delete HLS files', async function () { 81 describe('When deleting a specific file', function () {
60 this.timeout(30_000) 82 let webtorrentId: string
83 let hlsId: string
84
85 before(async function () {
86 {
87 const { uuid } = await servers[0].videos.quickUpload({ name: 'webtorrent' })
88 webtorrentId = uuid
89 }
90
91 {
92 const { uuid } = await servers[0].videos.quickUpload({ name: 'hls' })
93 hlsId = uuid
94 }
95
96 await waitJobs(servers)
97 })
98
99 it('Shoulde delete a webtorrent file', async function () {
100 const video = await servers[0].videos.get({ id: webtorrentId })
101 const files = video.files
102
103 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: files[0].id })
104
105 await waitJobs(servers)
106
107 for (const server of servers) {
108 const video = await server.videos.get({ id: webtorrentId })
109
110 expect(video.files).to.have.lengthOf(files.length - 1)
111 expect(video.files.find(f => f.id === files[0].id)).to.not.exist
112 }
113 })
114
115 it('Should delete all webtorrent files', async function () {
116 const video = await servers[0].videos.get({ id: webtorrentId })
117 const files = video.files
118
119 for (const file of files) {
120 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: file.id })
121 }
122
123 await waitJobs(servers)
124
125 for (const server of servers) {
126 const video = await server.videos.get({ id: webtorrentId })
127
128 expect(video.files).to.have.lengthOf(0)
129 }
130 })
131
132 it('Should delete a hls file', async function () {
133 const video = await servers[0].videos.get({ id: hlsId })
134 const files = video.streamingPlaylists[0].files
135 const toDelete = files[0]
136
137 await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: toDelete.id })
138
139 await waitJobs(servers)
140
141 for (const server of servers) {
142 const video = await server.videos.get({ id: hlsId })
143
144 expect(video.streamingPlaylists[0].files).to.have.lengthOf(files.length - 1)
145 expect(video.streamingPlaylists[0].files.find(f => f.id === toDelete.id)).to.not.exist
146
147 const { text } = await makeRawRequest(video.streamingPlaylists[0].playlistUrl)
148
149 expect(text.includes(`-${toDelete.resolution.id}.m3u8`)).to.be.false
150 expect(text.includes(`-${video.streamingPlaylists[0].files[0].resolution.id}.m3u8`)).to.be.true
151 }
152 })
153
154 it('Should delete all hls files', async function () {
155 const video = await servers[0].videos.get({ id: hlsId })
156 const files = video.streamingPlaylists[0].files
157
158 for (const file of files) {
159 await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: file.id })
160 }
161
162 await waitJobs(servers)
163
164 for (const server of servers) {
165 const video = await server.videos.get({ id: hlsId })
61 166
62 await servers[0].videos.removeHLSFiles({ videoId: validId2 }) 167 expect(video.streamingPlaylists).to.have.lengthOf(0)
168 }
169 })
63 170
64 await waitJobs(servers) 171 it('Should not delete last file of a video', async function () {
172 const webtorrentOnly = await servers[0].videos.get({ id: hlsId })
173 const hlsOnly = await servers[0].videos.get({ id: webtorrentId })
65 174
66 for (const server of servers) { 175 for (let i = 0; i < 4; i++) {
67 const video = await server.videos.get({ id: validId2 }) 176 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentOnly.id, fileId: webtorrentOnly.files[i].id })
177 await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[i].id })
178 }
68 179
69 expect(video.files).to.have.length.above(0) 180 const expectedStatus = HttpStatusCode.BAD_REQUEST_400
70 expect(video.streamingPlaylists).to.have.lengthOf(0) 181 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentOnly.id, fileId: webtorrentOnly.files[4].id, expectedStatus })
71 } 182 await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[4].id, expectedStatus })
183 })
72 }) 184 })
73 185
74 after(async function () { 186 after(async function () {
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
index e952c9777..c0b36d95b 100644
--- a/shared/server-commands/videos/videos-command.ts
+++ b/shared/server-commands/videos/videos-command.ts
@@ -20,10 +20,10 @@ import {
20 VideosCommonQuery, 20 VideosCommonQuery,
21 VideoTranscodingCreate 21 VideoTranscodingCreate
22} from '@shared/models' 22} from '@shared/models'
23import { VideoSource } from '@shared/models/videos/video-source'
23import { unwrapBody } from '../requests' 24import { unwrapBody } from '../requests'
24import { waitJobs } from '../server' 25import { waitJobs } from '../server'
25import { AbstractCommand, OverrideCommandOptions } from '../shared' 26import { AbstractCommand, OverrideCommandOptions } from '../shared'
26import { VideoSource } from '@shared/models/videos/video-source'
27 27
28export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & { 28export type VideoEdit = Partial<Omit<VideoCreate, 'thumbnailfile' | 'previewfile'>> & {
29 fixture?: string 29 fixture?: string
@@ -605,7 +605,7 @@ export class VideosCommand extends AbstractCommand {
605 605
606 // --------------------------------------------------------------------------- 606 // ---------------------------------------------------------------------------
607 607
608 removeHLSFiles (options: OverrideCommandOptions & { 608 removeHLSPlaylist (options: OverrideCommandOptions & {
609 videoId: number | string 609 videoId: number | string
610 }) { 610 }) {
611 const path = '/api/v1/videos/' + options.videoId + '/hls' 611 const path = '/api/v1/videos/' + options.videoId + '/hls'
@@ -619,7 +619,22 @@ export class VideosCommand extends AbstractCommand {
619 }) 619 })
620 } 620 }
621 621
622 removeWebTorrentFiles (options: OverrideCommandOptions & { 622 removeHLSFile (options: OverrideCommandOptions & {
623 videoId: number | string
624 fileId: number
625 }) {
626 const path = '/api/v1/videos/' + options.videoId + '/hls/' + options.fileId
627
628 return this.deleteRequest({
629 ...options,
630
631 path,
632 implicitToken: true,
633 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
634 })
635 }
636
637 removeAllWebTorrentFiles (options: OverrideCommandOptions & {
623 videoId: number | string 638 videoId: number | string
624 }) { 639 }) {
625 const path = '/api/v1/videos/' + options.videoId + '/webtorrent' 640 const path = '/api/v1/videos/' + options.videoId + '/webtorrent'
@@ -633,6 +648,21 @@ export class VideosCommand extends AbstractCommand {
633 }) 648 })
634 } 649 }
635 650
651 removeWebTorrentFile (options: OverrideCommandOptions & {
652 videoId: number | string
653 fileId: number
654 }) {
655 const path = '/api/v1/videos/' + options.videoId + '/webtorrent/' + options.fileId
656
657 return this.deleteRequest({
658 ...options,
659
660 path,
661 implicitToken: true,
662 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
663 })
664 }
665
636 runTranscoding (options: OverrideCommandOptions & { 666 runTranscoding (options: OverrideCommandOptions & {
637 videoId: number | string 667 videoId: number | string
638 transcodingType: 'hls' | 'webtorrent' 668 transcodingType: 'hls' | 'webtorrent'
diff --git a/yarn.lock b/yarn.lock
index 05fd3370a..090abda20 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4635,6 +4635,11 @@ eventemitter-asyncresource@^1.0.0:
4635 resolved "https://registry.yarnpkg.com/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz#734ff2e44bf448e627f7748f905d6bdd57bdb65b" 4635 resolved "https://registry.yarnpkg.com/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz#734ff2e44bf448e627f7748f905d6bdd57bdb65b"
4636 integrity sha512-39F7TBIV0G7gTelxwbEqnwhp90eqCPON1k0NwNfwhgKn4Co4ybUbj2pECcXT0B3ztRKZ7Pw1JujUUgmQJHcVAQ== 4636 integrity sha512-39F7TBIV0G7gTelxwbEqnwhp90eqCPON1k0NwNfwhgKn4Co4ybUbj2pECcXT0B3ztRKZ7Pw1JujUUgmQJHcVAQ==
4637 4637
4638eventemitter3@^4.0.4:
4639 version "4.0.7"
4640 resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
4641 integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
4642
4638events@3.3.0, events@^3.3.0: 4643events@3.3.0, events@^3.3.0:
4639 version "3.3.0" 4644 version "3.3.0"
4640 resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" 4645 resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
@@ -7122,6 +7127,14 @@ p-map@^2.1.0:
7122 resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" 7127 resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175"
7123 integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== 7128 integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==
7124 7129
7130p-queue@^6:
7131 version "6.6.2"
7132 resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
7133 integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
7134 dependencies:
7135 eventemitter3 "^4.0.4"
7136 p-timeout "^3.2.0"
7137
7125p-timeout@^3.0.0, p-timeout@^3.1.0, p-timeout@^3.2.0: 7138p-timeout@^3.0.0, p-timeout@^3.1.0, p-timeout@^3.2.0:
7126 version "3.2.0" 7139 version "3.2.0"
7127 resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" 7140 resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"