From 2186386cca113506791583cb07d6ccacba7af4e0 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 12 Jun 2018 20:04:58 +0200 Subject: Add concept of video state, and add ability to wait transcoding before publishing a video --- server/controllers/activitypub/client.ts | 8 +- server/controllers/activitypub/outbox.ts | 4 +- server/controllers/api/users.ts | 7 +- server/controllers/api/videos/index.ts | 47 ++++---- server/helpers/activitypub.ts | 26 ++-- .../custom-validators/activitypub/videos.ts | 6 + server/helpers/custom-validators/videos.ts | 24 ++-- server/helpers/utils.ts | 22 ++-- server/initializers/constants.ts | 10 +- server/initializers/migrations/0220-video-state.ts | 62 ++++++++++ server/lib/activitypub/audience.ts | 10 +- server/lib/activitypub/crawl.ts | 2 +- server/lib/activitypub/process/process-update.ts | 27 +++-- server/lib/activitypub/send/send-announce.ts | 14 +-- server/lib/activitypub/send/send-create.ts | 43 ++++--- server/lib/activitypub/send/send-like.ts | 33 +++--- server/lib/activitypub/send/send-undo.ts | 42 +++---- server/lib/activitypub/send/send-update.ts | 36 +++--- server/lib/activitypub/videos.ts | 80 ++++++++----- server/lib/job-queue/handlers/video-file.ts | 127 +++++++++++--------- server/lib/job-queue/job-queue.ts | 1 + server/middlewares/cache.ts | 2 +- server/middlewares/validators/videos.ts | 10 ++ server/models/video/video.ts | 132 +++++++++++---------- server/tests/api/check-params/videos.ts | 15 +-- server/tests/api/videos/multiple-servers.ts | 8 +- server/tests/api/videos/services.ts | 3 +- server/tests/api/videos/video-transcoder.ts | 74 +++++++++++- server/tests/cli/create-transcoding-job.ts | 2 +- server/tests/utils/videos/videos.ts | 3 + server/tools/import-videos.ts | 1 + server/tools/upload.ts | 1 + 32 files changed, 523 insertions(+), 359 deletions(-) create mode 100644 server/initializers/migrations/0220-video-state.ts (limited to 'server') diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 1c780783c..ea8e25f68 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -123,11 +123,11 @@ 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 - const audience = await getAudience(video.VideoChannel.Account.Actor, undefined, video.privacy === VideoPrivacy.PUBLIC) + const audience = getAudience(video.VideoChannel.Account.Actor, video.privacy === VideoPrivacy.PUBLIC) const videoObject = audiencify(video.toActivityPubObject(), audience) if (req.path.endsWith('/activity')) { - const data = await createActivityData(video.url, video.VideoChannel.Account.Actor, videoObject, undefined, audience) + const data = createActivityData(video.url, video.VideoChannel.Account.Actor, videoObject, audience) return activityPubResponse(activityPubContextify(data), res) } @@ -210,12 +210,12 @@ async function videoCommentController (req: express.Request, res: express.Respon const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined) const isPublic = true // Comments are always public - const audience = await getAudience(videoComment.Account.Actor, undefined, isPublic) + const audience = getAudience(videoComment.Account.Actor, isPublic) const videoCommentObject = audiencify(videoComment.toActivityPubObject(threadParentComments), audience) if (req.path.endsWith('/activity')) { - const data = await createActivityData(videoComment.url, videoComment.Account.Actor, videoCommentObject, undefined, audience) + const data = createActivityData(videoComment.url, videoComment.Account.Actor, videoCommentObject, audience) return activityPubResponse(activityPubContextify(data), res) } diff --git a/server/controllers/activitypub/outbox.ts b/server/controllers/activitypub/outbox.ts index 2793ae267..ae7adcd4c 100644 --- a/server/controllers/activitypub/outbox.ts +++ b/server/controllers/activitypub/outbox.ts @@ -54,12 +54,12 @@ async function buildActivities (actor: ActorModel, start: number, count: number) // This is a shared video if (video.VideoShares !== undefined && video.VideoShares.length !== 0) { const videoShare = video.VideoShares[0] - const announceActivity = await announceActivityData(videoShare.url, actor, video.url, undefined, createActivityAudience) + const announceActivity = announceActivityData(videoShare.url, actor, video.url, createActivityAudience) activities.push(announceActivity) } else { const videoObject = video.toActivityPubObject() - const createActivity = await createActivityData(video.url, byActor, videoObject, undefined, createActivityAudience) + const createActivity = createActivityData(video.url, byActor, videoObject, createActivityAudience) activities.push(createActivity) } diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index 8dff4b87c..2b40c44d9 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts @@ -166,7 +166,7 @@ export { async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) { const user = res.locals.oauth.token.User as UserModel - const resultList = await VideoModel.listAccountVideosForApi( + const resultList = await VideoModel.listUserVideosForApi( user.Account.id, req.query.start as number, req.query.count as number, @@ -174,7 +174,8 @@ async function getUserVideos (req: express.Request, res: express.Response, next: false // Display my NSFW videos ) - return res.json(getFormattedObjects(resultList.data, resultList.total)) + const additionalAttributes = { waitTranscoding: true, state: true } + return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes })) } async function createUserRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) { @@ -318,7 +319,7 @@ async function updateMe (req: express.Request, res: express.Response, next: expr } async function updateMyAvatar (req: express.Request, res: express.Response, next: express.NextFunction) { - const avatarPhysicalFile = req.files['avatarfile'][0] + const avatarPhysicalFile = req.files[ 'avatarfile' ][ 0 ] const user = res.locals.oauth.token.user const actor = user.Account.Actor diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 7f5e74626..9d9b2b0e1 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -1,6 +1,6 @@ import * as express from 'express' import { extname, join } from 'path' -import { VideoCreate, VideoPrivacy, VideoUpdate } from '../../../../shared' +import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' import { renamePromise } from '../../../helpers/core-utils' import { retryTransactionWrapper } from '../../../helpers/database-utils' import { getVideoFileResolution } from '../../../helpers/ffmpeg-utils' @@ -21,11 +21,11 @@ import { } from '../../../initializers' import { changeVideoChannelShare, + federateVideoIfNeeded, fetchRemoteVideoDescription, - getVideoActivityPubUrl, - shareVideoByServerAndChannel + getVideoActivityPubUrl } from '../../../lib/activitypub' -import { sendCreateVideo, sendCreateView, sendUpdateVideo } from '../../../lib/activitypub/send' +import { sendCreateView } from '../../../lib/activitypub/send' import { JobQueue } from '../../../lib/job-queue' import { Redis } from '../../../lib/redis' import { @@ -51,7 +51,7 @@ import { videoCommentRouter } from './comment' import { rateVideoRouter } from './rate' import { VideoFilter } from '../../../../shared/models/videos/video-query.type' import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' -import { isNSFWHidden, createReqFiles } from '../../../helpers/express-utils' +import { createReqFiles, isNSFWHidden } from '../../../helpers/express-utils' const videosRouter = express.Router() @@ -185,8 +185,10 @@ async function addVideo (req: express.Request, res: express.Response, videoPhysi category: videoInfo.category, licence: videoInfo.licence, language: videoInfo.language, - commentsEnabled: videoInfo.commentsEnabled, - nsfw: videoInfo.nsfw, + commentsEnabled: videoInfo.commentsEnabled || false, + waitTranscoding: videoInfo.waitTranscoding || false, + state: CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED, + nsfw: videoInfo.nsfw || false, description: videoInfo.description, support: videoInfo.support, privacy: videoInfo.privacy, @@ -194,19 +196,20 @@ async function addVideo (req: express.Request, res: express.Response, videoPhysi channelId: res.locals.videoChannel.id } const video = new VideoModel(videoData) - video.url = getVideoActivityPubUrl(video) + video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object + // Build the file object const { videoFileResolution } = await getVideoFileResolution(videoPhysicalFile.path) - const videoFileData = { extname: extname(videoPhysicalFile.filename), resolution: videoFileResolution, size: videoPhysicalFile.size } const videoFile = new VideoFileModel(videoFileData) + + // Move physical file const videoDir = CONFIG.STORAGE.VIDEOS_DIR const destination = join(videoDir, video.getVideoFilename(videoFile)) - await renamePromise(videoPhysicalFile.path, destination) // This is important in case if there is another attempt in the retry process videoPhysicalFile.filename = video.getVideoFilename(videoFile) @@ -230,6 +233,7 @@ async function addVideo (req: express.Request, res: express.Response, videoPhysi await video.createPreview(videoFile) } + // Create the torrent file await video.createTorrentAndSetInfoHash(videoFile) const videoCreated = await sequelizeTypescript.transaction(async t => { @@ -251,20 +255,14 @@ async function addVideo (req: express.Request, res: express.Response, videoPhysi video.Tags = tagInstances } - // Let transcoding job send the video to friends because the video file extension might change - if (CONFIG.TRANSCODING.ENABLED === true) return videoCreated - // Don't send video to remote servers, it is private - if (video.privacy === VideoPrivacy.PRIVATE) return videoCreated - - await sendCreateVideo(video, t) - await shareVideoByServerAndChannel(video, t) + await federateVideoIfNeeded(video, true, t) logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid) return videoCreated }) - if (CONFIG.TRANSCODING.ENABLED === true) { + if (video.state === VideoState.TO_TRANSCODE) { // Put uuid because we don't have id auto incremented for now const dataInput = { videoUUID: videoCreated.uuid, @@ -318,6 +316,7 @@ async function updateVideo (req: express.Request, res: express.Response) { if (videoInfoToUpdate.licence !== undefined) videoInstance.set('licence', videoInfoToUpdate.licence) if (videoInfoToUpdate.language !== undefined) videoInstance.set('language', videoInfoToUpdate.language) if (videoInfoToUpdate.nsfw !== undefined) videoInstance.set('nsfw', videoInfoToUpdate.nsfw) + if (videoInfoToUpdate.waitTranscoding !== undefined) videoInstance.set('waitTranscoding', videoInfoToUpdate.waitTranscoding) if (videoInfoToUpdate.support !== undefined) videoInstance.set('support', videoInfoToUpdate.support) if (videoInfoToUpdate.description !== undefined) videoInstance.set('description', videoInfoToUpdate.description) if (videoInfoToUpdate.commentsEnabled !== undefined) videoInstance.set('commentsEnabled', videoInfoToUpdate.commentsEnabled) @@ -343,19 +342,13 @@ async function updateVideo (req: express.Request, res: express.Response) { // Video channel update? if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) { await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t }) - videoInstance.VideoChannel = res.locals.videoChannel + videoInstanceUpdated.VideoChannel = res.locals.videoChannel if (wasPrivateVideo === false) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t) } - // Now we'll update the video's meta data to our friends - if (wasPrivateVideo === false) await sendUpdateVideo(videoInstanceUpdated, t) - - // Video is not private anymore, send a create action to remote servers - if (wasPrivateVideo === true && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE) { - await sendCreateVideo(videoInstanceUpdated, t) - await shareVideoByServerAndChannel(videoInstanceUpdated, t) - } + const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE + await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo) }) logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid) diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index d1f3ec02d..37a251697 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts @@ -8,22 +8,24 @@ import { signObject } from './peertube-crypto' import { pageToStartAndCount } from './core-utils' function activityPubContextify (data: T) { - return Object.assign(data,{ + return Object.assign(data, { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1', { - 'RsaSignature2017': 'https://w3id.org/security#RsaSignature2017', - 'Hashtag': 'as:Hashtag', - 'uuid': 'http://schema.org/identifier', - 'category': 'http://schema.org/category', - 'licence': 'http://schema.org/license', - 'sensitive': 'as:sensitive', - 'language': 'http://schema.org/inLanguage', - 'views': 'http://schema.org/Number', - 'size': 'http://schema.org/Number', - 'commentsEnabled': 'http://schema.org/Boolean', - 'support': 'http://schema.org/Text' + RsaSignature2017: 'https://w3id.org/security#RsaSignature2017', + Hashtag: 'as:Hashtag', + uuid: 'http://schema.org/identifier', + category: 'http://schema.org/category', + licence: 'http://schema.org/license', + sensitive: 'as:sensitive', + language: 'http://schema.org/inLanguage', + views: 'http://schema.org/Number', + stats: 'http://schema.org/Number', + size: 'http://schema.org/Number', + commentsEnabled: 'http://schema.org/Boolean', + waitTranscoding: 'http://schema.org/Boolean', + support: 'http://schema.org/Text' }, { likes: { diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts index 7e1d57c34..37c90a0c8 100644 --- a/server/helpers/custom-validators/activitypub/videos.ts +++ b/server/helpers/custom-validators/activitypub/videos.ts @@ -6,11 +6,13 @@ import { isVideoAbuseReasonValid, isVideoDurationValid, isVideoNameValid, + isVideoStateValid, isVideoTagValid, isVideoTruncatedDescriptionValid, isVideoViewsValid } from '../videos' import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' +import { VideoState } from '../../../../shared/models/videos' function sanitizeAndCheckVideoTorrentCreateActivity (activity: any) { return isBaseActivityValid(activity, 'Create') && @@ -50,6 +52,10 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { if (!setRemoteVideoTruncatedContent(video)) return false if (!setValidAttributedTo(video)) return false + // Default attributes + if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED + if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false + return isActivityPubUrlValid(video.id) && isVideoNameValid(video.name) && isActivityPubVideoDurationValid(video.duration) && diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index f365df985..8496e679a 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -10,7 +10,8 @@ import { VIDEO_LICENCES, VIDEO_MIMETYPE_EXT, VIDEO_PRIVACIES, - VIDEO_RATE_TYPES + VIDEO_RATE_TYPES, + VIDEO_STATES } from '../../initializers' import { VideoModel } from '../../models/video/video' import { exists, isArray, isFileValid } from './misc' @@ -21,11 +22,15 @@ const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES function isVideoCategoryValid (value: any) { - return value === null || VIDEO_CATEGORIES[value] !== undefined + return value === null || VIDEO_CATEGORIES[ value ] !== undefined +} + +function isVideoStateValid (value: any) { + return exists(value) && VIDEO_STATES[ value ] !== undefined } function isVideoLicenceValid (value: any) { - return value === null || VIDEO_LICENCES[value] !== undefined + return value === null || VIDEO_LICENCES[ value ] !== undefined } function isVideoLanguageValid (value: any) { @@ -79,20 +84,22 @@ function isVideoRatingTypeValid (value: string) { const videoFileTypes = Object.keys(VIDEO_MIMETYPE_EXT).map(m => `(${m})`) const videoFileTypesRegex = videoFileTypes.join('|') + function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { return isFileValid(files, videoFileTypesRegex, 'videofile') } const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME - .map(v => v.replace('.', '')) - .join('|') + .map(v => v.replace('.', '')) + .join('|') const videoImageTypesRegex = `image/(${videoImageTypes})` + function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { return isFileValid(files, videoImageTypesRegex, field, true) } function isVideoPrivacyValid (value: string) { - return validator.isInt(value + '') && VIDEO_PRIVACIES[value] !== undefined + return validator.isInt(value + '') && VIDEO_PRIVACIES[ value ] !== undefined } function isVideoFileInfoHashValid (value: string) { @@ -118,8 +125,8 @@ async function isVideoExist (id: string, res: Response) { if (!video) { res.status(404) - .json({ error: 'Video not found' }) - .end() + .json({ error: 'Video not found' }) + .end() return false } @@ -169,6 +176,7 @@ export { isVideoTagsValid, isVideoAbuseReasonValid, isVideoFile, + isVideoStateValid, isVideoViewsValid, isVideoRatingTypeValid, isVideoDurationValid, diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts index e4556fa12..8fa861281 100644 --- a/server/helpers/utils.ts +++ b/server/helpers/utils.ts @@ -1,6 +1,5 @@ import { Model } from 'sequelize-typescript' import * as ipaddr from 'ipaddr.js' -const isCidr = require('is-cidr') import { ResultList } from '../../shared' import { VideoResolution } from '../../shared/models/videos' import { CONFIG } from '../initializers' @@ -10,6 +9,8 @@ import { ApplicationModel } from '../models/application/application' import { pseudoRandomBytesPromise } from './core-utils' import { logger } from './logger' +const isCidr = require('is-cidr') + async function generateRandomString (size: number) { const raw = await pseudoRandomBytesPromise(size) @@ -17,22 +18,20 @@ async function generateRandomString (size: number) { } interface FormattableToJSON { - toFormattedJSON () + toFormattedJSON (args?: any) } -function getFormattedObjects (objects: T[], objectsTotal: number) { +function getFormattedObjects (objects: T[], objectsTotal: number, formattedArg?: any) { const formattedObjects: U[] = [] objects.forEach(object => { - formattedObjects.push(object.toFormattedJSON()) + formattedObjects.push(object.toFormattedJSON(formattedArg)) }) - const res: ResultList = { + return { total: objectsTotal, data: formattedObjects - } - - return res + } as ResultList } async function isSignupAllowed () { @@ -87,16 +86,17 @@ function computeResolutionsToTranscode (videoFileHeight: number) { const resolutionsEnabled: number[] = [] const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS + // Put in the order we want to proceed jobs const resolutions = [ - VideoResolution.H_240P, - VideoResolution.H_360P, VideoResolution.H_480P, + VideoResolution.H_360P, VideoResolution.H_720P, + VideoResolution.H_240P, VideoResolution.H_1080P ] for (const resolution of resolutions) { - if (configResolutions[resolution + 'p'] === true && videoFileHeight > resolution) { + if (configResolutions[ resolution + 'p' ] === true && videoFileHeight > resolution) { resolutionsEnabled.push(resolution) } } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 79e4bb7f0..8dbc1b060 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -1,6 +1,6 @@ import { IConfig } from 'config' import { dirname, join } from 'path' -import { JobType, VideoRateType } from '../../shared/models' +import { JobType, VideoRateType, VideoState } from '../../shared/models' import { ActivityPubActorType } from '../../shared/models/activitypub' import { FollowState } from '../../shared/models/actors' import { VideoPrivacy } from '../../shared/models/videos' @@ -14,7 +14,7 @@ let config: IConfig = require('config') // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 215 +const LAST_MIGRATION_VERSION = 220 // --------------------------------------------------------------------------- @@ -326,6 +326,11 @@ const VIDEO_PRIVACIES = { [VideoPrivacy.PRIVATE]: 'Private' } +const VIDEO_STATES = { + [VideoState.PUBLISHED]: 'Published', + [VideoState.TO_TRANSCODE]: 'To transcode' +} + const VIDEO_MIMETYPE_EXT = { 'video/webm': '.webm', 'video/ogg': '.ogv', @@ -493,6 +498,7 @@ export { VIDEO_LANGUAGES, VIDEO_PRIVACIES, VIDEO_LICENCES, + VIDEO_STATES, VIDEO_RATE_TYPES, VIDEO_MIMETYPE_EXT, VIDEO_TRANSCODING_FPS, diff --git a/server/initializers/migrations/0220-video-state.ts b/server/initializers/migrations/0220-video-state.ts new file mode 100644 index 000000000..491702157 --- /dev/null +++ b/server/initializers/migrations/0220-video-state.ts @@ -0,0 +1,62 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + // waitingTranscoding column + { + const data = { + type: Sequelize.BOOLEAN, + allowNull: true, + defaultValue: null + } + await utils.queryInterface.addColumn('video', 'waitTranscoding', data) + } + + { + const query = 'UPDATE video SET "waitTranscoding" = false' + await utils.sequelize.query(query) + } + + { + const data = { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: null + } + await utils.queryInterface.changeColumn('video', 'waitTranscoding', data) + } + + // state + { + const data = { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null + } + await utils.queryInterface.addColumn('video', 'state', data) + } + + { + // Published + const query = 'UPDATE video SET "state" = 1' + await utils.sequelize.query(query) + } + + { + const data = { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: null + } + await utils.queryInterface.changeColumn('video', 'state', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { up, down } diff --git a/server/lib/activitypub/audience.ts b/server/lib/activitypub/audience.ts index c1265dbcd..7164135b6 100644 --- a/server/lib/activitypub/audience.ts +++ b/server/lib/activitypub/audience.ts @@ -20,7 +20,7 @@ function getVideoCommentAudience ( isOrigin = false ) { const to = [ ACTIVITY_PUB.PUBLIC ] - const cc = [ ] + const cc = [] // Owner of the video we comment if (isOrigin === false) { @@ -55,7 +55,7 @@ async function getActorsInvolvedInVideo (video: VideoModel, t: Transaction) { return actors } -async function getAudience (actorSender: ActorModel, t: Transaction, isPublic = true) { +function getAudience (actorSender: ActorModel, isPublic = true) { return buildAudience([ actorSender.followersUrl ], isPublic) } @@ -67,14 +67,14 @@ function buildAudience (followerUrls: string[], isPublic = true) { to = [ ACTIVITY_PUB.PUBLIC ] cc = followerUrls } else { // Unlisted - to = [ ] - cc = [ ] + to = [] + cc = [] } return { to, cc } } -function audiencify (object: T, audience: ActivityAudience) { +function audiencify (object: T, audience: ActivityAudience) { return Object.assign(object, audience) } diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts index 7305b3969..d4fc786f7 100644 --- a/server/lib/activitypub/crawl.ts +++ b/server/lib/activitypub/crawl.ts @@ -28,7 +28,7 @@ async function crawlCollectionPage (uri: string, handler: (items: T[]) => Pr if (Array.isArray(body.orderedItems)) { const items = body.orderedItems - logger.info('Processing %i ActivityPub items for %s.', items.length, nextLink) + logger.info('Processing %i ActivityPub items for %s.', items.length, options.uri) await handler(items) } diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts index 2750f48c3..77de8c155 100644 --- a/server/lib/activitypub/process/process-update.ts +++ b/server/lib/activitypub/process/process-update.ts @@ -1,7 +1,6 @@ import * as Bluebird from 'bluebird' import { ActivityUpdate } from '../../../../shared/models/activitypub' import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor' -import { VideoTorrentObject } from '../../../../shared/models/activitypub/objects' import { retryTransactionWrapper } from '../../../helpers/database-utils' import { logger } from '../../../helpers/logger' import { resetSequelizeInstance } from '../../../helpers/utils' @@ -13,6 +12,7 @@ import { VideoChannelModel } from '../../../models/video/video-channel' import { VideoFileModel } from '../../../models/video/video-file' import { fetchAvatarIfExists, getOrCreateActorAndServerAndModel, updateActorAvatarInstance, updateActorInstance } from '../actor' import { + fetchRemoteVideo, generateThumbnailFromUrl, getOrCreateAccountAndVideoAndChannel, getOrCreateVideoChannel, @@ -51,15 +51,18 @@ function processUpdateVideo (actor: ActorModel, activity: ActivityUpdate) { } async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { - const videoAttributesToUpdate = activity.object as VideoTorrentObject + const videoUrl = activity.object.id - const res = await getOrCreateAccountAndVideoAndChannel(videoAttributesToUpdate.id) + const videoObject = await fetchRemoteVideo(videoUrl) + if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl) + + const res = await getOrCreateAccountAndVideoAndChannel(videoObject.id) // Fetch video channel outside the transaction - const newVideoChannelActor = await getOrCreateVideoChannel(videoAttributesToUpdate) + const newVideoChannelActor = await getOrCreateVideoChannel(videoObject) const newVideoChannel = newVideoChannelActor.VideoChannel - logger.debug('Updating remote video "%s".', videoAttributesToUpdate.uuid) + logger.debug('Updating remote video "%s".', videoObject.uuid) let videoInstance = res.video let videoFieldsSave: any @@ -77,7 +80,7 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { throw new Error('Account ' + actor.url + ' does not own video channel ' + videoChannel.Actor.url) } - const videoData = await videoActivityObjectToDBAttributes(newVideoChannel, videoAttributesToUpdate, activity.to) + const videoData = await videoActivityObjectToDBAttributes(newVideoChannel, videoObject, activity.to) videoInstance.set('name', videoData.name) videoInstance.set('uuid', videoData.uuid) videoInstance.set('url', videoData.url) @@ -88,6 +91,8 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { videoInstance.set('support', videoData.support) videoInstance.set('nsfw', videoData.nsfw) videoInstance.set('commentsEnabled', videoData.commentsEnabled) + videoInstance.set('waitTranscoding', videoData.waitTranscoding) + videoInstance.set('state', videoData.state) videoInstance.set('duration', videoData.duration) videoInstance.set('createdAt', videoData.createdAt) videoInstance.set('updatedAt', videoData.updatedAt) @@ -98,8 +103,8 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { await videoInstance.save(sequelizeOptions) // Don't block on request - generateThumbnailFromUrl(videoInstance, videoAttributesToUpdate.icon) - .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoAttributesToUpdate.id, { err })) + generateThumbnailFromUrl(videoInstance, videoObject.icon) + .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })) // Remove old video files const videoFileDestroyTasks: Bluebird[] = [] @@ -108,16 +113,16 @@ async function updateRemoteVideo (actor: ActorModel, activity: ActivityUpdate) { } await Promise.all(videoFileDestroyTasks) - const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoInstance, videoAttributesToUpdate) + const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoInstance, videoObject) const tasks = videoFileAttributes.map(f => VideoFileModel.create(f)) await Promise.all(tasks) - const tags = videoAttributesToUpdate.tag.map(t => t.name) + const tags = videoObject.tag.map(t => t.name) const tagInstances = await TagModel.findOrCreateTags(tags, t) await videoInstance.$set('Tags', tagInstances, sequelizeOptions) }) - logger.info('Remote video with uuid %s updated', videoAttributesToUpdate.uuid) + logger.info('Remote video with uuid %s updated', videoObject.uuid) } catch (err) { if (videoInstance !== undefined && videoFieldsSave !== undefined) { resetSequelizeInstance(videoInstance, videoFieldsSave) diff --git a/server/lib/activitypub/send/send-announce.ts b/server/lib/activitypub/send/send-announce.ts index fa1d47259..dfc099ff2 100644 --- a/server/lib/activitypub/send/send-announce.ts +++ b/server/lib/activitypub/send/send-announce.ts @@ -11,7 +11,7 @@ async function buildVideoAnnounce (byActor: ActorModel, videoShare: VideoShareMo const accountsToForwardView = await getActorsInvolvedInVideo(video, t) const audience = getObjectFollowersAudience(accountsToForwardView) - return announceActivityData(videoShare.url, byActor, announcedObject, t, audience) + return announceActivityData(videoShare.url, byActor, announcedObject, audience) } async function sendVideoAnnounce (byActor: ActorModel, videoShare: VideoShareModel, video: VideoModel, t: Transaction) { @@ -20,16 +20,8 @@ async function sendVideoAnnounce (byActor: ActorModel, videoShare: VideoShareMod return broadcastToFollowers(data, byActor, [ byActor ], t) } -async function announceActivityData ( - url: string, - byActor: ActorModel, - object: string, - t: Transaction, - audience?: ActivityAudience -): Promise { - if (!audience) { - audience = await getAudience(byActor, t) - } +function announceActivityData (url: string, byActor: ActorModel, object: string, audience?: ActivityAudience): ActivityAnnounce { + if (!audience) audience = getAudience(byActor) return { type: 'Announce', diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index 3ef4fcd3b..293947b05 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -23,8 +23,8 @@ async function sendCreateVideo (video: VideoModel, t: Transaction) { const byActor = video.VideoChannel.Account.Actor const videoObject = video.toActivityPubObject() - const audience = await getAudience(byActor, t, video.privacy === VideoPrivacy.PUBLIC) - const data = await createActivityData(video.url, byActor, videoObject, t, audience) + const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC) + const data = createActivityData(video.url, byActor, videoObject, audience) return broadcastToFollowers(data, byActor, [ byActor ], t) } @@ -33,7 +33,7 @@ async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, const url = getVideoAbuseActivityPubUrl(videoAbuse) const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] } - const data = await createActivityData(url, byActor, videoAbuse.toActivityPubObject(), t, audience) + const data = createActivityData(url, byActor, videoAbuse.toActivityPubObject(), audience) return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl) } @@ -57,7 +57,7 @@ async function sendCreateVideoComment (comment: VideoCommentModel, t: Transactio audience = getObjectFollowersAudience(actorsInvolvedInComment.concat(parentsCommentActors)) } - const data = await createActivityData(comment.url, byActor, commentObject, t, audience) + const data = createActivityData(comment.url, byActor, commentObject, audience) // This was a reply, send it to the parent actors const actorsException = [ byActor ] @@ -82,14 +82,14 @@ async function sendCreateView (byActor: ActorModel, video: VideoModel, t: Transa // Send to origin if (video.isOwned() === false) { const audience = getVideoAudience(video, actorsInvolvedInVideo) - const data = await createActivityData(url, byActor, viewActivityData, t, audience) + const data = createActivityData(url, byActor, viewActivityData, audience) return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl) } // Send to followers const audience = getObjectFollowersAudience(actorsInvolvedInVideo) - const data = await createActivityData(url, byActor, viewActivityData, t, audience) + const data = createActivityData(url, byActor, viewActivityData, audience) // Use the server actor to send the view const serverActor = await getServerActor() @@ -106,34 +106,31 @@ async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Tra // Send to origin if (video.isOwned() === false) { const audience = getVideoAudience(video, actorsInvolvedInVideo) - const data = await createActivityData(url, byActor, dislikeActivityData, t, audience) + const data = createActivityData(url, byActor, dislikeActivityData, audience) return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl) } // Send to followers const audience = getObjectFollowersAudience(actorsInvolvedInVideo) - const data = await createActivityData(url, byActor, dislikeActivityData, t, audience) + const data = createActivityData(url, byActor, dislikeActivityData, audience) const actorsException = [ byActor ] return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, actorsException) } -async function createActivityData (url: string, - byActor: ActorModel, - object: any, - t: Transaction, - audience?: ActivityAudience): Promise { - if (!audience) { - audience = await getAudience(byActor, t) - } - - return audiencify({ - type: 'Create' as 'Create', - id: url + '/activity', - actor: byActor.url, - object: audiencify(object, audience) - }, audience) +function createActivityData (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityCreate { + if (!audience) audience = getAudience(byActor) + + return audiencify( + { + type: 'Create' as 'Create', + id: url + '/activity', + actor: byActor.url, + object: audiencify(object, audience) + }, + audience + ) } function createDislikeActivityData (byActor: ActorModel, video: VideoModel) { diff --git a/server/lib/activitypub/send/send-like.ts b/server/lib/activitypub/send/send-like.ts index ddeb1fcd2..37ee7c096 100644 --- a/server/lib/activitypub/send/send-like.ts +++ b/server/lib/activitypub/send/send-like.ts @@ -14,36 +14,31 @@ async function sendLike (byActor: ActorModel, video: VideoModel, t: Transaction) // Send to origin if (video.isOwned() === false) { const audience = getVideoAudience(video, accountsInvolvedInVideo) - const data = await likeActivityData(url, byActor, video, t, audience) + const data = likeActivityData(url, byActor, video, audience) return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl) } // Send to followers const audience = getObjectFollowersAudience(accountsInvolvedInVideo) - const data = await likeActivityData(url, byActor, video, t, audience) + const data = likeActivityData(url, byActor, video, audience) const followersException = [ byActor ] return broadcastToFollowers(data, byActor, accountsInvolvedInVideo, t, followersException) } -async function likeActivityData ( - url: string, - byActor: ActorModel, - video: VideoModel, - t: Transaction, - audience?: ActivityAudience -): Promise { - if (!audience) { - audience = await getAudience(byActor, t) - } - - return audiencify({ - type: 'Like' as 'Like', - id: url, - actor: byActor.url, - object: video.url - }, audience) +function likeActivityData (url: string, byActor: ActorModel, video: VideoModel, audience?: ActivityAudience): ActivityLike { + if (!audience) audience = getAudience(byActor) + + return audiencify( + { + type: 'Like' as 'Like', + id: url, + actor: byActor.url, + object: video.url + }, + audience + ) } // --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts index 9733e66dc..33c3d2429 100644 --- a/server/lib/activitypub/send/send-undo.ts +++ b/server/lib/activitypub/send/send-undo.ts @@ -27,7 +27,7 @@ async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) { const undoUrl = getUndoActivityPubUrl(followUrl) const object = followActivityData(followUrl, me, following) - const data = await undoActivityData(undoUrl, me, object, t) + const data = undoActivityData(undoUrl, me, object) return unicastTo(data, me, following.inboxUrl) } @@ -37,18 +37,18 @@ async function sendUndoLike (byActor: ActorModel, video: VideoModel, t: Transact const undoUrl = getUndoActivityPubUrl(likeUrl) const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t) - const object = await likeActivityData(likeUrl, byActor, video, t) + const object = likeActivityData(likeUrl, byActor, video) // Send to origin if (video.isOwned() === false) { const audience = getVideoAudience(video, actorsInvolvedInVideo) - const data = await undoActivityData(undoUrl, byActor, object, t, audience) + const data = undoActivityData(undoUrl, byActor, object, audience) return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl) } const audience = getObjectFollowersAudience(actorsInvolvedInVideo) - const data = await undoActivityData(undoUrl, byActor, object, t, audience) + const data = undoActivityData(undoUrl, byActor, object, audience) const followersException = [ byActor ] return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException) @@ -60,16 +60,16 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t) const dislikeActivity = createDislikeActivityData(byActor, video) - const object = await createActivityData(dislikeUrl, byActor, dislikeActivity, t) + const object = createActivityData(dislikeUrl, byActor, dislikeActivity) if (video.isOwned() === false) { const audience = getVideoAudience(video, actorsInvolvedInVideo) - const data = await undoActivityData(undoUrl, byActor, object, t, audience) + const data = undoActivityData(undoUrl, byActor, object, audience) return unicastTo(data, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl) } - const data = await undoActivityData(undoUrl, byActor, object, t) + const data = undoActivityData(undoUrl, byActor, object) const followersException = [ byActor ] return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException) @@ -80,7 +80,7 @@ async function sendUndoAnnounce (byActor: ActorModel, videoShare: VideoShareMode const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t) const object = await buildVideoAnnounce(byActor, videoShare, video, t) - const data = await undoActivityData(undoUrl, byActor, object, t) + const data = undoActivityData(undoUrl, byActor, object) const followersException = [ byActor ] return broadcastToFollowers(data, byActor, actorsInvolvedInVideo, t, followersException) @@ -97,21 +97,21 @@ export { // --------------------------------------------------------------------------- -async function undoActivityData ( +function undoActivityData ( url: string, byActor: ActorModel, object: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce, - t: Transaction, audience?: ActivityAudience -): Promise { - if (!audience) { - audience = await getAudience(byActor, t) - } - - return audiencify({ - type: 'Undo' as 'Undo', - id: url, - actor: byActor.url, - object - }, audience) +): ActivityUndo { + if (!audience) audience = getAudience(byActor) + + return audiencify( + { + type: 'Undo' as 'Undo', + id: url, + actor: byActor.url, + object + }, + audience + ) } diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts index d64b88343..2fd374ec6 100644 --- a/server/lib/activitypub/send/send-update.ts +++ b/server/lib/activitypub/send/send-update.ts @@ -15,9 +15,9 @@ async function sendUpdateVideo (video: VideoModel, t: Transaction) { const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString()) const videoObject = video.toActivityPubObject() - const audience = await getAudience(byActor, t, video.privacy === VideoPrivacy.PUBLIC) + const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC) - const data = await updateActivityData(url, byActor, videoObject, t, audience) + const data = updateActivityData(url, byActor, videoObject, audience) const actorsInvolved = await VideoShareModel.loadActorsByShare(video.id, t) actorsInvolved.push(byActor) @@ -30,8 +30,8 @@ async function sendUpdateActor (accountOrChannel: AccountModel | VideoChannelMod const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString()) const accountOrChannelObject = accountOrChannel.toActivityPubObject() - const audience = await getAudience(byActor, t) - const data = await updateActivityData(url, byActor, accountOrChannelObject, t, audience) + const audience = getAudience(byActor) + const data = updateActivityData(url, byActor, accountOrChannelObject, audience) let actorsInvolved: ActorModel[] if (accountOrChannel instanceof AccountModel) { @@ -56,21 +56,17 @@ export { // --------------------------------------------------------------------------- -async function updateActivityData ( - url: string, - byActor: ActorModel, - object: any, - t: Transaction, - audience?: ActivityAudience -): Promise { - if (!audience) { - audience = await getAudience(byActor, t) - } +function updateActivityData (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityUpdate { + if (!audience) audience = getAudience(byActor) - return audiencify({ - type: 'Update' as 'Update', - id: url, - actor: byActor.url, - object: audiencify(object, audience) - }, audience) + return audiencify( + { + type: 'Update' as 'Update', + id: url, + actor: byActor.url, + object: audiencify(object, audience + ) + }, + audience + ) } diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 907f7e11e..7ec8ca193 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -1,8 +1,9 @@ import * as Bluebird from 'bluebird' +import * as sequelize from 'sequelize' import * as magnetUtil from 'magnet-uri' import { join } from 'path' import * as request from 'request' -import { ActivityIconObject } from '../../../shared/index' +import { ActivityIconObject, VideoState } from '../../../shared/index' import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos' import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos' @@ -21,6 +22,21 @@ import { VideoShareModel } from '../../models/video/video-share' import { getOrCreateActorAndServerAndModel } from './actor' import { addVideoComments } from './video-comments' import { crawlCollectionPage } from './crawl' +import { sendCreateVideo, sendUpdateVideo } from './send' +import { shareVideoByServerAndChannel } from './index' + +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) { + if (isNewVideo === true) { + // Now we'll add the video's meta data to our followers + await sendCreateVideo(video, transaction) + await shareVideoByServerAndChannel(video, transaction) + } else { + await sendUpdateVideo(video, transaction) + } + } +} function fetchRemoteVideoPreview (video: VideoModel, reject: Function) { const host = video.VideoChannel.Account.Actor.Server.host @@ -55,9 +71,11 @@ function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) return doRequestAndSaveToFile(options, thumbnailPath) } -async function videoActivityObjectToDBAttributes (videoChannel: VideoChannelModel, - videoObject: VideoTorrentObject, - to: string[] = []) { +async function videoActivityObjectToDBAttributes ( + videoChannel: VideoChannelModel, + videoObject: VideoTorrentObject, + to: string[] = [] +) { const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED const duration = videoObject.duration.replace(/[^\d]+/, '') @@ -90,6 +108,8 @@ async function videoActivityObjectToDBAttributes (videoChannel: VideoChannelMode support, nsfw: videoObject.sensitive, commentsEnabled: videoObject.commentsEnabled, + waitTranscoding: videoObject.waitTranscoding, + state: videoObject.state, channelId: videoChannel.id, duration: parseInt(duration, 10), createdAt: new Date(videoObject.published), @@ -185,22 +205,20 @@ async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor: } async function getOrCreateAccountAndVideoAndChannel (videoObject: VideoTorrentObject | string, actor?: ActorModel) { - if (typeof videoObject === 'string') { - const videoUrl = videoObject - - const videoFromDatabase = await VideoModel.loadByUrlAndPopulateAccount(videoUrl) - if (videoFromDatabase) { - return { - video: videoFromDatabase, - actor: videoFromDatabase.VideoChannel.Account.Actor, - channelActor: videoFromDatabase.VideoChannel.Actor - } + const videoUrl = typeof videoObject === 'string' ? videoObject : videoObject.id + + const videoFromDatabase = await VideoModel.loadByUrlAndPopulateAccount(videoUrl) + if (videoFromDatabase) { + return { + video: videoFromDatabase, + actor: videoFromDatabase.VideoChannel.Account.Actor, + channelActor: videoFromDatabase.VideoChannel.Actor } - - videoObject = await fetchRemoteVideo(videoUrl) - if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl) } + videoObject = await fetchRemoteVideo(videoUrl) + if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl) + if (!actor) { const actorObj = videoObject.attributedTo.find(a => a.type === 'Person') if (!actorObj) throw new Error('Cannot find associated actor to video ' + videoObject.url) @@ -291,20 +309,6 @@ async function addVideoShares (shareUrls: string[], instance: VideoModel) { } } -export { - getOrCreateAccountAndVideoAndChannel, - fetchRemoteVideoPreview, - fetchRemoteVideoDescription, - generateThumbnailFromUrl, - videoActivityObjectToDBAttributes, - videoFileActivityUrlToDBAttributes, - getOrCreateVideo, - getOrCreateVideoChannel, - addVideoShares -} - -// --------------------------------------------------------------------------- - async function fetchRemoteVideo (videoUrl: string): Promise { const options = { uri: videoUrl, @@ -324,3 +328,17 @@ async function fetchRemoteVideo (videoUrl: string): Promise return body } + +export { + federateVideoIfNeeded, + fetchRemoteVideo, + getOrCreateAccountAndVideoAndChannel, + fetchRemoteVideoPreview, + fetchRemoteVideoDescription, + generateThumbnailFromUrl, + videoActivityObjectToDBAttributes, + videoFileActivityUrlToDBAttributes, + getOrCreateVideo, + getOrCreateVideoChannel, + addVideoShares +} diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts index 85f7dbfc2..f5ad076a6 100644 --- a/server/lib/job-queue/handlers/video-file.ts +++ b/server/lib/job-queue/handlers/video-file.ts @@ -1,17 +1,16 @@ import * as kue from 'kue' -import { VideoResolution } from '../../../../shared' -import { VideoPrivacy } from '../../../../shared/models/videos' +import { VideoResolution, VideoState } from '../../../../shared' import { logger } from '../../../helpers/logger' import { computeResolutionsToTranscode } from '../../../helpers/utils' -import { sequelizeTypescript } from '../../../initializers' import { VideoModel } from '../../../models/video/video' -import { shareVideoByServerAndChannel } from '../../activitypub' -import { sendCreateVideo, sendUpdateVideo } from '../../activitypub/send' import { JobQueue } from '../job-queue' +import { federateVideoIfNeeded } from '../../activitypub' +import { retryTransactionWrapper } from '../../../helpers/database-utils' +import { sequelizeTypescript } from '../../../initializers' export type VideoFilePayload = { videoUUID: string - isNewVideo: boolean + isNewVideo?: boolean resolution?: VideoResolution isPortraitMode?: boolean } @@ -52,10 +51,20 @@ async function processVideoFile (job: kue.Job) { // Transcoding in other resolution if (payload.resolution) { await video.transcodeOriginalVideofile(payload.resolution, payload.isPortraitMode) - await onVideoFileTranscoderOrImportSuccess(video) + + const options = { + arguments: [ video ], + errorMessage: 'Cannot execute onVideoFileTranscoderOrImportSuccess with many retries.' + } + await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, options) } else { await video.optimizeOriginalVideofile() - await onVideoFileOptimizerSuccess(video, payload.isNewVideo) + + const options = { + arguments: [ video, payload.isNewVideo ], + errorMessage: 'Cannot execute onVideoFileOptimizerSuccess with many retries.' + } + await retryTransactionWrapper(onVideoFileOptimizerSuccess, options) } return video @@ -64,68 +73,70 @@ async function processVideoFile (job: kue.Job) { async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) { if (video === undefined) return undefined - // Maybe the video changed in database, refresh it - const videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid) - // Video does not exist anymore - if (!videoDatabase) return undefined + return sequelizeTypescript.transaction(async t => { + // Maybe the video changed in database, refresh it + let videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t) + // Video does not exist anymore + if (!videoDatabase) return undefined - if (video.privacy !== VideoPrivacy.PRIVATE) { - await sendUpdateVideo(video, undefined) - } + // We transcoded the video file in another format, now we can publish it + const oldState = videoDatabase.state + videoDatabase.state = VideoState.PUBLISHED + videoDatabase = await videoDatabase.save({ transaction: t }) + + // If the video was not published, we consider it is a new one for other instances + const isNewVideo = oldState !== VideoState.PUBLISHED + await federateVideoIfNeeded(videoDatabase, isNewVideo, t) - return undefined + return undefined + }) } async function onVideoFileOptimizerSuccess (video: VideoModel, isNewVideo: boolean) { if (video === undefined) return undefined - // Maybe the video changed in database, refresh it - const videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid) - // Video does not exist anymore - if (!videoDatabase) return undefined - - if (video.privacy !== VideoPrivacy.PRIVATE) { - if (isNewVideo !== false) { - // Now we'll add the video's meta data to our followers - await sequelizeTypescript.transaction(async t => { - await sendCreateVideo(video, t) - await shareVideoByServerAndChannel(video, t) - }) - } else { - await sendUpdateVideo(video, undefined) - } - } - - const { videoFileResolution } = await videoDatabase.getOriginalFileResolution() - - // Create transcoding jobs if there are enabled resolutions - const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution) - logger.info( - 'Resolutions computed for video %s and origin file height of %d.', videoDatabase.uuid, videoFileResolution, - { resolutions: resolutionsEnabled } - ) + // Outside the transaction (IO on disk) + const { videoFileResolution } = await video.getOriginalFileResolution() + + return sequelizeTypescript.transaction(async t => { + // Maybe the video changed in database, refresh it + const videoDatabase = await VideoModel.loadByUUIDAndPopulateAccountAndServerAndTags(video.uuid, t) + // Video does not exist anymore + if (!videoDatabase) return undefined + + // Create transcoding jobs if there are enabled resolutions + const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution) + logger.info( + 'Resolutions computed for video %s and origin file height of %d.', videoDatabase.uuid, videoFileResolution, + { resolutions: resolutionsEnabled } + ) + + if (resolutionsEnabled.length !== 0) { + const tasks: Promise[] = [] + + for (const resolution of resolutionsEnabled) { + const dataInput = { + videoUUID: videoDatabase.uuid, + resolution + } + + const p = JobQueue.Instance.createJob({ type: 'video-file', payload: dataInput }) + tasks.push(p) + } - if (resolutionsEnabled.length !== 0) { - const tasks: Promise[] = [] + await Promise.all(tasks) - for (const resolution of resolutionsEnabled) { - const dataInput = { - videoUUID: videoDatabase.uuid, - resolution, - isNewVideo - } + logger.info('Transcoding jobs created for uuid %s.', videoDatabase.uuid, { resolutionsEnabled }) + } else { + // No transcoding to do, it's now published + video.state = VideoState.PUBLISHED + video = await video.save({ transaction: t }) - const p = JobQueue.Instance.createJob({ type: 'video-file', payload: dataInput }) - tasks.push(p) + logger.info('No transcoding jobs created for video %s (no resolutions).', video.uuid) } - await Promise.all(tasks) - - logger.info('Transcoding jobs created for uuid %s.', videoDatabase.uuid, { resolutionsEnabled }) - } else { - logger.info('No transcoding jobs created for video %s (no resolutions enabled).') - return undefined - } + return federateVideoIfNeeded(video, isNewVideo, t) + }) } // --------------------------------------------------------------------------- diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index bdfa19b61..695fe0eea 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -79,6 +79,7 @@ class JobQueue { const res = await handlers[ handlerName ](job) return done(null, res) } catch (err) { + logger.error('Cannot execute job %d.', job.id, { err }) return done(err) } }) diff --git a/server/middlewares/cache.ts b/server/middlewares/cache.ts index bf6659687..1de44db70 100644 --- a/server/middlewares/cache.ts +++ b/server/middlewares/cache.ts @@ -14,7 +14,7 @@ function cacheRoute (lifetime: number) { // Not cached if (!cached) { - logger.debug('Not cached result for route %s.', req.originalUrl) + logger.debug('No cached results for route %s.', req.originalUrl) const sendSave = res.send.bind(res) diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts index c5c45fe58..e181aebdb 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos.ts @@ -55,8 +55,13 @@ const videosAddValidator = [ .customSanitizer(toValueOrNull) .custom(isVideoLanguageValid).withMessage('Should have a valid language'), body('nsfw') + .optional() .toBoolean() .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'), + body('waitTranscoding') + .optional() + .toBoolean() + .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'), body('description') .optional() .customSanitizer(toValueOrNull) @@ -70,6 +75,7 @@ const videosAddValidator = [ .customSanitizer(toValueOrNull) .custom(isVideoTagsValid).withMessage('Should have correct tags'), body('commentsEnabled') + .optional() .toBoolean() .custom(isBooleanValid).withMessage('Should have comments enabled boolean'), body('privacy') @@ -149,6 +155,10 @@ const videosUpdateValidator = [ .optional() .toBoolean() .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'), + body('waitTranscoding') + .optional() + .toBoolean() + .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'), body('privacy') .optional() .toInt() diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 1cb1e6798..59c378efa 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -25,7 +25,7 @@ import { Table, UpdatedAt } from 'sequelize-typescript' -import { VideoPrivacy, VideoResolution } from '../../../shared' +import { VideoPrivacy, VideoResolution, VideoState } from '../../../shared' import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' import { VideoFilter } from '../../../shared/models/videos/video-query.type' @@ -47,7 +47,7 @@ import { isVideoLanguageValid, isVideoLicenceValid, isVideoNameValid, - isVideoPrivacyValid, + isVideoPrivacyValid, isVideoStateValid, isVideoSupportValid } from '../../helpers/custom-validators/videos' import { generateImageFromVideoFile, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' @@ -66,7 +66,7 @@ import { VIDEO_EXT_MIMETYPE, VIDEO_LANGUAGES, VIDEO_LICENCES, - VIDEO_PRIVACIES + VIDEO_PRIVACIES, VIDEO_STATES } from '../../initializers' import { getVideoCommentsActivityPubUrl, @@ -93,10 +93,7 @@ enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', WITH_TAGS = 'WITH_TAGS', - WITH_FILES = 'WITH_FILES', - WITH_SHARES = 'WITH_SHARES', - WITH_RATES = 'WITH_RATES', - WITH_COMMENTS = 'WITH_COMMENTS' + WITH_FILES = 'WITH_FILES' } @Scopes({ @@ -183,7 +180,20 @@ enum ScopeNames { ')' ) }, - privacy: VideoPrivacy.PUBLIC + // Always list public videos + privacy: VideoPrivacy.PUBLIC, + // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding + [ Sequelize.Op.or ]: [ + { + state: VideoState.PUBLISHED + }, + { + [ Sequelize.Op.and ]: { + state: VideoState.TO_TRANSCODE, + waitTranscoding: false + } + } + ] }, include: [ videoChannelInclude ] } @@ -272,42 +282,6 @@ enum ScopeNames { required: true } ] - }, - [ScopeNames.WITH_SHARES]: { - include: [ - { - ['separate' as any]: true, - model: () => VideoShareModel.unscoped() - } - ] - }, - [ScopeNames.WITH_RATES]: { - include: [ - { - ['separate' as any]: true, - model: () => AccountVideoRateModel, - include: [ - { - model: () => AccountModel.unscoped(), - required: true, - include: [ - { - attributes: [ 'url' ], - model: () => ActorModel.unscoped() - } - ] - } - ] - } - ] - }, - [ScopeNames.WITH_COMMENTS]: { - include: [ - { - ['separate' as any]: true, - model: () => VideoCommentModel.unscoped() - } - ] } }) @Table({ @@ -335,7 +309,7 @@ enum ScopeNames { fields: [ 'channelId' ] }, { - fields: [ 'id', 'privacy' ] + fields: [ 'id', 'privacy', 'state', 'waitTranscoding' ] }, { fields: [ 'url'], @@ -435,6 +409,16 @@ export class VideoModel extends Model { @Column commentsEnabled: boolean + @AllowNull(false) + @Column + waitTranscoding: boolean + + @AllowNull(false) + @Default(null) + @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state')) + @Column + state: VideoState + @CreatedAt createdAt: Date @@ -671,7 +655,7 @@ export class VideoModel extends Model { }) } - static listAccountVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) { + static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, hideNSFW: boolean, withFiles = false) { const query: IFindOptions = { offset: start, limit: count, @@ -858,12 +842,13 @@ export class VideoModel extends Model { .findOne(options) } - static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string) { + static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string, t?: Sequelize.Transaction) { const options = { order: [ [ 'Tags', 'name', 'ASC' ] ], where: { uuid - } + }, + transaction: t } return VideoModel @@ -905,31 +890,23 @@ export class VideoModel extends Model { } private static getCategoryLabel (id: number) { - let categoryLabel = VIDEO_CATEGORIES[id] - if (!categoryLabel) categoryLabel = 'Misc' - - return categoryLabel + return VIDEO_CATEGORIES[id] || 'Misc' } private static getLicenceLabel (id: number) { - let licenceLabel = VIDEO_LICENCES[id] - if (!licenceLabel) licenceLabel = 'Unknown' - - return licenceLabel + return VIDEO_LICENCES[id] || 'Unknown' } private static getLanguageLabel (id: string) { - let languageLabel = VIDEO_LANGUAGES[id] - if (!languageLabel) languageLabel = 'Unknown' - - return languageLabel + return VIDEO_LANGUAGES[id] || 'Unknown' } private static getPrivacyLabel (id: number) { - let privacyLabel = VIDEO_PRIVACIES[id] - if (!privacyLabel) privacyLabel = 'Unknown' + return VIDEO_PRIVACIES[id] || 'Unknown' + } - return privacyLabel + private static getStateLabel (id: number) { + return VIDEO_STATES[id] || 'Unknown' } getOriginalFile () { @@ -1026,11 +1003,16 @@ export class VideoModel extends Model { return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) } - toFormattedJSON (): Video { + toFormattedJSON (options?: { + additionalAttributes: { + state: boolean, + waitTranscoding: boolean + } + }): Video { const formattedAccount = this.VideoChannel.Account.toFormattedJSON() const formattedVideoChannel = this.VideoChannel.toFormattedJSON() - return { + const videoObject: Video = { id: this.id, uuid: this.uuid, name: this.name, @@ -1082,6 +1064,19 @@ export class VideoModel extends Model { avatar: formattedVideoChannel.avatar } } + + if (options) { + if (options.additionalAttributes.state) { + videoObject.state = { + id: this.state, + label: VideoModel.getStateLabel(this.state) + } + } + + if (options.additionalAttributes.waitTranscoding) videoObject.waitTranscoding = this.waitTranscoding + } + + return videoObject } toFormattedDetailsJSON (): VideoDetails { @@ -1094,6 +1089,11 @@ export class VideoModel extends Model { account: this.VideoChannel.Account.toFormattedJSON(), tags: map(this.Tags, 'name'), commentsEnabled: this.commentsEnabled, + waitTranscoding: this.waitTranscoding, + state: { + id: this.state, + label: VideoModel.getStateLabel(this.state) + }, files: [] } @@ -1207,6 +1207,8 @@ export class VideoModel extends Model { language, views: this.views, sensitive: this.nsfw, + waitTranscoding: this.waitTranscoding, + state: this.state, commentsEnabled: this.commentsEnabled, published: this.publishedAt.toISOString(), updated: this.updatedAt.toISOString(), diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts index bc6c7fc46..04bed3b44 100644 --- a/server/tests/api/check-params/videos.ts +++ b/server/tests/api/check-params/videos.ts @@ -175,6 +175,7 @@ describe('Test videos API validator', function () { language: 'pt', nsfw: false, commentsEnabled: true, + waitTranscoding: true, description: 'my super description', support: 'my super support text', tags: [ 'tag1', 'tag2' ], @@ -224,20 +225,6 @@ describe('Test videos API validator', function () { await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) }) - it('Should fail without nsfw attribute', async function () { - const fields = omit(baseCorrectParams, 'nsfw') - const attaches = baseCorrectAttaches - - await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) - }) - - it('Should fail without commentsEnabled attribute', async function () { - const fields = omit(baseCorrectParams, 'commentsEnabled') - const attaches = baseCorrectAttaches - - await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches }) - }) - it('Should fail with a long description', async function () { const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(2500) }) const attaches = baseCorrectAttaches diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts index 5f9a76621..edc46a644 100644 --- a/server/tests/api/videos/multiple-servers.ts +++ b/server/tests/api/videos/multiple-servers.ts @@ -924,7 +924,7 @@ describe('Test multiple servers', function () { describe('With minimum parameters', function () { it('Should upload and propagate the video', async function () { - this.timeout(50000) + this.timeout(60000) const path = '/api/v1/videos/upload' @@ -934,16 +934,14 @@ describe('Test multiple servers', function () { .set('Authorization', 'Bearer ' + servers[1].accessToken) .field('name', 'minimum parameters') .field('privacy', '1') - .field('nsfw', 'false') .field('channelId', '1') - .field('commentsEnabled', 'true') const filePath = join(__dirname, '..', '..', 'fixtures', 'video_short.webm') await req.attach('videofile', filePath) .expect(200) - await wait(25000) + await wait(40000) for (const server of servers) { const res = await getVideosList(server.url) @@ -964,7 +962,7 @@ describe('Test multiple servers', function () { }, isLocal, duration: 5, - commentsEnabled: true, + commentsEnabled: false, tags: [ ], privacy: VideoPrivacy.PUBLIC, channel: { diff --git a/server/tests/api/videos/services.ts b/server/tests/api/videos/services.ts index 45b4a1a81..51db000a2 100644 --- a/server/tests/api/videos/services.ts +++ b/server/tests/api/videos/services.ts @@ -32,7 +32,8 @@ describe('Test services', function () { const oembedUrl = 'http://localhost:9001/videos/watch/' + server.video.uuid const res = await getOEmbed(server.url, oembedUrl) - const expectedHtml = `' const expectedThumbnailUrl = 'http://localhost:9001/static/previews/' + server.video.uuid + '.jpg' diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts index ef929960d..1eace6491 100644 --- a/server/tests/api/videos/video-transcoder.ts +++ b/server/tests/api/videos/video-transcoder.ts @@ -2,11 +2,22 @@ import * as chai from 'chai' import 'mocha' -import { VideoDetails } from '../../../../shared/models/videos' +import { VideoDetails, VideoState } from '../../../../shared/models/videos' import { getVideoFileFPS } from '../../../helpers/ffmpeg-utils' import { - flushAndRunMultipleServers, flushTests, getVideo, getVideosList, killallServers, root, ServerInfo, setAccessTokensToServers, uploadVideo, - wait, webtorrentAdd + doubleFollow, + flushAndRunMultipleServers, + flushTests, + getMyVideos, + getVideo, + getVideosList, + killallServers, + root, + ServerInfo, + setAccessTokensToServers, + uploadVideo, + wait, + webtorrentAdd } from '../../utils' import { join } from 'path' @@ -109,6 +120,63 @@ describe('Test video transcoding', function () { } }) + it('Should wait transcoding before publishing the video', async function () { + this.timeout(80000) + + await doubleFollow(servers[0], servers[1]) + + await wait(15000) + + { + // Upload the video, but wait transcoding + const videoAttributes = { + name: 'waiting video', + fixture: 'video_short1.webm', + waitTranscoding: true + } + const resVideo = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributes) + const videoId = resVideo.body.video.uuid + + // Should be in transcode state + const { body } = await getVideo(servers[ 1 ].url, videoId) + expect(body.name).to.equal('waiting video') + expect(body.state.id).to.equal(VideoState.TO_TRANSCODE) + expect(body.state.label).to.equal('To transcode') + expect(body.waitTranscoding).to.be.true + + // Should have my video + const resMyVideos = await getMyVideos(servers[1].url, servers[1].accessToken, 0, 10) + const videoToFindInMine = resMyVideos.body.data.find(v => v.name === 'waiting video') + expect(videoToFindInMine).not.to.be.undefined + expect(videoToFindInMine.state.id).to.equal(VideoState.TO_TRANSCODE) + expect(videoToFindInMine.state.label).to.equal('To transcode') + expect(videoToFindInMine.waitTranscoding).to.be.true + + // Should not list this video + const resVideos = await getVideosList(servers[1].url) + const videoToFindInList = resVideos.body.data.find(v => v.name === 'waiting video') + expect(videoToFindInList).to.be.undefined + + // Server 1 should not have the video yet + await getVideo(servers[0].url, videoId, 404) + } + + await wait(30000) + + for (const server of servers) { + const res = await getVideosList(server.url) + const videoToFind = res.body.data.find(v => v.name === 'waiting video') + expect(videoToFind).not.to.be.undefined + + const res2 = await getVideo(server.url, videoToFind.id) + const videoDetails: VideoDetails = res2.body + + expect(videoDetails.state.id).to.equal(VideoState.PUBLISHED) + expect(videoDetails.state.label).to.equal('Published') + expect(videoDetails.waitTranscoding).to.be.true + } + }) + after(async function () { killallServers(servers) diff --git a/server/tests/cli/create-transcoding-job.ts b/server/tests/cli/create-transcoding-job.ts index 557dd8af9..fe1c0c03d 100644 --- a/server/tests/cli/create-transcoding-job.ts +++ b/server/tests/cli/create-transcoding-job.ts @@ -65,7 +65,7 @@ describe('Test create transcoding jobs', function () { const env = getEnvCli(servers[0]) await execCLI(`${env} npm run create-transcoding-job -- -v ${video2UUID}`) - await wait(30000) + await wait(40000) for (const server of servers) { const res = await getVideosList(server.url) diff --git a/server/tests/utils/videos/videos.ts b/server/tests/utils/videos/videos.ts index ab0ce12ec..2c1d20ef1 100644 --- a/server/tests/utils/videos/videos.ts +++ b/server/tests/utils/videos/videos.ts @@ -27,6 +27,7 @@ type VideoAttributes = { language?: string nsfw?: boolean commentsEnabled?: boolean + waitTranscoding?: boolean description?: string tags?: string[] channelId?: number @@ -326,6 +327,7 @@ async function uploadVideo (url: string, accessToken: string, videoAttributesArg language: 'zh', channelId: defaultChannelId, nsfw: true, + waitTranscoding: false, description: 'my super description', support: 'my super support text', tags: [ 'tag' ], @@ -341,6 +343,7 @@ async function uploadVideo (url: string, accessToken: string, videoAttributesArg .field('name', attributes.name) .field('nsfw', JSON.stringify(attributes.nsfw)) .field('commentsEnabled', JSON.stringify(attributes.commentsEnabled)) + .field('waitTranscoding', JSON.stringify(attributes.waitTranscoding)) .field('privacy', attributes.privacy.toString()) .field('channelId', attributes.channelId) diff --git a/server/tools/import-videos.ts b/server/tools/import-videos.ts index fd351ae7e..e49fbb2f5 100644 --- a/server/tools/import-videos.ts +++ b/server/tools/import-videos.ts @@ -176,6 +176,7 @@ async function uploadVideoOnPeerTube (videoInfo: any, videoPath: string, languag licence, language, nsfw: isNSFW(videoInfo), + waitTranscoding: true, commentsEnabled: true, description: videoInfo.description || undefined, support: undefined, diff --git a/server/tools/upload.ts b/server/tools/upload.ts index 177d849f3..4d40c8c1a 100644 --- a/server/tools/upload.ts +++ b/server/tools/upload.ts @@ -84,6 +84,7 @@ async function run () { fixture: program['file'], thumbnailfile: program['thumbnailPath'], previewfile: program['previewPath'], + waitTranscoding: true, privacy: program['privacy'], support: undefined } -- cgit v1.2.3