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, downloadImage } from '../../helpers/requests'
14 import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE, 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, getAPUrl } 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()
99 return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
102 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
103 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
104 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
106 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
107 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
110 return getOrCreateActorAndServerAndModel(channel.id, 'all')
119 refreshVideo?: boolean
121 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
122 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
124 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
126 if (syncParam.likes === true) {
127 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
128 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
130 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
133 if (syncParam.dislikes === true) {
134 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
135 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
137 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
140 if (syncParam.shares === true) {
141 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
142 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
144 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
147 if (syncParam.comments === true) {
148 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
149 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
151 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
154 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
157 async function getOrCreateVideoAndAccountAndChannel (options: {
158 videoObject: VideoTorrentObject | string,
159 syncParam?: SyncParam,
160 fetchType?: VideoFetchByUrlType,
161 allowRefresh?: boolean // true by default
164 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
165 const fetchType = options.fetchType || 'all'
166 const allowRefresh = options.allowRefresh !== false
169 const videoUrl = getAPUrl(options.videoObject)
171 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172 if (videoFromDatabase) {
174 if (allowRefresh === true) {
175 const refreshOptions = {
176 video: videoFromDatabase,
177 fetchedType: fetchType,
181 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
182 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
185 return { video: videoFromDatabase }
188 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
189 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
191 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
192 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
194 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
199 async function updateVideoFromAP (options: {
201 videoObject: VideoTorrentObject,
202 account: AccountModel,
203 channel: VideoChannelModel,
204 overrideTo?: string[]
206 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
207 let videoFieldsSave: any
210 await sequelizeTypescript.transaction(async t => {
211 const sequelizeOptions = {
215 videoFieldsSave = options.video.toJSON()
217 // Check actor has the right to update the video
218 const videoChannel = options.video.VideoChannel
219 if (videoChannel.Account.id !== options.account.id) {
220 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
223 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
224 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
225 options.video.set('name', videoData.name)
226 options.video.set('uuid', videoData.uuid)
227 options.video.set('url', videoData.url)
228 options.video.set('category', videoData.category)
229 options.video.set('licence', videoData.licence)
230 options.video.set('language', videoData.language)
231 options.video.set('description', videoData.description)
232 options.video.set('support', videoData.support)
233 options.video.set('nsfw', videoData.nsfw)
234 options.video.set('commentsEnabled', videoData.commentsEnabled)
235 options.video.set('waitTranscoding', videoData.waitTranscoding)
236 options.video.set('state', videoData.state)
237 options.video.set('duration', videoData.duration)
238 options.video.set('createdAt', videoData.createdAt)
239 options.video.set('publishedAt', videoData.publishedAt)
240 options.video.set('privacy', videoData.privacy)
241 options.video.set('channelId', videoData.channelId)
242 options.video.set('views', videoData.views)
244 await options.video.save(sequelizeOptions)
247 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
248 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
250 // Remove video files that do not exist anymore
251 const destroyTasks = options.video.VideoFiles
252 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
253 .map(f => f.destroy(sequelizeOptions))
254 await Promise.all(destroyTasks)
256 // Update or add other one
257 const upsertTasks = videoFileAttributes.map(a => {
258 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
259 .then(([ file ]) => file)
262 options.video.VideoFiles = await Promise.all(upsertTasks)
267 const tags = options.videoObject.tag.map(tag => tag.name)
268 const tagInstances = await TagModel.findOrCreateTags(tags, t)
269 await options.video.$set('Tags', tagInstances, sequelizeOptions)
274 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
276 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
277 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
279 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
283 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
285 if (options.video !== undefined && videoFieldsSave !== undefined) {
286 resetSequelizeInstance(options.video, videoFieldsSave)
289 // This is just a debug because we will retry the insert
290 logger.debug('Cannot update the remote video.', { err })
295 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
297 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
301 async function refreshVideoIfNeeded (options: {
303 fetchedType: VideoFetchByUrlType,
305 }): Promise<VideoModel> {
306 if (!options.video.isOutdated()) return options.video
308 // We need more attributes if the argument video was fetched with not enough joints
309 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
312 const { response, videoObject } = await fetchRemoteVideo(video.url)
313 if (response.statusCode === 404) {
314 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
316 // Video does not exist anymore
317 await video.destroy()
321 if (videoObject === undefined) {
322 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
324 await video.setAsRefreshed()
328 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
329 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
331 const updateOptions = {
335 channel: channelActor.VideoChannel
337 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
338 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
342 logger.warn('Cannot refresh video %s.', options.video.url, { err })
344 // Don't refresh in loop
345 await video.setAsRefreshed()
352 refreshVideoIfNeeded,
353 federateVideoIfNeeded,
355 getOrCreateVideoAndAccountAndChannel,
356 fetchRemoteVideoStaticFile,
357 fetchRemoteVideoDescription,
358 generateThumbnailFromUrl,
359 getOrCreateVideoChannelFromVideoObject
362 // ---------------------------------------------------------------------------
364 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
365 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
367 const urlMediaType = url.mediaType || url.mimeType
368 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
371 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
372 logger.debug('Adding remote video %s.', videoObject.id)
374 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
375 const sequelizeOptions = { transaction: t }
377 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
378 const video = VideoModel.build(videoData)
380 const videoCreated = await video.save(sequelizeOptions)
383 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
384 if (videoFileAttributes.length === 0) {
385 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
388 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
389 await Promise.all(videoFilePromises)
392 const tags = videoObject.tag.map(t => t.name)
393 const tagInstances = await TagModel.findOrCreateTags(tags, t)
394 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
397 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
398 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
400 await Promise.all(videoCaptionsPromises)
402 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
404 videoCreated.VideoChannel = channelActor.VideoChannel
408 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
409 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
411 if (waitThumbnail === true) await p
416 async function videoActivityObjectToDBAttributes (
417 videoChannel: VideoChannelModel,
418 videoObject: VideoTorrentObject,
421 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
422 const duration = videoObject.duration.replace(/[^\d]+/, '')
424 let language: string | undefined
425 if (videoObject.language) {
426 language = videoObject.language.identifier
429 let category: number | undefined
430 if (videoObject.category) {
431 category = parseInt(videoObject.category.identifier, 10)
434 let licence: number | undefined
435 if (videoObject.licence) {
436 licence = parseInt(videoObject.licence.identifier, 10)
439 const description = videoObject.content || null
440 const support = videoObject.support || null
443 name: videoObject.name,
444 uuid: videoObject.uuid,
451 nsfw: videoObject.sensitive,
452 commentsEnabled: videoObject.commentsEnabled,
453 waitTranscoding: videoObject.waitTranscoding,
454 state: videoObject.state,
455 channelId: videoChannel.id,
456 duration: parseInt(duration, 10),
457 createdAt: new Date(videoObject.published),
458 publishedAt: new Date(videoObject.published),
459 // FIXME: updatedAt does not seems to be considered by Sequelize
460 updatedAt: new Date(videoObject.updated),
461 views: videoObject.views,
469 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
470 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
472 if (fileUrls.length === 0) {
473 throw new Error('Cannot find video files for ' + video.url)
476 const attributes: VideoFileModel[] = []
477 for (const fileUrl of fileUrls) {
478 // Fetch associated magnet uri
479 const magnet = videoObject.url.find(u => {
480 const mediaType = u.mediaType || u.mimeType
481 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
484 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
486 const parsed = magnetUtil.decode(magnet.href)
487 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
488 throw new Error('Cannot parse magnet URI ' + magnet.href)
491 const mediaType = fileUrl.mediaType || fileUrl.mimeType
493 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
494 infoHash: parsed.infoHash,
495 resolution: fileUrl.height,
498 fps: fileUrl.fps || -1
500 attributes.push(attribute)