1 import * as Bluebird from 'bluebird'
2 import * as sequelize from 'sequelize'
3 import * as magnetUtil from 'magnet-uri'
4 import * as request from 'request'
5 import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
6 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
7 import { VideoPrivacy } from '../../../shared/models/videos'
8 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
9 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
10 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
11 import { logger } from '../../helpers/logger'
12 import { doRequest, downloadImage } from '../../helpers/requests'
13 import { ACTIVITY_PUB, CONFIG, MIMETYPES, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
14 import { ActorModel } from '../../models/activitypub/actor'
15 import { TagModel } from '../../models/video/tag'
16 import { VideoModel } from '../../models/video/video'
17 import { VideoChannelModel } from '../../models/video/video-channel'
18 import { VideoFileModel } from '../../models/video/video-file'
19 import { getOrCreateActorAndServerAndModel } from './actor'
20 import { addVideoComments } from './video-comments'
21 import { crawlCollectionPage } from './crawl'
22 import { sendCreateVideo, sendUpdateVideo } from './send'
23 import { isArray } from '../../helpers/custom-validators/misc'
24 import { VideoCaptionModel } from '../../models/video/video-caption'
25 import { JobQueue } from '../job-queue'
26 import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
27 import { createRates } from './video-rates'
28 import { addVideoShares, shareVideoByServerAndChannel } from './share'
29 import { AccountModel } from '../../models/account/account'
30 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
31 import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub'
32 import { Notifier } from '../notifier'
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, created: false }
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)
196 return { video, created: true }
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 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
218 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
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)
245 options.video.set('views', videoData.views)
247 await options.video.save(sequelizeOptions)
250 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
251 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
253 // Remove video files that do not exist anymore
254 const destroyTasks = options.video.VideoFiles
255 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
256 .map(f => f.destroy(sequelizeOptions))
257 await Promise.all(destroyTasks)
259 // Update or add other one
260 const upsertTasks = videoFileAttributes.map(a => {
261 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
262 .then(([ file ]) => file)
265 options.video.VideoFiles = await Promise.all(upsertTasks)
270 const tags = options.videoObject.tag.map(tag => tag.name)
271 const tagInstances = await TagModel.findOrCreateTags(tags, t)
272 await options.video.$set('Tags', tagInstances, sequelizeOptions)
277 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
279 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
280 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
282 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
287 if (wasPrivateVideo || wasUnlistedVideo) {
288 Notifier.Instance.notifyOnNewVideo(options.video)
293 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
295 if (options.video !== undefined && videoFieldsSave !== undefined) {
296 resetSequelizeInstance(options.video, videoFieldsSave)
299 // This is just a debug because we will retry the insert
300 logger.debug('Cannot update the remote video.', { err })
305 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
307 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
311 async function refreshVideoIfNeeded (options: {
313 fetchedType: VideoFetchByUrlType,
315 }): Promise<VideoModel> {
316 if (!options.video.isOutdated()) return options.video
318 // We need more attributes if the argument video was fetched with not enough joints
319 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
322 const { response, videoObject } = await fetchRemoteVideo(video.url)
323 if (response.statusCode === 404) {
324 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
326 // Video does not exist anymore
327 await video.destroy()
331 if (videoObject === undefined) {
332 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
334 await video.setAsRefreshed()
338 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
339 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
341 const updateOptions = {
345 channel: channelActor.VideoChannel
347 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
348 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
352 logger.warn('Cannot refresh video %s.', options.video.url, { err })
354 // Don't refresh in loop
355 await video.setAsRefreshed()
362 refreshVideoIfNeeded,
363 federateVideoIfNeeded,
365 getOrCreateVideoAndAccountAndChannel,
366 fetchRemoteVideoStaticFile,
367 fetchRemoteVideoDescription,
368 generateThumbnailFromUrl,
369 getOrCreateVideoChannelFromVideoObject
372 // ---------------------------------------------------------------------------
374 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
375 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
377 const urlMediaType = url.mediaType || url.mimeType
378 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
381 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
382 logger.debug('Adding remote video %s.', videoObject.id)
384 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
385 const sequelizeOptions = { transaction: t }
387 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
388 const video = VideoModel.build(videoData)
390 const videoCreated = await video.save(sequelizeOptions)
393 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
394 if (videoFileAttributes.length === 0) {
395 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
398 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
399 await Promise.all(videoFilePromises)
402 const tags = videoObject.tag.map(t => t.name)
403 const tagInstances = await TagModel.findOrCreateTags(tags, t)
404 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
407 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
408 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
410 await Promise.all(videoCaptionsPromises)
412 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
414 videoCreated.VideoChannel = channelActor.VideoChannel
418 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
419 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
421 if (waitThumbnail === true) await p
426 async function videoActivityObjectToDBAttributes (
427 videoChannel: VideoChannelModel,
428 videoObject: VideoTorrentObject,
431 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
432 const duration = videoObject.duration.replace(/[^\d]+/, '')
434 let language: string | undefined
435 if (videoObject.language) {
436 language = videoObject.language.identifier
439 let category: number | undefined
440 if (videoObject.category) {
441 category = parseInt(videoObject.category.identifier, 10)
444 let licence: number | undefined
445 if (videoObject.licence) {
446 licence = parseInt(videoObject.licence.identifier, 10)
449 const description = videoObject.content || null
450 const support = videoObject.support || null
453 name: videoObject.name,
454 uuid: videoObject.uuid,
461 nsfw: videoObject.sensitive,
462 commentsEnabled: videoObject.commentsEnabled,
463 waitTranscoding: videoObject.waitTranscoding,
464 state: videoObject.state,
465 channelId: videoChannel.id,
466 duration: parseInt(duration, 10),
467 createdAt: new Date(videoObject.published),
468 publishedAt: new Date(videoObject.published),
469 // FIXME: updatedAt does not seems to be considered by Sequelize
470 updatedAt: new Date(videoObject.updated),
471 views: videoObject.views,
479 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
480 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
482 if (fileUrls.length === 0) {
483 throw new Error('Cannot find video files for ' + video.url)
486 const attributes: VideoFileModel[] = []
487 for (const fileUrl of fileUrls) {
488 // Fetch associated magnet uri
489 const magnet = videoObject.url.find(u => {
490 const mediaType = u.mediaType || u.mimeType
491 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
494 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
496 const parsed = magnetUtil.decode(magnet.href)
497 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
498 throw new Error('Cannot parse magnet URI ' + magnet.href)
501 const mediaType = fileUrl.mediaType || fileUrl.mimeType
503 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
504 infoHash: parsed.infoHash,
505 resolution: fileUrl.height,
508 fps: fileUrl.fps || -1
510 attributes.push(attribute)