aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2021-02-15 14:08:16 +0100
committerChocobozzz <chocobozzz@cpy.re>2021-02-16 10:36:44 +0100
commit6302d599cdf98b5a5363a2a1dcdc266447950191 (patch)
treeb7dc6dc0f08f0fb8a20720242c9c0a71afeeaa3f /server
parenta8b1b40485145ac1eae513a661d7dd6e0986ce96 (diff)
downloadPeerTube-6302d599cdf98b5a5363a2a1dcdc266447950191.tar.gz
PeerTube-6302d599cdf98b5a5363a2a1dcdc266447950191.tar.zst
PeerTube-6302d599cdf98b5a5363a2a1dcdc266447950191.zip
Generate a name for caption files
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/videos/captions.ts28
-rw-r--r--server/controllers/api/videos/import.ts32
-rw-r--r--server/controllers/lazy-static.ts7
-rw-r--r--server/helpers/captions-utils.ts12
-rw-r--r--server/helpers/middlewares/video-captions.ts1
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0580-caption-filename.ts48
-rw-r--r--server/lib/activitypub/playlist.ts2
-rw-r--r--server/lib/activitypub/videos.ts21
-rw-r--r--server/lib/files-cache/videos-caption-cache.ts30
-rw-r--r--server/lib/files-cache/videos-preview-cache.ts1
-rw-r--r--server/lib/thumbnail.ts3
-rw-r--r--server/models/video/video-caption.ts58
-rw-r--r--server/models/video/video-playlist.ts2
-rw-r--r--server/tests/api/server/services.ts27
-rw-r--r--server/tests/api/videos/video-captions.ts12
-rw-r--r--server/types/models/video/video-caption.ts4
17 files changed, 183 insertions, 107 deletions
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
index bf82e2c19..ad7423a31 100644
--- a/server/controllers/api/videos/captions.ts
+++ b/server/controllers/api/videos/captions.ts
@@ -1,17 +1,17 @@
1import * as express from 'express' 1import * as express from 'express'
2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' 2import { MVideoCaption } from '@server/types/models'
3import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators' 3import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
4import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
4import { createReqFiles } from '../../../helpers/express-utils' 5import { createReqFiles } from '../../../helpers/express-utils'
5import { MIMETYPES } from '../../../initializers/constants'
6import { getFormattedObjects } from '../../../helpers/utils'
7import { VideoCaptionModel } from '../../../models/video/video-caption'
8import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
9import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' 7import { getFormattedObjects } from '../../../helpers/utils'
10import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
11import { CONFIG } from '../../../initializers/config' 8import { CONFIG } from '../../../initializers/config'
9import { MIMETYPES } from '../../../initializers/constants'
12import { sequelizeTypescript } from '../../../initializers/database' 10import { sequelizeTypescript } from '../../../initializers/database'
13import { MVideoCaptionVideo } from '@server/types/models' 11import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 12import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
13import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators'
14import { VideoCaptionModel } from '../../../models/video/video-caption'
15 15
16const reqVideoCaptionAdd = createReqFiles( 16const reqVideoCaptionAdd = createReqFiles(
17 [ 'captionfile' ], 17 [ 'captionfile' ],
@@ -57,17 +57,19 @@ async function addVideoCaption (req: express.Request, res: express.Response) {
57 const videoCaptionPhysicalFile = req.files['captionfile'][0] 57 const videoCaptionPhysicalFile = req.files['captionfile'][0]
58 const video = res.locals.videoAll 58 const video = res.locals.videoAll
59 59
60 const captionLanguage = req.params.captionLanguage
61
60 const videoCaption = new VideoCaptionModel({ 62 const videoCaption = new VideoCaptionModel({
61 videoId: video.id, 63 videoId: video.id,
62 language: req.params.captionLanguage 64 filename: VideoCaptionModel.generateCaptionName(captionLanguage),
63 }) as MVideoCaptionVideo 65 language: captionLanguage
64 videoCaption.Video = video 66 }) as MVideoCaption
65 67
66 // Move physical file 68 // Move physical file
67 await moveAndProcessCaptionFile(videoCaptionPhysicalFile, videoCaption) 69 await moveAndProcessCaptionFile(videoCaptionPhysicalFile, videoCaption)
68 70
69 await sequelizeTypescript.transaction(async t => { 71 await sequelizeTypescript.transaction(async t => {
70 await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, null, t) 72 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
71 73
72 // Update video update 74 // Update video update
73 await federateVideoIfNeeded(video, false, t) 75 await federateVideoIfNeeded(video, false, t)
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 01f41e7bc..c689cb6f9 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -9,9 +9,9 @@ import {
9 MThumbnail, 9 MThumbnail,
10 MUser, 10 MUser,
11 MVideoAccountDefault, 11 MVideoAccountDefault,
12 MVideoCaptionVideo, 12 MVideoCaption,
13 MVideoTag, 13 MVideoTag,
14 MVideoThumbnailAccountDefault, 14 MVideoThumbnail,
15 MVideoWithBlacklistLight 15 MVideoWithBlacklistLight
16} from '@server/types/models' 16} from '@server/types/models'
17import { MVideoImport, MVideoImportFormattable } from '@server/types/models/video/video-import' 17import { MVideoImport, MVideoImportFormattable } from '@server/types/models/video/video-import'
@@ -154,20 +154,16 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
154 154
155 const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo) 155 const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo)
156 156
157 let thumbnailModel: MThumbnail
158
159 // Process video thumbnail from request.files 157 // Process video thumbnail from request.files
160 thumbnailModel = await processThumbnail(req, video) 158 let thumbnailModel = await processThumbnail(req, video)
161 159
162 // Process video thumbnail from url if processing from request.files failed 160 // Process video thumbnail from url if processing from request.files failed
163 if (!thumbnailModel && youtubeDLInfo.thumbnailUrl) { 161 if (!thumbnailModel && youtubeDLInfo.thumbnailUrl) {
164 thumbnailModel = await processThumbnailFromUrl(youtubeDLInfo.thumbnailUrl, video) 162 thumbnailModel = await processThumbnailFromUrl(youtubeDLInfo.thumbnailUrl, video)
165 } 163 }
166 164
167 let previewModel: MThumbnail
168
169 // Process video preview from request.files 165 // Process video preview from request.files
170 previewModel = await processPreview(req, video) 166 let previewModel = await processPreview(req, video)
171 167
172 // Process video preview from url if processing from request.files failed 168 // Process video preview from url if processing from request.files failed
173 if (!previewModel && youtubeDLInfo.thumbnailUrl) { 169 if (!previewModel && youtubeDLInfo.thumbnailUrl) {
@@ -199,15 +195,15 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
199 for (const subtitle of subtitles) { 195 for (const subtitle of subtitles) {
200 const videoCaption = new VideoCaptionModel({ 196 const videoCaption = new VideoCaptionModel({
201 videoId: video.id, 197 videoId: video.id,
202 language: subtitle.language 198 language: subtitle.language,
203 }) as MVideoCaptionVideo 199 filename: VideoCaptionModel.generateCaptionName(subtitle.language)
204 videoCaption.Video = video 200 }) as MVideoCaption
205 201
206 // Move physical file 202 // Move physical file
207 await moveAndProcessCaptionFile(subtitle, videoCaption) 203 await moveAndProcessCaptionFile(subtitle, videoCaption)
208 204
209 await sequelizeTypescript.transaction(async t => { 205 await sequelizeTypescript.transaction(async t => {
210 await VideoCaptionModel.insertOrReplaceLanguage(video.id, subtitle.language, null, t) 206 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
211 }) 207 })
212 } 208 }
213 } catch (err) { 209 } catch (err) {
@@ -227,7 +223,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
227 return res.json(videoImport.toFormattedJSON()).end() 223 return res.json(videoImport.toFormattedJSON()).end()
228} 224}
229 225
230function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo) { 226function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo): MVideoThumbnail {
231 const videoData = { 227 const videoData = {
232 name: body.name || importData.name || 'Unknown name', 228 name: body.name || importData.name || 'Unknown name',
233 remote: false, 229 remote: false,
@@ -252,7 +248,7 @@ function buildVideo (channelId: number, body: VideoImportCreate, importData: You
252 return video 248 return video
253} 249}
254 250
255async function processThumbnail (req: express.Request, video: VideoModel) { 251async function processThumbnail (req: express.Request, video: MVideoThumbnail) {
256 const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined 252 const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined
257 if (thumbnailField) { 253 if (thumbnailField) {
258 const thumbnailPhysicalFile = thumbnailField[0] 254 const thumbnailPhysicalFile = thumbnailField[0]
@@ -268,7 +264,7 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
268 return undefined 264 return undefined
269} 265}
270 266
271async function processPreview (req: express.Request, video: VideoModel) { 267async function processPreview (req: express.Request, video: MVideoThumbnail): Promise<MThumbnail> {
272 const previewField = req.files ? req.files['previewfile'] : undefined 268 const previewField = req.files ? req.files['previewfile'] : undefined
273 if (previewField) { 269 if (previewField) {
274 const previewPhysicalFile = previewField[0] 270 const previewPhysicalFile = previewField[0]
@@ -284,7 +280,7 @@ async function processPreview (req: express.Request, video: VideoModel) {
284 return undefined 280 return undefined
285} 281}
286 282
287async function processThumbnailFromUrl (url: string, video: VideoModel) { 283async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) {
288 try { 284 try {
289 return createVideoMiniatureFromUrl(url, video, ThumbnailType.MINIATURE) 285 return createVideoMiniatureFromUrl(url, video, ThumbnailType.MINIATURE)
290 } catch (err) { 286 } catch (err) {
@@ -293,7 +289,7 @@ async function processThumbnailFromUrl (url: string, video: VideoModel) {
293 } 289 }
294} 290}
295 291
296async function processPreviewFromUrl (url: string, video: VideoModel) { 292async function processPreviewFromUrl (url: string, video: MVideoThumbnail) {
297 try { 293 try {
298 return createVideoMiniatureFromUrl(url, video, ThumbnailType.PREVIEW) 294 return createVideoMiniatureFromUrl(url, video, ThumbnailType.PREVIEW)
299 } catch (err) { 295 } catch (err) {
@@ -303,7 +299,7 @@ async function processPreviewFromUrl (url: string, video: VideoModel) {
303} 299}
304 300
305function insertIntoDB (parameters: { 301function insertIntoDB (parameters: {
306 video: MVideoThumbnailAccountDefault 302 video: MVideoThumbnail
307 thumbnailModel: MThumbnail 303 thumbnailModel: MThumbnail
308 previewModel: MThumbnail 304 previewModel: MThumbnail
309 videoChannel: MChannelAccountDefault 305 videoChannel: MChannelAccountDefault
diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts
index 847d24fd4..656dea223 100644
--- a/server/controllers/lazy-static.ts
+++ b/server/controllers/lazy-static.ts
@@ -23,7 +23,7 @@ lazyStaticRouter.use(
23) 23)
24 24
25lazyStaticRouter.use( 25lazyStaticRouter.use(
26 LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt', 26 LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename',
27 asyncMiddleware(getVideoCaption) 27 asyncMiddleware(getVideoCaption)
28) 28)
29 29
@@ -78,10 +78,7 @@ async function getPreview (req: express.Request, res: express.Response) {
78} 78}
79 79
80async function getVideoCaption (req: express.Request, res: express.Response) { 80async function getVideoCaption (req: express.Request, res: express.Response) {
81 const result = await VideosCaptionCache.Instance.getFilePath({ 81 const result = await VideosCaptionCache.Instance.getFilePath(req.params.filename)
82 videoId: req.params.videoId,
83 language: req.params.captionLanguage
84 })
85 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 82 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
86 83
87 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER }) 84 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER })
diff --git a/server/helpers/captions-utils.ts b/server/helpers/captions-utils.ts
index 7cbfb3561..401f2fb7b 100644
--- a/server/helpers/captions-utils.ts
+++ b/server/helpers/captions-utils.ts
@@ -1,12 +1,12 @@
1import { createReadStream, createWriteStream, move, remove } from 'fs-extra'
1import { join } from 'path' 2import { join } from 'path'
2import { CONFIG } from '../initializers/config'
3import * as srt2vtt from 'srt-to-vtt' 3import * as srt2vtt from 'srt-to-vtt'
4import { createReadStream, createWriteStream, move, remove } from 'fs-extra' 4import { MVideoCaption } from '@server/types/models'
5import { MVideoCaptionFormattable } from '@server/types/models' 5import { CONFIG } from '../initializers/config'
6 6
7async function moveAndProcessCaptionFile (physicalFile: { filename: string, path: string }, videoCaption: MVideoCaptionFormattable) { 7async function moveAndProcessCaptionFile (physicalFile: { filename: string, path: string }, videoCaption: MVideoCaption) {
8 const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR 8 const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR
9 const destination = join(videoCaptionsDir, videoCaption.getCaptionName()) 9 const destination = join(videoCaptionsDir, videoCaption.filename)
10 10
11 // Convert this srt file to vtt 11 // Convert this srt file to vtt
12 if (physicalFile.path.endsWith('.srt')) { 12 if (physicalFile.path.endsWith('.srt')) {
@@ -17,7 +17,7 @@ async function moveAndProcessCaptionFile (physicalFile: { filename: string, path
17 } 17 }
18 18
19 // This is important in case if there is another attempt in the retry process 19 // This is important in case if there is another attempt in the retry process
20 physicalFile.filename = videoCaption.getCaptionName() 20 physicalFile.filename = videoCaption.filename
21 physicalFile.path = destination 21 physicalFile.path = destination
22} 22}
23 23
diff --git a/server/helpers/middlewares/video-captions.ts b/server/helpers/middlewares/video-captions.ts
index 10267eda1..226d3c5f8 100644
--- a/server/helpers/middlewares/video-captions.ts
+++ b/server/helpers/middlewares/video-captions.ts
@@ -9,7 +9,6 @@ async function doesVideoCaptionExist (video: MVideoId, language: string, res: Re
9 if (!videoCaption) { 9 if (!videoCaption) {
10 res.status(HttpStatusCode.NOT_FOUND_404) 10 res.status(HttpStatusCode.NOT_FOUND_404)
11 .json({ error: 'Video caption not found' }) 11 .json({ error: 'Video caption not found' })
12 .end()
13 12
14 return false 13 return false
15 } 14 }
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index a9f7a8e58..be5db8fe8 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
24 24
25// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
26 26
27const LAST_MIGRATION_VERSION = 575 27const LAST_MIGRATION_VERSION = 580
28 28
29// --------------------------------------------------------------------------- 29// ---------------------------------------------------------------------------
30 30
diff --git a/server/initializers/migrations/0580-caption-filename.ts b/server/initializers/migrations/0580-caption-filename.ts
new file mode 100644
index 000000000..5281fd0c0
--- /dev/null
+++ b/server/initializers/migrations/0580-caption-filename.ts
@@ -0,0 +1,48 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 {
10 const data = {
11 type: Sequelize.STRING,
12 allowNull: true,
13 defaultValue: null
14 }
15
16 await utils.queryInterface.addColumn('videoCaption', 'filename', data)
17 }
18
19 {
20 const query = `UPDATE "videoCaption" SET "filename" = s.uuid || '-' || s.language || '.vtt' ` +
21 `FROM (` +
22 ` SELECT "videoCaption"."id", video.uuid, "videoCaption".language ` +
23 ` FROM "videoCaption" INNER JOIN video ON video.id = "videoCaption"."videoId"` +
24 `) AS s ` +
25 `WHERE "videoCaption".id = s.id`
26
27 await utils.sequelize.query(query)
28 }
29
30 {
31 const data = {
32 type: Sequelize.STRING,
33 allowNull: false,
34 defaultValue: null
35 }
36
37 await utils.queryInterface.changeColumn('videoCaption', 'filename', data)
38 }
39}
40
41function down (options) {
42 throw new Error('Not implemented.')
43}
44
45export {
46 up,
47 down
48}
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts
index 8b54a001a..53298e968 100644
--- a/server/lib/activitypub/playlist.ts
+++ b/server/lib/activitypub/playlist.ts
@@ -99,8 +99,6 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
99 return Promise.resolve() 99 return Promise.resolve()
100 }) 100 })
101 101
102 logger.info('toto', { playlist, id: playlist.id })
103
104 const refreshedPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(playlist.id, null) 102 const refreshedPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(playlist.id, null)
105 103
106 if (playlistObject.icon) { 104 if (playlistObject.icon) {
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index b5a199e67..201ef0302 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -56,6 +56,7 @@ import {
56 MVideoAccountLightBlacklistAllFiles, 56 MVideoAccountLightBlacklistAllFiles,
57 MVideoAP, 57 MVideoAP,
58 MVideoAPWithoutCaption, 58 MVideoAPWithoutCaption,
59 MVideoCaption,
59 MVideoFile, 60 MVideoFile,
60 MVideoFullLight, 61 MVideoFullLight,
61 MVideoId, 62 MVideoId,
@@ -90,7 +91,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
90 // Fetch more attributes that we will need to serialize in AP object 91 // Fetch more attributes that we will need to serialize in AP object
91 if (isArray(video.VideoCaptions) === false) { 92 if (isArray(video.VideoCaptions) === false) {
92 video.VideoCaptions = await video.$get('VideoCaptions', { 93 video.VideoCaptions = await video.$get('VideoCaptions', {
93 attributes: [ 'language' ], 94 attributes: [ 'filename', 'language' ],
94 transaction 95 transaction
95 }) 96 })
96 } 97 }
@@ -423,7 +424,14 @@ async function updateVideoFromAP (options: {
423 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t) 424 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t)
424 425
425 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => { 426 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
426 return VideoCaptionModel.insertOrReplaceLanguage(videoUpdated.id, c.identifier, c.url, t) 427 const caption = new VideoCaptionModel({
428 videoId: videoUpdated.id,
429 filename: VideoCaptionModel.generateCaptionName(c.identifier),
430 language: c.identifier,
431 fileUrl: c.url
432 }) as MVideoCaption
433
434 return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
427 }) 435 })
428 await Promise.all(videoCaptionsPromises) 436 await Promise.all(videoCaptionsPromises)
429 } 437 }
@@ -629,7 +637,14 @@ async function createVideo (videoObject: VideoObject, channel: MChannelAccountLi
629 637
630 // Process captions 638 // Process captions
631 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => { 639 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
632 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, c.url, t) 640 const caption = new VideoCaptionModel({
641 videoId: videoCreated.id,
642 filename: VideoCaptionModel.generateCaptionName(c.identifier),
643 language: c.identifier,
644 fileUrl: c.url
645 }) as MVideoCaption
646
647 return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
633 }) 648 })
634 await Promise.all(videoCaptionsPromises) 649 await Promise.all(videoCaptionsPromises)
635 650
diff --git a/server/lib/files-cache/videos-caption-cache.ts b/server/lib/files-cache/videos-caption-cache.ts
index 26ab3bd0d..ee0447010 100644
--- a/server/lib/files-cache/videos-caption-cache.ts
+++ b/server/lib/files-cache/videos-caption-cache.ts
@@ -1,17 +1,13 @@
1import { join } from 'path' 1import { join } from 'path'
2import { doRequestAndSaveToFile } from '@server/helpers/requests'
3import { CONFIG } from '../../initializers/config'
2import { FILES_CACHE } from '../../initializers/constants' 4import { FILES_CACHE } from '../../initializers/constants'
3import { VideoModel } from '../../models/video/video' 5import { VideoModel } from '../../models/video/video'
4import { VideoCaptionModel } from '../../models/video/video-caption' 6import { VideoCaptionModel } from '../../models/video/video-caption'
5import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' 7import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
6import { CONFIG } from '../../initializers/config'
7import { logger } from '../../helpers/logger'
8import { doRequestAndSaveToFile } from '@server/helpers/requests'
9 8
10type GetPathParam = { videoId: string, language: string } 9class VideosCaptionCache extends AbstractVideoStaticFileCache <string> {
11 10
12class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
13
14 private static readonly KEY_DELIMITER = '%'
15 private static instance: VideosCaptionCache 11 private static instance: VideosCaptionCache
16 12
17 private constructor () { 13 private constructor () {
@@ -22,32 +18,28 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
22 return this.instance || (this.instance = new this()) 18 return this.instance || (this.instance = new this())
23 } 19 }
24 20
25 async getFilePathImpl (params: GetPathParam) { 21 async getFilePathImpl (filename: string) {
26 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language) 22 const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(filename)
27 if (!videoCaption) return undefined 23 if (!videoCaption) return undefined
28 24
29 if (videoCaption.isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName()) } 25 if (videoCaption.isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.filename) }
30 26
31 const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language 27 return this.loadRemoteFile(filename)
32 return this.loadRemoteFile(key)
33 } 28 }
34 29
30 // Key is the caption filename
35 protected async loadRemoteFile (key: string) { 31 protected async loadRemoteFile (key: string) {
36 logger.debug('Loading remote caption file %s.', key) 32 const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(key)
37
38 const [ videoId, language ] = key.split(VideosCaptionCache.KEY_DELIMITER)
39
40 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(videoId, language)
41 if (!videoCaption) return undefined 33 if (!videoCaption) return undefined
42 34
43 if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.') 35 if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
44 36
45 // Used to fetch the path 37 // Used to fetch the path
46 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 38 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoCaption.videoId)
47 if (!video) return undefined 39 if (!video) return undefined
48 40
49 const remoteUrl = videoCaption.getFileUrl(video) 41 const remoteUrl = videoCaption.getFileUrl(video)
50 const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.getCaptionName()) 42 const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.filename)
51 43
52 await doRequestAndSaveToFile({ uri: remoteUrl }, destPath) 44 await doRequestAndSaveToFile({ uri: remoteUrl }, destPath)
53 45
diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts
index 51146d718..47488da74 100644
--- a/server/lib/files-cache/videos-preview-cache.ts
+++ b/server/lib/files-cache/videos-preview-cache.ts
@@ -28,6 +28,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
28 return this.loadRemoteFile(thumbnail.Video.uuid) 28 return this.loadRemoteFile(thumbnail.Video.uuid)
29 } 29 }
30 30
31 // Key is the video UUID
31 protected async loadRemoteFile (key: string) { 32 protected async loadRemoteFile (key: string) {
32 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(key) 33 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(key)
33 if (!video) return undefined 34 if (!video) return undefined
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
index 740b83acb..33aa7159c 100644
--- a/server/lib/thumbnail.ts
+++ b/server/lib/thumbnail.ts
@@ -166,6 +166,9 @@ async function createThumbnailFromFunction (parameters: {
166}) { 166}) {
167 const { thumbnailCreator, filename, width, height, type, existingThumbnail, automaticallyGenerated = null, fileUrl = null } = parameters 167 const { thumbnailCreator, filename, width, height, type, existingThumbnail, automaticallyGenerated = null, fileUrl = null } = parameters
168 168
169 // Remove old file
170 if (existingThumbnail) await existingThumbnail.removeThumbnail()
171
169 const thumbnail = existingThumbnail || new ThumbnailModel() 172 const thumbnail = existingThumbnail || new ThumbnailModel()
170 173
171 thumbnail.filename = filename 174 thumbnail.filename = filename
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index e8e883dd0..a1553ea15 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -16,7 +16,7 @@ import {
16 UpdatedAt 16 UpdatedAt
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub' 18import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub'
19import { MVideoAccountLight, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models' 19import { MVideoAccountLight, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models'
20import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' 20import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
21import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' 21import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
22import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
@@ -24,6 +24,7 @@ import { CONFIG } from '../../initializers/config'
24import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' 24import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
25import { buildWhereIdOrUUID, throwIfNotValid } from '../utils' 25import { buildWhereIdOrUUID, throwIfNotValid } from '../utils'
26import { VideoModel } from './video' 26import { VideoModel } from './video'
27import { v4 as uuidv4 } from 'uuid'
27 28
28export enum ScopeNames { 29export enum ScopeNames {
29 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE' 30 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
@@ -45,6 +46,10 @@ export enum ScopeNames {
45 tableName: 'videoCaption', 46 tableName: 'videoCaption',
46 indexes: [ 47 indexes: [
47 { 48 {
49 fields: [ 'filename' ],
50 unique: true
51 },
52 {
48 fields: [ 'videoId' ] 53 fields: [ 'videoId' ]
49 }, 54 },
50 { 55 {
@@ -65,6 +70,10 @@ export class VideoCaptionModel extends Model {
65 @Column 70 @Column
66 language: string 71 language: string
67 72
73 @AllowNull(false)
74 @Column
75 filename: string
76
68 @AllowNull(true) 77 @AllowNull(true)
69 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max)) 78 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
70 fileUrl: string 79 fileUrl: string
@@ -88,12 +97,12 @@ export class VideoCaptionModel extends Model {
88 } 97 }
89 98
90 if (instance.isOwned()) { 99 if (instance.isOwned()) {
91 logger.info('Removing captions %s of video %s.', instance.Video.uuid, instance.language) 100 logger.info('Removing caption %s.', instance.filename)
92 101
93 try { 102 try {
94 await instance.removeCaptionFile() 103 await instance.removeCaptionFile()
95 } catch (err) { 104 } catch (err) {
96 logger.error('Cannot remove caption file of video %s.', instance.Video.uuid) 105 logger.error('Cannot remove caption file %s.', instance.filename)
97 } 106 }
98 } 107 }
99 108
@@ -119,15 +128,28 @@ export class VideoCaptionModel extends Model {
119 return VideoCaptionModel.findOne(query) 128 return VideoCaptionModel.findOne(query)
120 } 129 }
121 130
122 static insertOrReplaceLanguage (videoId: number, language: string, fileUrl: string, transaction: Transaction) { 131 static loadWithVideoByFilename (filename: string): Promise<MVideoCaptionVideo> {
123 const values = { 132 const query = {
124 videoId, 133 where: {
125 language, 134 filename
126 fileUrl 135 },
136 include: [
137 {
138 model: VideoModel.unscoped(),
139 attributes: [ 'id', 'remote', 'uuid' ]
140 }
141 ]
127 } 142 }
128 143
129 return VideoCaptionModel.upsert(values, { transaction, returning: true }) 144 return VideoCaptionModel.findOne(query)
130 .then(([ caption ]) => caption) 145 }
146
147 static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
148 const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language)
149 // Delete existing file
150 if (existing) await existing.destroy({ transaction })
151
152 return caption.save({ transaction })
131 } 153 }
132 154
133 static listVideoCaptions (videoId: number): Promise<MVideoCaptionVideo[]> { 155 static listVideoCaptions (videoId: number): Promise<MVideoCaptionVideo[]> {
@@ -156,6 +178,10 @@ export class VideoCaptionModel extends Model {
156 return VideoCaptionModel.destroy(query) 178 return VideoCaptionModel.destroy(query)
157 } 179 }
158 180
181 static generateCaptionName (language: string) {
182 return `${uuidv4()}-${language}.vtt`
183 }
184
159 isOwned () { 185 isOwned () {
160 return this.Video.remote === false 186 return this.Video.remote === false
161 } 187 }
@@ -170,16 +196,12 @@ export class VideoCaptionModel extends Model {
170 } 196 }
171 } 197 }
172 198
173 getCaptionStaticPath (this: MVideoCaptionFormattable) { 199 getCaptionStaticPath (this: MVideoCaption) {
174 return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName()) 200 return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
175 }
176
177 getCaptionName (this: MVideoCaptionFormattable) {
178 return `${this.Video.uuid}-${this.language}.vtt`
179 } 201 }
180 202
181 removeCaptionFile (this: MVideoCaptionFormattable) { 203 removeCaptionFile (this: MVideoCaption) {
182 return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName()) 204 return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.filename)
183 } 205 }
184 206
185 getFileUrl (video: MVideoAccountLight) { 207 getFileUrl (video: MVideoAccountLight) {
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 93ecf8cea..9e6ff1f81 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -471,7 +471,7 @@ export class VideoPlaylistModel extends Model {
471 generateThumbnailName () { 471 generateThumbnailName () {
472 const extension = '.jpg' 472 const extension = '.jpg'
473 473
474 return 'playlist-' + this.uuid + extension 474 return 'playlist-' + uuidv4() + extension
475 } 475 }
476 476
477 getThumbnailUrl () { 477 getThumbnailUrl () {
diff --git a/server/tests/api/server/services.ts b/server/tests/api/server/services.ts
index 680e7a817..df910c111 100644
--- a/server/tests/api/server/services.ts
+++ b/server/tests/api/server/services.ts
@@ -2,24 +2,25 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { Video, VideoPlaylistPrivacy } from '@shared/models'
5import { 6import {
7 addVideoInPlaylist,
8 createVideoPlaylist,
6 getOEmbed, 9 getOEmbed,
7 getVideosList, 10 getVideosList,
8 ServerInfo, 11 ServerInfo,
9 setAccessTokensToServers, 12 setAccessTokensToServers,
10 setDefaultVideoChannel, 13 setDefaultVideoChannel,
11 uploadVideo, 14 uploadVideo
12 createVideoPlaylist,
13 addVideoInPlaylist
14} from '../../../../shared/extra-utils' 15} from '../../../../shared/extra-utils'
15import { cleanupTests, flushAndRunServer } from '../../../../shared/extra-utils/server/servers' 16import { cleanupTests, flushAndRunServer } from '../../../../shared/extra-utils/server/servers'
16import { VideoPlaylistPrivacy } from '@shared/models'
17 17
18const expect = chai.expect 18const expect = chai.expect
19 19
20describe('Test services', function () { 20describe('Test services', function () {
21 let server: ServerInfo = null 21 let server: ServerInfo = null
22 let playlistUUID: string 22 let playlistUUID: string
23 let video: Video
23 24
24 before(async function () { 25 before(async function () {
25 this.timeout(30000) 26 this.timeout(30000)
@@ -36,7 +37,7 @@ describe('Test services', function () {
36 await uploadVideo(server.url, server.accessToken, videoAttributes) 37 await uploadVideo(server.url, server.accessToken, videoAttributes)
37 38
38 const res = await getVideosList(server.url) 39 const res = await getVideosList(server.url)
39 server.video = res.body.data[0] 40 video = res.body.data[0]
40 } 41 }
41 42
42 { 43 {
@@ -57,23 +58,23 @@ describe('Test services', function () {
57 token: server.accessToken, 58 token: server.accessToken,
58 playlistId: res.body.videoPlaylist.id, 59 playlistId: res.body.videoPlaylist.id,
59 elementAttrs: { 60 elementAttrs: {
60 videoId: server.video.id 61 videoId: video.id
61 } 62 }
62 }) 63 })
63 } 64 }
64 }) 65 })
65 66
66 it('Should have a valid oEmbed video response', async function () { 67 it('Should have a valid oEmbed video response', async function () {
67 const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + server.video.uuid 68 const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + video.uuid
68 69
69 const res = await getOEmbed(server.url, oembedUrl) 70 const res = await getOEmbed(server.url, oembedUrl)
70 const expectedHtml = '<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" ' + 71 const expectedHtml = '<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" ' +
71 `src="http://localhost:${server.port}/videos/embed/${server.video.uuid}" ` + 72 `src="http://localhost:${server.port}/videos/embed/${video.uuid}" ` +
72 'frameborder="0" allowfullscreen></iframe>' 73 'frameborder="0" allowfullscreen></iframe>'
73 const expectedThumbnailUrl = 'http://localhost:' + server.port + '/lazy-static/previews/' + server.video.uuid + '.jpg' 74 const expectedThumbnailUrl = 'http://localhost:' + server.port + video.previewPath
74 75
75 expect(res.body.html).to.equal(expectedHtml) 76 expect(res.body.html).to.equal(expectedHtml)
76 expect(res.body.title).to.equal(server.video.name) 77 expect(res.body.title).to.equal(video.name)
77 expect(res.body.author_name).to.equal(server.videoChannel.displayName) 78 expect(res.body.author_name).to.equal(server.videoChannel.displayName)
78 expect(res.body.width).to.equal(560) 79 expect(res.body.width).to.equal(560)
79 expect(res.body.height).to.equal(315) 80 expect(res.body.height).to.equal(315)
@@ -101,18 +102,18 @@ describe('Test services', function () {
101 }) 102 })
102 103
103 it('Should have a valid oEmbed response with small max height query', async function () { 104 it('Should have a valid oEmbed response with small max height query', async function () {
104 const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + server.video.uuid 105 const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + video.uuid
105 const format = 'json' 106 const format = 'json'
106 const maxHeight = 50 107 const maxHeight = 50
107 const maxWidth = 50 108 const maxWidth = 50
108 109
109 const res = await getOEmbed(server.url, oembedUrl, format, maxHeight, maxWidth) 110 const res = await getOEmbed(server.url, oembedUrl, format, maxHeight, maxWidth)
110 const expectedHtml = '<iframe width="50" height="50" sandbox="allow-same-origin allow-scripts" ' + 111 const expectedHtml = '<iframe width="50" height="50" sandbox="allow-same-origin allow-scripts" ' +
111 `src="http://localhost:${server.port}/videos/embed/${server.video.uuid}" ` + 112 `src="http://localhost:${server.port}/videos/embed/${video.uuid}" ` +
112 'frameborder="0" allowfullscreen></iframe>' 113 'frameborder="0" allowfullscreen></iframe>'
113 114
114 expect(res.body.html).to.equal(expectedHtml) 115 expect(res.body.html).to.equal(expectedHtml)
115 expect(res.body.title).to.equal(server.video.name) 116 expect(res.body.title).to.equal(video.name)
116 expect(res.body.author_name).to.equal(server.videoChannel.displayName) 117 expect(res.body.author_name).to.equal(server.videoChannel.displayName)
117 expect(res.body.height).to.equal(50) 118 expect(res.body.height).to.equal(50)
118 expect(res.body.width).to.equal(50) 119 expect(res.body.width).to.equal(50)
diff --git a/server/tests/api/videos/video-captions.ts b/server/tests/api/videos/video-captions.ts
index 5b36dc021..14ecedfa6 100644
--- a/server/tests/api/videos/video-captions.ts
+++ b/server/tests/api/videos/video-captions.ts
@@ -24,6 +24,8 @@ import { VideoCaption } from '../../../../shared/models/videos/caption/video-cap
24const expect = chai.expect 24const expect = chai.expect
25 25
26describe('Test video captions', function () { 26describe('Test video captions', function () {
27 const uuidRegex = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
28
27 let servers: ServerInfo[] 29 let servers: ServerInfo[]
28 let videoUUID: string 30 let videoUUID: string
29 31
@@ -83,13 +85,13 @@ describe('Test video captions', function () {
83 const caption1: VideoCaption = res.body.data[0] 85 const caption1: VideoCaption = res.body.data[0]
84 expect(caption1.language.id).to.equal('ar') 86 expect(caption1.language.id).to.equal('ar')
85 expect(caption1.language.label).to.equal('Arabic') 87 expect(caption1.language.label).to.equal('Arabic')
86 expect(caption1.captionPath).to.equal('/lazy-static/video-captions/' + videoUUID + '-ar.vtt') 88 expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$'))
87 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.') 89 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.')
88 90
89 const caption2: VideoCaption = res.body.data[1] 91 const caption2: VideoCaption = res.body.data[1]
90 expect(caption2.language.id).to.equal('zh') 92 expect(caption2.language.id).to.equal('zh')
91 expect(caption2.language.label).to.equal('Chinese') 93 expect(caption2.language.label).to.equal('Chinese')
92 expect(caption2.captionPath).to.equal('/lazy-static/video-captions/' + videoUUID + '-zh.vtt') 94 expect(caption2.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$'))
93 await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.') 95 await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.')
94 } 96 }
95 }) 97 })
@@ -117,7 +119,7 @@ describe('Test video captions', function () {
117 const caption1: VideoCaption = res.body.data[0] 119 const caption1: VideoCaption = res.body.data[0]
118 expect(caption1.language.id).to.equal('ar') 120 expect(caption1.language.id).to.equal('ar')
119 expect(caption1.language.label).to.equal('Arabic') 121 expect(caption1.language.label).to.equal('Arabic')
120 expect(caption1.captionPath).to.equal('/lazy-static/video-captions/' + videoUUID + '-ar.vtt') 122 expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$'))
121 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.') 123 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.')
122 } 124 }
123 }) 125 })
@@ -148,7 +150,7 @@ describe('Test video captions', function () {
148 const caption1: VideoCaption = res.body.data[0] 150 const caption1: VideoCaption = res.body.data[0]
149 expect(caption1.language.id).to.equal('ar') 151 expect(caption1.language.id).to.equal('ar')
150 expect(caption1.language.label).to.equal('Arabic') 152 expect(caption1.language.label).to.equal('Arabic')
151 expect(caption1.captionPath).to.equal('/lazy-static/video-captions/' + videoUUID + '-ar.vtt') 153 expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-ar.vtt$'))
152 154
153 const expected = 'WEBVTT FILE\r\n' + 155 const expected = 'WEBVTT FILE\r\n' +
154 '\r\n' + 156 '\r\n' +
@@ -185,7 +187,7 @@ describe('Test video captions', function () {
185 187
186 expect(caption.language.id).to.equal('zh') 188 expect(caption.language.id).to.equal('zh')
187 expect(caption.language.label).to.equal('Chinese') 189 expect(caption.language.label).to.equal('Chinese')
188 expect(caption.captionPath).to.equal('/lazy-static/video-captions/' + videoUUID + '-zh.vtt') 190 expect(caption.captionPath).to.match(new RegExp('^/lazy-static/video-captions/' + uuidRegex + '-zh.vtt$'))
189 await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.') 191 await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.')
190 } 192 }
191 }) 193 })
diff --git a/server/types/models/video/video-caption.ts b/server/types/models/video/video-caption.ts
index ab80ff830..1f761a866 100644
--- a/server/types/models/video/video-caption.ts
+++ b/server/types/models/video/video-caption.ts
@@ -1,5 +1,5 @@
1import { PickWith } from '@shared/core-utils'
1import { VideoCaptionModel } from '../../../models/video/video-caption' 2import { VideoCaptionModel } from '../../../models/video/video-caption'
2import { FunctionProperties, PickWith } from '@shared/core-utils'
3import { MVideo, MVideoUUID } from './video' 3import { MVideo, MVideoUUID } from './video'
4 4
5type Use<K extends keyof VideoCaptionModel, M> = PickWith<VideoCaptionModel, K, M> 5type Use<K extends keyof VideoCaptionModel, M> = PickWith<VideoCaptionModel, K, M>
@@ -22,6 +22,6 @@ export type MVideoCaptionVideo =
22// Format for API or AP object 22// Format for API or AP object
23 23
24export type MVideoCaptionFormattable = 24export type MVideoCaptionFormattable =
25 FunctionProperties<MVideoCaption> & 25 MVideoCaption &
26 Pick<MVideoCaption, 'language'> & 26 Pick<MVideoCaption, 'language'> &
27 Use<'Video', MVideoUUID> 27 Use<'Video', MVideoUUID>