]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/lib/activitypub/videos.ts
Refractor retry transaction function
[github/Chocobozzz/PeerTube.git] / server / lib / activitypub / videos.ts
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, VideoState } from '../../../shared/index'
7 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
8 import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos'
9 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
10 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
11 import { 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, STATIC_PATHS, VIDEO_MIMETYPE_EXT } from '../../initializers'
15 import { AccountVideoRateModel } from '../../models/account/account-video-rate'
16 import { ActorModel } from '../../models/activitypub/actor'
17 import { TagModel } from '../../models/video/tag'
18 import { VideoModel } from '../../models/video/video'
19 import { VideoChannelModel } from '../../models/video/video-channel'
20 import { VideoFileModel } from '../../models/video/video-file'
21 import { VideoShareModel } from '../../models/video/video-share'
22 import { getOrCreateActorAndServerAndModel } from './actor'
23 import { addVideoComments } from './video-comments'
24 import { crawlCollectionPage } from './crawl'
25 import { sendCreateVideo, sendUpdateVideo } from './send'
26 import { shareVideoByServerAndChannel } from './index'
27
28 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
29 // If the video is not private and published, we federate it
30 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
31 if (isNewVideo === true) {
32 // Now we'll add the video's meta data to our followers
33 await sendCreateVideo(video, transaction)
34 await shareVideoByServerAndChannel(video, transaction)
35 } else {
36 await sendUpdateVideo(video, transaction)
37 }
38 }
39 }
40
41 function fetchRemoteVideoPreview (video: VideoModel, reject: Function) {
42 const host = video.VideoChannel.Account.Actor.Server.host
43 const path = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
44
45 // We need to provide a callback, if no we could have an uncaught exception
46 return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
47 if (err) reject(err)
48 })
49 }
50
51 async function fetchRemoteVideoDescription (video: VideoModel) {
52 const host = video.VideoChannel.Account.Actor.Server.host
53 const path = video.getDescriptionPath()
54 const options = {
55 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
56 json: true
57 }
58
59 const { body } = await doRequest(options)
60 return body.description ? body.description : ''
61 }
62
63 function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
64 const thumbnailName = video.getThumbnailName()
65 const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName)
66
67 const options = {
68 method: 'GET',
69 uri: icon.url
70 }
71 return doRequestAndSaveToFile(options, thumbnailPath)
72 }
73
74 async function videoActivityObjectToDBAttributes (
75 videoChannel: VideoChannelModel,
76 videoObject: VideoTorrentObject,
77 to: string[] = []
78 ) {
79 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
80 const duration = videoObject.duration.replace(/[^\d]+/, '')
81
82 let language: string = null
83 if (videoObject.language) {
84 language = videoObject.language.identifier
85 }
86
87 let category: number = null
88 if (videoObject.category) {
89 category = parseInt(videoObject.category.identifier, 10)
90 }
91
92 let licence: number = null
93 if (videoObject.licence) {
94 licence = parseInt(videoObject.licence.identifier, 10)
95 }
96
97 const description = videoObject.content || null
98 const support = videoObject.support || null
99
100 return {
101 name: videoObject.name,
102 uuid: videoObject.uuid,
103 url: videoObject.id,
104 category,
105 licence,
106 language,
107 description,
108 support,
109 nsfw: videoObject.sensitive,
110 commentsEnabled: videoObject.commentsEnabled,
111 waitTranscoding: videoObject.waitTranscoding,
112 state: videoObject.state,
113 channelId: videoChannel.id,
114 duration: parseInt(duration, 10),
115 createdAt: new Date(videoObject.published),
116 publishedAt: new Date(videoObject.published),
117 // FIXME: updatedAt does not seems to be considered by Sequelize
118 updatedAt: new Date(videoObject.updated),
119 views: videoObject.views,
120 likes: 0,
121 dislikes: 0,
122 remote: true,
123 privacy
124 }
125 }
126
127 function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) {
128 const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
129 const fileUrls = videoObject.url.filter(u => {
130 return mimeTypes.indexOf(u.mimeType) !== -1 && u.mimeType.startsWith('video/')
131 })
132
133 if (fileUrls.length === 0) {
134 throw new Error('Cannot find video files for ' + videoCreated.url)
135 }
136
137 const attributes = []
138 for (const fileUrl of fileUrls) {
139 // Fetch associated magnet uri
140 const magnet = videoObject.url.find(u => {
141 return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.width === fileUrl.width
142 })
143
144 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
145
146 const parsed = magnetUtil.decode(magnet.href)
147 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) throw new Error('Cannot parse magnet URI ' + magnet.href)
148
149 const attribute = {
150 extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ],
151 infoHash: parsed.infoHash,
152 resolution: fileUrl.width,
153 size: fileUrl.size,
154 videoId: videoCreated.id
155 }
156 attributes.push(attribute)
157 }
158
159 return attributes
160 }
161
162 function getOrCreateVideoChannel (videoObject: VideoTorrentObject) {
163 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
164 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
165
166 return getOrCreateActorAndServerAndModel(channel.id)
167 }
168
169 async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor: ActorModel) {
170 logger.debug('Adding remote video %s.', videoObject.id)
171
172 return sequelizeTypescript.transaction(async t => {
173 const sequelizeOptions = {
174 transaction: t
175 }
176 const videoFromDatabase = await VideoModel.loadByUUIDOrURLAndPopulateAccount(videoObject.uuid, videoObject.id, t)
177 if (videoFromDatabase) return videoFromDatabase
178
179 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
180 const video = VideoModel.build(videoData)
181
182 // Don't block on request
183 generateThumbnailFromUrl(video, videoObject.icon)
184 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
185
186 const videoCreated = await video.save(sequelizeOptions)
187
188 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
189 if (videoFileAttributes.length === 0) {
190 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
191 }
192
193 const tasks = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
194 await Promise.all(tasks)
195
196 const tags = videoObject.tag.map(t => t.name)
197 const tagInstances = await TagModel.findOrCreateTags(tags, t)
198 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
199
200 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
201
202 videoCreated.VideoChannel = channelActor.VideoChannel
203 return videoCreated
204 })
205 }
206
207 async function getOrCreateAccountAndVideoAndChannel (videoObject: VideoTorrentObject | string, actor?: ActorModel) {
208 const videoUrl = typeof videoObject === 'string' ? videoObject : videoObject.id
209
210 const videoFromDatabase = await VideoModel.loadByUrlAndPopulateAccount(videoUrl)
211 if (videoFromDatabase) {
212 return {
213 video: videoFromDatabase,
214 actor: videoFromDatabase.VideoChannel.Account.Actor,
215 channelActor: videoFromDatabase.VideoChannel.Actor
216 }
217 }
218
219 videoObject = await fetchRemoteVideo(videoUrl)
220 if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
221
222 if (!actor) {
223 const actorObj = videoObject.attributedTo.find(a => a.type === 'Person')
224 if (!actorObj) throw new Error('Cannot find associated actor to video ' + videoObject.url)
225
226 actor = await getOrCreateActorAndServerAndModel(actorObj.id)
227 }
228
229 const channelActor = await getOrCreateVideoChannel(videoObject)
230
231 const video = await retryTransactionWrapper(getOrCreateVideo, videoObject, channelActor)
232
233 // Process outside the transaction because we could fetch remote data
234 logger.info('Adding likes of video %s.', video.uuid)
235 await crawlCollectionPage<string>(videoObject.likes, (items) => createRates(items, video, 'like'))
236
237 logger.info('Adding dislikes of video %s.', video.uuid)
238 await crawlCollectionPage<string>(videoObject.dislikes, (items) => createRates(items, video, 'dislike'))
239
240 logger.info('Adding shares of video %s.', video.uuid)
241 await crawlCollectionPage<string>(videoObject.shares, (items) => addVideoShares(items, video))
242
243 logger.info('Adding comments of video %s.', video.uuid)
244 await crawlCollectionPage<string>(videoObject.comments, (items) => addVideoComments(items, video))
245
246 return { actor, channelActor, video }
247 }
248
249 async function createRates (actorUrls: string[], video: VideoModel, rate: VideoRateType) {
250 let rateCounts = 0
251 const tasks: Bluebird<number>[] = []
252
253 for (const actorUrl of actorUrls) {
254 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
255 const p = AccountVideoRateModel
256 .create({
257 videoId: video.id,
258 accountId: actor.Account.id,
259 type: rate
260 })
261 .then(() => rateCounts += 1)
262
263 tasks.push(p)
264 }
265
266 await Promise.all(tasks)
267
268 logger.info('Adding %d %s to video %s.', rateCounts, rate, video.uuid)
269
270 // This is "likes" and "dislikes"
271 await video.increment(rate + 's', { by: rateCounts })
272
273 return
274 }
275
276 async function addVideoShares (shareUrls: string[], instance: VideoModel) {
277 for (const shareUrl of shareUrls) {
278 // Fetch url
279 const { body } = await doRequest({
280 uri: shareUrl,
281 json: true,
282 activityPub: true
283 })
284 if (!body || !body.actor) {
285 logger.warn('Cannot add remote share with url: %s, skipping...', shareUrl)
286 continue
287 }
288
289 const actorUrl = body.actor
290 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
291
292 const entry = {
293 actorId: actor.id,
294 videoId: instance.id,
295 url: shareUrl
296 }
297
298 await VideoShareModel.findOrCreate({
299 where: {
300 url: shareUrl
301 },
302 defaults: entry
303 })
304 }
305 }
306
307 async function fetchRemoteVideo (videoUrl: string): Promise<VideoTorrentObject> {
308 const options = {
309 uri: videoUrl,
310 method: 'GET',
311 json: true,
312 activityPub: true
313 }
314
315 logger.info('Fetching remote video %s.', videoUrl)
316
317 const { body } = await doRequest(options)
318
319 if (sanitizeAndCheckVideoTorrentObject(body) === false) {
320 logger.debug('Remote video JSON is not valid.', { body })
321 return undefined
322 }
323
324 return body
325 }
326
327 export {
328 federateVideoIfNeeded,
329 fetchRemoteVideo,
330 getOrCreateAccountAndVideoAndChannel,
331 fetchRemoteVideoPreview,
332 fetchRemoteVideoDescription,
333 generateThumbnailFromUrl,
334 videoActivityObjectToDBAttributes,
335 videoFileActivityUrlToDBAttributes,
336 getOrCreateVideo,
337 getOrCreateVideoChannel,
338 addVideoShares
339 }