1 import * as Bluebird from 'bluebird'
2 import * as sequelize from 'sequelize'
3 import * as magnetUtil from 'magnet-uri'
4 import { join } from 'path'
5 import * as request from 'request'
6 import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
7 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
8 import { VideoPrivacy } from '../../../shared/models/videos'
9 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
10 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
11 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
12 import { logger } from '../../helpers/logger'
13 import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests'
14 import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, VIDEO_MIMETYPE_EXT } from '../../initializers'
15 import { ActorModel } from '../../models/activitypub/actor'
16 import { TagModel } from '../../models/video/tag'
17 import { VideoModel } from '../../models/video/video'
18 import { VideoChannelModel } from '../../models/video/video-channel'
19 import { VideoFileModel } from '../../models/video/video-file'
20 import { getOrCreateActorAndServerAndModel } from './actor'
21 import { addVideoComments } from './video-comments'
22 import { crawlCollectionPage } from './crawl'
23 import { sendCreateVideo, sendUpdateVideo } from './send'
24 import { isArray } from '../../helpers/custom-validators/misc'
25 import { VideoCaptionModel } from '../../models/video/video-caption'
26 import { JobQueue } from '../job-queue'
27 import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
28 import { createRates } from './video-rates'
29 import { addVideoShares, shareVideoByServerAndChannel } from './share'
30 import { AccountModel } from '../../models/account/account'
31 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
32 import { checkUrlsSameHost } from '../../helpers/activitypub'
34 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
35 // If the video is not private and published, we federate it
36 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
37 // Fetch more attributes that we will need to serialize in AP object
38 if (isArray(video.VideoCaptions) === false) {
39 video.VideoCaptions = await video.$get('VideoCaptions', {
40 attributes: [ 'language' ],
42 }) as VideoCaptionModel[]
46 // Now we'll add the video's meta data to our followers
47 await sendCreateVideo(video, transaction)
48 await shareVideoByServerAndChannel(video, transaction)
50 await sendUpdateVideo(video, transaction)
55 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
63 logger.info('Fetching remote video %s.', videoUrl)
65 const { response, body } = await doRequest(options)
67 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
68 logger.debug('Remote video JSON is not valid.', { body })
69 return { response, videoObject: undefined }
72 return { response, videoObject: body }
75 async function fetchRemoteVideoDescription (video: VideoModel) {
76 const host = video.VideoChannel.Account.Actor.Server.host
77 const path = video.getDescriptionAPIPath()
79 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
83 const { body } = await doRequest(options)
84 return body.description ? body.description : ''
87 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
88 const host = video.VideoChannel.Account.Actor.Server.host
90 // We need to provide a callback, if no we could have an uncaught exception
91 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
96 function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
97 const thumbnailName = video.getThumbnailName()
98 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
104 return doRequestAndSaveToFile(options, thumbnailPath)
107 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
108 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
109 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
111 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
112 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
115 return getOrCreateActorAndServerAndModel(channel.id, 'all')
124 refreshVideo: boolean
126 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
127 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
129 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
131 if (syncParam.likes === true) {
132 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
133 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
135 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
138 if (syncParam.dislikes === true) {
139 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
140 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
142 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
145 if (syncParam.shares === true) {
146 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
147 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
149 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
152 if (syncParam.comments === true) {
153 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
154 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
156 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
159 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
162 async function getOrCreateVideoAndAccountAndChannel (options: {
163 videoObject: VideoTorrentObject | string,
164 syncParam?: SyncParam,
165 fetchType?: VideoFetchByUrlType,
166 refreshViews?: boolean
169 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
170 const fetchType = options.fetchType || 'all'
171 const refreshViews = options.refreshViews || false
174 const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
176 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
177 if (videoFromDatabase) {
178 const refreshOptions = {
179 video: videoFromDatabase,
180 fetchedType: fetchType,
184 const p = refreshVideoIfNeeded(refreshOptions)
185 if (syncParam.refreshVideo === true) videoFromDatabase = await p
187 return { video: videoFromDatabase }
190 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
191 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
193 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
194 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
196 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
201 async function updateVideoFromAP (options: {
203 videoObject: VideoTorrentObject,
204 account: AccountModel,
205 channel: VideoChannelModel,
206 updateViews: boolean,
207 overrideTo?: string[]
209 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
210 let videoFieldsSave: any
213 await sequelizeTypescript.transaction(async t => {
214 const sequelizeOptions = {
218 videoFieldsSave = options.video.toJSON()
220 // Check actor has the right to update the video
221 const videoChannel = options.video.VideoChannel
222 if (videoChannel.Account.id !== options.account.id) {
223 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
226 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
227 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
228 options.video.set('name', videoData.name)
229 options.video.set('uuid', videoData.uuid)
230 options.video.set('url', videoData.url)
231 options.video.set('category', videoData.category)
232 options.video.set('licence', videoData.licence)
233 options.video.set('language', videoData.language)
234 options.video.set('description', videoData.description)
235 options.video.set('support', videoData.support)
236 options.video.set('nsfw', videoData.nsfw)
237 options.video.set('commentsEnabled', videoData.commentsEnabled)
238 options.video.set('waitTranscoding', videoData.waitTranscoding)
239 options.video.set('state', videoData.state)
240 options.video.set('duration', videoData.duration)
241 options.video.set('createdAt', videoData.createdAt)
242 options.video.set('publishedAt', videoData.publishedAt)
243 options.video.set('privacy', videoData.privacy)
244 options.video.set('channelId', videoData.channelId)
246 if (options.updateViews === true) options.video.set('views', videoData.views)
247 await options.video.save(sequelizeOptions)
249 // Don't block on request
250 generateThumbnailFromUrl(options.video, options.videoObject.icon)
251 .catch(err => logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err }))
254 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
255 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
257 // Remove video files that do not exist anymore
258 const destroyTasks = options.video.VideoFiles
259 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
260 .map(f => f.destroy(sequelizeOptions))
261 await Promise.all(destroyTasks)
263 // Update or add other one
264 const upsertTasks = videoFileAttributes.map(a => {
265 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
266 .then(([ file ]) => file)
269 options.video.VideoFiles = await Promise.all(upsertTasks)
274 const tags = options.videoObject.tag.map(tag => tag.name)
275 const tagInstances = await TagModel.findOrCreateTags(tags, t)
276 await options.video.$set('Tags', tagInstances, sequelizeOptions)
281 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
283 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
284 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
286 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
290 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
292 if (options.video !== undefined && videoFieldsSave !== undefined) {
293 resetSequelizeInstance(options.video, videoFieldsSave)
296 // This is just a debug because we will retry the insert
297 logger.debug('Cannot update the remote video.', { err })
304 federateVideoIfNeeded,
306 getOrCreateVideoAndAccountAndChannel,
307 fetchRemoteVideoStaticFile,
308 fetchRemoteVideoDescription,
309 generateThumbnailFromUrl,
310 getOrCreateVideoChannelFromVideoObject
313 // ---------------------------------------------------------------------------
315 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
316 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
318 const urlMediaType = url.mediaType || url.mimeType
319 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
322 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
323 logger.debug('Adding remote video %s.', videoObject.id)
325 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
326 const sequelizeOptions = { transaction: t }
328 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
329 const video = VideoModel.build(videoData)
331 const videoCreated = await video.save(sequelizeOptions)
334 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
335 if (videoFileAttributes.length === 0) {
336 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
339 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
340 await Promise.all(videoFilePromises)
343 const tags = videoObject.tag.map(t => t.name)
344 const tagInstances = await TagModel.findOrCreateTags(tags, t)
345 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
348 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
349 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
351 await Promise.all(videoCaptionsPromises)
353 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
355 videoCreated.VideoChannel = channelActor.VideoChannel
359 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
360 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
362 if (waitThumbnail === true) await p
367 async function refreshVideoIfNeeded (options: {
369 fetchedType: VideoFetchByUrlType,
370 syncParam: SyncParam,
371 refreshViews: boolean
372 }): Promise<VideoModel> {
373 if (!options.video.isOutdated()) return options.video
375 // We need more attributes if the argument video was fetched with not enough joints
376 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
379 const { response, videoObject } = await fetchRemoteVideo(video.url)
380 if (response.statusCode === 404) {
381 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
383 // Video does not exist anymore
384 await video.destroy()
388 if (videoObject === undefined) {
389 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
393 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
394 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
396 const updateOptions = {
400 channel: channelActor.VideoChannel,
401 updateViews: options.refreshViews
403 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
404 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
408 logger.warn('Cannot refresh video %s.', options.video.url, { err })
413 async function videoActivityObjectToDBAttributes (
414 videoChannel: VideoChannelModel,
415 videoObject: VideoTorrentObject,
418 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
419 const duration = videoObject.duration.replace(/[^\d]+/, '')
421 let language: string | undefined
422 if (videoObject.language) {
423 language = videoObject.language.identifier
426 let category: number | undefined
427 if (videoObject.category) {
428 category = parseInt(videoObject.category.identifier, 10)
431 let licence: number | undefined
432 if (videoObject.licence) {
433 licence = parseInt(videoObject.licence.identifier, 10)
436 const description = videoObject.content || null
437 const support = videoObject.support || null
440 name: videoObject.name,
441 uuid: videoObject.uuid,
448 nsfw: videoObject.sensitive,
449 commentsEnabled: videoObject.commentsEnabled,
450 waitTranscoding: videoObject.waitTranscoding,
451 state: videoObject.state,
452 channelId: videoChannel.id,
453 duration: parseInt(duration, 10),
454 createdAt: new Date(videoObject.published),
455 publishedAt: new Date(videoObject.published),
456 // FIXME: updatedAt does not seems to be considered by Sequelize
457 updatedAt: new Date(videoObject.updated),
458 views: videoObject.views,
466 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
467 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
469 if (fileUrls.length === 0) {
470 throw new Error('Cannot find video files for ' + video.url)
473 const attributes: VideoFileModel[] = []
474 for (const fileUrl of fileUrls) {
475 // Fetch associated magnet uri
476 const magnet = videoObject.url.find(u => {
477 const mediaType = u.mediaType || u.mimeType
478 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
481 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
483 const parsed = magnetUtil.decode(magnet.href)
484 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
485 throw new Error('Cannot parse magnet URI ' + magnet.href)
488 const mediaType = fileUrl.mediaType || fileUrl.mimeType
490 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
491 infoHash: parsed.infoHash,
492 resolution: fileUrl.height,
495 fps: fileUrl.fps || -1
497 attributes.push(attribute)