aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/activitypub/client.ts5
-rw-r--r--server/controllers/api/config.ts14
-rw-r--r--server/controllers/api/videos/captions.ts100
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/controllers/client.ts2
-rw-r--r--server/controllers/feeds.ts2
-rw-r--r--server/controllers/services.ts4
-rw-r--r--server/controllers/static.ts21
-rw-r--r--server/helpers/activitypub.ts1
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts13
-rw-r--r--server/helpers/custom-validators/video-captions.ts41
-rw-r--r--server/helpers/custom-validators/videos.ts24
-rw-r--r--server/initializers/constants.ts23
-rw-r--r--server/initializers/database.ts2
-rw-r--r--server/lib/activitypub/process/process-update.ts12
-rw-r--r--server/lib/activitypub/videos.ts29
-rw-r--r--server/lib/cache/abstract-video-static-file-cache.ts54
-rw-r--r--server/lib/cache/videos-caption-cache.ts53
-rw-r--r--server/lib/cache/videos-preview-cache.ts60
-rw-r--r--server/middlewares/validators/video-captions.ts70
-rw-r--r--server/middlewares/validators/videos.ts36
-rw-r--r--server/models/video/video-caption.ts173
-rw-r--r--server/models/video/video.ts40
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/video-captions.ts223
-rw-r--r--server/tests/api/index-fast.ts1
-rw-r--r--server/tests/api/server/config.ts158
-rw-r--r--server/tests/api/server/follows.ts28
-rw-r--r--server/tests/api/videos/video-captions.ts139
-rw-r--r--server/tests/fixtures/subtitle-good1.vtt8
-rw-r--r--server/tests/fixtures/subtitle-good2.vtt8
-rw-r--r--server/tests/utils/miscs/miscs.ts1
-rw-r--r--server/tests/utils/videos/video-captions.ts66
34 files changed, 1220 insertions, 197 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index ea8e25f68..3e6361906 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -25,6 +25,8 @@ import {
25 getVideoLikesActivityPubUrl, 25 getVideoLikesActivityPubUrl,
26 getVideoSharesActivityPubUrl 26 getVideoSharesActivityPubUrl
27} from '../../lib/activitypub' 27} from '../../lib/activitypub'
28import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
29import { VideoCaptionModel } from '../../models/video/video-caption'
28 30
29const activityPubClientRouter = express.Router() 31const activityPubClientRouter = express.Router()
30 32
@@ -123,6 +125,9 @@ async function accountFollowingController (req: express.Request, res: express.Re
123async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) { 125async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) {
124 const video: VideoModel = res.locals.video 126 const video: VideoModel = res.locals.video
125 127
128 // We need captions to render AP object
129 video.VideoCaptions = await VideoCaptionModel.listVideoCaptions(video.id)
130
126 const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC) 131 const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
127 const videoObject = audiencify(video.toActivityPubObject(), audience) 132 const videoObject = audiencify(video.toActivityPubObject(), audience)
128 133
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index f678e3c4a..3788975a9 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -80,6 +80,14 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
80 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME 80 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
81 } 81 }
82 }, 82 },
83 videoCaption: {
84 file: {
85 size: {
86 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
87 },
88 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
89 }
90 },
83 user: { 91 user: {
84 videoQuota: CONFIG.USER.VIDEO_QUOTA 92 videoQuota: CONFIG.USER.VIDEO_QUOTA
85 } 93 }
@@ -122,12 +130,13 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
122 130
123 // Force number conversion 131 // Force number conversion
124 toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10) 132 toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10)
133 toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10)
125 toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10) 134 toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
126 toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10) 135 toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
127 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) 136 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
128 137
129 // camelCase to snake_case key 138 // camelCase to snake_case key
130 const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription') 139 const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription', 'cache.videoCaptions')
131 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota 140 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
132 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute 141 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
133 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription 142 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
@@ -172,6 +181,9 @@ function customConfig (): CustomConfig {
172 cache: { 181 cache: {
173 previews: { 182 previews: {
174 size: CONFIG.CACHE.PREVIEWS.SIZE 183 size: CONFIG.CACHE.PREVIEWS.SIZE
184 },
185 captions: {
186 size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
175 } 187 }
176 }, 188 },
177 signup: { 189 signup: {
diff --git a/server/controllers/api/videos/captions.ts b/server/controllers/api/videos/captions.ts
new file mode 100644
index 000000000..05412a17f
--- /dev/null
+++ b/server/controllers/api/videos/captions.ts
@@ -0,0 +1,100 @@
1import * as express from 'express'
2import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
3import {
4 addVideoCaptionValidator,
5 deleteVideoCaptionValidator,
6 listVideoCaptionsValidator
7} from '../../../middlewares/validators/video-captions'
8import { createReqFiles } from '../../../helpers/express-utils'
9import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
10import { getFormattedObjects } from '../../../helpers/utils'
11import { VideoCaptionModel } from '../../../models/video/video-caption'
12import { renamePromise } from '../../../helpers/core-utils'
13import { join } from 'path'
14import { VideoModel } from '../../../models/video/video'
15import { logger } from '../../../helpers/logger'
16import { federateVideoIfNeeded } from '../../../lib/activitypub'
17
18const reqVideoCaptionAdd = createReqFiles(
19 [ 'captionfile' ],
20 VIDEO_CAPTIONS_MIMETYPE_EXT,
21 {
22 captionfile: CONFIG.STORAGE.CAPTIONS_DIR
23 }
24)
25
26const videoCaptionsRouter = express.Router()
27
28videoCaptionsRouter.get('/:videoId/captions',
29 asyncMiddleware(listVideoCaptionsValidator),
30 asyncMiddleware(listVideoCaptions)
31)
32videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
33 authenticate,
34 reqVideoCaptionAdd,
35 asyncMiddleware(addVideoCaptionValidator),
36 asyncRetryTransactionMiddleware(addVideoCaption)
37)
38videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
39 authenticate,
40 asyncMiddleware(deleteVideoCaptionValidator),
41 asyncRetryTransactionMiddleware(deleteVideoCaption)
42)
43
44// ---------------------------------------------------------------------------
45
46export {
47 videoCaptionsRouter
48}
49
50// ---------------------------------------------------------------------------
51
52async function listVideoCaptions (req: express.Request, res: express.Response) {
53 const data = await VideoCaptionModel.listVideoCaptions(res.locals.video.id)
54
55 return res.json(getFormattedObjects(data, data.length))
56}
57
58async function addVideoCaption (req: express.Request, res: express.Response) {
59 const videoCaptionPhysicalFile = req.files['captionfile'][0]
60 const video = res.locals.video as VideoModel
61
62 const videoCaption = new VideoCaptionModel({
63 videoId: video.id,
64 language: req.params.captionLanguage
65 })
66 videoCaption.Video = video
67
68 // Move physical file
69 const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR
70 const destination = join(videoCaptionsDir, videoCaption.getCaptionName())
71 await renamePromise(videoCaptionPhysicalFile.path, destination)
72 // This is important in case if there is another attempt in the retry process
73 videoCaptionPhysicalFile.filename = videoCaption.getCaptionName()
74 videoCaptionPhysicalFile.path = destination
75
76 await sequelizeTypescript.transaction(async t => {
77 await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, t)
78
79 // Update video update
80 await federateVideoIfNeeded(video, false, t)
81 })
82
83 return res.status(204).end()
84}
85
86async function deleteVideoCaption (req: express.Request, res: express.Response) {
87 const video = res.locals.video as VideoModel
88 const videoCaption = res.locals.videoCaption as VideoCaptionModel
89
90 await sequelizeTypescript.transaction(async t => {
91 await videoCaption.destroy({ transaction: t })
92
93 // Send video update
94 await federateVideoIfNeeded(video, false, t)
95 })
96
97 logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid)
98
99 return res.type('json').status(204).end()
100}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 8c93ae89c..bbb5b8b4c 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -53,6 +53,7 @@ import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
53import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' 53import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
54import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' 54import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
56import { videoCaptionsRouter } from './captions'
56 57
57const videosRouter = express.Router() 58const videosRouter = express.Router()
58 59
@@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter)
78videosRouter.use('/', blacklistRouter) 79videosRouter.use('/', blacklistRouter)
79videosRouter.use('/', rateVideoRouter) 80videosRouter.use('/', rateVideoRouter)
80videosRouter.use('/', videoCommentRouter) 81videosRouter.use('/', videoCommentRouter)
82videosRouter.use('/', videoCaptionsRouter)
81 83
82videosRouter.get('/categories', listVideoCategories) 84videosRouter.get('/categories', listVideoCategories)
83videosRouter.get('/licences', listVideoLicences) 85videosRouter.get('/licences', listVideoLicences)
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
index 5413f61e8..bfdf35021 100644
--- a/server/controllers/client.ts
+++ b/server/controllers/client.ts
@@ -118,7 +118,7 @@ function addOpenGraphAndOEmbedTags (htmlStringPage: string, video: VideoModel) {
118 118
119 const videoNameEscaped = escapeHTML(video.name) 119 const videoNameEscaped = escapeHTML(video.name)
120 const videoDescriptionEscaped = escapeHTML(video.description) 120 const videoDescriptionEscaped = escapeHTML(video.description)
121 const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedPath() 121 const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedStaticPath()
122 122
123 const openGraphMetaTags = { 123 const openGraphMetaTags = {
124 'og:type': 'video', 124 'og:type': 'video',
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index 1773fc71e..ff6b423d9 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -129,7 +129,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response, n
129 torrent: torrents, 129 torrent: torrents,
130 thumbnail: [ 130 thumbnail: [
131 { 131 {
132 url: CONFIG.WEBSERVER.URL + video.getThumbnailPath(), 132 url: CONFIG.WEBSERVER.URL + video.getThumbnailStaticPath(),
133 height: THUMBNAILS_SIZE.height, 133 height: THUMBNAILS_SIZE.height,
134 width: THUMBNAILS_SIZE.width 134 width: THUMBNAILS_SIZE.width
135 } 135 }
diff --git a/server/controllers/services.ts b/server/controllers/services.ts
index bd4404b62..352d0b19a 100644
--- a/server/controllers/services.ts
+++ b/server/controllers/services.ts
@@ -29,8 +29,8 @@ function generateOEmbed (req: express.Request, res: express.Response, next: expr
29 const maxHeight = parseInt(req.query.maxheight, 10) 29 const maxHeight = parseInt(req.query.maxheight, 10)
30 const maxWidth = parseInt(req.query.maxwidth, 10) 30 const maxWidth = parseInt(req.query.maxwidth, 10)
31 31
32 const embedUrl = webserverUrl + video.getEmbedPath() 32 const embedUrl = webserverUrl + video.getEmbedStaticPath()
33 let thumbnailUrl = webserverUrl + video.getPreviewPath() 33 let thumbnailUrl = webserverUrl + video.getPreviewStaticPath()
34 let embedWidth = EMBED_SIZE.width 34 let embedWidth = EMBED_SIZE.width
35 let embedHeight = EMBED_SIZE.height 35 let embedHeight = EMBED_SIZE.height
36 36
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 139ba67cc..679999859 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -4,6 +4,7 @@ import { CONFIG, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../
4import { VideosPreviewCache } from '../lib/cache' 4import { VideosPreviewCache } from '../lib/cache'
5import { asyncMiddleware, videosGetValidator } from '../middlewares' 5import { asyncMiddleware, videosGetValidator } from '../middlewares'
6import { VideoModel } from '../models/video/video' 6import { VideoModel } from '../models/video/video'
7import { VideosCaptionCache } from '../lib/cache/videos-caption-cache'
7 8
8const staticRouter = express.Router() 9const staticRouter = express.Router()
9 10
@@ -49,12 +50,18 @@ staticRouter.use(
49 express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE }) 50 express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE })
50) 51)
51 52
52// Video previews path for express 53// We don't have video previews, fetch them from the origin instance
53staticRouter.use( 54staticRouter.use(
54 STATIC_PATHS.PREVIEWS + ':uuid.jpg', 55 STATIC_PATHS.PREVIEWS + ':uuid.jpg',
55 asyncMiddleware(getPreview) 56 asyncMiddleware(getPreview)
56) 57)
57 58
59// We don't have video captions, fetch them from the origin instance
60staticRouter.use(
61 STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt',
62 asyncMiddleware(getVideoCaption)
63)
64
58// robots.txt service 65// robots.txt service
59staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => { 66staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => {
60 res.type('text/plain') 67 res.type('text/plain')
@@ -70,7 +77,17 @@ export {
70// --------------------------------------------------------------------------- 77// ---------------------------------------------------------------------------
71 78
72async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) { 79async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) {
73 const path = await VideosPreviewCache.Instance.getPreviewPath(req.params.uuid) 80 const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
81 if (!path) return res.sendStatus(404)
82
83 return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
84}
85
86async function getVideoCaption (req: express.Request, res: express.Response) {
87 const path = await VideosCaptionCache.Instance.getFilePath({
88 videoId: req.params.videoId,
89 language: req.params.captionLanguage
90 })
74 if (!path) return res.sendStatus(404) 91 if (!path) return res.sendStatus(404)
75 92
76 return res.sendFile(path, { maxAge: STATIC_MAX_AGE }) 93 return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts
index 37a251697..c49142a04 100644
--- a/server/helpers/activitypub.ts
+++ b/server/helpers/activitypub.ts
@@ -18,6 +18,7 @@ function activityPubContextify <T> (data: T) {
18 uuid: 'http://schema.org/identifier', 18 uuid: 'http://schema.org/identifier',
19 category: 'http://schema.org/category', 19 category: 'http://schema.org/category',
20 licence: 'http://schema.org/license', 20 licence: 'http://schema.org/license',
21 subtitleLanguage: 'http://schema.org/subtitleLanguage',
21 sensitive: 'as:sensitive', 22 sensitive: 'as:sensitive',
22 language: 'http://schema.org/inLanguage', 23 language: 'http://schema.org/inLanguage',
23 views: 'http://schema.org/Number', 24 views: 'http://schema.org/Number',
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index 37c90a0c8..d97bbd2a9 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -51,6 +51,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
51 if (!setValidRemoteVideoUrls(video)) return false 51 if (!setValidRemoteVideoUrls(video)) return false
52 if (!setRemoteVideoTruncatedContent(video)) return false 52 if (!setRemoteVideoTruncatedContent(video)) return false
53 if (!setValidAttributedTo(video)) return false 53 if (!setValidAttributedTo(video)) return false
54 if (!setValidRemoteCaptions(video)) return false
54 55
55 // Default attributes 56 // Default attributes
56 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED 57 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
@@ -98,6 +99,18 @@ function setValidRemoteTags (video: any) {
98 return true 99 return true
99} 100}
100 101
102function setValidRemoteCaptions (video: any) {
103 if (!video.subtitleLanguage) video.subtitleLanguage = []
104
105 if (Array.isArray(video.subtitleLanguage) === false) return false
106
107 video.subtitleLanguage = video.subtitleLanguage.filter(caption => {
108 return isRemoteStringIdentifierValid(caption)
109 })
110
111 return true
112}
113
101function isRemoteNumberIdentifierValid (data: any) { 114function isRemoteNumberIdentifierValid (data: any) {
102 return validator.isInt(data.identifier, { min: 0 }) 115 return validator.isInt(data.identifier, { min: 0 })
103} 116}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
new file mode 100644
index 000000000..fd4dc740b
--- /dev/null
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -0,0 +1,41 @@
1import { CONSTRAINTS_FIELDS, VIDEO_LANGUAGES } from '../../initializers'
2import { exists, isFileValid } from './misc'
3import { Response } from 'express'
4import { VideoModel } from '../../models/video/video'
5import { VideoCaptionModel } from '../../models/video/video-caption'
6
7function isVideoCaptionLanguageValid (value: any) {
8 return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined
9}
10
11const videoCaptionTypes = CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
12 .map(v => v.replace('.', ''))
13 .join('|')
14const videoCaptionsTypesRegex = `text/(${videoCaptionTypes})`
15
16function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
17 return isFileValid(files, videoCaptionsTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
18}
19
20async function isVideoCaptionExist (video: VideoModel, language: string, res: Response) {
21 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language)
22
23 if (!videoCaption) {
24 res.status(404)
25 .json({ error: 'Video caption not found' })
26 .end()
27
28 return false
29 }
30
31 res.locals.videoCaption = videoCaption
32 return true
33}
34
35// ---------------------------------------------------------------------------
36
37export {
38 isVideoCaptionFile,
39 isVideoCaptionLanguageValid,
40 isVideoCaptionExist
41}
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 672f06dc0..b5cb126d9 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -126,6 +126,29 @@ function isVideoFileSizeValid (value: string) {
126 return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE) 126 return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE)
127} 127}
128 128
129function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: Response) {
130 // Retrieve the user who did the request
131 if (video.isOwned() === false) {
132 res.status(403)
133 .json({ error: 'Cannot manage a video of another server.' })
134 .end()
135 return false
136 }
137
138 // Check if the user can delete the video
139 // The user can delete it if he has the right
140 // Or if s/he is the video's account
141 const account = video.VideoChannel.Account
142 if (user.hasRight(right) === false && account.userId !== user.id) {
143 res.status(403)
144 .json({ error: 'Cannot manage a video of another user.' })
145 .end()
146 return false
147 }
148
149 return true
150}
151
129async function isVideoExist (id: string, res: Response) { 152async function isVideoExist (id: string, res: Response) {
130 let video: VideoModel 153 let video: VideoModel
131 154
@@ -179,6 +202,7 @@ async function isVideoChannelOfAccountExist (channelId: number, user: UserModel,
179 202
180export { 203export {
181 isVideoCategoryValid, 204 isVideoCategoryValid,
205 checkUserCanManageVideo,
182 isVideoLicenceValid, 206 isVideoLicenceValid,
183 isVideoLanguageValid, 207 isVideoLanguageValid,
184 isVideoTruncatedDescriptionValid, 208 isVideoTruncatedDescriptionValid,
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index c5bc886d8..49809e64c 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -138,6 +138,7 @@ const CONFIG = {
138 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')), 138 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
139 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')), 139 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
140 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), 140 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
141 CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
141 TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')), 142 TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
142 CACHE_DIR: buildPath(config.get<string>('storage.cache')) 143 CACHE_DIR: buildPath(config.get<string>('storage.cache'))
143 }, 144 },
@@ -183,6 +184,9 @@ const CONFIG = {
183 CACHE: { 184 CACHE: {
184 PREVIEWS: { 185 PREVIEWS: {
185 get SIZE () { return config.get<number>('cache.previews.size') } 186 get SIZE () { return config.get<number>('cache.previews.size') }
187 },
188 VIDEO_CAPTIONS: {
189 get SIZE () { return config.get<number>('cache.captions.size') }
186 } 190 }
187 }, 191 },
188 INSTANCE: { 192 INSTANCE: {
@@ -225,6 +229,14 @@ const CONSTRAINTS_FIELDS = {
225 SUPPORT: { min: 3, max: 500 }, // Length 229 SUPPORT: { min: 3, max: 500 }, // Length
226 URL: { min: 3, max: 2000 } // Length 230 URL: { min: 3, max: 2000 } // Length
227 }, 231 },
232 VIDEO_CAPTIONS: {
233 CAPTION_FILE: {
234 EXTNAME: [ '.vtt' ],
235 FILE_SIZE: {
236 max: 2 * 1024 * 1024 // 2MB
237 }
238 }
239 },
228 VIDEOS: { 240 VIDEOS: {
229 NAME: { min: 3, max: 120 }, // Length 241 NAME: { min: 3, max: 120 }, // Length
230 LANGUAGE: { min: 1, max: 10 }, // Length 242 LANGUAGE: { min: 1, max: 10 }, // Length
@@ -351,6 +363,10 @@ const IMAGE_MIMETYPE_EXT = {
351 'image/jpeg': '.jpg' 363 'image/jpeg': '.jpg'
352} 364}
353 365
366const VIDEO_CAPTIONS_MIMETYPE_EXT = {
367 'text/vtt': '.vtt'
368}
369
354// --------------------------------------------------------------------------- 370// ---------------------------------------------------------------------------
355 371
356const SERVER_ACTOR_NAME = 'peertube' 372const SERVER_ACTOR_NAME = 'peertube'
@@ -403,7 +419,8 @@ const STATIC_PATHS = {
403 THUMBNAILS: '/static/thumbnails/', 419 THUMBNAILS: '/static/thumbnails/',
404 TORRENTS: '/static/torrents/', 420 TORRENTS: '/static/torrents/',
405 WEBSEED: '/static/webseed/', 421 WEBSEED: '/static/webseed/',
406 AVATARS: '/static/avatars/' 422 AVATARS: '/static/avatars/',
423 VIDEO_CAPTIONS: '/static/video-captions/'
407} 424}
408const STATIC_DOWNLOAD_PATHS = { 425const STATIC_DOWNLOAD_PATHS = {
409 TORRENTS: '/download/torrents/', 426 TORRENTS: '/download/torrents/',
@@ -435,7 +452,8 @@ const EMBED_SIZE = {
435// Sub folders of cache directory 452// Sub folders of cache directory
436const CACHE = { 453const CACHE = {
437 DIRECTORIES: { 454 DIRECTORIES: {
438 PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews') 455 PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
456 VIDEO_CAPTIONS: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions')
439 } 457 }
440} 458}
441 459
@@ -490,6 +508,7 @@ updateWebserverConfig()
490 508
491export { 509export {
492 API_VERSION, 510 API_VERSION,
511 VIDEO_CAPTIONS_MIMETYPE_EXT,
493 AVATARS_SIZE, 512 AVATARS_SIZE,
494 ACCEPT_HEADERS, 513 ACCEPT_HEADERS,
495 BCRYPT_SALT_SIZE, 514 BCRYPT_SALT_SIZE,
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 4d90c90fc..434d7ef19 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -23,6 +23,7 @@ import { VideoShareModel } from '../models/video/video-share'
23import { VideoTagModel } from '../models/video/video-tag' 23import { VideoTagModel } from '../models/video/video-tag'
24import { CONFIG } from './constants' 24import { CONFIG } from './constants'
25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' 25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
26import { VideoCaptionModel } from '../models/video/video-caption'
26 27
27require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 28require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
28 29
@@ -71,6 +72,7 @@ async function initDatabaseModels (silent: boolean) {
71 VideoChannelModel, 72 VideoChannelModel,
72 VideoShareModel, 73 VideoShareModel,
73 VideoFileModel, 74 VideoFileModel,
75 VideoCaptionModel,
74 VideoBlacklistModel, 76 VideoBlacklistModel,
75 VideoTagModel, 77 VideoTagModel,
76 VideoModel, 78 VideoModel,
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index 73db461c3..62791ff1b 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -19,6 +19,7 @@ import {
19 videoFileActivityUrlToDBAttributes 19 videoFileActivityUrlToDBAttributes
20} from '../videos' 20} from '../videos'
21import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos' 21import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
22import { VideoCaptionModel } from '../../../models/video/video-caption'
22 23
23async function processUpdateActivity (activity: ActivityUpdate) { 24async function processUpdateActivity (activity: ActivityUpdate) {
24 const actor = await getOrCreateActorAndServerAndModel(activity.actor) 25 const actor = await getOrCreateActorAndServerAndModel(activity.actor)
@@ -110,9 +111,18 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
110 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f)) 111 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f))
111 await Promise.all(tasks) 112 await Promise.all(tasks)
112 113
113 const tags = videoObject.tag.map(t => t.name) 114 // Update Tags
115 const tags = videoObject.tag.map(tag => tag.name)
114 const tagInstances = await TagModel.findOrCreateTags(tags, t) 116 const tagInstances = await TagModel.findOrCreateTags(tags, t)
115 await videoInstance.$set('Tags', tagInstances, sequelizeOptions) 117 await videoInstance.$set('Tags', tagInstances, sequelizeOptions)
118
119 // Update captions
120 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoInstance.id, t)
121
122 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
123 return VideoCaptionModel.insertOrReplaceLanguage(videoInstance.id, c.identifier, t)
124 })
125 await Promise.all(videoCaptionsPromises)
116 }) 126 })
117 127
118 logger.info('Remote video with uuid %s updated', videoObject.uuid) 128 logger.info('Remote video with uuid %s updated', videoObject.uuid)
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index a16828fda..fdc082b61 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -24,10 +24,20 @@ import { addVideoComments } from './video-comments'
24import { crawlCollectionPage } from './crawl' 24import { crawlCollectionPage } from './crawl'
25import { sendCreateVideo, sendUpdateVideo } from './send' 25import { sendCreateVideo, sendUpdateVideo } from './send'
26import { shareVideoByServerAndChannel } from './index' 26import { shareVideoByServerAndChannel } from './index'
27import { isArray } from '../../helpers/custom-validators/misc'
28import { VideoCaptionModel } from '../../models/video/video-caption'
27 29
28async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { 30async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
29 // If the video is not private and published, we federate it 31 // If the video is not private and published, we federate it
30 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) { 32 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
33 // Fetch more attributes that we will need to serialize in AP object
34 if (isArray(video.VideoCaptions) === false) {
35 video.VideoCaptions = await video.$get('VideoCaptions', {
36 attributes: [ 'language' ],
37 transaction
38 }) as VideoCaptionModel[]
39 }
40
31 if (isNewVideo === true) { 41 if (isNewVideo === true) {
32 // Now we'll add the video's meta data to our followers 42 // Now we'll add the video's meta data to our followers
33 await sendCreateVideo(video, transaction) 43 await sendCreateVideo(video, transaction)
@@ -38,9 +48,8 @@ async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, tr
38 } 48 }
39} 49}
40 50
41function fetchRemoteVideoPreview (video: VideoModel, reject: Function) { 51function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
42 const host = video.VideoChannel.Account.Actor.Server.host 52 const host = video.VideoChannel.Account.Actor.Server.host
43 const path = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
44 53
45 // We need to provide a callback, if no we could have an uncaught exception 54 // We need to provide a callback, if no we could have an uncaught exception
46 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => { 55 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
@@ -179,24 +188,32 @@ async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor:
179 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to) 188 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
180 const video = VideoModel.build(videoData) 189 const video = VideoModel.build(videoData)
181 190
182 // Don't block on request 191 // Don't block on remote HTTP request (we are in a transaction!)
183 generateThumbnailFromUrl(video, videoObject.icon) 192 generateThumbnailFromUrl(video, videoObject.icon)
184 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })) 193 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
185 194
186 const videoCreated = await video.save(sequelizeOptions) 195 const videoCreated = await video.save(sequelizeOptions)
187 196
197 // Process files
188 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject) 198 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
189 if (videoFileAttributes.length === 0) { 199 if (videoFileAttributes.length === 0) {
190 throw new Error('Cannot find valid files for video %s ' + videoObject.url) 200 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
191 } 201 }
192 202
193 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) 203 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
194 await Promise.all(tasks) 204 await Promise.all(videoFilePromises)
195 205
206 // Process tags
196 const tags = videoObject.tag.map(t => t.name) 207 const tags = videoObject.tag.map(t => t.name)
197 const tagInstances = await TagModel.findOrCreateTags(tags, t) 208 const tagInstances = await TagModel.findOrCreateTags(tags, t)
198 await videoCreated.$set('Tags', tagInstances, sequelizeOptions) 209 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
199 210
211 // Process captions
212 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
213 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
214 })
215 await Promise.all(videoCaptionsPromises)
216
200 logger.info('Remote video with uuid %s inserted.', videoObject.uuid) 217 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
201 218
202 videoCreated.VideoChannel = channelActor.VideoChannel 219 videoCreated.VideoChannel = channelActor.VideoChannel
@@ -328,7 +345,7 @@ export {
328 federateVideoIfNeeded, 345 federateVideoIfNeeded,
329 fetchRemoteVideo, 346 fetchRemoteVideo,
330 getOrCreateAccountAndVideoAndChannel, 347 getOrCreateAccountAndVideoAndChannel,
331 fetchRemoteVideoPreview, 348 fetchRemoteVideoStaticFile,
332 fetchRemoteVideoDescription, 349 fetchRemoteVideoDescription,
333 generateThumbnailFromUrl, 350 generateThumbnailFromUrl,
334 videoActivityObjectToDBAttributes, 351 videoActivityObjectToDBAttributes,
diff --git a/server/lib/cache/abstract-video-static-file-cache.ts b/server/lib/cache/abstract-video-static-file-cache.ts
new file mode 100644
index 000000000..7eeeb6b3a
--- /dev/null
+++ b/server/lib/cache/abstract-video-static-file-cache.ts
@@ -0,0 +1,54 @@
1import * as AsyncLRU from 'async-lru'
2import { createWriteStream } from 'fs'
3import { join } from 'path'
4import { unlinkPromise } from '../../helpers/core-utils'
5import { logger } from '../../helpers/logger'
6import { CACHE, CONFIG } from '../../initializers'
7import { VideoModel } from '../../models/video/video'
8import { fetchRemoteVideoStaticFile } from '../activitypub'
9import { VideoCaptionModel } from '../../models/video/video-caption'
10
11export abstract class AbstractVideoStaticFileCache <T> {
12
13 protected lru
14
15 abstract getFilePath (params: T): Promise<string>
16
17 // Load and save the remote file, then return the local path from filesystem
18 protected abstract loadRemoteFile (key: string): Promise<string>
19
20 init (max: number) {
21 this.lru = new AsyncLRU({
22 max,
23 load: (key, cb) => {
24 this.loadRemoteFile(key)
25 .then(res => cb(null, res))
26 .catch(err => cb(err))
27 }
28 })
29
30 this.lru.on('evict', (obj: { key: string, value: string }) => {
31 unlinkPromise(obj.value).then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
32 })
33 }
34
35 protected loadFromLRU (key: string) {
36 return new Promise<string>((res, rej) => {
37 this.lru.get(key, (err, value) => {
38 err ? rej(err) : res(value)
39 })
40 })
41 }
42
43 protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {
44 return new Promise<string>((res, rej) => {
45 const req = fetchRemoteVideoStaticFile(video, remoteStaticPath, rej)
46
47 const stream = createWriteStream(destPath)
48
49 req.pipe(stream)
50 .on('error', (err) => rej(err))
51 .on('finish', () => res(destPath))
52 })
53 }
54}
diff --git a/server/lib/cache/videos-caption-cache.ts b/server/lib/cache/videos-caption-cache.ts
new file mode 100644
index 000000000..1336610b2
--- /dev/null
+++ b/server/lib/cache/videos-caption-cache.ts
@@ -0,0 +1,53 @@
1import { join } from 'path'
2import { CACHE, CONFIG } from '../../initializers'
3import { VideoModel } from '../../models/video/video'
4import { VideoCaptionModel } from '../../models/video/video-caption'
5import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
6
7type GetPathParam = { videoId: string, language: string }
8
9class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
10
11 private static readonly KEY_DELIMITER = '%'
12 private static instance: VideosCaptionCache
13
14 private constructor () {
15 super()
16 }
17
18 static get Instance () {
19 return this.instance || (this.instance = new this())
20 }
21
22 async getFilePath (params: GetPathParam) {
23 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
24 if (!videoCaption) return undefined
25
26 if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
27
28 const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
29 return this.loadFromLRU(key)
30 }
31
32 protected async loadRemoteFile (key: string) {
33 const [ videoId, language ] = key.split(VideosCaptionCache.KEY_DELIMITER)
34
35 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(videoId, language)
36 if (!videoCaption) return undefined
37
38 if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
39
40 // Used to fetch the path
41 const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
42 if (!video) return undefined
43
44 const remoteStaticPath = videoCaption.getCaptionStaticPath()
45 const destPath = join(CACHE.DIRECTORIES.VIDEO_CAPTIONS, videoCaption.getCaptionName())
46
47 return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
48 }
49}
50
51export {
52 VideosCaptionCache
53}
diff --git a/server/lib/cache/videos-preview-cache.ts b/server/lib/cache/videos-preview-cache.ts
index d09d55e11..1c0e7ed9d 100644
--- a/server/lib/cache/videos-preview-cache.ts
+++ b/server/lib/cache/videos-preview-cache.ts
@@ -1,71 +1,39 @@
1import * as asyncLRU from 'async-lru'
2import { createWriteStream } from 'fs'
3import { join } from 'path' 1import { join } from 'path'
4import { unlinkPromise } from '../../helpers/core-utils' 2import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers'
5import { logger } from '../../helpers/logger'
6import { CACHE, CONFIG } from '../../initializers'
7import { VideoModel } from '../../models/video/video' 3import { VideoModel } from '../../models/video/video'
8import { fetchRemoteVideoPreview } from '../activitypub' 4import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
9 5
10class VideosPreviewCache { 6class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
11 7
12 private static instance: VideosPreviewCache 8 private static instance: VideosPreviewCache
13 9
14 private lru 10 private constructor () {
15 11 super()
16 private constructor () { } 12 }
17 13
18 static get Instance () { 14 static get Instance () {
19 return this.instance || (this.instance = new this()) 15 return this.instance || (this.instance = new this())
20 } 16 }
21 17
22 init (max: number) { 18 async getFilePath (videoUUID: string) {
23 this.lru = new asyncLRU({ 19 const video = await VideoModel.loadByUUID(videoUUID)
24 max,
25 load: (key, cb) => {
26 this.loadPreviews(key)
27 .then(res => cb(null, res))
28 .catch(err => cb(err))
29 }
30 })
31
32 this.lru.on('evict', (obj: { key: string, value: string }) => {
33 unlinkPromise(obj.value).then(() => logger.debug('%s evicted from VideosPreviewCache', obj.value))
34 })
35 }
36
37 async getPreviewPath (key: string) {
38 const video = await VideoModel.loadByUUID(key)
39 if (!video) return undefined 20 if (!video) return undefined
40 21
41 if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()) 22 if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
42 23
43 return new Promise<string>((res, rej) => { 24 return this.loadFromLRU(videoUUID)
44 this.lru.get(key, (err, value) => {
45 err ? rej(err) : res(value)
46 })
47 })
48 } 25 }
49 26
50 private async loadPreviews (key: string) { 27 protected async loadRemoteFile (key: string) {
51 const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key) 28 const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
52 if (!video) return undefined 29 if (!video) return undefined
53 30
54 if (video.isOwned()) throw new Error('Cannot load preview of owned video.') 31 if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
55
56 return this.saveRemotePreviewAndReturnPath(video)
57 }
58 32
59 private saveRemotePreviewAndReturnPath (video: VideoModel) { 33 const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
60 return new Promise<string>((res, rej) => { 34 const destPath = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
61 const req = fetchRemoteVideoPreview(video, rej)
62 const path = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
63 const stream = createWriteStream(path)
64 35
65 req.pipe(stream) 36 return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
66 .on('error', (err) => rej(err))
67 .on('finish', () => res(path))
68 })
69 } 37 }
70} 38}
71 39
diff --git a/server/middlewares/validators/video-captions.ts b/server/middlewares/validators/video-captions.ts
new file mode 100644
index 000000000..b6d92d380
--- /dev/null
+++ b/server/middlewares/validators/video-captions.ts
@@ -0,0 +1,70 @@
1import * as express from 'express'
2import { areValidationErrors } from './utils'
3import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos'
4import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
5import { body, param } from 'express-validator/check'
6import { CONSTRAINTS_FIELDS } from '../../initializers'
7import { UserRight } from '../../../shared'
8import { logger } from '../../helpers/logger'
9import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
10
11const addVideoCaptionValidator = [
12 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
13 param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
14 body('captionfile')
15 .custom((value, { req }) => isVideoCaptionFile(req.files, 'captionfile')).withMessage(
16 'This caption file is not supported or too large. Please, make sure it is of the following type : '
17 + CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME.join(', ')
18 ),
19
20 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
21 logger.debug('Checking addVideoCaption parameters', { parameters: req.body })
22
23 if (areValidationErrors(req, res)) return
24 if (!await isVideoExist(req.params.videoId, res)) return
25
26 // Check if the user who did the request is able to update the video
27 const user = res.locals.oauth.token.User
28 if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
29
30 return next()
31 }
32]
33
34const deleteVideoCaptionValidator = [
35 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
36 param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
37
38 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
39 logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params })
40
41 if (areValidationErrors(req, res)) return
42 if (!await isVideoExist(req.params.videoId, res)) return
43 if (!await isVideoCaptionExist(res.locals.video, req.params.captionLanguage, res)) return
44
45 // Check if the user who did the request is able to update the video
46 const user = res.locals.oauth.token.User
47 if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
48
49 return next()
50 }
51]
52
53const listVideoCaptionsValidator = [
54 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
55
56 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
57 logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
58
59 if (areValidationErrors(req, res)) return
60 if (!await isVideoExist(req.params.videoId, res)) return
61
62 return next()
63 }
64]
65
66export {
67 addVideoCaptionValidator,
68 listVideoCaptionsValidator,
69 deleteVideoCaptionValidator
70}
diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts
index 59d65d5a4..899def6fc 100644
--- a/server/middlewares/validators/videos.ts
+++ b/server/middlewares/validators/videos.ts
@@ -12,6 +12,7 @@ import {
12 toValueOrNull 12 toValueOrNull
13} from '../../helpers/custom-validators/misc' 13} from '../../helpers/custom-validators/misc'
14import { 14import {
15 checkUserCanManageVideo,
15 isScheduleVideoUpdatePrivacyValid, 16 isScheduleVideoUpdatePrivacyValid,
16 isVideoAbuseReasonValid, 17 isVideoAbuseReasonValid,
17 isVideoCategoryValid, 18 isVideoCategoryValid,
@@ -31,8 +32,6 @@ import {
31import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils' 32import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils'
32import { logger } from '../../helpers/logger' 33import { logger } from '../../helpers/logger'
33import { CONSTRAINTS_FIELDS } from '../../initializers' 34import { CONSTRAINTS_FIELDS } from '../../initializers'
34import { UserModel } from '../../models/account/user'
35import { VideoModel } from '../../models/video/video'
36import { VideoShareModel } from '../../models/video/video-share' 35import { VideoShareModel } from '../../models/video/video-share'
37import { authenticate } from '../oauth' 36import { authenticate } from '../oauth'
38import { areValidationErrors } from './utils' 37import { areValidationErrors } from './utils'
@@ -40,17 +39,17 @@ import { areValidationErrors } from './utils'
40const videosAddValidator = [ 39const videosAddValidator = [
41 body('videofile') 40 body('videofile')
42 .custom((value, { req }) => isVideoFile(req.files)).withMessage( 41 .custom((value, { req }) => isVideoFile(req.files)).withMessage(
43 'This file is not supported or too large. Please, make sure it is of the following type : ' 42 'This file is not supported or too large. Please, make sure it is of the following type: '
44 + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') 43 + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
45 ), 44 ),
46 body('thumbnailfile') 45 body('thumbnailfile')
47 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( 46 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
48 'This thumbnail file is not supported or too large. Please, make sure it is of the following type : ' 47 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
49 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 48 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
50 ), 49 ),
51 body('previewfile') 50 body('previewfile')
52 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( 51 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
53 'This preview file is not supported or too large. Please, make sure it is of the following type : ' 52 'This preview file is not supported or too large. Please, make sure it is of the following type: '
54 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 53 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
55 ), 54 ),
56 body('name').custom(isVideoNameValid).withMessage('Should have a valid name'), 55 body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
@@ -152,12 +151,12 @@ const videosUpdateValidator = [
152 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 151 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
153 body('thumbnailfile') 152 body('thumbnailfile')
154 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( 153 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
155 'This thumbnail file is not supported or too large. Please, make sure it is of the following type : ' 154 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
156 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 155 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
157 ), 156 ),
158 body('previewfile') 157 body('previewfile')
159 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( 158 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
160 'This preview file is not supported or too large. Please, make sure it is of the following type : ' 159 'This preview file is not supported or too large. Please, make sure it is of the following type: '
161 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 160 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
162 ), 161 ),
163 body('name') 162 body('name')
@@ -373,29 +372,6 @@ export {
373 372
374// --------------------------------------------------------------------------- 373// ---------------------------------------------------------------------------
375 374
376function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: express.Response) {
377 // Retrieve the user who did the request
378 if (video.isOwned() === false) {
379 res.status(403)
380 .json({ error: 'Cannot manage a video of another server.' })
381 .end()
382 return false
383 }
384
385 // Check if the user can delete the video
386 // The user can delete it if he has the right
387 // Or if s/he is the video's account
388 const account = video.VideoChannel.Account
389 if (user.hasRight(right) === false && account.userId !== user.id) {
390 res.status(403)
391 .json({ error: 'Cannot manage a video of another user.' })
392 .end()
393 return false
394 }
395
396 return true
397}
398
399function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) { 375function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) {
400 // Files are optional 376 // Files are optional
401 if (!req.files) return false 377 if (!req.files) return false
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
new file mode 100644
index 000000000..9920dfc7c
--- /dev/null
+++ b/server/models/video/video-caption.ts
@@ -0,0 +1,173 @@
1import * as Sequelize from 'sequelize'
2import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 ForeignKey,
9 Is,
10 Model,
11 Scopes,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { throwIfNotValid } from '../utils'
16import { VideoModel } from './video'
17import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
18import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
19import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers'
20import { join } from 'path'
21import { logger } from '../../helpers/logger'
22import { unlinkPromise } from '../../helpers/core-utils'
23
24export enum ScopeNames {
25 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
26}
27
28@Scopes({
29 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
30 include: [
31 {
32 attributes: [ 'uuid', 'remote' ],
33 model: () => VideoModel.unscoped(),
34 required: true
35 }
36 ]
37 }
38})
39
40@Table({
41 tableName: 'videoCaption',
42 indexes: [
43 {
44 fields: [ 'videoId' ]
45 },
46 {
47 fields: [ 'videoId', 'language' ],
48 unique: true
49 }
50 ]
51})
52export class VideoCaptionModel extends Model<VideoCaptionModel> {
53 @CreatedAt
54 createdAt: Date
55
56 @UpdatedAt
57 updatedAt: Date
58
59 @AllowNull(false)
60 @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
61 @Column
62 language: string
63
64 @ForeignKey(() => VideoModel)
65 @Column
66 videoId: number
67
68 @BelongsTo(() => VideoModel, {
69 foreignKey: {
70 allowNull: false
71 },
72 onDelete: 'CASCADE'
73 })
74 Video: VideoModel
75
76 @BeforeDestroy
77 static async removeFiles (instance: VideoCaptionModel) {
78
79 if (instance.isOwned()) {
80 if (!instance.Video) {
81 instance.Video = await instance.$get('Video') as VideoModel
82 }
83
84 logger.debug('Removing captions %s of video %s.', instance.Video.uuid, instance.language)
85 return instance.removeCaptionFile()
86 }
87
88 return undefined
89 }
90
91 static loadByVideoIdAndLanguage (videoId: string | number, language: string) {
92 const videoInclude = {
93 model: VideoModel.unscoped(),
94 attributes: [ 'id', 'remote', 'uuid' ],
95 where: { }
96 }
97
98 if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
99 else videoInclude.where['id'] = videoId
100
101 const query = {
102 where: {
103 language
104 },
105 include: [
106 videoInclude
107 ]
108 }
109
110 return VideoCaptionModel.findOne(query)
111 }
112
113 static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) {
114 const values = {
115 videoId,
116 language
117 }
118
119 return VideoCaptionModel.upsert(values, { transaction })
120 }
121
122 static listVideoCaptions (videoId: number) {
123 const query = {
124 order: [ [ 'language', 'ASC' ] ],
125 where: {
126 videoId
127 }
128 }
129
130 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
131 }
132
133 static getLanguageLabel (language: string) {
134 return VIDEO_LANGUAGES[language] || 'Unknown'
135 }
136
137 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) {
138 const query = {
139 where: {
140 videoId
141 },
142 transaction
143 }
144
145 return VideoCaptionModel.destroy(query)
146 }
147
148 isOwned () {
149 return this.Video.remote === false
150 }
151
152 toFormattedJSON (): VideoCaption {
153 return {
154 language: {
155 id: this.language,
156 label: VideoCaptionModel.getLanguageLabel(this.language)
157 },
158 captionPath: this.getCaptionStaticPath()
159 }
160 }
161
162 getCaptionStaticPath () {
163 return join(STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName())
164 }
165
166 getCaptionName () {
167 return `${this.Video.uuid}-${this.language}.vtt`
168 }
169
170 removeCaptionFile () {
171 return unlinkPromise(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName())
172 }
173}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index ab33b7c99..74a3a5d05 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -92,6 +92,7 @@ import { VideoFileModel } from './video-file'
92import { VideoShareModel } from './video-share' 92import { VideoShareModel } from './video-share'
93import { VideoTagModel } from './video-tag' 93import { VideoTagModel } from './video-tag'
94import { ScheduleVideoUpdateModel } from './schedule-video-update' 94import { ScheduleVideoUpdateModel } from './schedule-video-update'
95import { VideoCaptionModel } from './video-caption'
95 96
96export enum ScopeNames { 97export enum ScopeNames {
97 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 98 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@@ -526,6 +527,17 @@ export class VideoModel extends Model<VideoModel> {
526 }) 527 })
527 ScheduleVideoUpdate: ScheduleVideoUpdateModel 528 ScheduleVideoUpdate: ScheduleVideoUpdateModel
528 529
530 @HasMany(() => VideoCaptionModel, {
531 foreignKey: {
532 name: 'videoId',
533 allowNull: false
534 },
535 onDelete: 'cascade',
536 hooks: true,
537 ['separate' as any]: true
538 })
539 VideoCaptions: VideoCaptionModel[]
540
529 @BeforeDestroy 541 @BeforeDestroy
530 static async sendDelete (instance: VideoModel, options) { 542 static async sendDelete (instance: VideoModel, options) {
531 if (instance.isOwned()) { 543 if (instance.isOwned()) {
@@ -550,7 +562,7 @@ export class VideoModel extends Model<VideoModel> {
550 } 562 }
551 563
552 @BeforeDestroy 564 @BeforeDestroy
553 static async removeFilesAndSendDelete (instance: VideoModel) { 565 static async removeFiles (instance: VideoModel) {
554 const tasks: Promise<any>[] = [] 566 const tasks: Promise<any>[] = []
555 567
556 logger.debug('Removing files of video %s.', instance.url) 568 logger.debug('Removing files of video %s.', instance.url)
@@ -616,6 +628,11 @@ export class VideoModel extends Model<VideoModel> {
616 }, 628 },
617 include: [ 629 include: [
618 { 630 {
631 attributes: [ 'language' ],
632 model: VideoCaptionModel.unscoped(),
633 required: false
634 },
635 {
619 attributes: [ 'id', 'url' ], 636 attributes: [ 'id', 'url' ],
620 model: VideoShareModel.unscoped(), 637 model: VideoShareModel.unscoped(),
621 required: false, 638 required: false,
@@ -1028,15 +1045,15 @@ export class VideoModel extends Model<VideoModel> {
1028 videoFile.infoHash = parsedTorrent.infoHash 1045 videoFile.infoHash = parsedTorrent.infoHash
1029 } 1046 }
1030 1047
1031 getEmbedPath () { 1048 getEmbedStaticPath () {
1032 return '/videos/embed/' + this.uuid 1049 return '/videos/embed/' + this.uuid
1033 } 1050 }
1034 1051
1035 getThumbnailPath () { 1052 getThumbnailStaticPath () {
1036 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) 1053 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
1037 } 1054 }
1038 1055
1039 getPreviewPath () { 1056 getPreviewStaticPath () {
1040 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 1057 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1041 } 1058 }
1042 1059
@@ -1077,9 +1094,9 @@ export class VideoModel extends Model<VideoModel> {
1077 views: this.views, 1094 views: this.views,
1078 likes: this.likes, 1095 likes: this.likes,
1079 dislikes: this.dislikes, 1096 dislikes: this.dislikes,
1080 thumbnailPath: this.getThumbnailPath(), 1097 thumbnailPath: this.getThumbnailStaticPath(),
1081 previewPath: this.getPreviewPath(), 1098 previewPath: this.getPreviewStaticPath(),
1082 embedPath: this.getEmbedPath(), 1099 embedPath: this.getEmbedStaticPath(),
1083 createdAt: this.createdAt, 1100 createdAt: this.createdAt,
1084 updatedAt: this.updatedAt, 1101 updatedAt: this.updatedAt,
1085 publishedAt: this.publishedAt, 1102 publishedAt: this.publishedAt,
@@ -1247,6 +1264,14 @@ export class VideoModel extends Model<VideoModel> {
1247 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid 1264 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1248 }) 1265 })
1249 1266
1267 const subtitleLanguage = []
1268 for (const caption of this.VideoCaptions) {
1269 subtitleLanguage.push({
1270 identifier: caption.language,
1271 name: VideoCaptionModel.getLanguageLabel(caption.language)
1272 })
1273 }
1274
1250 return { 1275 return {
1251 type: 'Video' as 'Video', 1276 type: 'Video' as 'Video',
1252 id: this.url, 1277 id: this.url,
@@ -1267,6 +1292,7 @@ export class VideoModel extends Model<VideoModel> {
1267 mediaType: 'text/markdown', 1292 mediaType: 'text/markdown',
1268 content: this.getTruncatedDescription(), 1293 content: this.getTruncatedDescription(),
1269 support: this.support, 1294 support: this.support,
1295 subtitleLanguage,
1270 icon: { 1296 icon: {
1271 type: 'Image', 1297 type: 'Image',
1272 url: this.getThumbnailUrl(baseUrlHttp), 1298 url: this.getThumbnailUrl(baseUrlHttp),
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 6aa31e38d..03855237f 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -35,6 +35,9 @@ describe('Test config API validators', function () {
35 cache: { 35 cache: {
36 previews: { 36 previews: {
37 size: 2 37 size: 2
38 },
39 captions: {
40 size: 3
38 } 41 }
39 }, 42 },
40 signup: { 43 signup: {
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 4c3b372f5..c0e0302df 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -6,6 +6,7 @@ import './services'
6import './users' 6import './users'
7import './video-abuses' 7import './video-abuses'
8import './video-blacklist' 8import './video-blacklist'
9import './video-captions'
9import './video-channels' 10import './video-channels'
10import './video-comments' 11import './video-comments'
11import './videos' 12import './videos'
diff --git a/server/tests/api/check-params/video-captions.ts b/server/tests/api/check-params/video-captions.ts
new file mode 100644
index 000000000..12f890db8
--- /dev/null
+++ b/server/tests/api/check-params/video-captions.ts
@@ -0,0 +1,223 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 createUser,
7 flushTests,
8 killallServers,
9 makeDeleteRequest,
10 makeGetRequest,
11 makeUploadRequest,
12 runServer,
13 ServerInfo,
14 setAccessTokensToServers,
15 uploadVideo,
16 userLogin
17} from '../../utils'
18import { join } from 'path'
19
20describe('Test video captions API validator', function () {
21 const path = '/api/v1/videos/'
22
23 let server: ServerInfo
24 let userAccessToken: string
25 let videoUUID: string
26
27 // ---------------------------------------------------------------
28
29 before(async function () {
30 this.timeout(30000)
31
32 await flushTests()
33
34 server = await runServer(1)
35
36 await setAccessTokensToServers([ server ])
37
38 {
39 const res = await uploadVideo(server.url, server.accessToken, {})
40 videoUUID = res.body.video.uuid
41 }
42
43 {
44 const user = {
45 username: 'user1',
46 password: 'my super password'
47 }
48 await createUser(server.url, server.accessToken, user.username, user.password)
49 userAccessToken = await userLogin(server, user)
50 }
51 })
52
53 describe('When adding video caption', function () {
54 const fields = { }
55 const attaches = {
56 'captionfile': join(__dirname, '..', '..', 'fixtures', 'subtitle-good1.vtt')
57 }
58
59 it('Should fail without a valid uuid', async function () {
60 await makeUploadRequest({
61 method: 'PUT',
62 url: server.url,
63 path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions',
64 token: server.accessToken,
65 fields,
66 attaches
67 })
68 })
69
70 it('Should fail with an unknown id', async function () {
71 await makeUploadRequest({
72 method: 'PUT',
73 url: server.url,
74 path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions',
75 token: server.accessToken,
76 fields,
77 attaches
78 })
79 })
80
81 it('Should fail with a missing language in path', async function () {
82 const captionPath = path + videoUUID + '/captions'
83 await makeUploadRequest({
84 method: 'PUT',
85 url: server.url,
86 path: captionPath,
87 token: server.accessToken,
88 fields,
89 attaches
90 })
91 })
92
93 it('Should fail with an unknown language', async function () {
94 const captionPath = path + videoUUID + '/captions/15'
95 await makeUploadRequest({
96 method: 'PUT',
97 url: server.url,
98 path: captionPath,
99 token: server.accessToken,
100 fields,
101 attaches
102 })
103 })
104
105 it('Should fail without access token', async function () {
106 const captionPath = path + videoUUID + '/captions/fr'
107 await makeUploadRequest({
108 method: 'PUT',
109 url: server.url,
110 path: captionPath,
111 fields,
112 attaches,
113 statusCodeExpected: 401
114 })
115 })
116
117 it('Should fail with a bad access token', async function () {
118 const captionPath = path + videoUUID + '/captions/fr'
119 await makeUploadRequest({
120 method: 'PUT',
121 url: server.url,
122 path: captionPath,
123 token: 'blabla',
124 fields,
125 attaches,
126 statusCodeExpected: 401
127 })
128 })
129
130 it('Should success with the correct parameters', async function () {
131 const captionPath = path + videoUUID + '/captions/fr'
132 await makeUploadRequest({
133 method: 'PUT',
134 url: server.url,
135 path: captionPath,
136 token: server.accessToken,
137 fields,
138 attaches,
139 statusCodeExpected: 204
140 })
141 })
142 })
143
144 describe('When listing video captions', function () {
145 it('Should fail without a valid uuid', async function () {
146 await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions' })
147 })
148
149 it('Should fail with an unknown id', async function () {
150 await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions', statusCodeExpected: 404 })
151 })
152
153 it('Should success with the correct parameters', async function () {
154 await makeGetRequest({ url: server.url, path: path + videoUUID + '/captions', statusCodeExpected: 200 })
155 })
156 })
157
158 describe('When deleting video caption', function () {
159 it('Should fail without a valid uuid', async function () {
160 await makeDeleteRequest({
161 url: server.url,
162 path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions/fr',
163 token: server.accessToken
164 })
165 })
166
167 it('Should fail with an unknown id', async function () {
168 await makeDeleteRequest({
169 url: server.url,
170 path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/fr',
171 token: server.accessToken,
172 statusCodeExpected: 404
173 })
174 })
175
176 it('Should fail with an invalid language', async function () {
177 await makeDeleteRequest({
178 url: server.url,
179 path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/16',
180 token: server.accessToken
181 })
182 })
183
184 it('Should fail with a missing language', async function () {
185 const captionPath = path + videoUUID + '/captions'
186 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
187 })
188
189 it('Should fail with an unknown language', async function () {
190 const captionPath = path + videoUUID + '/captions/15'
191 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
192 })
193
194 it('Should fail without access token', async function () {
195 const captionPath = path + videoUUID + '/captions/fr'
196 await makeDeleteRequest({ url: server.url, path: captionPath, statusCodeExpected: 401 })
197 })
198
199 it('Should fail with a bad access token', async function () {
200 const captionPath = path + videoUUID + '/captions/fr'
201 await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', statusCodeExpected: 401 })
202 })
203
204 it('Should fail with another user', async function () {
205 const captionPath = path + videoUUID + '/captions/fr'
206 await makeDeleteRequest({ url: server.url, path: captionPath, token: userAccessToken, statusCodeExpected: 403 })
207 })
208
209 it('Should success with the correct parameters', async function () {
210 const captionPath = path + videoUUID + '/captions/fr'
211 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken, statusCodeExpected: 204 })
212 })
213 })
214
215 after(async function () {
216 killallServers([ server ])
217
218 // Keep the logs if the test failed
219 if (this['ok']) {
220 await flushTests()
221 }
222 })
223})
diff --git a/server/tests/api/index-fast.ts b/server/tests/api/index-fast.ts
index 2454ec2f9..d530dfc06 100644
--- a/server/tests/api/index-fast.ts
+++ b/server/tests/api/index-fast.ts
@@ -4,6 +4,7 @@ import './check-params'
4import './users/users' 4import './users/users'
5import './videos/single-server' 5import './videos/single-server'
6import './videos/video-abuse' 6import './videos/video-abuse'
7import './videos/video-captions'
7import './videos/video-blacklist' 8import './videos/video-blacklist'
8import './videos/video-blacklist-management' 9import './videos/video-blacklist-management'
9import './videos/video-description' 10import './videos/video-description'
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 4de0d6b10..79b5aaf2d 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -14,6 +14,61 @@ import {
14 registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig 14 registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig
15} from '../../utils/index' 15} from '../../utils/index'
16 16
17function checkInitialConfig (data: CustomConfig) {
18 expect(data.instance.name).to.equal('PeerTube')
19 expect(data.instance.shortDescription).to.equal(
20 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
21 'with WebTorrent and Angular.'
22 )
23 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
24 expect(data.instance.terms).to.equal('No terms for now.')
25 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
26 expect(data.instance.defaultNSFWPolicy).to.equal('display')
27 expect(data.instance.customizations.css).to.be.empty
28 expect(data.instance.customizations.javascript).to.be.empty
29 expect(data.services.twitter.username).to.equal('@Chocobozzz')
30 expect(data.services.twitter.whitelisted).to.be.false
31 expect(data.cache.previews.size).to.equal(1)
32 expect(data.cache.captions.size).to.equal(1)
33 expect(data.signup.enabled).to.be.true
34 expect(data.signup.limit).to.equal(4)
35 expect(data.admin.email).to.equal('admin1@example.com')
36 expect(data.user.videoQuota).to.equal(5242880)
37 expect(data.transcoding.enabled).to.be.false
38 expect(data.transcoding.threads).to.equal(2)
39 expect(data.transcoding.resolutions['240p']).to.be.true
40 expect(data.transcoding.resolutions['360p']).to.be.true
41 expect(data.transcoding.resolutions['480p']).to.be.true
42 expect(data.transcoding.resolutions['720p']).to.be.true
43 expect(data.transcoding.resolutions['1080p']).to.be.true
44}
45
46function checkUpdatedConfig (data: CustomConfig) {
47 expect(data.instance.name).to.equal('PeerTube updated')
48 expect(data.instance.shortDescription).to.equal('my short description')
49 expect(data.instance.description).to.equal('my super description')
50 expect(data.instance.terms).to.equal('my super terms')
51 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
52 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
53 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
54 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
55 expect(data.services.twitter.username).to.equal('@Kuja')
56 expect(data.services.twitter.whitelisted).to.be.true
57 expect(data.cache.previews.size).to.equal(2)
58 expect(data.cache.captions.size).to.equal(3)
59 expect(data.signup.enabled).to.be.false
60 expect(data.signup.limit).to.equal(5)
61 expect(data.admin.email).to.equal('superadmin1@example.com')
62 expect(data.user.videoQuota).to.equal(5242881)
63 expect(data.transcoding.enabled).to.be.true
64 expect(data.transcoding.threads).to.equal(1)
65 expect(data.transcoding.resolutions['240p']).to.be.false
66 expect(data.transcoding.resolutions['360p']).to.be.true
67 expect(data.transcoding.resolutions['480p']).to.be.true
68 expect(data.transcoding.resolutions['720p']).to.be.false
69 expect(data.transcoding.resolutions['1080p']).to.be.false
70}
71
17describe('Test config', function () { 72describe('Test config', function () {
18 let server = null 73 let server = null
19 74
@@ -51,35 +106,11 @@ describe('Test config', function () {
51 const res = await getCustomConfig(server.url, server.accessToken) 106 const res = await getCustomConfig(server.url, server.accessToken)
52 const data = res.body as CustomConfig 107 const data = res.body as CustomConfig
53 108
54 expect(data.instance.name).to.equal('PeerTube') 109 checkInitialConfig(data)
55 expect(data.instance.shortDescription).to.equal(
56 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
57 'with WebTorrent and Angular.'
58 )
59 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
60 expect(data.instance.terms).to.equal('No terms for now.')
61 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
62 expect(data.instance.defaultNSFWPolicy).to.equal('display')
63 expect(data.instance.customizations.css).to.be.empty
64 expect(data.instance.customizations.javascript).to.be.empty
65 expect(data.services.twitter.username).to.equal('@Chocobozzz')
66 expect(data.services.twitter.whitelisted).to.be.false
67 expect(data.cache.previews.size).to.equal(1)
68 expect(data.signup.enabled).to.be.true
69 expect(data.signup.limit).to.equal(4)
70 expect(data.admin.email).to.equal('admin1@example.com')
71 expect(data.user.videoQuota).to.equal(5242880)
72 expect(data.transcoding.enabled).to.be.false
73 expect(data.transcoding.threads).to.equal(2)
74 expect(data.transcoding.resolutions['240p']).to.be.true
75 expect(data.transcoding.resolutions['360p']).to.be.true
76 expect(data.transcoding.resolutions['480p']).to.be.true
77 expect(data.transcoding.resolutions['720p']).to.be.true
78 expect(data.transcoding.resolutions['1080p']).to.be.true
79 }) 110 })
80 111
81 it('Should update the customized configuration', async function () { 112 it('Should update the customized configuration', async function () {
82 const newCustomConfig = { 113 const newCustomConfig: CustomConfig = {
83 instance: { 114 instance: {
84 name: 'PeerTube updated', 115 name: 'PeerTube updated',
85 shortDescription: 'my short description', 116 shortDescription: 'my short description',
@@ -101,6 +132,9 @@ describe('Test config', function () {
101 cache: { 132 cache: {
102 previews: { 133 previews: {
103 size: 2 134 size: 2
135 },
136 captions: {
137 size: 3
104 } 138 }
105 }, 139 },
106 signup: { 140 signup: {
@@ -130,28 +164,7 @@ describe('Test config', function () {
130 const res = await getCustomConfig(server.url, server.accessToken) 164 const res = await getCustomConfig(server.url, server.accessToken)
131 const data = res.body 165 const data = res.body
132 166
133 expect(data.instance.name).to.equal('PeerTube updated') 167 checkUpdatedConfig(data)
134 expect(data.instance.shortDescription).to.equal('my short description')
135 expect(data.instance.description).to.equal('my super description')
136 expect(data.instance.terms).to.equal('my super terms')
137 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
138 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
139 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
140 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
141 expect(data.services.twitter.username).to.equal('@Kuja')
142 expect(data.services.twitter.whitelisted).to.be.true
143 expect(data.cache.previews.size).to.equal(2)
144 expect(data.signup.enabled).to.be.false
145 expect(data.signup.limit).to.equal(5)
146 expect(data.admin.email).to.equal('superadmin1@example.com')
147 expect(data.user.videoQuota).to.equal(5242881)
148 expect(data.transcoding.enabled).to.be.true
149 expect(data.transcoding.threads).to.equal(1)
150 expect(data.transcoding.resolutions['240p']).to.be.false
151 expect(data.transcoding.resolutions['360p']).to.be.true
152 expect(data.transcoding.resolutions['480p']).to.be.true
153 expect(data.transcoding.resolutions['720p']).to.be.false
154 expect(data.transcoding.resolutions['1080p']).to.be.false
155 }) 168 })
156 169
157 it('Should have the configuration updated after a restart', async function () { 170 it('Should have the configuration updated after a restart', async function () {
@@ -164,28 +177,7 @@ describe('Test config', function () {
164 const res = await getCustomConfig(server.url, server.accessToken) 177 const res = await getCustomConfig(server.url, server.accessToken)
165 const data = res.body 178 const data = res.body
166 179
167 expect(data.instance.name).to.equal('PeerTube updated') 180 checkUpdatedConfig(data)
168 expect(data.instance.shortDescription).to.equal('my short description')
169 expect(data.instance.description).to.equal('my super description')
170 expect(data.instance.terms).to.equal('my super terms')
171 expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
172 expect(data.instance.defaultNSFWPolicy).to.equal('blur')
173 expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
174 expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
175 expect(data.services.twitter.username).to.equal('@Kuja')
176 expect(data.services.twitter.whitelisted).to.be.true
177 expect(data.cache.previews.size).to.equal(2)
178 expect(data.signup.enabled).to.be.false
179 expect(data.signup.limit).to.equal(5)
180 expect(data.admin.email).to.equal('superadmin1@example.com')
181 expect(data.user.videoQuota).to.equal(5242881)
182 expect(data.transcoding.enabled).to.be.true
183 expect(data.transcoding.threads).to.equal(1)
184 expect(data.transcoding.resolutions['240p']).to.be.false
185 expect(data.transcoding.resolutions['360p']).to.be.true
186 expect(data.transcoding.resolutions['480p']).to.be.true
187 expect(data.transcoding.resolutions['720p']).to.be.false
188 expect(data.transcoding.resolutions['1080p']).to.be.false
189 }) 181 })
190 182
191 it('Should fetch the about information', async function () { 183 it('Should fetch the about information', async function () {
@@ -206,31 +198,7 @@ describe('Test config', function () {
206 const res = await getCustomConfig(server.url, server.accessToken) 198 const res = await getCustomConfig(server.url, server.accessToken)
207 const data = res.body 199 const data = res.body
208 200
209 expect(data.instance.name).to.equal('PeerTube') 201 checkInitialConfig(data)
210 expect(data.instance.shortDescription).to.equal(
211 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
212 'with WebTorrent and Angular.'
213 )
214 expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
215 expect(data.instance.terms).to.equal('No terms for now.')
216 expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
217 expect(data.instance.defaultNSFWPolicy).to.equal('display')
218 expect(data.instance.customizations.css).to.be.empty
219 expect(data.instance.customizations.javascript).to.be.empty
220 expect(data.services.twitter.username).to.equal('@Chocobozzz')
221 expect(data.services.twitter.whitelisted).to.be.false
222 expect(data.cache.previews.size).to.equal(1)
223 expect(data.signup.enabled).to.be.true
224 expect(data.signup.limit).to.equal(4)
225 expect(data.admin.email).to.equal('admin1@example.com')
226 expect(data.user.videoQuota).to.equal(5242880)
227 expect(data.transcoding.enabled).to.be.false
228 expect(data.transcoding.threads).to.equal(2)
229 expect(data.transcoding.resolutions['240p']).to.be.true
230 expect(data.transcoding.resolutions['360p']).to.be.true
231 expect(data.transcoding.resolutions['480p']).to.be.true
232 expect(data.transcoding.resolutions['720p']).to.be.true
233 expect(data.transcoding.resolutions['1080p']).to.be.true
234 }) 202 })
235 203
236 after(async function () { 204 after(async function () {
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts
index ce42df0a6..a19b47509 100644
--- a/server/tests/api/server/follows.ts
+++ b/server/tests/api/server/follows.ts
@@ -26,6 +26,8 @@ import {
26} from '../../utils/videos/video-comments' 26} from '../../utils/videos/video-comments'
27import { rateVideo } from '../../utils/videos/videos' 27import { rateVideo } from '../../utils/videos/videos'
28import { waitJobs } from '../../utils/server/jobs' 28import { waitJobs } from '../../utils/server/jobs'
29import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
30import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
29 31
30const expect = chai.expect 32const expect = chai.expect
31 33
@@ -244,6 +246,16 @@ describe('Test follows', function () {
244 const text3 = 'my second answer to thread 1' 246 const text3 = 'my second answer to thread 1'
245 await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3) 247 await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3)
246 } 248 }
249
250 {
251 await createVideoCaption({
252 url: servers[2].url,
253 accessToken: servers[2].accessToken,
254 language: 'ar',
255 videoId: video4.id,
256 fixture: 'subtitle-good2.vtt'
257 })
258 }
247 } 259 }
248 260
249 await waitJobs(servers) 261 await waitJobs(servers)
@@ -266,7 +278,7 @@ describe('Test follows', function () {
266 await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0) 278 await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0)
267 }) 279 })
268 280
269 it('Should propagate videos', async function () { 281 it('Should have propagated videos', async function () {
270 const res = await getVideosList(servers[ 0 ].url) 282 const res = await getVideosList(servers[ 0 ].url)
271 expect(res.body.total).to.equal(7) 283 expect(res.body.total).to.equal(7)
272 284
@@ -314,7 +326,7 @@ describe('Test follows', function () {
314 await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes) 326 await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes)
315 }) 327 })
316 328
317 it('Should propagate comments', async function () { 329 it('Should have propagated comments', async function () {
318 const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5) 330 const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5)
319 331
320 expect(res1.body.total).to.equal(1) 332 expect(res1.body.total).to.equal(1)
@@ -353,6 +365,18 @@ describe('Test follows', function () {
353 expect(secondChild.children).to.have.lengthOf(0) 365 expect(secondChild.children).to.have.lengthOf(0)
354 }) 366 })
355 367
368 it('Should have propagated captions', async function () {
369 const res = await listVideoCaptions(servers[0].url, video4.id)
370 expect(res.body.total).to.equal(1)
371 expect(res.body.data).to.have.lengthOf(1)
372
373 const caption1: VideoCaption = res.body.data[0]
374 expect(caption1.language.id).to.equal('ar')
375 expect(caption1.language.label).to.equal('Arabic')
376 expect(caption1.captionPath).to.equal('/static/video-captions/' + video4.uuid + '-ar.vtt')
377 await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
378 })
379
356 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () { 380 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
357 this.timeout(5000) 381 this.timeout(5000)
358 382
diff --git a/server/tests/api/videos/video-captions.ts b/server/tests/api/videos/video-captions.ts
new file mode 100644
index 000000000..cbf5268f0
--- /dev/null
+++ b/server/tests/api/videos/video-captions.ts
@@ -0,0 +1,139 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { doubleFollow, flushAndRunMultipleServers, uploadVideo } from '../../utils'
6import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
7import { waitJobs } from '../../utils/server/jobs'
8import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
9import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
10
11const expect = chai.expect
12
13describe('Test video captions', function () {
14 let servers: ServerInfo[]
15 let videoUUID: string
16
17 before(async function () {
18 this.timeout(30000)
19
20 await flushTests()
21
22 servers = await flushAndRunMultipleServers(2)
23
24 await setAccessTokensToServers(servers)
25 await doubleFollow(servers[0], servers[1])
26
27 await waitJobs(servers)
28
29 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'my video name' })
30 videoUUID = res.body.video.uuid
31
32 await waitJobs(servers)
33 })
34
35 it('Should list the captions and return an empty list', async function () {
36 for (const server of servers) {
37 const res = await listVideoCaptions(server.url, videoUUID)
38 expect(res.body.total).to.equal(0)
39 expect(res.body.data).to.have.lengthOf(0)
40 }
41 })
42
43 it('Should create two new captions', async function () {
44 this.timeout(30000)
45
46 await createVideoCaption({
47 url: servers[0].url,
48 accessToken: servers[0].accessToken,
49 language: 'ar',
50 videoId: videoUUID,
51 fixture: 'subtitle-good1.vtt'
52 })
53
54 await createVideoCaption({
55 url: servers[0].url,
56 accessToken: servers[0].accessToken,
57 language: 'zh',
58 videoId: videoUUID,
59 fixture: 'subtitle-good2.vtt'
60 })
61
62 await waitJobs(servers)
63 })
64
65 it('Should list these uploaded captions', async function () {
66 for (const server of servers) {
67 const res = await listVideoCaptions(server.url, videoUUID)
68 expect(res.body.total).to.equal(2)
69 expect(res.body.data).to.have.lengthOf(2)
70
71 const caption1: VideoCaption = res.body.data[0]
72 expect(caption1.language.id).to.equal('ar')
73 expect(caption1.language.label).to.equal('Arabic')
74 expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
75 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.')
76
77 const caption2: VideoCaption = res.body.data[1]
78 expect(caption2.language.id).to.equal('zh')
79 expect(caption2.language.label).to.equal('Chinese')
80 expect(caption2.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
81 await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.')
82 }
83 })
84
85 it('Should replace an existing caption', async function () {
86 this.timeout(30000)
87
88 await createVideoCaption({
89 url: servers[0].url,
90 accessToken: servers[0].accessToken,
91 language: 'ar',
92 videoId: videoUUID,
93 fixture: 'subtitle-good2.vtt'
94 })
95
96 await waitJobs(servers)
97 })
98
99 it('Should have this caption updated', async function () {
100 for (const server of servers) {
101 const res = await listVideoCaptions(server.url, videoUUID)
102 expect(res.body.total).to.equal(2)
103 expect(res.body.data).to.have.lengthOf(2)
104
105 const caption1: VideoCaption = res.body.data[0]
106 expect(caption1.language.id).to.equal('ar')
107 expect(caption1.language.label).to.equal('Arabic')
108 expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
109 await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.')
110 }
111 })
112
113 it('Should remove one caption', async function () {
114 this.timeout(30000)
115
116 await deleteVideoCaption(servers[0].url, servers[0].accessToken, videoUUID, 'ar')
117
118 await waitJobs(servers)
119 })
120
121 it('Should only list the caption that was not deleted', async function () {
122 for (const server of servers) {
123 const res = await listVideoCaptions(server.url, videoUUID)
124 expect(res.body.total).to.equal(1)
125 expect(res.body.data).to.have.lengthOf(1)
126
127 const caption: VideoCaption = res.body.data[0]
128
129 expect(caption.language.id).to.equal('zh')
130 expect(caption.language.label).to.equal('Chinese')
131 expect(caption.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
132 await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.')
133 }
134 })
135
136 after(async function () {
137 killallServers(servers)
138 })
139})
diff --git a/server/tests/fixtures/subtitle-good1.vtt b/server/tests/fixtures/subtitle-good1.vtt
new file mode 100644
index 000000000..04cd23946
--- /dev/null
+++ b/server/tests/fixtures/subtitle-good1.vtt
@@ -0,0 +1,8 @@
1WEBVTT
2
300:01.000 --> 00:04.000
4Subtitle good 1.
5
600:05.000 --> 00:09.000
7- It will perforate your stomach.
8- You could die. \ No newline at end of file
diff --git a/server/tests/fixtures/subtitle-good2.vtt b/server/tests/fixtures/subtitle-good2.vtt
new file mode 100644
index 000000000..4d3256def
--- /dev/null
+++ b/server/tests/fixtures/subtitle-good2.vtt
@@ -0,0 +1,8 @@
1WEBVTT
2
300:01.000 --> 00:04.000
4Subtitle good 2.
5
600:05.000 --> 00:09.000
7- It will perforate your stomach.
8- You could die. \ No newline at end of file
diff --git a/server/tests/utils/miscs/miscs.ts b/server/tests/utils/miscs/miscs.ts
index 7ac60a983..5e46004a7 100644
--- a/server/tests/utils/miscs/miscs.ts
+++ b/server/tests/utils/miscs/miscs.ts
@@ -5,7 +5,6 @@ import { isAbsolute, join } from 'path'
5import * as request from 'supertest' 5import * as request from 'supertest'
6import * as WebTorrent from 'webtorrent' 6import * as WebTorrent from 'webtorrent'
7import { readFileBufferPromise } from '../../../helpers/core-utils' 7import { readFileBufferPromise } from '../../../helpers/core-utils'
8import { ServerInfo } from '..'
9 8
10const expect = chai.expect 9const expect = chai.expect
11let webtorrent = new WebTorrent() 10let webtorrent = new WebTorrent()
diff --git a/server/tests/utils/videos/video-captions.ts b/server/tests/utils/videos/video-captions.ts
new file mode 100644
index 000000000..207e89632
--- /dev/null
+++ b/server/tests/utils/videos/video-captions.ts
@@ -0,0 +1,66 @@
1import { makeDeleteRequest, makeGetRequest } from '../'
2import { buildAbsoluteFixturePath, makeUploadRequest } from '../index'
3import * as request from 'supertest'
4import * as chai from 'chai'
5
6const expect = chai.expect
7
8function createVideoCaption (args: {
9 url: string,
10 accessToken: string
11 videoId: string | number
12 language: string
13 fixture: string
14}) {
15 const path = '/api/v1/videos/' + args.videoId + '/captions/' + args.language
16
17 return makeUploadRequest({
18 method: 'PUT',
19 url: args.url,
20 path,
21 token: args.accessToken,
22 fields: {},
23 attaches: {
24 captionfile: buildAbsoluteFixturePath(args.fixture)
25 },
26 statusCodeExpected: 204
27 })
28}
29
30function listVideoCaptions (url: string, videoId: string | number) {
31 const path = '/api/v1/videos/' + videoId + '/captions'
32
33 return makeGetRequest({
34 url,
35 path,
36 statusCodeExpected: 200
37 })
38}
39
40function deleteVideoCaption (url: string, token: string, videoId: string | number, language: string) {
41 const path = '/api/v1/videos/' + videoId + '/captions/' + language
42
43 return makeDeleteRequest({
44 url,
45 token,
46 path,
47 statusCodeExpected: 204
48 })
49}
50
51async function testCaptionFile (url: string, captionPath: string, containsString: string) {
52 const res = await request(url)
53 .get(captionPath)
54 .expect(200)
55
56 expect(res.text).to.contain(containsString)
57}
58
59// ---------------------------------------------------------------------------
60
61export {
62 createVideoCaption,
63 listVideoCaptions,
64 testCaptionFile,
65 deleteVideoCaption
66}