diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts
index c4e6f44de..b67874401 100644
--- a/client/src/app/videos/+video-edit/video-update.component.ts
+++ b/client/src/app/videos/+video-edit/video-update.component.ts
@@ -12,6 +12,7 @@ import { VideoService } from '../../shared/video/video.service'
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { VideoCaptionService } from '@app/shared/video-caption'
@Component({
selector: 'my-videos-update',
@@ -25,6 +26,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
videoPrivacies = []
userVideoChannels = []
schedulePublicationPossible = false
+ videoCaptions = []
constructor (
protected formValidatorService: FormValidatorService,
@@ -36,6 +38,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
private authService: AuthService,
private loadingBar: LoadingBarService,
private videoChannelService: VideoChannelService,
+ private videoCaptionService: VideoCaptionService,
private i18n: I18n
) {
super()
@@ -63,12 +66,21 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))),
map(videoChannels => ({ video, videoChannels }))
)
+ }),
+ switchMap(({ video, videoChannels }) => {
+ return this.videoCaptionService
+ .listCaptions(video.id)
+ .pipe(
+ map(result => result.data),
+ map(videoCaptions => ({ video, videoChannels, videoCaptions }))
+ )
})
)
.subscribe(
- ({ video, videoChannels }) => {
+ ({ video, videoChannels, videoCaptions }) => {
this.video = new VideoEdit(video)
this.userVideoChannels = videoChannels
+ this.videoCaptions = videoCaptions
// We cannot set private a video that was not private
if (this.video.privacy !== VideoPrivacy.PRIVATE) {
@@ -102,21 +114,27 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.loadingBar.start()
this.isUpdatingVideo = true
- this.videoService.updateVideo(this.video)
- .subscribe(
- () => {
- this.isUpdatingVideo = false
- this.loadingBar.complete()
- this.notificationsService.success(this.i18n('Success'), this.i18n('Video updated.'))
- this.router.navigate([ '/videos/watch', this.video.uuid ])
- },
- err => {
- this.isUpdatingVideo = false
- this.notificationsService.error(this.i18n('Error'), err.message)
- console.error(err)
- }
- )
+ // Update the video
+ this.videoService.updateVideo(this.video)
+ .pipe(
+ // Then update captions
+ switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
+ )
+ .subscribe(
+ () => {
+ this.isUpdatingVideo = false
+ this.loadingBar.complete()
+ this.notificationsService.success(this.i18n('Success'), this.i18n('Video updated.'))
+ this.router.navigate([ '/videos/watch', this.video.uuid ])
+ },
+
+ err => {
+ this.isUpdatingVideo = false
+ this.notificationsService.error(this.i18n('Error'), err.message)
+ console.error(err)
+ }
+ )
}
diff --git a/config/default.yaml b/config/default.yaml
index 9a9b5833f..d59425365 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -49,6 +49,7 @@ storage:
previews: 'storage/previews/'
thumbnails: 'storage/thumbnails/'
torrents: 'storage/torrents/'
+ captions: 'storage/captions/'
cache: 'storage/cache/'
log:
@@ -57,6 +58,8 @@ log:
cache:
previews:
size: 1 # Max number of previews you want to cache
+ captions:
+ size: 1 # Max number of video captions/subtitles you want to cache
admin:
email: 'admin@example.com' # Your personal email as administrator
diff --git a/config/production.yaml.example b/config/production.yaml.example
index a4c80b1f1..98cdd7ca7 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -50,6 +50,7 @@ storage:
previews: '/var/www/peertube/storage/previews/'
thumbnails: '/var/www/peertube/storage/thumbnails/'
torrents: '/var/www/peertube/storage/torrents/'
+ captions: '/var/www/peertube/storage/captions/'
cache: '/var/www/peertube/storage/cache/'
log:
diff --git a/config/test-1.yaml b/config/test-1.yaml
index cb658397c..503bbc661 100644
--- a/config/test-1.yaml
+++ b/config/test-1.yaml
@@ -16,6 +16,7 @@ storage:
previews: 'test1/previews/'
thumbnails: 'test1/thumbnails/'
torrents: 'test1/torrents/'
+ captions: 'test1/captions/'
cache: 'test1/cache/'
admin:
diff --git a/config/test-2.yaml b/config/test-2.yaml
index 7b9787c91..8c77bf581 100644
--- a/config/test-2.yaml
+++ b/config/test-2.yaml
@@ -16,6 +16,7 @@ storage:
previews: 'test2/previews/'
thumbnails: 'test2/thumbnails/'
torrents: 'test2/torrents/'
+ captions: 'test2/captions/'
cache: 'test2/cache/'
admin:
diff --git a/config/test-3.yaml b/config/test-3.yaml
index e7e30c07b..82d89567a 100644
--- a/config/test-3.yaml
+++ b/config/test-3.yaml
@@ -16,6 +16,7 @@ storage:
previews: 'test3/previews/'
thumbnails: 'test3/thumbnails/'
torrents: 'test3/torrents/'
+ captions: 'test3/captions/'
cache: 'test3/cache/'
admin:
diff --git a/config/test-4.yaml b/config/test-4.yaml
index b80acd765..1aa56d041 100644
--- a/config/test-4.yaml
+++ b/config/test-4.yaml
@@ -16,6 +16,7 @@ storage:
previews: 'test4/previews/'
thumbnails: 'test4/thumbnails/'
torrents: 'test4/torrents/'
+ captions: 'test4/captions/'
cache: 'test4/cache/'
admin:
diff --git a/config/test-5.yaml b/config/test-5.yaml
index 29d06f1da..5f1c2f583 100644
--- a/config/test-5.yaml
+++ b/config/test-5.yaml
@@ -16,6 +16,7 @@ storage:
previews: 'test5/previews/'
thumbnails: 'test5/thumbnails/'
torrents: 'test5/torrents/'
+ captions: 'test5/captions/'
cache: 'test5/cache/'
admin:
diff --git a/config/test-6.yaml b/config/test-6.yaml
index 4fdc2402e..719629844 100644
--- a/config/test-6.yaml
+++ b/config/test-6.yaml
@@ -16,6 +16,7 @@ storage:
previews: 'test6/previews/'
thumbnails: 'test6/thumbnails/'
torrents: 'test6/torrents/'
+ captions: 'test6/captions/'
cache: 'test6/cache/'
admin:
diff --git a/server.ts b/server.ts
index fffb8038f..a7fea34da 100644
--- a/server.ts
+++ b/server.ts
@@ -1,4 +1,6 @@
// FIXME: https://github.com/nodejs/node/pull/16853
+import { VideosCaptionCache } from './server/lib/cache/videos-caption-cache'
+
require('tls').DEFAULT_ECDH_CURVE = 'auto'
import { isTestInstance } from './server/helpers/core-utils'
@@ -181,6 +183,7 @@ async function startApplication () {
// Caches initializations
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
+ VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE)
// Enable Schedulers
BadActorFollowScheduler.Instance.enable()
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 {
getVideoLikesActivityPubUrl,
getVideoSharesActivityPubUrl
} from '../../lib/activitypub'
+import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
+import { VideoCaptionModel } from '../../models/video/video-caption'
const activityPubClientRouter = express.Router()
@@ -123,6 +125,9 @@ async function accountFollowingController (req: express.Request, res: express.Re
async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) {
const video: VideoModel = res.locals.video
+ // We need captions to render AP object
+ video.VideoCaptions = await VideoCaptionModel.listVideoCaptions(video.id)
+
const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC)
const videoObject = audiencify(video.toActivityPubObject(), audience)
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
extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
}
},
+ videoCaption: {
+ file: {
+ size: {
+ max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
+ },
+ extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
+ }
+ },
user: {
videoQuota: CONFIG.USER.VIDEO_QUOTA
}
@@ -122,12 +130,13 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
// Force number conversion
toUpdate.cache.previews.size = parseInt('' + toUpdate.cache.previews.size, 10)
+ toUpdate.cache.captions.size = parseInt('' + toUpdate.cache.captions.size, 10)
toUpdate.signup.limit = parseInt('' + toUpdate.signup.limit, 10)
toUpdate.user.videoQuota = parseInt('' + toUpdate.user.videoQuota, 10)
toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
// camelCase to snake_case key
- const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription')
+ const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription', 'cache.videoCaptions')
toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
@@ -172,6 +181,9 @@ function customConfig (): CustomConfig {
cache: {
previews: {
size: CONFIG.CACHE.PREVIEWS.SIZE
+ },
+ captions: {
+ size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
}
},
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 @@
+import * as express from 'express'
+import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
+import {
+ addVideoCaptionValidator,
+ deleteVideoCaptionValidator,
+ listVideoCaptionsValidator
+} from '../../../middlewares/validators/video-captions'
+import { createReqFiles } from '../../../helpers/express-utils'
+import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
+import { getFormattedObjects } from '../../../helpers/utils'
+import { VideoCaptionModel } from '../../../models/video/video-caption'
+import { renamePromise } from '../../../helpers/core-utils'
+import { join } from 'path'
+import { VideoModel } from '../../../models/video/video'
+import { logger } from '../../../helpers/logger'
+import { federateVideoIfNeeded } from '../../../lib/activitypub'
+
+const reqVideoCaptionAdd = createReqFiles(
+ [ 'captionfile' ],
+ VIDEO_CAPTIONS_MIMETYPE_EXT,
+ {
+ captionfile: CONFIG.STORAGE.CAPTIONS_DIR
+ }
+)
+
+const videoCaptionsRouter = express.Router()
+
+videoCaptionsRouter.get('/:videoId/captions',
+ asyncMiddleware(listVideoCaptionsValidator),
+ asyncMiddleware(listVideoCaptions)
+)
+videoCaptionsRouter.put('/:videoId/captions/:captionLanguage',
+ authenticate,
+ reqVideoCaptionAdd,
+ asyncMiddleware(addVideoCaptionValidator),
+ asyncRetryTransactionMiddleware(addVideoCaption)
+)
+videoCaptionsRouter.delete('/:videoId/captions/:captionLanguage',
+ authenticate,
+ asyncMiddleware(deleteVideoCaptionValidator),
+ asyncRetryTransactionMiddleware(deleteVideoCaption)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ videoCaptionsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function listVideoCaptions (req: express.Request, res: express.Response) {
+ const data = await VideoCaptionModel.listVideoCaptions(res.locals.video.id)
+
+ return res.json(getFormattedObjects(data, data.length))
+}
+
+async function addVideoCaption (req: express.Request, res: express.Response) {
+ const videoCaptionPhysicalFile = req.files['captionfile'][0]
+ const video = res.locals.video as VideoModel
+
+ const videoCaption = new VideoCaptionModel({
+ videoId: video.id,
+ language: req.params.captionLanguage
+ })
+ videoCaption.Video = video
+
+ // Move physical file
+ const videoCaptionsDir = CONFIG.STORAGE.CAPTIONS_DIR
+ const destination = join(videoCaptionsDir, videoCaption.getCaptionName())
+ await renamePromise(videoCaptionPhysicalFile.path, destination)
+ // This is important in case if there is another attempt in the retry process
+ videoCaptionPhysicalFile.filename = videoCaption.getCaptionName()
+ videoCaptionPhysicalFile.path = destination
+
+ await sequelizeTypescript.transaction(async t => {
+ await VideoCaptionModel.insertOrReplaceLanguage(video.id, req.params.captionLanguage, t)
+
+ // Update video update
+ await federateVideoIfNeeded(video, false, t)
+ })
+
+ return res.status(204).end()
+}
+
+async function deleteVideoCaption (req: express.Request, res: express.Response) {
+ const video = res.locals.video as VideoModel
+ const videoCaption = res.locals.videoCaption as VideoCaptionModel
+
+ await sequelizeTypescript.transaction(async t => {
+ await videoCaption.destroy({ transaction: t })
+
+ // Send video update
+ await federateVideoIfNeeded(video, false, t)
+ })
+
+ logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid)
+
+ return res.type('json').status(204).end()
+}
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'
import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
+import { videoCaptionsRouter } from './captions'
const videosRouter = express.Router()
@@ -78,6 +79,7 @@ videosRouter.use('/', abuseVideoRouter)
videosRouter.use('/', blacklistRouter)
videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter)
+videosRouter.use('/', videoCaptionsRouter)
videosRouter.get('/categories', listVideoCategories)
videosRouter.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) {
const videoNameEscaped = escapeHTML(video.name)
const videoDescriptionEscaped = escapeHTML(video.description)
- const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedPath()
+ const embedUrl = CONFIG.WEBSERVER.URL + video.getEmbedStaticPath()
const openGraphMetaTags = {
'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
torrent: torrents,
thumbnail: [
{
- url: CONFIG.WEBSERVER.URL + video.getThumbnailPath(),
+ url: CONFIG.WEBSERVER.URL + video.getThumbnailStaticPath(),
height: THUMBNAILS_SIZE.height,
width: THUMBNAILS_SIZE.width
}
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
const maxHeight = parseInt(req.query.maxheight, 10)
const maxWidth = parseInt(req.query.maxwidth, 10)
- const embedUrl = webserverUrl + video.getEmbedPath()
- let thumbnailUrl = webserverUrl + video.getPreviewPath()
+ const embedUrl = webserverUrl + video.getEmbedStaticPath()
+ let thumbnailUrl = webserverUrl + video.getPreviewStaticPath()
let embedWidth = EMBED_SIZE.width
let embedHeight = EMBED_SIZE.height
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 '../
import { VideosPreviewCache } from '../lib/cache'
import { asyncMiddleware, videosGetValidator } from '../middlewares'
import { VideoModel } from '../models/video/video'
+import { VideosCaptionCache } from '../lib/cache/videos-caption-cache'
const staticRouter = express.Router()
@@ -49,12 +50,18 @@ staticRouter.use(
express.static(avatarsPhysicalPath, { maxAge: STATIC_MAX_AGE })
)
-// Video previews path for express
+// We don't have video previews, fetch them from the origin instance
staticRouter.use(
STATIC_PATHS.PREVIEWS + ':uuid.jpg',
asyncMiddleware(getPreview)
)
+// We don't have video captions, fetch them from the origin instance
+staticRouter.use(
+ STATIC_PATHS.VIDEO_CAPTIONS + ':videoId-:captionLanguage([a-z]+).vtt',
+ asyncMiddleware(getVideoCaption)
+)
+
// robots.txt service
staticRouter.get('/robots.txt', (req: express.Request, res: express.Response) => {
res.type('text/plain')
@@ -70,7 +77,17 @@ export {
// ---------------------------------------------------------------------------
async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) {
- const path = await VideosPreviewCache.Instance.getPreviewPath(req.params.uuid)
+ const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
+ if (!path) return res.sendStatus(404)
+
+ return res.sendFile(path, { maxAge: STATIC_MAX_AGE })
+}
+
+async function getVideoCaption (req: express.Request, res: express.Response) {
+ const path = await VideosCaptionCache.Instance.getFilePath({
+ videoId: req.params.videoId,
+ language: req.params.captionLanguage
+ })
if (!path) return res.sendStatus(404)
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
(data: T) {
uuid: 'http://schema.org/identifier',
category: 'http://schema.org/category',
licence: 'http://schema.org/license',
+ subtitleLanguage: 'http://schema.org/subtitleLanguage',
sensitive: 'as:sensitive',
language: 'http://schema.org/inLanguage',
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) {
if (!setValidRemoteVideoUrls(video)) return false
if (!setRemoteVideoTruncatedContent(video)) return false
if (!setValidAttributedTo(video)) return false
+ if (!setValidRemoteCaptions(video)) return false
// Default attributes
if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
@@ -98,6 +99,18 @@ function setValidRemoteTags (video: any) {
return true
}
+function setValidRemoteCaptions (video: any) {
+ if (!video.subtitleLanguage) video.subtitleLanguage = []
+
+ if (Array.isArray(video.subtitleLanguage) === false) return false
+
+ video.subtitleLanguage = video.subtitleLanguage.filter(caption => {
+ return isRemoteStringIdentifierValid(caption)
+ })
+
+ return true
+}
+
function isRemoteNumberIdentifierValid (data: any) {
return validator.isInt(data.identifier, { min: 0 })
}
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 @@
+import { CONSTRAINTS_FIELDS, VIDEO_LANGUAGES } from '../../initializers'
+import { exists, isFileValid } from './misc'
+import { Response } from 'express'
+import { VideoModel } from '../../models/video/video'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+
+function isVideoCaptionLanguageValid (value: any) {
+ return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined
+}
+
+const videoCaptionTypes = CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
+ .map(v => v.replace('.', ''))
+ .join('|')
+const videoCaptionsTypesRegex = `text/(${videoCaptionTypes})`
+
+function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
+ return isFileValid(files, videoCaptionsTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
+}
+
+async function isVideoCaptionExist (video: VideoModel, language: string, res: Response) {
+ const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language)
+
+ if (!videoCaption) {
+ res.status(404)
+ .json({ error: 'Video caption not found' })
+ .end()
+
+ return false
+ }
+
+ res.locals.videoCaption = videoCaption
+ return true
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ isVideoCaptionFile,
+ isVideoCaptionLanguageValid,
+ isVideoCaptionExist
+}
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) {
return exists(value) && validator.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.FILE_SIZE)
}
+function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: Response) {
+ // Retrieve the user who did the request
+ if (video.isOwned() === false) {
+ res.status(403)
+ .json({ error: 'Cannot manage a video of another server.' })
+ .end()
+ return false
+ }
+
+ // Check if the user can delete the video
+ // The user can delete it if he has the right
+ // Or if s/he is the video's account
+ const account = video.VideoChannel.Account
+ if (user.hasRight(right) === false && account.userId !== user.id) {
+ res.status(403)
+ .json({ error: 'Cannot manage a video of another user.' })
+ .end()
+ return false
+ }
+
+ return true
+}
+
async function isVideoExist (id: string, res: Response) {
let video: VideoModel
@@ -179,6 +202,7 @@ async function isVideoChannelOfAccountExist (channelId: number, user: UserModel,
export {
isVideoCategoryValid,
+ checkUserCanManageVideo,
isVideoLicenceValid,
isVideoLanguageValid,
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 = {
VIDEOS_DIR: buildPath(config.get('storage.videos')),
THUMBNAILS_DIR: buildPath(config.get('storage.thumbnails')),
PREVIEWS_DIR: buildPath(config.get('storage.previews')),
+ CAPTIONS_DIR: buildPath(config.get('storage.captions')),
TORRENTS_DIR: buildPath(config.get('storage.torrents')),
CACHE_DIR: buildPath(config.get('storage.cache'))
},
@@ -183,6 +184,9 @@ const CONFIG = {
CACHE: {
PREVIEWS: {
get SIZE () { return config.get('cache.previews.size') }
+ },
+ VIDEO_CAPTIONS: {
+ get SIZE () { return config.get('cache.captions.size') }
}
},
INSTANCE: {
@@ -225,6 +229,14 @@ const CONSTRAINTS_FIELDS = {
SUPPORT: { min: 3, max: 500 }, // Length
URL: { min: 3, max: 2000 } // Length
},
+ VIDEO_CAPTIONS: {
+ CAPTION_FILE: {
+ EXTNAME: [ '.vtt' ],
+ FILE_SIZE: {
+ max: 2 * 1024 * 1024 // 2MB
+ }
+ }
+ },
VIDEOS: {
NAME: { min: 3, max: 120 }, // Length
LANGUAGE: { min: 1, max: 10 }, // Length
@@ -351,6 +363,10 @@ const IMAGE_MIMETYPE_EXT = {
'image/jpeg': '.jpg'
}
+const VIDEO_CAPTIONS_MIMETYPE_EXT = {
+ 'text/vtt': '.vtt'
+}
+
// ---------------------------------------------------------------------------
const SERVER_ACTOR_NAME = 'peertube'
@@ -403,7 +419,8 @@ const STATIC_PATHS = {
THUMBNAILS: '/static/thumbnails/',
TORRENTS: '/static/torrents/',
WEBSEED: '/static/webseed/',
- AVATARS: '/static/avatars/'
+ AVATARS: '/static/avatars/',
+ VIDEO_CAPTIONS: '/static/video-captions/'
}
const STATIC_DOWNLOAD_PATHS = {
TORRENTS: '/download/torrents/',
@@ -435,7 +452,8 @@ const EMBED_SIZE = {
// Sub folders of cache directory
const CACHE = {
DIRECTORIES: {
- PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews')
+ PREVIEWS: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
+ VIDEO_CAPTIONS: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions')
}
}
@@ -490,6 +508,7 @@ updateWebserverConfig()
export {
API_VERSION,
+ VIDEO_CAPTIONS_MIMETYPE_EXT,
AVATARS_SIZE,
ACCEPT_HEADERS,
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'
import { VideoTagModel } from '../models/video/video-tag'
import { CONFIG } from './constants'
import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
+import { VideoCaptionModel } from '../models/video/video-caption'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@@ -71,6 +72,7 @@ async function initDatabaseModels (silent: boolean) {
VideoChannelModel,
VideoShareModel,
VideoFileModel,
+ VideoCaptionModel,
VideoBlacklistModel,
VideoTagModel,
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 {
videoFileActivityUrlToDBAttributes
} from '../videos'
import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
+import { VideoCaptionModel } from '../../../models/video/video-caption'
async function processUpdateActivity (activity: ActivityUpdate) {
const actor = await getOrCreateActorAndServerAndModel(activity.actor)
@@ -110,9 +111,18 @@ async function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate)
const tasks = videoFileAttributes.map(f => VideoFileModel.create(f))
await Promise.all(tasks)
- const tags = videoObject.tag.map(t => t.name)
+ // Update Tags
+ const tags = videoObject.tag.map(tag => tag.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t)
await videoInstance.$set('Tags', tagInstances, sequelizeOptions)
+
+ // Update captions
+ await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoInstance.id, t)
+
+ const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
+ return VideoCaptionModel.insertOrReplaceLanguage(videoInstance.id, c.identifier, t)
+ })
+ await Promise.all(videoCaptionsPromises)
})
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'
import { crawlCollectionPage } from './crawl'
import { sendCreateVideo, sendUpdateVideo } from './send'
import { shareVideoByServerAndChannel } from './index'
+import { isArray } from '../../helpers/custom-validators/misc'
+import { VideoCaptionModel } from '../../models/video/video-caption'
async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
// If the video is not private and published, we federate it
if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
+ // Fetch more attributes that we will need to serialize in AP object
+ if (isArray(video.VideoCaptions) === false) {
+ video.VideoCaptions = await video.$get('VideoCaptions', {
+ attributes: [ 'language' ],
+ transaction
+ }) as VideoCaptionModel[]
+ }
+
if (isNewVideo === true) {
// Now we'll add the video's meta data to our followers
await sendCreateVideo(video, transaction)
@@ -38,9 +48,8 @@ async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, tr
}
}
-function fetchRemoteVideoPreview (video: VideoModel, reject: Function) {
+function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
const host = video.VideoChannel.Account.Actor.Server.host
- const path = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
// We need to provide a callback, if no we could have an uncaught exception
return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
@@ -179,24 +188,32 @@ async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor:
const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
const video = VideoModel.build(videoData)
- // Don't block on request
+ // Don't block on remote HTTP request (we are in a transaction!)
generateThumbnailFromUrl(video, videoObject.icon)
.catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
const videoCreated = await video.save(sequelizeOptions)
+ // Process files
const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
if (videoFileAttributes.length === 0) {
throw new Error('Cannot find valid files for video %s ' + videoObject.url)
}
- const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
- await Promise.all(tasks)
+ const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
+ await Promise.all(videoFilePromises)
+ // Process tags
const tags = videoObject.tag.map(t => t.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t)
await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
+ // Process captions
+ const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
+ return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
+ })
+ await Promise.all(videoCaptionsPromises)
+
logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
videoCreated.VideoChannel = channelActor.VideoChannel
@@ -328,7 +345,7 @@ export {
federateVideoIfNeeded,
fetchRemoteVideo,
getOrCreateAccountAndVideoAndChannel,
- fetchRemoteVideoPreview,
+ fetchRemoteVideoStaticFile,
fetchRemoteVideoDescription,
generateThumbnailFromUrl,
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 @@
+import * as AsyncLRU from 'async-lru'
+import { createWriteStream } from 'fs'
+import { join } from 'path'
+import { unlinkPromise } from '../../helpers/core-utils'
+import { logger } from '../../helpers/logger'
+import { CACHE, CONFIG } from '../../initializers'
+import { VideoModel } from '../../models/video/video'
+import { fetchRemoteVideoStaticFile } from '../activitypub'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+
+export abstract class AbstractVideoStaticFileCache {
+
+ protected lru
+
+ abstract getFilePath (params: T): Promise
+
+ // Load and save the remote file, then return the local path from filesystem
+ protected abstract loadRemoteFile (key: string): Promise
+
+ init (max: number) {
+ this.lru = new AsyncLRU({
+ max,
+ load: (key, cb) => {
+ this.loadRemoteFile(key)
+ .then(res => cb(null, res))
+ .catch(err => cb(err))
+ }
+ })
+
+ this.lru.on('evict', (obj: { key: string, value: string }) => {
+ unlinkPromise(obj.value).then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
+ })
+ }
+
+ protected loadFromLRU (key: string) {
+ return new Promise((res, rej) => {
+ this.lru.get(key, (err, value) => {
+ err ? rej(err) : res(value)
+ })
+ })
+ }
+
+ protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {
+ return new Promise((res, rej) => {
+ const req = fetchRemoteVideoStaticFile(video, remoteStaticPath, rej)
+
+ const stream = createWriteStream(destPath)
+
+ req.pipe(stream)
+ .on('error', (err) => rej(err))
+ .on('finish', () => res(destPath))
+ })
+ }
+}
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 @@
+import { join } from 'path'
+import { CACHE, CONFIG } from '../../initializers'
+import { VideoModel } from '../../models/video/video'
+import { VideoCaptionModel } from '../../models/video/video-caption'
+import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
+
+type GetPathParam = { videoId: string, language: string }
+
+class VideosCaptionCache extends AbstractVideoStaticFileCache {
+
+ private static readonly KEY_DELIMITER = '%'
+ private static instance: VideosCaptionCache
+
+ private constructor () {
+ super()
+ }
+
+ static get Instance () {
+ return this.instance || (this.instance = new this())
+ }
+
+ async getFilePath (params: GetPathParam) {
+ const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
+ if (!videoCaption) return undefined
+
+ if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
+
+ const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
+ return this.loadFromLRU(key)
+ }
+
+ protected async loadRemoteFile (key: string) {
+ const [ videoId, language ] = key.split(VideosCaptionCache.KEY_DELIMITER)
+
+ const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(videoId, language)
+ if (!videoCaption) return undefined
+
+ if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
+
+ // Used to fetch the path
+ const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(videoId)
+ if (!video) return undefined
+
+ const remoteStaticPath = videoCaption.getCaptionStaticPath()
+ const destPath = join(CACHE.DIRECTORIES.VIDEO_CAPTIONS, videoCaption.getCaptionName())
+
+ return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
+ }
+}
+
+export {
+ VideosCaptionCache
+}
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 @@
-import * as asyncLRU from 'async-lru'
-import { createWriteStream } from 'fs'
import { join } from 'path'
-import { unlinkPromise } from '../../helpers/core-utils'
-import { logger } from '../../helpers/logger'
-import { CACHE, CONFIG } from '../../initializers'
+import { CACHE, CONFIG, STATIC_PATHS } from '../../initializers'
import { VideoModel } from '../../models/video/video'
-import { fetchRemoteVideoPreview } from '../activitypub'
+import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
-class VideosPreviewCache {
+class VideosPreviewCache extends AbstractVideoStaticFileCache {
private static instance: VideosPreviewCache
- private lru
-
- private constructor () { }
+ private constructor () {
+ super()
+ }
static get Instance () {
return this.instance || (this.instance = new this())
}
- init (max: number) {
- this.lru = new asyncLRU({
- max,
- load: (key, cb) => {
- this.loadPreviews(key)
- .then(res => cb(null, res))
- .catch(err => cb(err))
- }
- })
-
- this.lru.on('evict', (obj: { key: string, value: string }) => {
- unlinkPromise(obj.value).then(() => logger.debug('%s evicted from VideosPreviewCache', obj.value))
- })
- }
-
- async getPreviewPath (key: string) {
- const video = await VideoModel.loadByUUID(key)
+ async getFilePath (videoUUID: string) {
+ const video = await VideoModel.loadByUUID(videoUUID)
if (!video) return undefined
if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
- return new Promise((res, rej) => {
- this.lru.get(key, (err, value) => {
- err ? rej(err) : res(value)
- })
- })
+ return this.loadFromLRU(videoUUID)
}
- private async loadPreviews (key: string) {
+ protected async loadRemoteFile (key: string) {
const video = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(key)
if (!video) return undefined
- if (video.isOwned()) throw new Error('Cannot load preview of owned video.')
+ if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
- return this.saveRemotePreviewAndReturnPath(video)
- }
+ const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
+ const destPath = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
- private saveRemotePreviewAndReturnPath (video: VideoModel) {
- return new Promise((res, rej) => {
- const req = fetchRemoteVideoPreview(video, rej)
- const path = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName())
- const stream = createWriteStream(path)
-
- req.pipe(stream)
- .on('error', (err) => rej(err))
- .on('finish', () => res(path))
- })
+ return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
}
}
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 @@
+import * as express from 'express'
+import { areValidationErrors } from './utils'
+import { checkUserCanManageVideo, isVideoExist } from '../../helpers/custom-validators/videos'
+import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
+import { body, param } from 'express-validator/check'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
+import { UserRight } from '../../../shared'
+import { logger } from '../../helpers/logger'
+import { isVideoCaptionExist, isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
+
+const addVideoCaptionValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
+ param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
+ body('captionfile')
+ .custom((value, { req }) => isVideoCaptionFile(req.files, 'captionfile')).withMessage(
+ 'This caption file is not supported or too large. Please, make sure it is of the following type : '
+ + CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME.join(', ')
+ ),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking addVideoCaption parameters', { parameters: req.body })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+
+ // Check if the user who did the request is able to update the video
+ const user = res.locals.oauth.token.User
+ if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
+
+ return next()
+ }
+]
+
+const deleteVideoCaptionValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
+ param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+ if (!await isVideoCaptionExist(res.locals.video, req.params.captionLanguage, res)) return
+
+ // Check if the user who did the request is able to update the video
+ const user = res.locals.oauth.token.User
+ if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
+
+ return next()
+ }
+]
+
+const listVideoCaptionsValidator = [
+ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
+
+ if (areValidationErrors(req, res)) return
+ if (!await isVideoExist(req.params.videoId, res)) return
+
+ return next()
+ }
+]
+
+export {
+ addVideoCaptionValidator,
+ listVideoCaptionsValidator,
+ deleteVideoCaptionValidator
+}
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 {
toValueOrNull
} from '../../helpers/custom-validators/misc'
import {
+ checkUserCanManageVideo,
isScheduleVideoUpdatePrivacyValid,
isVideoAbuseReasonValid,
isVideoCategoryValid,
@@ -31,8 +32,6 @@ import {
import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils'
import { logger } from '../../helpers/logger'
import { CONSTRAINTS_FIELDS } from '../../initializers'
-import { UserModel } from '../../models/account/user'
-import { VideoModel } from '../../models/video/video'
import { VideoShareModel } from '../../models/video/video-share'
import { authenticate } from '../oauth'
import { areValidationErrors } from './utils'
@@ -40,17 +39,17 @@ import { areValidationErrors } from './utils'
const videosAddValidator = [
body('videofile')
.custom((value, { req }) => isVideoFile(req.files)).withMessage(
- 'This file is not supported or too large. Please, make sure it is of the following type : '
+ 'This file is not supported or too large. Please, make sure it is of the following type: '
+ CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
),
body('thumbnailfile')
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
- 'This thumbnail file is not supported or too large. Please, make sure it is of the following type : '
+ 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
+ CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('previewfile')
.custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
- 'This preview file is not supported or too large. Please, make sure it is of the following type : '
+ 'This preview file is not supported or too large. Please, make sure it is of the following type: '
+ CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
@@ -152,12 +151,12 @@ const videosUpdateValidator = [
param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
body('thumbnailfile')
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
- 'This thumbnail file is not supported or too large. Please, make sure it is of the following type : '
+ 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
+ CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('previewfile')
.custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
- 'This preview file is not supported or too large. Please, make sure it is of the following type : '
+ 'This preview file is not supported or too large. Please, make sure it is of the following type: '
+ CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('name')
@@ -373,29 +372,6 @@ export {
// ---------------------------------------------------------------------------
-function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: express.Response) {
- // Retrieve the user who did the request
- if (video.isOwned() === false) {
- res.status(403)
- .json({ error: 'Cannot manage a video of another server.' })
- .end()
- return false
- }
-
- // Check if the user can delete the video
- // The user can delete it if he has the right
- // Or if s/he is the video's account
- const account = video.VideoChannel.Account
- if (user.hasRight(right) === false && account.userId !== user.id) {
- res.status(403)
- .json({ error: 'Cannot manage a video of another user.' })
- .end()
- return false
- }
-
- return true
-}
-
function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) {
// Files are optional
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 @@
+import * as Sequelize from 'sequelize'
+import {
+ AllowNull,
+ BeforeDestroy,
+ BelongsTo,
+ Column,
+ CreatedAt,
+ ForeignKey,
+ Is,
+ Model,
+ Scopes,
+ Table,
+ UpdatedAt
+} from 'sequelize-typescript'
+import { throwIfNotValid } from '../utils'
+import { VideoModel } from './video'
+import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
+import { VideoCaption } from '../../../shared/models/videos/video-caption.model'
+import { CONFIG, STATIC_PATHS, VIDEO_LANGUAGES } from '../../initializers'
+import { join } from 'path'
+import { logger } from '../../helpers/logger'
+import { unlinkPromise } from '../../helpers/core-utils'
+
+export enum ScopeNames {
+ WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
+}
+
+@Scopes({
+ [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
+ include: [
+ {
+ attributes: [ 'uuid', 'remote' ],
+ model: () => VideoModel.unscoped(),
+ required: true
+ }
+ ]
+ }
+})
+
+@Table({
+ tableName: 'videoCaption',
+ indexes: [
+ {
+ fields: [ 'videoId' ]
+ },
+ {
+ fields: [ 'videoId', 'language' ],
+ unique: true
+ }
+ ]
+})
+export class VideoCaptionModel extends Model {
+ @CreatedAt
+ createdAt: Date
+
+ @UpdatedAt
+ updatedAt: Date
+
+ @AllowNull(false)
+ @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
+ @Column
+ language: string
+
+ @ForeignKey(() => VideoModel)
+ @Column
+ videoId: number
+
+ @BelongsTo(() => VideoModel, {
+ foreignKey: {
+ allowNull: false
+ },
+ onDelete: 'CASCADE'
+ })
+ Video: VideoModel
+
+ @BeforeDestroy
+ static async removeFiles (instance: VideoCaptionModel) {
+
+ if (instance.isOwned()) {
+ if (!instance.Video) {
+ instance.Video = await instance.$get('Video') as VideoModel
+ }
+
+ logger.debug('Removing captions %s of video %s.', instance.Video.uuid, instance.language)
+ return instance.removeCaptionFile()
+ }
+
+ return undefined
+ }
+
+ static loadByVideoIdAndLanguage (videoId: string | number, language: string) {
+ const videoInclude = {
+ model: VideoModel.unscoped(),
+ attributes: [ 'id', 'remote', 'uuid' ],
+ where: { }
+ }
+
+ if (typeof videoId === 'string') videoInclude.where['uuid'] = videoId
+ else videoInclude.where['id'] = videoId
+
+ const query = {
+ where: {
+ language
+ },
+ include: [
+ videoInclude
+ ]
+ }
+
+ return VideoCaptionModel.findOne(query)
+ }
+
+ static insertOrReplaceLanguage (videoId: number, language: string, transaction: Sequelize.Transaction) {
+ const values = {
+ videoId,
+ language
+ }
+
+ return VideoCaptionModel.upsert(values, { transaction })
+ }
+
+ static listVideoCaptions (videoId: number) {
+ const query = {
+ order: [ [ 'language', 'ASC' ] ],
+ where: {
+ videoId
+ }
+ }
+
+ return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
+ }
+
+ static getLanguageLabel (language: string) {
+ return VIDEO_LANGUAGES[language] || 'Unknown'
+ }
+
+ static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Sequelize.Transaction) {
+ const query = {
+ where: {
+ videoId
+ },
+ transaction
+ }
+
+ return VideoCaptionModel.destroy(query)
+ }
+
+ isOwned () {
+ return this.Video.remote === false
+ }
+
+ toFormattedJSON (): VideoCaption {
+ return {
+ language: {
+ id: this.language,
+ label: VideoCaptionModel.getLanguageLabel(this.language)
+ },
+ captionPath: this.getCaptionStaticPath()
+ }
+ }
+
+ getCaptionStaticPath () {
+ return join(STATIC_PATHS.VIDEO_CAPTIONS, this.getCaptionName())
+ }
+
+ getCaptionName () {
+ return `${this.Video.uuid}-${this.language}.vtt`
+ }
+
+ removeCaptionFile () {
+ return unlinkPromise(CONFIG.STORAGE.CAPTIONS_DIR + this.getCaptionName())
+ }
+}
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'
import { VideoShareModel } from './video-share'
import { VideoTagModel } from './video-tag'
import { ScheduleVideoUpdateModel } from './schedule-video-update'
+import { VideoCaptionModel } from './video-caption'
export enum ScopeNames {
AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@@ -526,6 +527,17 @@ export class VideoModel extends Model {
})
ScheduleVideoUpdate: ScheduleVideoUpdateModel
+ @HasMany(() => VideoCaptionModel, {
+ foreignKey: {
+ name: 'videoId',
+ allowNull: false
+ },
+ onDelete: 'cascade',
+ hooks: true,
+ ['separate' as any]: true
+ })
+ VideoCaptions: VideoCaptionModel[]
+
@BeforeDestroy
static async sendDelete (instance: VideoModel, options) {
if (instance.isOwned()) {
@@ -550,7 +562,7 @@ export class VideoModel extends Model {
}
@BeforeDestroy
- static async removeFilesAndSendDelete (instance: VideoModel) {
+ static async removeFiles (instance: VideoModel) {
const tasks: Promise[] = []
logger.debug('Removing files of video %s.', instance.url)
@@ -615,6 +627,11 @@ export class VideoModel extends Model {
]
},
include: [
+ {
+ attributes: [ 'language' ],
+ model: VideoCaptionModel.unscoped(),
+ required: false
+ },
{
attributes: [ 'id', 'url' ],
model: VideoShareModel.unscoped(),
@@ -1028,15 +1045,15 @@ export class VideoModel extends Model {
videoFile.infoHash = parsedTorrent.infoHash
}
- getEmbedPath () {
+ getEmbedStaticPath () {
return '/videos/embed/' + this.uuid
}
- getThumbnailPath () {
+ getThumbnailStaticPath () {
return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
}
- getPreviewPath () {
+ getPreviewStaticPath () {
return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
}
@@ -1077,9 +1094,9 @@ export class VideoModel extends Model {
views: this.views,
likes: this.likes,
dislikes: this.dislikes,
- thumbnailPath: this.getThumbnailPath(),
- previewPath: this.getPreviewPath(),
- embedPath: this.getEmbedPath(),
+ thumbnailPath: this.getThumbnailStaticPath(),
+ previewPath: this.getPreviewStaticPath(),
+ embedPath: this.getEmbedStaticPath(),
createdAt: this.createdAt,
updatedAt: this.updatedAt,
publishedAt: this.publishedAt,
@@ -1247,6 +1264,14 @@ export class VideoModel extends Model {
href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
})
+ const subtitleLanguage = []
+ for (const caption of this.VideoCaptions) {
+ subtitleLanguage.push({
+ identifier: caption.language,
+ name: VideoCaptionModel.getLanguageLabel(caption.language)
+ })
+ }
+
return {
type: 'Video' as 'Video',
id: this.url,
@@ -1267,6 +1292,7 @@ export class VideoModel extends Model {
mediaType: 'text/markdown',
content: this.getTruncatedDescription(),
support: this.support,
+ subtitleLanguage,
icon: {
type: 'Image',
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 () {
cache: {
previews: {
size: 2
+ },
+ captions: {
+ size: 3
}
},
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'
import './users'
import './video-abuses'
import './video-blacklist'
+import './video-captions'
import './video-channels'
import './video-comments'
import './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 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+ createUser,
+ flushTests,
+ killallServers,
+ makeDeleteRequest,
+ makeGetRequest,
+ makeUploadRequest,
+ runServer,
+ ServerInfo,
+ setAccessTokensToServers,
+ uploadVideo,
+ userLogin
+} from '../../utils'
+import { join } from 'path'
+
+describe('Test video captions API validator', function () {
+ const path = '/api/v1/videos/'
+
+ let server: ServerInfo
+ let userAccessToken: string
+ let videoUUID: string
+
+ // ---------------------------------------------------------------
+
+ before(async function () {
+ this.timeout(30000)
+
+ await flushTests()
+
+ server = await runServer(1)
+
+ await setAccessTokensToServers([ server ])
+
+ {
+ const res = await uploadVideo(server.url, server.accessToken, {})
+ videoUUID = res.body.video.uuid
+ }
+
+ {
+ const user = {
+ username: 'user1',
+ password: 'my super password'
+ }
+ await createUser(server.url, server.accessToken, user.username, user.password)
+ userAccessToken = await userLogin(server, user)
+ }
+ })
+
+ describe('When adding video caption', function () {
+ const fields = { }
+ const attaches = {
+ 'captionfile': join(__dirname, '..', '..', 'fixtures', 'subtitle-good1.vtt')
+ }
+
+ it('Should fail without a valid uuid', async function () {
+ await makeUploadRequest({
+ method: 'PUT',
+ url: server.url,
+ path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions',
+ token: server.accessToken,
+ fields,
+ attaches
+ })
+ })
+
+ it('Should fail with an unknown id', async function () {
+ await makeUploadRequest({
+ method: 'PUT',
+ url: server.url,
+ path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions',
+ token: server.accessToken,
+ fields,
+ attaches
+ })
+ })
+
+ it('Should fail with a missing language in path', async function () {
+ const captionPath = path + videoUUID + '/captions'
+ await makeUploadRequest({
+ method: 'PUT',
+ url: server.url,
+ path: captionPath,
+ token: server.accessToken,
+ fields,
+ attaches
+ })
+ })
+
+ it('Should fail with an unknown language', async function () {
+ const captionPath = path + videoUUID + '/captions/15'
+ await makeUploadRequest({
+ method: 'PUT',
+ url: server.url,
+ path: captionPath,
+ token: server.accessToken,
+ fields,
+ attaches
+ })
+ })
+
+ it('Should fail without access token', async function () {
+ const captionPath = path + videoUUID + '/captions/fr'
+ await makeUploadRequest({
+ method: 'PUT',
+ url: server.url,
+ path: captionPath,
+ fields,
+ attaches,
+ statusCodeExpected: 401
+ })
+ })
+
+ it('Should fail with a bad access token', async function () {
+ const captionPath = path + videoUUID + '/captions/fr'
+ await makeUploadRequest({
+ method: 'PUT',
+ url: server.url,
+ path: captionPath,
+ token: 'blabla',
+ fields,
+ attaches,
+ statusCodeExpected: 401
+ })
+ })
+
+ it('Should success with the correct parameters', async function () {
+ const captionPath = path + videoUUID + '/captions/fr'
+ await makeUploadRequest({
+ method: 'PUT',
+ url: server.url,
+ path: captionPath,
+ token: server.accessToken,
+ fields,
+ attaches,
+ statusCodeExpected: 204
+ })
+ })
+ })
+
+ describe('When listing video captions', function () {
+ it('Should fail without a valid uuid', async function () {
+ await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions' })
+ })
+
+ it('Should fail with an unknown id', async function () {
+ await makeGetRequest({ url: server.url, path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions', statusCodeExpected: 404 })
+ })
+
+ it('Should success with the correct parameters', async function () {
+ await makeGetRequest({ url: server.url, path: path + videoUUID + '/captions', statusCodeExpected: 200 })
+ })
+ })
+
+ describe('When deleting video caption', function () {
+ it('Should fail without a valid uuid', async function () {
+ await makeDeleteRequest({
+ url: server.url,
+ path: path + '4da6fde3-88f7-4d16-b119-108df563d0b06/captions/fr',
+ token: server.accessToken
+ })
+ })
+
+ it('Should fail with an unknown id', async function () {
+ await makeDeleteRequest({
+ url: server.url,
+ path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/fr',
+ token: server.accessToken,
+ statusCodeExpected: 404
+ })
+ })
+
+ it('Should fail with an invalid language', async function () {
+ await makeDeleteRequest({
+ url: server.url,
+ path: path + '4da6fde3-88f7-4d16-b119-108df5630b06/captions/16',
+ token: server.accessToken
+ })
+ })
+
+ it('Should fail with a missing language', async function () {
+ const captionPath = path + videoUUID + '/captions'
+ await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
+ })
+
+ it('Should fail with an unknown language', async function () {
+ const captionPath = path + videoUUID + '/captions/15'
+ await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
+ })
+
+ it('Should fail without access token', async function () {
+ const captionPath = path + videoUUID + '/captions/fr'
+ await makeDeleteRequest({ url: server.url, path: captionPath, statusCodeExpected: 401 })
+ })
+
+ it('Should fail with a bad access token', async function () {
+ const captionPath = path + videoUUID + '/captions/fr'
+ await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', statusCodeExpected: 401 })
+ })
+
+ it('Should fail with another user', async function () {
+ const captionPath = path + videoUUID + '/captions/fr'
+ await makeDeleteRequest({ url: server.url, path: captionPath, token: userAccessToken, statusCodeExpected: 403 })
+ })
+
+ it('Should success with the correct parameters', async function () {
+ const captionPath = path + videoUUID + '/captions/fr'
+ await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken, statusCodeExpected: 204 })
+ })
+ })
+
+ after(async function () {
+ killallServers([ server ])
+
+ // Keep the logs if the test failed
+ if (this['ok']) {
+ await flushTests()
+ }
+ })
+})
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'
import './users/users'
import './videos/single-server'
import './videos/video-abuse'
+import './videos/video-captions'
import './videos/video-blacklist'
import './videos/video-blacklist-management'
import './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 {
registerUser, getCustomConfig, setAccessTokensToServers, updateCustomConfig
} from '../../utils/index'
+function checkInitialConfig (data: CustomConfig) {
+ expect(data.instance.name).to.equal('PeerTube')
+ expect(data.instance.shortDescription).to.equal(
+ 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
+ 'with WebTorrent and Angular.'
+ )
+ expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
+ expect(data.instance.terms).to.equal('No terms for now.')
+ expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
+ expect(data.instance.defaultNSFWPolicy).to.equal('display')
+ expect(data.instance.customizations.css).to.be.empty
+ expect(data.instance.customizations.javascript).to.be.empty
+ expect(data.services.twitter.username).to.equal('@Chocobozzz')
+ expect(data.services.twitter.whitelisted).to.be.false
+ expect(data.cache.previews.size).to.equal(1)
+ expect(data.cache.captions.size).to.equal(1)
+ expect(data.signup.enabled).to.be.true
+ expect(data.signup.limit).to.equal(4)
+ expect(data.admin.email).to.equal('admin1@example.com')
+ expect(data.user.videoQuota).to.equal(5242880)
+ expect(data.transcoding.enabled).to.be.false
+ expect(data.transcoding.threads).to.equal(2)
+ expect(data.transcoding.resolutions['240p']).to.be.true
+ expect(data.transcoding.resolutions['360p']).to.be.true
+ expect(data.transcoding.resolutions['480p']).to.be.true
+ expect(data.transcoding.resolutions['720p']).to.be.true
+ expect(data.transcoding.resolutions['1080p']).to.be.true
+}
+
+function checkUpdatedConfig (data: CustomConfig) {
+ expect(data.instance.name).to.equal('PeerTube updated')
+ expect(data.instance.shortDescription).to.equal('my short description')
+ expect(data.instance.description).to.equal('my super description')
+ expect(data.instance.terms).to.equal('my super terms')
+ expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
+ expect(data.instance.defaultNSFWPolicy).to.equal('blur')
+ expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
+ expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
+ expect(data.services.twitter.username).to.equal('@Kuja')
+ expect(data.services.twitter.whitelisted).to.be.true
+ expect(data.cache.previews.size).to.equal(2)
+ expect(data.cache.captions.size).to.equal(3)
+ expect(data.signup.enabled).to.be.false
+ expect(data.signup.limit).to.equal(5)
+ expect(data.admin.email).to.equal('superadmin1@example.com')
+ expect(data.user.videoQuota).to.equal(5242881)
+ expect(data.transcoding.enabled).to.be.true
+ expect(data.transcoding.threads).to.equal(1)
+ expect(data.transcoding.resolutions['240p']).to.be.false
+ expect(data.transcoding.resolutions['360p']).to.be.true
+ expect(data.transcoding.resolutions['480p']).to.be.true
+ expect(data.transcoding.resolutions['720p']).to.be.false
+ expect(data.transcoding.resolutions['1080p']).to.be.false
+}
+
describe('Test config', function () {
let server = null
@@ -51,35 +106,11 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body as CustomConfig
- expect(data.instance.name).to.equal('PeerTube')
- expect(data.instance.shortDescription).to.equal(
- 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
- 'with WebTorrent and Angular.'
- )
- expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
- expect(data.instance.terms).to.equal('No terms for now.')
- expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
- expect(data.instance.defaultNSFWPolicy).to.equal('display')
- expect(data.instance.customizations.css).to.be.empty
- expect(data.instance.customizations.javascript).to.be.empty
- expect(data.services.twitter.username).to.equal('@Chocobozzz')
- expect(data.services.twitter.whitelisted).to.be.false
- expect(data.cache.previews.size).to.equal(1)
- expect(data.signup.enabled).to.be.true
- expect(data.signup.limit).to.equal(4)
- expect(data.admin.email).to.equal('admin1@example.com')
- expect(data.user.videoQuota).to.equal(5242880)
- expect(data.transcoding.enabled).to.be.false
- expect(data.transcoding.threads).to.equal(2)
- expect(data.transcoding.resolutions['240p']).to.be.true
- expect(data.transcoding.resolutions['360p']).to.be.true
- expect(data.transcoding.resolutions['480p']).to.be.true
- expect(data.transcoding.resolutions['720p']).to.be.true
- expect(data.transcoding.resolutions['1080p']).to.be.true
+ checkInitialConfig(data)
})
it('Should update the customized configuration', async function () {
- const newCustomConfig = {
+ const newCustomConfig: CustomConfig = {
instance: {
name: 'PeerTube updated',
shortDescription: 'my short description',
@@ -101,6 +132,9 @@ describe('Test config', function () {
cache: {
previews: {
size: 2
+ },
+ captions: {
+ size: 3
}
},
signup: {
@@ -130,28 +164,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body
- expect(data.instance.name).to.equal('PeerTube updated')
- expect(data.instance.shortDescription).to.equal('my short description')
- expect(data.instance.description).to.equal('my super description')
- expect(data.instance.terms).to.equal('my super terms')
- expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
- expect(data.instance.defaultNSFWPolicy).to.equal('blur')
- expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
- expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
- expect(data.services.twitter.username).to.equal('@Kuja')
- expect(data.services.twitter.whitelisted).to.be.true
- expect(data.cache.previews.size).to.equal(2)
- expect(data.signup.enabled).to.be.false
- expect(data.signup.limit).to.equal(5)
- expect(data.admin.email).to.equal('superadmin1@example.com')
- expect(data.user.videoQuota).to.equal(5242881)
- expect(data.transcoding.enabled).to.be.true
- expect(data.transcoding.threads).to.equal(1)
- expect(data.transcoding.resolutions['240p']).to.be.false
- expect(data.transcoding.resolutions['360p']).to.be.true
- expect(data.transcoding.resolutions['480p']).to.be.true
- expect(data.transcoding.resolutions['720p']).to.be.false
- expect(data.transcoding.resolutions['1080p']).to.be.false
+ checkUpdatedConfig(data)
})
it('Should have the configuration updated after a restart', async function () {
@@ -164,28 +177,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body
- expect(data.instance.name).to.equal('PeerTube updated')
- expect(data.instance.shortDescription).to.equal('my short description')
- expect(data.instance.description).to.equal('my super description')
- expect(data.instance.terms).to.equal('my super terms')
- expect(data.instance.defaultClientRoute).to.equal('/videos/recently-added')
- expect(data.instance.defaultNSFWPolicy).to.equal('blur')
- expect(data.instance.customizations.javascript).to.equal('alert("coucou")')
- expect(data.instance.customizations.css).to.equal('body { background-color: red; }')
- expect(data.services.twitter.username).to.equal('@Kuja')
- expect(data.services.twitter.whitelisted).to.be.true
- expect(data.cache.previews.size).to.equal(2)
- expect(data.signup.enabled).to.be.false
- expect(data.signup.limit).to.equal(5)
- expect(data.admin.email).to.equal('superadmin1@example.com')
- expect(data.user.videoQuota).to.equal(5242881)
- expect(data.transcoding.enabled).to.be.true
- expect(data.transcoding.threads).to.equal(1)
- expect(data.transcoding.resolutions['240p']).to.be.false
- expect(data.transcoding.resolutions['360p']).to.be.true
- expect(data.transcoding.resolutions['480p']).to.be.true
- expect(data.transcoding.resolutions['720p']).to.be.false
- expect(data.transcoding.resolutions['1080p']).to.be.false
+ checkUpdatedConfig(data)
})
it('Should fetch the about information', async function () {
@@ -206,31 +198,7 @@ describe('Test config', function () {
const res = await getCustomConfig(server.url, server.accessToken)
const data = res.body
- expect(data.instance.name).to.equal('PeerTube')
- expect(data.instance.shortDescription).to.equal(
- 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser ' +
- 'with WebTorrent and Angular.'
- )
- expect(data.instance.description).to.equal('Welcome to this PeerTube instance!')
- expect(data.instance.terms).to.equal('No terms for now.')
- expect(data.instance.defaultClientRoute).to.equal('/videos/trending')
- expect(data.instance.defaultNSFWPolicy).to.equal('display')
- expect(data.instance.customizations.css).to.be.empty
- expect(data.instance.customizations.javascript).to.be.empty
- expect(data.services.twitter.username).to.equal('@Chocobozzz')
- expect(data.services.twitter.whitelisted).to.be.false
- expect(data.cache.previews.size).to.equal(1)
- expect(data.signup.enabled).to.be.true
- expect(data.signup.limit).to.equal(4)
- expect(data.admin.email).to.equal('admin1@example.com')
- expect(data.user.videoQuota).to.equal(5242880)
- expect(data.transcoding.enabled).to.be.false
- expect(data.transcoding.threads).to.equal(2)
- expect(data.transcoding.resolutions['240p']).to.be.true
- expect(data.transcoding.resolutions['360p']).to.be.true
- expect(data.transcoding.resolutions['480p']).to.be.true
- expect(data.transcoding.resolutions['720p']).to.be.true
- expect(data.transcoding.resolutions['1080p']).to.be.true
+ checkInitialConfig(data)
})
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 {
} from '../../utils/videos/video-comments'
import { rateVideo } from '../../utils/videos/videos'
import { waitJobs } from '../../utils/server/jobs'
+import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
+import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
const expect = chai.expect
@@ -244,6 +246,16 @@ describe('Test follows', function () {
const text3 = 'my second answer to thread 1'
await addVideoCommentReply(servers[ 2 ].url, servers[ 2 ].accessToken, video4.id, threadId, text3)
}
+
+ {
+ await createVideoCaption({
+ url: servers[2].url,
+ accessToken: servers[2].accessToken,
+ language: 'ar',
+ videoId: video4.id,
+ fixture: 'subtitle-good2.vtt'
+ })
+ }
}
await waitJobs(servers)
@@ -266,7 +278,7 @@ describe('Test follows', function () {
await expectAccountFollows(servers[2].url, 'peertube@localhost:9003', 1, 0)
})
- it('Should propagate videos', async function () {
+ it('Should have propagated videos', async function () {
const res = await getVideosList(servers[ 0 ].url)
expect(res.body.total).to.equal(7)
@@ -314,7 +326,7 @@ describe('Test follows', function () {
await completeVideoCheck(servers[ 0 ].url, video4, checkAttributes)
})
- it('Should propagate comments', async function () {
+ it('Should have propagated comments', async function () {
const res1 = await getVideoCommentThreads(servers[0].url, video4.id, 0, 5)
expect(res1.body.total).to.equal(1)
@@ -353,6 +365,18 @@ describe('Test follows', function () {
expect(secondChild.children).to.have.lengthOf(0)
})
+ it('Should have propagated captions', async function () {
+ const res = await listVideoCaptions(servers[0].url, video4.id)
+ expect(res.body.total).to.equal(1)
+ expect(res.body.data).to.have.lengthOf(1)
+
+ const caption1: VideoCaption = res.body.data[0]
+ expect(caption1.language.id).to.equal('ar')
+ expect(caption1.language.label).to.equal('Arabic')
+ expect(caption1.captionPath).to.equal('/static/video-captions/' + video4.uuid + '-ar.vtt')
+ await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
+ })
+
it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
this.timeout(5000)
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 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import { doubleFollow, flushAndRunMultipleServers, uploadVideo } from '../../utils'
+import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
+import { waitJobs } from '../../utils/server/jobs'
+import { createVideoCaption, deleteVideoCaption, listVideoCaptions, testCaptionFile } from '../../utils/videos/video-captions'
+import { VideoCaption } from '../../../../shared/models/videos/video-caption.model'
+
+const expect = chai.expect
+
+describe('Test video captions', function () {
+ let servers: ServerInfo[]
+ let videoUUID: string
+
+ before(async function () {
+ this.timeout(30000)
+
+ await flushTests()
+
+ servers = await flushAndRunMultipleServers(2)
+
+ await setAccessTokensToServers(servers)
+ await doubleFollow(servers[0], servers[1])
+
+ await waitJobs(servers)
+
+ const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'my video name' })
+ videoUUID = res.body.video.uuid
+
+ await waitJobs(servers)
+ })
+
+ it('Should list the captions and return an empty list', async function () {
+ for (const server of servers) {
+ const res = await listVideoCaptions(server.url, videoUUID)
+ expect(res.body.total).to.equal(0)
+ expect(res.body.data).to.have.lengthOf(0)
+ }
+ })
+
+ it('Should create two new captions', async function () {
+ this.timeout(30000)
+
+ await createVideoCaption({
+ url: servers[0].url,
+ accessToken: servers[0].accessToken,
+ language: 'ar',
+ videoId: videoUUID,
+ fixture: 'subtitle-good1.vtt'
+ })
+
+ await createVideoCaption({
+ url: servers[0].url,
+ accessToken: servers[0].accessToken,
+ language: 'zh',
+ videoId: videoUUID,
+ fixture: 'subtitle-good2.vtt'
+ })
+
+ await waitJobs(servers)
+ })
+
+ it('Should list these uploaded captions', async function () {
+ for (const server of servers) {
+ const res = await listVideoCaptions(server.url, videoUUID)
+ expect(res.body.total).to.equal(2)
+ expect(res.body.data).to.have.lengthOf(2)
+
+ const caption1: VideoCaption = res.body.data[0]
+ expect(caption1.language.id).to.equal('ar')
+ expect(caption1.language.label).to.equal('Arabic')
+ expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
+ await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 1.')
+
+ const caption2: VideoCaption = res.body.data[1]
+ expect(caption2.language.id).to.equal('zh')
+ expect(caption2.language.label).to.equal('Chinese')
+ expect(caption2.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
+ await testCaptionFile(server.url, caption2.captionPath, 'Subtitle good 2.')
+ }
+ })
+
+ it('Should replace an existing caption', async function () {
+ this.timeout(30000)
+
+ await createVideoCaption({
+ url: servers[0].url,
+ accessToken: servers[0].accessToken,
+ language: 'ar',
+ videoId: videoUUID,
+ fixture: 'subtitle-good2.vtt'
+ })
+
+ await waitJobs(servers)
+ })
+
+ it('Should have this caption updated', async function () {
+ for (const server of servers) {
+ const res = await listVideoCaptions(server.url, videoUUID)
+ expect(res.body.total).to.equal(2)
+ expect(res.body.data).to.have.lengthOf(2)
+
+ const caption1: VideoCaption = res.body.data[0]
+ expect(caption1.language.id).to.equal('ar')
+ expect(caption1.language.label).to.equal('Arabic')
+ expect(caption1.captionPath).to.equal('/static/video-captions/' + videoUUID + '-ar.vtt')
+ await testCaptionFile(server.url, caption1.captionPath, 'Subtitle good 2.')
+ }
+ })
+
+ it('Should remove one caption', async function () {
+ this.timeout(30000)
+
+ await deleteVideoCaption(servers[0].url, servers[0].accessToken, videoUUID, 'ar')
+
+ await waitJobs(servers)
+ })
+
+ it('Should only list the caption that was not deleted', async function () {
+ for (const server of servers) {
+ const res = await listVideoCaptions(server.url, videoUUID)
+ expect(res.body.total).to.equal(1)
+ expect(res.body.data).to.have.lengthOf(1)
+
+ const caption: VideoCaption = res.body.data[0]
+
+ expect(caption.language.id).to.equal('zh')
+ expect(caption.language.label).to.equal('Chinese')
+ expect(caption.captionPath).to.equal('/static/video-captions/' + videoUUID + '-zh.vtt')
+ await testCaptionFile(server.url, caption.captionPath, 'Subtitle good 2.')
+ }
+ })
+
+ after(async function () {
+ killallServers(servers)
+ })
+})
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 @@
+WEBVTT
+
+00:01.000 --> 00:04.000
+Subtitle good 1.
+
+00:05.000 --> 00:09.000
+- It will perforate your stomach.
+- 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 @@
+WEBVTT
+
+00:01.000 --> 00:04.000
+Subtitle good 2.
+
+00:05.000 --> 00:09.000
+- It will perforate your stomach.
+- 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'
import * as request from 'supertest'
import * as WebTorrent from 'webtorrent'
import { readFileBufferPromise } from '../../../helpers/core-utils'
-import { ServerInfo } from '..'
const expect = chai.expect
let 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 @@
+import { makeDeleteRequest, makeGetRequest } from '../'
+import { buildAbsoluteFixturePath, makeUploadRequest } from '../index'
+import * as request from 'supertest'
+import * as chai from 'chai'
+
+const expect = chai.expect
+
+function createVideoCaption (args: {
+ url: string,
+ accessToken: string
+ videoId: string | number
+ language: string
+ fixture: string
+}) {
+ const path = '/api/v1/videos/' + args.videoId + '/captions/' + args.language
+
+ return makeUploadRequest({
+ method: 'PUT',
+ url: args.url,
+ path,
+ token: args.accessToken,
+ fields: {},
+ attaches: {
+ captionfile: buildAbsoluteFixturePath(args.fixture)
+ },
+ statusCodeExpected: 204
+ })
+}
+
+function listVideoCaptions (url: string, videoId: string | number) {
+ const path = '/api/v1/videos/' + videoId + '/captions'
+
+ return makeGetRequest({
+ url,
+ path,
+ statusCodeExpected: 200
+ })
+}
+
+function deleteVideoCaption (url: string, token: string, videoId: string | number, language: string) {
+ const path = '/api/v1/videos/' + videoId + '/captions/' + language
+
+ return makeDeleteRequest({
+ url,
+ token,
+ path,
+ statusCodeExpected: 204
+ })
+}
+
+async function testCaptionFile (url: string, captionPath: string, containsString: string) {
+ const res = await request(url)
+ .get(captionPath)
+ .expect(200)
+
+ expect(res.text).to.contain(containsString)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ createVideoCaption,
+ listVideoCaptions,
+ testCaptionFile,
+ deleteVideoCaption
+}
diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-torrent-object.ts
index c4071a6d9..90de8967b 100644
--- a/shared/models/activitypub/objects/video-torrent-object.ts
+++ b/shared/models/activitypub/objects/video-torrent-object.ts
@@ -17,6 +17,7 @@ export interface VideoTorrentObject {
category: ActivityIdentifierObject
licence: ActivityIdentifierObject
language: ActivityIdentifierObject
+ subtitleLanguage: ActivityIdentifierObject[]
views: number
sensitive: boolean
commentsEnabled: boolean
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index a3a651cd8..9c4718e43 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -25,6 +25,10 @@ export interface CustomConfig {
previews: {
size: number
}
+
+ captions: {
+ size: number
+ }
}
signup: {
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index da0996dae..217d142cd 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -44,6 +44,15 @@ export interface ServerConfig {
}
}
+ videoCaption: {
+ file: {
+ size: {
+ max: number
+ },
+ extensions: string[]
+ }
+ }
+
user: {
videoQuota: number
}
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index 9edfb559a..cb9669772 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -14,3 +14,5 @@ export * from './video-resolution.enum'
export * from './video-update.model'
export * from './video.model'
export * from './video-state.enum'
+export * from './video-caption-update.model'
+export { VideoConstant } from './video-constant.model'
diff --git a/shared/models/videos/video-caption-update.model.ts b/shared/models/videos/video-caption-update.model.ts
new file mode 100644
index 000000000..ff5728715
--- /dev/null
+++ b/shared/models/videos/video-caption-update.model.ts
@@ -0,0 +1,4 @@
+export interface VideoCaptionUpdate {
+ language: string
+ captionfile: Blob
+}
diff --git a/shared/models/videos/video-caption.model.ts b/shared/models/videos/video-caption.model.ts
new file mode 100644
index 000000000..4695224ce
--- /dev/null
+++ b/shared/models/videos/video-caption.model.ts
@@ -0,0 +1,6 @@
+import { VideoConstant } from './video-constant.model'
+
+export interface VideoCaption {
+ language: VideoConstant
+ captionPath: string
+}
diff --git a/shared/models/videos/video-constant.model.ts b/shared/models/videos/video-constant.model.ts
new file mode 100644
index 000000000..342a7c0cf
--- /dev/null
+++ b/shared/models/videos/video-constant.model.ts
@@ -0,0 +1,4 @@
+export interface VideoConstant {
+ id: T
+ label: string
+}
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index 4e1f15ee3..f7bbaac76 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -4,11 +4,7 @@ import { Avatar } from '../avatars/avatar.model'
import { VideoChannel } from './video-channel.model'
import { VideoPrivacy } from './video-privacy.enum'
import { VideoScheduleUpdate } from './video-schedule-update.model'
-
-export interface VideoConstant {
- id: T
- label: string
-}
+import { VideoConstant } from './video-constant.model'
export interface VideoFile {
magnetUri: string
diff --git a/support/docker/production/config/production.yaml b/support/docker/production/config/production.yaml
index 64fc9e82c..ddac23c4e 100644
--- a/support/docker/production/config/production.yaml
+++ b/support/docker/production/config/production.yaml
@@ -38,6 +38,7 @@ storage:
previews: '../data/previews/'
thumbnails: '../data/thumbnails/'
torrents: '../data/torrents/'
+ captions: '../data/captions/'
cache: '../data/cache/'
log: