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, doRequestAndSaveToFile } from '../../helpers/requests'
14 import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, 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'
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) {
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()
97 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
103 return doRequestAndSaveToFile(options, thumbnailPath)
106 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
107 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
108 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
110 return getOrCreateActorAndServerAndModel(channel.id)
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
163 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
164 const fetchType = options.fetchType || 'all'
167 const videoUrl = typeof options.videoObject === 'string' ? options.videoObject : options.videoObject.id
169 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
170 if (videoFromDatabase) {
171 const p = retryTransactionWrapper(refreshVideoIfNeeded, videoFromDatabase, fetchType, syncParam)
172 if (syncParam.refreshVideo === true) videoFromDatabase = await p
174 return { video: videoFromDatabase }
177 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
178 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
180 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
181 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
183 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
188 async function updateVideoFromAP (
190 videoObject: VideoTorrentObject,
191 account: AccountModel,
192 channel: VideoChannelModel,
193 overrideTo?: string[]
195 logger.debug('Updating remote video "%s".', videoObject.uuid)
196 let videoFieldsSave: any
199 const updatedVideo: VideoModel = await sequelizeTypescript.transaction(async t => {
200 const sequelizeOptions = {
204 videoFieldsSave = video.toJSON()
206 // Check actor has the right to update the video
207 const videoChannel = video.VideoChannel
208 if (videoChannel.Account.id !== account.id) {
209 throw new Error('Account ' + account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
212 const to = overrideTo ? overrideTo : videoObject.to
213 const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to)
214 video.set('name', videoData.name)
215 video.set('uuid', videoData.uuid)
216 video.set('url', videoData.url)
217 video.set('category', videoData.category)
218 video.set('licence', videoData.licence)
219 video.set('language', videoData.language)
220 video.set('description', videoData.description)
221 video.set('support', videoData.support)
222 video.set('nsfw', videoData.nsfw)
223 video.set('commentsEnabled', videoData.commentsEnabled)
224 video.set('waitTranscoding', videoData.waitTranscoding)
225 video.set('state', videoData.state)
226 video.set('duration', videoData.duration)
227 video.set('createdAt', videoData.createdAt)
228 video.set('publishedAt', videoData.publishedAt)
229 video.set('views', videoData.views)
230 video.set('privacy', videoData.privacy)
231 video.set('channelId', videoData.channelId)
233 await video.save(sequelizeOptions)
235 // Don't block on request
236 generateThumbnailFromUrl(video, videoObject.icon)
237 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
239 // Remove old video files
240 const videoFileDestroyTasks: Bluebird<void>[] = []
241 for (const videoFile of video.VideoFiles) {
242 videoFileDestroyTasks.push(videoFile.destroy(sequelizeOptions))
244 await Promise.all(videoFileDestroyTasks)
246 const videoFileAttributes = videoFileActivityUrlToDBAttributes(video, videoObject)
247 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, sequelizeOptions))
248 await Promise.all(tasks)
251 const tags = videoObject.tag.map(tag => tag.name)
252 const tagInstances = await TagModel.findOrCreateTags(tags, t)
253 await video.$set('Tags', tagInstances, sequelizeOptions)
256 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(video.id, t)
258 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
259 return VideoCaptionModel.insertOrReplaceLanguage(video.id, c.identifier, t)
261 await Promise.all(videoCaptionsPromises)
264 logger.info('Remote video with uuid %s updated', videoObject.uuid)
268 if (video !== undefined && videoFieldsSave !== undefined) {
269 resetSequelizeInstance(video, videoFieldsSave)
272 // This is just a debug because we will retry the insert
273 logger.debug('Cannot update the remote video.', { err })
280 federateVideoIfNeeded,
282 getOrCreateVideoAndAccountAndChannel,
283 fetchRemoteVideoStaticFile,
284 fetchRemoteVideoDescription,
285 generateThumbnailFromUrl,
286 getOrCreateVideoChannelFromVideoObject
289 // ---------------------------------------------------------------------------
291 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
292 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
294 return mimeTypes.indexOf(url.mimeType) !== -1 && url.mimeType.startsWith('video/')
297 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
298 logger.debug('Adding remote video %s.', videoObject.id)
300 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
301 const sequelizeOptions = { transaction: t }
303 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
304 const video = VideoModel.build(videoData)
306 const videoCreated = await video.save(sequelizeOptions)
309 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
310 if (videoFileAttributes.length === 0) {
311 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
314 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
315 await Promise.all(videoFilePromises)
318 const tags = videoObject.tag.map(t => t.name)
319 const tagInstances = await TagModel.findOrCreateTags(tags, t)
320 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
323 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
324 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
326 await Promise.all(videoCaptionsPromises)
328 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
330 videoCreated.VideoChannel = channelActor.VideoChannel
334 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
335 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
337 if (waitThumbnail === true) await p
342 async function refreshVideoIfNeeded (videoArg: VideoModel, fetchedType: VideoFetchByUrlType, syncParam: SyncParam): Promise<VideoModel> {
343 // We need more attributes if the argument video was fetched with not enough joints
344 const video = fetchedType === 'all' ? videoArg : await VideoModel.loadByUrlAndPopulateAccount(videoArg.url)
346 if (!video.isOutdated()) return video
349 const { response, videoObject } = await fetchRemoteVideo(video.url)
350 if (response.statusCode === 404) {
351 // Video does not exist anymore
352 await video.destroy()
356 if (videoObject === undefined) {
357 logger.warn('Cannot refresh remote video: invalid body.')
361 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
362 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
364 await updateVideoFromAP(video, videoObject, account, channelActor.VideoChannel)
365 await syncVideoExternalAttributes(video, videoObject, syncParam)
367 logger.warn('Cannot refresh video.', { err })
372 async function videoActivityObjectToDBAttributes (
373 videoChannel: VideoChannelModel,
374 videoObject: VideoTorrentObject,
377 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
378 const duration = videoObject.duration.replace(/[^\d]+/, '')
380 let language: string | undefined
381 if (videoObject.language) {
382 language = videoObject.language.identifier
385 let category: number | undefined
386 if (videoObject.category) {
387 category = parseInt(videoObject.category.identifier, 10)
390 let licence: number | undefined
391 if (videoObject.licence) {
392 licence = parseInt(videoObject.licence.identifier, 10)
395 const description = videoObject.content || null
396 const support = videoObject.support || null
399 name: videoObject.name,
400 uuid: videoObject.uuid,
407 nsfw: videoObject.sensitive,
408 commentsEnabled: videoObject.commentsEnabled,
409 waitTranscoding: videoObject.waitTranscoding,
410 state: videoObject.state,
411 channelId: videoChannel.id,
412 duration: parseInt(duration, 10),
413 createdAt: new Date(videoObject.published),
414 publishedAt: new Date(videoObject.published),
415 // FIXME: updatedAt does not seems to be considered by Sequelize
416 updatedAt: new Date(videoObject.updated),
417 views: videoObject.views,
425 function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
426 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[]
428 if (fileUrls.length === 0) {
429 throw new Error('Cannot find video files for ' + videoCreated.url)
432 const attributes: VideoFileModel[] = []
433 for (const fileUrl of fileUrls) {
434 // Fetch associated magnet uri
435 const magnet = videoObject.url.find(u => {
436 return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.height === fileUrl.height
439 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
441 const parsed = magnetUtil.decode(magnet.href)
442 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
443 throw new Error('Cannot parse magnet URI ' + magnet.href)
447 extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
448 infoHash: parsed.infoHash,
449 resolution: fileUrl.height,
451 videoId: videoCreated.id,
454 attributes.push(attribute)