diff options
-rw-r--r-- | client/src/app/+videos/+video-edit/shared/video-edit.component.html | 2 | ||||
-rw-r--r-- | client/src/app/+videos/+video-edit/shared/video-edit.component.scss | 7 | ||||
-rw-r--r-- | client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts | 1 | ||||
-rw-r--r-- | server/lib/object-storage/videos.ts | 5 | ||||
-rw-r--r-- | server/lib/transcoding/transcoding.ts | 28 | ||||
-rw-r--r-- | server/models/video/video-caption.ts | 3 | ||||
-rw-r--r-- | server/models/video/video-file.ts | 41 | ||||
-rw-r--r-- | server/models/video/video.ts | 21 | ||||
-rw-r--r-- | server/tests/api/transcoding/create-transcoding.ts | 14 | ||||
-rw-r--r-- | shared/models/videos/caption/video-caption.model.ts | 1 |
10 files changed, 102 insertions, 21 deletions
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index 650448a74..2892d603d 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html | |||
@@ -183,7 +183,7 @@ | |||
183 | [href]="videoCaption.captionPath" | 183 | [href]="videoCaption.captionPath" |
184 | >{{ videoCaption.language.label }}</a> | 184 | >{{ videoCaption.language.label }}</a> |
185 | 185 | ||
186 | <div i18n class="caption-entry-state">Already uploaded ✔</div> | 186 | <div i18n class="caption-entry-state">Already uploaded on {{ videoCaption.updatedAt | date }} ✔</div> |
187 | 187 | ||
188 | <span i18n class="caption-entry-edit" (click)="videoCaptionEditModal.show()">Edit</span> | 188 | <span i18n class="caption-entry-edit" (click)="videoCaptionEditModal.show()">Edit</span> |
189 | <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span> | 189 | <span i18n class="caption-entry-delete" (click)="deleteCaption(videoCaption)">Delete</span> |
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss index e8a6c6e42..a8075cc6d 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss | |||
@@ -41,7 +41,6 @@ my-peertube-checkbox { | |||
41 | a.caption-entry-label { | 41 | a.caption-entry-label { |
42 | @include disable-default-a-behaviour; | 42 | @include disable-default-a-behaviour; |
43 | 43 | ||
44 | flex-grow: 1; | ||
45 | color: #000; | 44 | color: #000; |
46 | 45 | ||
47 | &:hover { | 46 | &:hover { |
@@ -53,11 +52,13 @@ my-peertube-checkbox { | |||
53 | @include margin-right(20px); | 52 | @include margin-right(20px); |
54 | 53 | ||
55 | font-weight: bold; | 54 | font-weight: bold; |
56 | width: 150px; | 55 | min-width: 100px; |
57 | } | 56 | } |
58 | 57 | ||
59 | .caption-entry-state { | 58 | .caption-entry-state { |
60 | width: 200px; | 59 | @include margin-right(15px); |
60 | |||
61 | min-width: 250px; | ||
61 | 62 | ||
62 | &.caption-entry-state-create { | 63 | &.caption-entry-state-create { |
63 | color: #39CC0B; | 64 | color: #39CC0B; |
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts index 129e80bc0..8d578cae6 100644 --- a/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts +++ b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts | |||
@@ -6,6 +6,7 @@ export interface VideoCaptionEdit { | |||
6 | 6 | ||
7 | action?: 'CREATE' | 'REMOVE' | 'UPDATE' | 7 | action?: 'CREATE' | 'REMOVE' | 'UPDATE' |
8 | captionfile?: any | 8 | captionfile?: any |
9 | updatedAt?: string | ||
9 | } | 10 | } |
10 | 11 | ||
11 | export type VideoCaptionWithPathEdit = VideoCaptionEdit & { captionPath?: string } | 12 | export type VideoCaptionWithPathEdit = VideoCaptionEdit & { captionPath?: string } |
diff --git a/server/lib/object-storage/videos.ts b/server/lib/object-storage/videos.ts index 066b48ab0..66e738200 100644 --- a/server/lib/object-storage/videos.ts +++ b/server/lib/object-storage/videos.ts | |||
@@ -26,6 +26,10 @@ function removeHLSObjectStorage (playlist: MStreamingPlaylistVideo) { | |||
26 | return removePrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) | 26 | return removePrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) |
27 | } | 27 | } |
28 | 28 | ||
29 | function removeHLSFileObjectStorage (playlist: MStreamingPlaylistVideo, filename: string) { | ||
30 | return removeObject(generateHLSObjectStorageKey(playlist, filename), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) | ||
31 | } | ||
32 | |||
29 | function removeWebTorrentObjectStorage (videoFile: MVideoFile) { | 33 | function removeWebTorrentObjectStorage (videoFile: MVideoFile) { |
30 | return removeObject(generateWebTorrentObjectStorageKey(videoFile.filename), CONFIG.OBJECT_STORAGE.VIDEOS) | 34 | return removeObject(generateWebTorrentObjectStorageKey(videoFile.filename), CONFIG.OBJECT_STORAGE.VIDEOS) |
31 | } | 35 | } |
@@ -63,6 +67,7 @@ export { | |||
63 | storeHLSFile, | 67 | storeHLSFile, |
64 | 68 | ||
65 | removeHLSObjectStorage, | 69 | removeHLSObjectStorage, |
70 | removeHLSFileObjectStorage, | ||
66 | removeWebTorrentObjectStorage, | 71 | removeWebTorrentObjectStorage, |
67 | 72 | ||
68 | makeWebTorrentFileAvailable, | 73 | makeWebTorrentFileAvailable, |
diff --git a/server/lib/transcoding/transcoding.ts b/server/lib/transcoding/transcoding.ts index b64ce6e1f..69a973fbd 100644 --- a/server/lib/transcoding/transcoding.ts +++ b/server/lib/transcoding/transcoding.ts | |||
@@ -2,7 +2,9 @@ import { Job } from 'bull' | |||
2 | import { copyFile, ensureDir, move, remove, stat } from 'fs-extra' | 2 | import { copyFile, ensureDir, move, remove, stat } from 'fs-extra' |
3 | import { basename, extname as extnameUtil, join } from 'path' | 3 | import { basename, extname as extnameUtil, join } from 'path' |
4 | import { toEven } from '@server/helpers/core-utils' | 4 | import { toEven } from '@server/helpers/core-utils' |
5 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | ||
5 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 6 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' |
7 | import { sequelizeTypescript } from '@server/initializers/database' | ||
6 | import { MStreamingPlaylistFilesVideo, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | 8 | import { MStreamingPlaylistFilesVideo, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' |
7 | import { VideoResolution, VideoStorage } from '../../../shared/models/videos' | 9 | import { VideoResolution, VideoStorage } from '../../../shared/models/videos' |
8 | import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' | 10 | import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' |
@@ -29,8 +31,6 @@ import { | |||
29 | } from '../paths' | 31 | } from '../paths' |
30 | import { VideoPathManager } from '../video-path-manager' | 32 | import { VideoPathManager } from '../video-path-manager' |
31 | import { VideoTranscodingProfilesManager } from './default-transcoding-profiles' | 33 | import { VideoTranscodingProfilesManager } from './default-transcoding-profiles' |
32 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | ||
33 | import { sequelizeTypescript } from '@server/initializers/database' | ||
34 | 34 | ||
35 | /** | 35 | /** |
36 | * | 36 | * |
@@ -259,6 +259,9 @@ async function onWebTorrentVideoFileTranscoding ( | |||
259 | 259 | ||
260 | await createTorrentAndSetInfoHash(video, videoFile) | 260 | await createTorrentAndSetInfoHash(video, videoFile) |
261 | 261 | ||
262 | const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution }) | ||
263 | if (oldFile) await video.removeWebTorrentFileAndTorrent(oldFile) | ||
264 | |||
262 | await VideoFileModel.customUpsert(videoFile, 'video', undefined) | 265 | await VideoFileModel.customUpsert(videoFile, 'video', undefined) |
263 | video.VideoFiles = await video.$get('VideoFiles') | 266 | video.VideoFiles = await video.$get('VideoFiles') |
264 | 267 | ||
@@ -311,17 +314,15 @@ async function generateHlsPlaylistCommon (options: { | |||
311 | await transcodeVOD(transcodeOptions) | 314 | await transcodeVOD(transcodeOptions) |
312 | 315 | ||
313 | // Create or update the playlist | 316 | // Create or update the playlist |
314 | const playlist = await retryTransactionWrapper(() => { | 317 | const { playlist, oldPlaylistFilename, oldSegmentsSha256Filename } = await retryTransactionWrapper(() => { |
315 | return sequelizeTypescript.transaction(async transaction => { | 318 | return sequelizeTypescript.transaction(async transaction => { |
316 | const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video, transaction) | 319 | const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video, transaction) |
317 | 320 | ||
318 | if (!playlist.playlistFilename) { | 321 | const oldPlaylistFilename = playlist.playlistFilename |
319 | playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive) | 322 | const oldSegmentsSha256Filename = playlist.segmentsSha256Filename |
320 | } | ||
321 | 323 | ||
322 | if (!playlist.segmentsSha256Filename) { | 324 | playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive) |
323 | playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive) | 325 | playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive) |
324 | } | ||
325 | 326 | ||
326 | playlist.p2pMediaLoaderInfohashes = [] | 327 | playlist.p2pMediaLoaderInfohashes = [] |
327 | playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION | 328 | playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION |
@@ -330,10 +331,13 @@ async function generateHlsPlaylistCommon (options: { | |||
330 | 331 | ||
331 | await playlist.save({ transaction }) | 332 | await playlist.save({ transaction }) |
332 | 333 | ||
333 | return playlist | 334 | return { playlist, oldPlaylistFilename, oldSegmentsSha256Filename } |
334 | }) | 335 | }) |
335 | }) | 336 | }) |
336 | 337 | ||
338 | if (oldPlaylistFilename) await video.removeStreamingPlaylistFile(playlist, oldPlaylistFilename) | ||
339 | if (oldSegmentsSha256Filename) await video.removeStreamingPlaylistFile(playlist, oldSegmentsSha256Filename) | ||
340 | |||
337 | // Build the new playlist file | 341 | // Build the new playlist file |
338 | const extname = extnameUtil(videoFilename) | 342 | const extname = extnameUtil(videoFilename) |
339 | const newVideoFile = new VideoFileModel({ | 343 | const newVideoFile = new VideoFileModel({ |
@@ -364,11 +368,15 @@ async function generateHlsPlaylistCommon (options: { | |||
364 | 368 | ||
365 | await createTorrentAndSetInfoHash(playlist, newVideoFile) | 369 | await createTorrentAndSetInfoHash(playlist, newVideoFile) |
366 | 370 | ||
371 | const oldFile = await VideoFileModel.loadHLSFile({ playlistId: playlist.id, fps: newVideoFile.fps, resolution: newVideoFile.resolution }) | ||
372 | if (oldFile) await video.removeStreamingPlaylistVideoFile(playlist, oldFile) | ||
373 | |||
367 | const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) | 374 | const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) |
368 | 375 | ||
369 | const playlistWithFiles = playlist as MStreamingPlaylistFilesVideo | 376 | const playlistWithFiles = playlist as MStreamingPlaylistFilesVideo |
370 | playlistWithFiles.VideoFiles = await playlist.$get('VideoFiles') | 377 | playlistWithFiles.VideoFiles = await playlist.$get('VideoFiles') |
371 | playlist.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles) | 378 | playlist.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles) |
379 | playlist.storage = VideoStorage.FILE_SYSTEM | ||
372 | 380 | ||
373 | await playlist.save() | 381 | await playlist.save() |
374 | 382 | ||
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts index 6b240f116..5fbcd6e3b 100644 --- a/server/models/video/video-caption.ts +++ b/server/models/video/video-caption.ts | |||
@@ -195,7 +195,8 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption | |||
195 | id: this.language, | 195 | id: this.language, |
196 | label: VideoCaptionModel.getLanguageLabel(this.language) | 196 | label: VideoCaptionModel.getLanguageLabel(this.language) |
197 | }, | 197 | }, |
198 | captionPath: this.getCaptionStaticPath() | 198 | captionPath: this.getCaptionStaticPath(), |
199 | updatedAt: this.updatedAt.toISOString() | ||
199 | } | 200 | } |
200 | } | 201 | } |
201 | 202 | ||
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index 4aaee1ffa..d4f07f85f 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts | |||
@@ -405,15 +405,16 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel> | |||
405 | mode: 'streaming-playlist' | 'video', | 405 | mode: 'streaming-playlist' | 'video', |
406 | transaction: Transaction | 406 | transaction: Transaction |
407 | ) { | 407 | ) { |
408 | const baseWhere = { | 408 | const baseFind = { |
409 | fps: videoFile.fps, | 409 | fps: videoFile.fps, |
410 | resolution: videoFile.resolution | 410 | resolution: videoFile.resolution, |
411 | transaction | ||
411 | } | 412 | } |
412 | 413 | ||
413 | if (mode === 'streaming-playlist') Object.assign(baseWhere, { videoStreamingPlaylistId: videoFile.videoStreamingPlaylistId }) | 414 | const element = mode === 'streaming-playlist' |
414 | else Object.assign(baseWhere, { videoId: videoFile.videoId }) | 415 | ? await VideoFileModel.loadHLSFile({ ...baseFind, playlistId: videoFile.videoStreamingPlaylistId }) |
416 | : await VideoFileModel.loadWebTorrentFile({ ...baseFind, videoId: videoFile.videoId }) | ||
415 | 417 | ||
416 | const element = await VideoFileModel.findOne({ where: baseWhere, transaction }) | ||
417 | if (!element) return videoFile.save({ transaction }) | 418 | if (!element) return videoFile.save({ transaction }) |
418 | 419 | ||
419 | for (const k of Object.keys(videoFile.toJSON())) { | 420 | for (const k of Object.keys(videoFile.toJSON())) { |
@@ -423,6 +424,36 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel> | |||
423 | return element.save({ transaction }) | 424 | return element.save({ transaction }) |
424 | } | 425 | } |
425 | 426 | ||
427 | static async loadWebTorrentFile (options: { | ||
428 | videoId: number | ||
429 | fps: number | ||
430 | resolution: number | ||
431 | transaction?: Transaction | ||
432 | }) { | ||
433 | const where = { | ||
434 | fps: options.fps, | ||
435 | resolution: options.resolution, | ||
436 | videoId: options.videoId | ||
437 | } | ||
438 | |||
439 | return VideoFileModel.findOne({ where, transaction: options.transaction }) | ||
440 | } | ||
441 | |||
442 | static async loadHLSFile (options: { | ||
443 | playlistId: number | ||
444 | fps: number | ||
445 | resolution: number | ||
446 | transaction?: Transaction | ||
447 | }) { | ||
448 | const where = { | ||
449 | fps: options.fps, | ||
450 | resolution: options.resolution, | ||
451 | videoStreamingPlaylistId: options.playlistId | ||
452 | } | ||
453 | |||
454 | return VideoFileModel.findOne({ where, transaction: options.transaction }) | ||
455 | } | ||
456 | |||
426 | static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) { | 457 | static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) { |
427 | const options = { | 458 | const options = { |
428 | where: { videoStreamingPlaylistId } | 459 | where: { videoStreamingPlaylistId } |
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 55da53058..27e605be6 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -26,7 +26,7 @@ import { | |||
26 | } from 'sequelize-typescript' | 26 | } from 'sequelize-typescript' |
27 | import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' | 27 | import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' |
28 | import { LiveManager } from '@server/lib/live/live-manager' | 28 | import { LiveManager } from '@server/lib/live/live-manager' |
29 | import { removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' | 29 | import { removeHLSFileObjectStorage, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' |
30 | import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths' | 30 | import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths' |
31 | import { VideoPathManager } from '@server/lib/video-path-manager' | 31 | import { VideoPathManager } from '@server/lib/video-path-manager' |
32 | import { getServerActor } from '@server/models/application/application' | 32 | import { getServerActor } from '@server/models/application/application' |
@@ -1816,6 +1816,25 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> { | |||
1816 | } | 1816 | } |
1817 | } | 1817 | } |
1818 | 1818 | ||
1819 | async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) { | ||
1820 | const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename) | ||
1821 | await videoFile.removeTorrent() | ||
1822 | await remove(filePath) | ||
1823 | |||
1824 | if (videoFile.storage === VideoStorage.OBJECT_STORAGE) { | ||
1825 | await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), videoFile.filename) | ||
1826 | } | ||
1827 | } | ||
1828 | |||
1829 | async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) { | ||
1830 | const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename) | ||
1831 | await remove(filePath) | ||
1832 | |||
1833 | if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) { | ||
1834 | await removeHLSFileObjectStorage(streamingPlaylist.withVideo(this), filename) | ||
1835 | } | ||
1836 | } | ||
1837 | |||
1819 | isOutdated () { | 1838 | isOutdated () { |
1820 | if (this.isOwned()) return false | 1839 | if (this.isOwned()) return false |
1821 | 1840 | ||
diff --git a/server/tests/api/transcoding/create-transcoding.ts b/server/tests/api/transcoding/create-transcoding.ts index a4defdf51..e3867fdad 100644 --- a/server/tests/api/transcoding/create-transcoding.ts +++ b/server/tests/api/transcoding/create-transcoding.ts | |||
@@ -46,6 +46,8 @@ function runTests (objectStorage: boolean) { | |||
46 | let videoUUID: string | 46 | let videoUUID: string |
47 | let publishedAt: string | 47 | let publishedAt: string |
48 | 48 | ||
49 | let shouldBeDeleted: string[] | ||
50 | |||
49 | before(async function () { | 51 | before(async function () { |
50 | this.timeout(120000) | 52 | this.timeout(120000) |
51 | 53 | ||
@@ -187,6 +189,12 @@ function runTests (objectStorage: boolean) { | |||
187 | expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(1) | 189 | expect(videoDetails.streamingPlaylists[0].files).to.have.lengthOf(1) |
188 | 190 | ||
189 | if (objectStorage) await checkFilesInObjectStorage(videoDetails) | 191 | if (objectStorage) await checkFilesInObjectStorage(videoDetails) |
192 | |||
193 | shouldBeDeleted = [ | ||
194 | videoDetails.streamingPlaylists[0].files[0].fileUrl, | ||
195 | videoDetails.streamingPlaylists[0].playlistUrl, | ||
196 | videoDetails.streamingPlaylists[0].segmentsSha256Url | ||
197 | ] | ||
190 | } | 198 | } |
191 | 199 | ||
192 | await servers[0].config.updateExistingSubConfig({ | 200 | await servers[0].config.updateExistingSubConfig({ |
@@ -227,6 +235,12 @@ function runTests (objectStorage: boolean) { | |||
227 | } | 235 | } |
228 | }) | 236 | }) |
229 | 237 | ||
238 | it('Should have correctly deleted previous files', async function () { | ||
239 | for (const fileUrl of shouldBeDeleted) { | ||
240 | await makeRawRequest(fileUrl, HttpStatusCode.NOT_FOUND_404) | ||
241 | } | ||
242 | }) | ||
243 | |||
230 | it('Should not have updated published at attributes', async function () { | 244 | it('Should not have updated published at attributes', async function () { |
231 | const video = await servers[0].videos.get({ id: videoUUID }) | 245 | const video = await servers[0].videos.get({ id: videoUUID }) |
232 | 246 | ||
diff --git a/shared/models/videos/caption/video-caption.model.ts b/shared/models/videos/caption/video-caption.model.ts index d3c73e1a6..6d5665006 100644 --- a/shared/models/videos/caption/video-caption.model.ts +++ b/shared/models/videos/caption/video-caption.model.ts | |||
@@ -3,4 +3,5 @@ import { VideoConstant } from '../video-constant.model' | |||
3 | export interface VideoCaption { | 3 | export interface VideoCaption { |
4 | language: VideoConstant<string> | 4 | language: VideoConstant<string> |
5 | captionPath: string | 5 | captionPath: string |
6 | updatedAt: string | ||
6 | } | 7 | } |