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()
98 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
100 return downloadImage(icon.url, thumbnailPath, THUMBNAILS_SIZE)
103 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
104 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
105 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
107 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
108 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
111 return getOrCreateActorAndServerAndModel(channel.id, 'all')
120 refreshVideo?: boolean
122 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
123 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
125 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
127 if (syncParam.likes === true) {
128 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
129 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
131 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
134 if (syncParam.dislikes === true) {
135 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
136 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
138 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
141 if (syncParam.shares === true) {
142 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
143 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
145 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
148 if (syncParam.comments === true) {
149 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
150 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
152 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
155 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
158 async function getOrCreateVideoAndAccountAndChannel (options: {
159 videoObject: VideoTorrentObject | string,
160 syncParam?: SyncParam,
161 fetchType?: VideoFetchByUrlType
164 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
165 const fetchType = options.fetchType || 'all'
168 const videoUrl = getAPUrl(options.videoObject)
170 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
171 if (videoFromDatabase) {
172 const refreshOptions = {
173 video: videoFromDatabase,
174 fetchedType: fetchType,
178 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
179 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
181 return { video: videoFromDatabase }
184 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
185 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
187 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
188 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
190 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
195 async function updateVideoFromAP (options: {
197 videoObject: VideoTorrentObject,
198 account: AccountModel,
199 channel: VideoChannelModel,
200 overrideTo?: string[]
202 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
203 let videoFieldsSave: any
206 await sequelizeTypescript.transaction(async t => {
207 const sequelizeOptions = {
211 videoFieldsSave = options.video.toJSON()
213 // Check actor has the right to update the video
214 const videoChannel = options.video.VideoChannel
215 if (videoChannel.Account.id !== options.account.id) {
216 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
219 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
220 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
221 options.video.set('name', videoData.name)
222 options.video.set('uuid', videoData.uuid)
223 options.video.set('url', videoData.url)
224 options.video.set('category', videoData.category)
225 options.video.set('licence', videoData.licence)
226 options.video.set('language', videoData.language)
227 options.video.set('description', videoData.description)
228 options.video.set('support', videoData.support)
229 options.video.set('nsfw', videoData.nsfw)
230 options.video.set('commentsEnabled', videoData.commentsEnabled)
231 options.video.set('waitTranscoding', videoData.waitTranscoding)
232 options.video.set('state', videoData.state)
233 options.video.set('duration', videoData.duration)
234 options.video.set('createdAt', videoData.createdAt)
235 options.video.set('publishedAt', videoData.publishedAt)
236 options.video.set('privacy', videoData.privacy)
237 options.video.set('channelId', videoData.channelId)
238 options.video.set('views', videoData.views)
240 await options.video.save(sequelizeOptions)
243 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
244 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
246 // Remove video files that do not exist anymore
247 const destroyTasks = options.video.VideoFiles
248 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
249 .map(f => f.destroy(sequelizeOptions))
250 await Promise.all(destroyTasks)
252 // Update or add other one
253 const upsertTasks = videoFileAttributes.map(a => {
254 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
255 .then(([ file ]) => file)
258 options.video.VideoFiles = await Promise.all(upsertTasks)
263 const tags = options.videoObject.tag.map(tag => tag.name)
264 const tagInstances = await TagModel.findOrCreateTags(tags, t)
265 await options.video.$set('Tags', tagInstances, sequelizeOptions)
270 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
272 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
273 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
275 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
279 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
281 if (options.video !== undefined && videoFieldsSave !== undefined) {
282 resetSequelizeInstance(options.video, videoFieldsSave)
285 // This is just a debug because we will retry the insert
286 logger.debug('Cannot update the remote video.', { err })
291 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
293 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
297 async function refreshVideoIfNeeded (options: {
299 fetchedType: VideoFetchByUrlType,
301 }): Promise<VideoModel> {
302 if (!options.video.isOutdated()) return options.video
304 // We need more attributes if the argument video was fetched with not enough joints
305 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
308 const { response, videoObject } = await fetchRemoteVideo(video.url)
309 if (response.statusCode === 404) {
310 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
312 // Video does not exist anymore
313 await video.destroy()
317 if (videoObject === undefined) {
318 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
320 await video.setAsRefreshed()
324 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
325 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
327 const updateOptions = {
331 channel: channelActor.VideoChannel
333 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
334 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
338 logger.warn('Cannot refresh video %s.', options.video.url, { err })
340 // Don't refresh in loop
341 await video.setAsRefreshed()
348 refreshVideoIfNeeded,
349 federateVideoIfNeeded,
351 getOrCreateVideoAndAccountAndChannel,
352 fetchRemoteVideoStaticFile,
353 fetchRemoteVideoDescription,
354 generateThumbnailFromUrl,
355 getOrCreateVideoChannelFromVideoObject
358 // ---------------------------------------------------------------------------
360 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
361 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
363 const urlMediaType = url.mediaType || url.mimeType
364 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
367 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
368 logger.debug('Adding remote video %s.', videoObject.id)
370 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
371 const sequelizeOptions = { transaction: t }
373 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
374 const video = VideoModel.build(videoData)
376 const videoCreated = await video.save(sequelizeOptions)
379 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
380 if (videoFileAttributes.length === 0) {
381 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
384 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
385 await Promise.all(videoFilePromises)
388 const tags = videoObject.tag.map(t => t.name)
389 const tagInstances = await TagModel.findOrCreateTags(tags, t)
390 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
393 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
394 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
396 await Promise.all(videoCaptionsPromises)
398 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
400 videoCreated.VideoChannel = channelActor.VideoChannel
404 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
405 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
407 if (waitThumbnail === true) await p
412 async function videoActivityObjectToDBAttributes (
413 videoChannel: VideoChannelModel,
414 videoObject: VideoTorrentObject,
417 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
418 const duration = videoObject.duration.replace(/[^\d]+/, '')
420 let language: string | undefined
421 if (videoObject.language) {
422 language = videoObject.language.identifier
425 let category: number | undefined
426 if (videoObject.category) {
427 category = parseInt(videoObject.category.identifier, 10)
430 let licence: number | undefined
431 if (videoObject.licence) {
432 licence = parseInt(videoObject.licence.identifier, 10)
435 const description = videoObject.content || null
436 const support = videoObject.support || null
439 name: videoObject.name,
440 uuid: videoObject.uuid,
447 nsfw: videoObject.sensitive,
448 commentsEnabled: videoObject.commentsEnabled,
449 waitTranscoding: videoObject.waitTranscoding,
450 state: videoObject.state,
451 channelId: videoChannel.id,
452 duration: parseInt(duration, 10),
453 createdAt: new Date(videoObject.published),
454 publishedAt: new Date(videoObject.published),
455 // FIXME: updatedAt does not seems to be considered by Sequelize
456 updatedAt: new Date(videoObject.updated),
457 views: videoObject.views,
465 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
466 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
468 if (fileUrls.length === 0) {
469 throw new Error('Cannot find video files for ' + video.url)
472 const attributes: VideoFileModel[] = []
473 for (const fileUrl of fileUrls) {
474 // Fetch associated magnet uri
475 const magnet = videoObject.url.find(u => {
476 const mediaType = u.mediaType || u.mimeType
477 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
480 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
482 const parsed = magnetUtil.decode(magnet.href)
483 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
484 throw new Error('Cannot parse magnet URI ' + magnet.href)
487 const mediaType = fileUrl.mediaType || fileUrl.mimeType
489 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
490 infoHash: parsed.infoHash,
491 resolution: fileUrl.height,
494 fps: fileUrl.fps || -1
496 attributes.push(attribute)