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'
33 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
34 // If the video is not private and published, we federate it
35 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
36 // Fetch more attributes that we will need to serialize in AP object
37 if (isArray(video.VideoCaptions) === false) {
38 video.VideoCaptions = await video.$get('VideoCaptions', {
39 attributes: [ 'language' ],
41 }) as VideoCaptionModel[]
45 // Now we'll add the video's meta data to our followers
46 await sendCreateVideo(video, transaction)
47 await shareVideoByServerAndChannel(video, transaction)
49 await sendUpdateVideo(video, transaction)
54 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
62 logger.info('Fetching remote video %s.', videoUrl)
64 const { response, body } = await doRequest(options)
66 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
67 logger.debug('Remote video JSON is not valid.', { body })
68 return { response, videoObject: undefined }
71 return { response, videoObject: body }
74 async function fetchRemoteVideoDescription (video: VideoModel) {
75 const host = video.VideoChannel.Account.Actor.Server.host
76 const path = video.getDescriptionAPIPath()
78 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
82 const { body } = await doRequest(options)
83 return body.description ? body.description : ''
86 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
87 const host = video.VideoChannel.Account.Actor.Server.host
89 // We need to provide a callback, if no we could have an uncaught exception
90 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
95 function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
96 const thumbnailName = video.getThumbnailName()
98 return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
101 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
102 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
103 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
105 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
106 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
109 return getOrCreateActorAndServerAndModel(channel.id, 'all')
118 refreshVideo?: boolean
120 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
121 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
123 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
125 if (syncParam.likes === true) {
126 await crawlCollectionPage<string>(fetchedVideo.likes, items => createRates(items, video, 'like'))
127 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
129 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
132 if (syncParam.dislikes === true) {
133 await crawlCollectionPage<string>(fetchedVideo.dislikes, items => createRates(items, video, 'dislike'))
134 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
136 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
139 if (syncParam.shares === true) {
140 await crawlCollectionPage<string>(fetchedVideo.shares, items => addVideoShares(items, video))
141 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
143 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
146 if (syncParam.comments === true) {
147 await crawlCollectionPage<string>(fetchedVideo.comments, items => addVideoComments(items, video))
148 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
150 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
153 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
156 async function getOrCreateVideoAndAccountAndChannel (options: {
157 videoObject: VideoTorrentObject | string,
158 syncParam?: SyncParam,
159 fetchType?: VideoFetchByUrlType,
160 allowRefresh?: boolean // true by default
163 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
164 const fetchType = options.fetchType || 'all'
165 const allowRefresh = options.allowRefresh !== false
168 const videoUrl = getAPUrl(options.videoObject)
170 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
171 if (videoFromDatabase) {
173 if (allowRefresh === true) {
174 const refreshOptions = {
175 video: videoFromDatabase,
176 fetchedType: fetchType,
180 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
181 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } })
184 return { video: videoFromDatabase }
187 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
188 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
190 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
191 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
193 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
198 async function updateVideoFromAP (options: {
200 videoObject: VideoTorrentObject,
201 account: AccountModel,
202 channel: VideoChannelModel,
203 overrideTo?: string[]
205 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
206 let videoFieldsSave: any
209 await sequelizeTypescript.transaction(async t => {
210 const sequelizeOptions = {
214 videoFieldsSave = options.video.toJSON()
216 // Check actor has the right to update the video
217 const videoChannel = options.video.VideoChannel
218 if (videoChannel.Account.id !== options.account.id) {
219 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
222 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
223 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
224 options.video.set('name', videoData.name)
225 options.video.set('uuid', videoData.uuid)
226 options.video.set('url', videoData.url)
227 options.video.set('category', videoData.category)
228 options.video.set('licence', videoData.licence)
229 options.video.set('language', videoData.language)
230 options.video.set('description', videoData.description)
231 options.video.set('support', videoData.support)
232 options.video.set('nsfw', videoData.nsfw)
233 options.video.set('commentsEnabled', videoData.commentsEnabled)
234 options.video.set('waitTranscoding', videoData.waitTranscoding)
235 options.video.set('state', videoData.state)
236 options.video.set('duration', videoData.duration)
237 options.video.set('createdAt', videoData.createdAt)
238 options.video.set('publishedAt', videoData.publishedAt)
239 options.video.set('privacy', videoData.privacy)
240 options.video.set('channelId', videoData.channelId)
241 options.video.set('views', videoData.views)
243 await options.video.save(sequelizeOptions)
246 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
247 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
249 // Remove video files that do not exist anymore
250 const destroyTasks = options.video.VideoFiles
251 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
252 .map(f => f.destroy(sequelizeOptions))
253 await Promise.all(destroyTasks)
255 // Update or add other one
256 const upsertTasks = videoFileAttributes.map(a => {
257 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
258 .then(([ file ]) => file)
261 options.video.VideoFiles = await Promise.all(upsertTasks)
266 const tags = options.videoObject.tag.map(tag => tag.name)
267 const tagInstances = await TagModel.findOrCreateTags(tags, t)
268 await options.video.$set('Tags', tagInstances, sequelizeOptions)
273 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
275 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
276 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
278 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
282 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
284 if (options.video !== undefined && videoFieldsSave !== undefined) {
285 resetSequelizeInstance(options.video, videoFieldsSave)
288 // This is just a debug because we will retry the insert
289 logger.debug('Cannot update the remote video.', { err })
294 await generateThumbnailFromUrl(options.video, options.videoObject.icon)
296 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
300 async function refreshVideoIfNeeded (options: {
302 fetchedType: VideoFetchByUrlType,
304 }): Promise<VideoModel> {
305 if (!options.video.isOutdated()) return options.video
307 // We need more attributes if the argument video was fetched with not enough joints
308 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
311 const { response, videoObject } = await fetchRemoteVideo(video.url)
312 if (response.statusCode === 404) {
313 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
315 // Video does not exist anymore
316 await video.destroy()
320 if (videoObject === undefined) {
321 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
323 await video.setAsRefreshed()
327 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
328 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
330 const updateOptions = {
334 channel: channelActor.VideoChannel
336 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
337 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
341 logger.warn('Cannot refresh video %s.', options.video.url, { err })
343 // Don't refresh in loop
344 await video.setAsRefreshed()
351 refreshVideoIfNeeded,
352 federateVideoIfNeeded,
354 getOrCreateVideoAndAccountAndChannel,
355 fetchRemoteVideoStaticFile,
356 fetchRemoteVideoDescription,
357 generateThumbnailFromUrl,
358 getOrCreateVideoChannelFromVideoObject
361 // ---------------------------------------------------------------------------
363 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
364 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
366 const urlMediaType = url.mediaType || url.mimeType
367 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
370 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
371 logger.debug('Adding remote video %s.', videoObject.id)
373 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
374 const sequelizeOptions = { transaction: t }
376 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
377 const video = VideoModel.build(videoData)
379 const videoCreated = await video.save(sequelizeOptions)
382 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
383 if (videoFileAttributes.length === 0) {
384 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
387 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
388 await Promise.all(videoFilePromises)
391 const tags = videoObject.tag.map(t => t.name)
392 const tagInstances = await TagModel.findOrCreateTags(tags, t)
393 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
396 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
397 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
399 await Promise.all(videoCaptionsPromises)
401 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
403 videoCreated.VideoChannel = channelActor.VideoChannel
407 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
408 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
410 if (waitThumbnail === true) await p
415 async function videoActivityObjectToDBAttributes (
416 videoChannel: VideoChannelModel,
417 videoObject: VideoTorrentObject,
420 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
421 const duration = videoObject.duration.replace(/[^\d]+/, '')
423 let language: string | undefined
424 if (videoObject.language) {
425 language = videoObject.language.identifier
428 let category: number | undefined
429 if (videoObject.category) {
430 category = parseInt(videoObject.category.identifier, 10)
433 let licence: number | undefined
434 if (videoObject.licence) {
435 licence = parseInt(videoObject.licence.identifier, 10)
438 const description = videoObject.content || null
439 const support = videoObject.support || null
442 name: videoObject.name,
443 uuid: videoObject.uuid,
450 nsfw: videoObject.sensitive,
451 commentsEnabled: videoObject.commentsEnabled,
452 waitTranscoding: videoObject.waitTranscoding,
453 state: videoObject.state,
454 channelId: videoChannel.id,
455 duration: parseInt(duration, 10),
456 createdAt: new Date(videoObject.published),
457 publishedAt: new Date(videoObject.published),
458 // FIXME: updatedAt does not seems to be considered by Sequelize
459 updatedAt: new Date(videoObject.updated),
460 views: videoObject.views,
468 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
469 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
471 if (fileUrls.length === 0) {
472 throw new Error('Cannot find video files for ' + video.url)
475 const attributes: VideoFileModel[] = []
476 for (const fileUrl of fileUrls) {
477 // Fetch associated magnet uri
478 const magnet = videoObject.url.find(u => {
479 const mediaType = u.mediaType || u.mimeType
480 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
483 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
485 const parsed = magnetUtil.decode(magnet.href)
486 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
487 throw new Error('Cannot parse magnet URI ' + magnet.href)
490 const mediaType = fileUrl.mediaType || fileUrl.mimeType
492 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
493 infoHash: parsed.infoHash,
494 resolution: fileUrl.height,
497 fps: fileUrl.fps || -1
499 attributes.push(attribute)