From 40e87e9ecc54e3513fb586928330a7855eb192c6 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 12 Jul 2018 19:02:00 +0200 Subject: Implement captions/subtitles --- server/controllers/activitypub/client.ts | 5 + server/controllers/api/config.ts | 14 +- server/controllers/api/videos/captions.ts | 100 +++++++++ server/controllers/api/videos/index.ts | 2 + server/controllers/client.ts | 2 +- server/controllers/feeds.ts | 2 +- server/controllers/services.ts | 4 +- server/controllers/static.ts | 21 +- server/helpers/activitypub.ts | 1 + .../custom-validators/activitypub/videos.ts | 13 ++ server/helpers/custom-validators/video-captions.ts | 41 ++++ server/helpers/custom-validators/videos.ts | 24 +++ server/initializers/constants.ts | 23 ++- server/initializers/database.ts | 2 + server/lib/activitypub/process/process-update.ts | 12 +- server/lib/activitypub/videos.ts | 29 ++- .../lib/cache/abstract-video-static-file-cache.ts | 54 +++++ server/lib/cache/videos-caption-cache.ts | 53 +++++ server/lib/cache/videos-preview-cache.ts | 60 ++---- server/middlewares/validators/video-captions.ts | 70 +++++++ server/middlewares/validators/videos.ts | 36 +--- server/models/video/video-caption.ts | 173 ++++++++++++++++ server/models/video/video.ts | 40 +++- server/tests/api/check-params/config.ts | 3 + server/tests/api/check-params/index.ts | 1 + server/tests/api/check-params/video-captions.ts | 223 +++++++++++++++++++++ server/tests/api/index-fast.ts | 1 + server/tests/api/server/config.ts | 158 ++++++--------- server/tests/api/server/follows.ts | 28 ++- server/tests/api/videos/video-captions.ts | 139 +++++++++++++ server/tests/fixtures/subtitle-good1.vtt | 8 + server/tests/fixtures/subtitle-good2.vtt | 8 + server/tests/utils/miscs/miscs.ts | 1 - server/tests/utils/videos/video-captions.ts | 66 ++++++ 34 files changed, 1220 insertions(+), 197 deletions(-) create mode 100644 server/controllers/api/videos/captions.ts create mode 100644 server/helpers/custom-validators/video-captions.ts create mode 100644 server/lib/cache/abstract-video-static-file-cache.ts create mode 100644 server/lib/cache/videos-caption-cache.ts create mode 100644 server/middlewares/validators/video-captions.ts create mode 100644 server/models/video/video-caption.ts create mode 100644 server/tests/api/check-params/video-captions.ts create mode 100644 server/tests/api/videos/video-captions.ts create mode 100644 server/tests/fixtures/subtitle-good1.vtt create mode 100644 server/tests/fixtures/subtitle-good2.vtt create mode 100644 server/tests/utils/videos/video-captions.ts (limited to 'server') 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.') - - return this.saveRemotePreviewAndReturnPath(video) - } + if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.') - 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) + const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName()) + const destPath = join(CACHE.DIRECTORIES.PREVIEWS, video.getPreviewName()) - 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 +} -- cgit v1.2.3