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,
162 refreshViews?: boolean
165 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
166 const fetchType = options.fetchType || 'all'
167 const refreshViews = options.refreshViews || false
170 const videoUrl = getAPUrl(options.videoObject)
172 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
173 if (videoFromDatabase) {
174 const refreshOptions = {
175 video: videoFromDatabase,
176 fetchedType: fetchType,
180 const p = refreshVideoIfNeeded(refreshOptions)
181 if (syncParam.refreshVideo === true) videoFromDatabase = await p
183 return { video: videoFromDatabase }
186 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
187 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
189 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
190 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
192 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
197 async function updateVideoFromAP (options: {
199 videoObject: VideoTorrentObject,
200 account: AccountModel,
201 channel: VideoChannelModel,
202 updateViews: boolean,
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)
242 if (options.updateViews === true) 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 })
302 federateVideoIfNeeded,
304 getOrCreateVideoAndAccountAndChannel,
305 fetchRemoteVideoStaticFile,
306 fetchRemoteVideoDescription,
307 generateThumbnailFromUrl,
308 getOrCreateVideoChannelFromVideoObject
311 // ---------------------------------------------------------------------------
313 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
314 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
316 const urlMediaType = url.mediaType || url.mimeType
317 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
320 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
321 logger.debug('Adding remote video %s.', videoObject.id)
323 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
324 const sequelizeOptions = { transaction: t }
326 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
327 const video = VideoModel.build(videoData)
329 const videoCreated = await video.save(sequelizeOptions)
332 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
333 if (videoFileAttributes.length === 0) {
334 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
337 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
338 await Promise.all(videoFilePromises)
341 const tags = videoObject.tag.map(t => t.name)
342 const tagInstances = await TagModel.findOrCreateTags(tags, t)
343 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
346 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
347 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
349 await Promise.all(videoCaptionsPromises)
351 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
353 videoCreated.VideoChannel = channelActor.VideoChannel
357 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
358 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
360 if (waitThumbnail === true) await p
365 async function refreshVideoIfNeeded (options: {
367 fetchedType: VideoFetchByUrlType,
368 syncParam: SyncParam,
369 refreshViews: boolean
370 }): Promise<VideoModel> {
371 if (!options.video.isOutdated()) return options.video
373 // We need more attributes if the argument video was fetched with not enough joints
374 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
377 const { response, videoObject } = await fetchRemoteVideo(video.url)
378 if (response.statusCode === 404) {
379 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
381 // Video does not exist anymore
382 await video.destroy()
386 if (videoObject === undefined) {
387 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
391 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
392 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
394 const updateOptions = {
398 channel: channelActor.VideoChannel,
399 updateViews: options.refreshViews
401 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
402 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
406 logger.warn('Cannot refresh video %s.', options.video.url, { err })
411 async function videoActivityObjectToDBAttributes (
412 videoChannel: VideoChannelModel,
413 videoObject: VideoTorrentObject,
416 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
417 const duration = videoObject.duration.replace(/[^\d]+/, '')
419 let language: string | undefined
420 if (videoObject.language) {
421 language = videoObject.language.identifier
424 let category: number | undefined
425 if (videoObject.category) {
426 category = parseInt(videoObject.category.identifier, 10)
429 let licence: number | undefined
430 if (videoObject.licence) {
431 licence = parseInt(videoObject.licence.identifier, 10)
434 const description = videoObject.content || null
435 const support = videoObject.support || null
438 name: videoObject.name,
439 uuid: videoObject.uuid,
446 nsfw: videoObject.sensitive,
447 commentsEnabled: videoObject.commentsEnabled,
448 waitTranscoding: videoObject.waitTranscoding,
449 state: videoObject.state,
450 channelId: videoChannel.id,
451 duration: parseInt(duration, 10),
452 createdAt: new Date(videoObject.published),
453 publishedAt: new Date(videoObject.published),
454 // FIXME: updatedAt does not seems to be considered by Sequelize
455 updatedAt: new Date(videoObject.updated),
456 views: videoObject.views,
464 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
465 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
467 if (fileUrls.length === 0) {
468 throw new Error('Cannot find video files for ' + video.url)
471 const attributes: VideoFileModel[] = []
472 for (const fileUrl of fileUrls) {
473 // Fetch associated magnet uri
474 const magnet = videoObject.url.find(u => {
475 const mediaType = u.mediaType || u.mimeType
476 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
479 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
481 const parsed = magnetUtil.decode(magnet.href)
482 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
483 throw new Error('Cannot parse magnet URI ' + magnet.href)
486 const mediaType = fileUrl.mediaType || fileUrl.mimeType
488 extname: VIDEO_MIMETYPE_EXT[ mediaType ],
489 infoHash: parsed.infoHash,
490 resolution: fileUrl.height,
493 fps: fileUrl.fps || -1
495 attributes.push(attribute)