]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/lib/activitypub/videos.ts
Link to follower profile from administration (#1922)
[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 * as request from 'request'
5 import {
6 ActivityPlaylistSegmentHashesObject,
7 ActivityPlaylistUrlObject,
8 ActivityUrlObject,
9 ActivityVideoUrlObject,
10 VideoState
11 } from '../../../shared/index'
12 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
13 import { VideoPrivacy } from '../../../shared/models/videos'
14 import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
15 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
16 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
17 import { logger } from '../../helpers/logger'
18 import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests'
19 import {
20 ACTIVITY_PUB,
21 MIMETYPES,
22 P2P_MEDIA_LOADER_PEER_VERSION,
23 PREVIEWS_SIZE,
24 REMOTE_SCHEME,
25 STATIC_PATHS
26 } from '../../initializers/constants'
27 import { ActorModel } from '../../models/activitypub/actor'
28 import { TagModel } from '../../models/video/tag'
29 import { VideoModel } from '../../models/video/video'
30 import { VideoChannelModel } from '../../models/video/video-channel'
31 import { VideoFileModel } from '../../models/video/video-file'
32 import { getOrCreateActorAndServerAndModel } from './actor'
33 import { addVideoComments } from './video-comments'
34 import { crawlCollectionPage } from './crawl'
35 import { sendCreateVideo, sendUpdateVideo } from './send'
36 import { isArray } from '../../helpers/custom-validators/misc'
37 import { VideoCaptionModel } from '../../models/video/video-caption'
38 import { JobQueue } from '../job-queue'
39 import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
40 import { createRates } from './video-rates'
41 import { addVideoShares, shareVideoByServerAndChannel } from './share'
42 import { AccountModel } from '../../models/account/account'
43 import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
44 import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
45 import { Notifier } from '../notifier'
46 import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
47 import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
48 import { AccountVideoRateModel } from '../../models/account/account-video-rate'
49 import { VideoShareModel } from '../../models/video/video-share'
50 import { VideoCommentModel } from '../../models/video/video-comment'
51 import { sequelizeTypescript } from '../../initializers/database'
52 import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
53 import { ThumbnailModel } from '../../models/video/thumbnail'
54 import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
55 import { join } from 'path'
56 import { FilteredModelAttributes } from '../../typings/sequelize'
57
58 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
59 // If the video is not private and is published, we federate it
60 if (video.privacy !== VideoPrivacy.PRIVATE && video.state === VideoState.PUBLISHED) {
61 // Fetch more attributes that we will need to serialize in AP object
62 if (isArray(video.VideoCaptions) === false) {
63 video.VideoCaptions = await video.$get('VideoCaptions', {
64 attributes: [ 'language' ],
65 transaction
66 }) as VideoCaptionModel[]
67 }
68
69 if (isNewVideo) {
70 // Now we'll add the video's meta data to our followers
71 await sendCreateVideo(video, transaction)
72 await shareVideoByServerAndChannel(video, transaction)
73 } else {
74 await sendUpdateVideo(video, transaction)
75 }
76 }
77 }
78
79 async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
80 const options = {
81 uri: videoUrl,
82 method: 'GET',
83 json: true,
84 activityPub: true
85 }
86
87 logger.info('Fetching remote video %s.', videoUrl)
88
89 const { response, body } = await doRequest(options)
90
91 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
92 logger.debug('Remote video JSON is not valid.', { body })
93 return { response, videoObject: undefined }
94 }
95
96 return { response, videoObject: body }
97 }
98
99 async function fetchRemoteVideoDescription (video: VideoModel) {
100 const host = video.VideoChannel.Account.Actor.Server.host
101 const path = video.getDescriptionAPIPath()
102 const options = {
103 uri: REMOTE_SCHEME.HTTP + '://' + host + path,
104 json: true
105 }
106
107 const { body } = await doRequest(options)
108 return body.description ? body.description : ''
109 }
110
111 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, destPath: string) {
112 const url = buildRemoteBaseUrl(video, path)
113
114 // We need to provide a callback, if no we could have an uncaught exception
115 return doRequestAndSaveToFile({ uri: url }, destPath)
116 }
117
118 function buildRemoteBaseUrl (video: VideoModel, path: string) {
119 const host = video.VideoChannel.Account.Actor.Server.host
120
121 return REMOTE_SCHEME.HTTP + '://' + host + path
122 }
123
124 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
125 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
126 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
127
128 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
129 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
130 }
131
132 return getOrCreateActorAndServerAndModel(channel.id, 'all')
133 }
134
135 type SyncParam = {
136 likes: boolean
137 dislikes: boolean
138 shares: boolean
139 comments: boolean
140 thumbnail: boolean
141 refreshVideo?: boolean
142 }
143 async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
144 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
145
146 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
147
148 if (syncParam.likes === true) {
149 const handler = items => createRates(items, video, 'like')
150 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
151
152 await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
153 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err }))
154 } else {
155 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
156 }
157
158 if (syncParam.dislikes === true) {
159 const handler = items => createRates(items, video, 'dislike')
160 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
161
162 await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
163 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err }))
164 } else {
165 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
166 }
167
168 if (syncParam.shares === true) {
169 const handler = items => addVideoShares(items, video)
170 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
171
172 await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
173 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err }))
174 } else {
175 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
176 }
177
178 if (syncParam.comments === true) {
179 const handler = items => addVideoComments(items, video)
180 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
181
182 await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
183 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err }))
184 } else {
185 jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
186 }
187
188 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }))
189 }
190
191 async function getOrCreateVideoAndAccountAndChannel (options: {
192 videoObject: { id: string } | string,
193 syncParam?: SyncParam,
194 fetchType?: VideoFetchByUrlType,
195 allowRefresh?: boolean // true by default
196 }) {
197 // Default params
198 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
199 const fetchType = options.fetchType || 'all'
200 const allowRefresh = options.allowRefresh !== false
201
202 // Get video url
203 const videoUrl = getAPId(options.videoObject)
204
205 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
206 if (videoFromDatabase) {
207 if (videoFromDatabase.isOutdated() && allowRefresh === true) {
208 const refreshOptions = {
209 video: videoFromDatabase,
210 fetchedType: fetchType,
211 syncParam
212 }
213
214 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
215 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoFromDatabase.url } })
216 }
217
218 return { video: videoFromDatabase, created: false }
219 }
220
221 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
222 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
223
224 const channelActor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
225 const video = await retryTransactionWrapper(createVideo, fetchedVideo, channelActor, syncParam.thumbnail)
226
227 await syncVideoExternalAttributes(video, fetchedVideo, syncParam)
228
229 return { video, created: true }
230 }
231
232 async function updateVideoFromAP (options: {
233 video: VideoModel,
234 videoObject: VideoTorrentObject,
235 account: AccountModel,
236 channel: VideoChannelModel,
237 overrideTo?: string[]
238 }) {
239 logger.debug('Updating remote video "%s".', options.videoObject.uuid)
240
241 let videoFieldsSave: any
242 const wasPrivateVideo = options.video.privacy === VideoPrivacy.PRIVATE
243 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
244
245 try {
246 let thumbnailModel: ThumbnailModel
247
248 try {
249 thumbnailModel = await createVideoMiniatureFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.MINIATURE)
250 } catch (err) {
251 logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
252 }
253
254 await sequelizeTypescript.transaction(async t => {
255 const sequelizeOptions = { transaction: t }
256
257 videoFieldsSave = options.video.toJSON()
258
259 // Check actor has the right to update the video
260 const videoChannel = options.video.VideoChannel
261 if (videoChannel.Account.id !== options.account.id) {
262 throw new Error('Account ' + options.account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
263 }
264
265 const to = options.overrideTo ? options.overrideTo : options.videoObject.to
266 const videoData = await videoActivityObjectToDBAttributes(options.channel, options.videoObject, to)
267 options.video.set('name', videoData.name)
268 options.video.set('uuid', videoData.uuid)
269 options.video.set('url', videoData.url)
270 options.video.set('category', videoData.category)
271 options.video.set('licence', videoData.licence)
272 options.video.set('language', videoData.language)
273 options.video.set('description', videoData.description)
274 options.video.set('support', videoData.support)
275 options.video.set('nsfw', videoData.nsfw)
276 options.video.set('commentsEnabled', videoData.commentsEnabled)
277 options.video.set('downloadEnabled', videoData.downloadEnabled)
278 options.video.set('waitTranscoding', videoData.waitTranscoding)
279 options.video.set('state', videoData.state)
280 options.video.set('duration', videoData.duration)
281 options.video.set('createdAt', videoData.createdAt)
282 options.video.set('publishedAt', videoData.publishedAt)
283 options.video.set('originallyPublishedAt', videoData.originallyPublishedAt)
284 options.video.set('privacy', videoData.privacy)
285 options.video.set('channelId', videoData.channelId)
286 options.video.set('views', videoData.views)
287
288 await options.video.save(sequelizeOptions)
289
290 if (thumbnailModel) if (thumbnailModel) await options.video.addAndSaveThumbnail(thumbnailModel, t)
291
292 // FIXME: use icon URL instead
293 const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
294 const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
295 await options.video.addAndSaveThumbnail(previewModel, t)
296
297 {
298 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
299 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
300
301 // Remove video files that do not exist anymore
302 const destroyTasks = options.video.VideoFiles
303 .filter(f => !newVideoFiles.find(newFile => newFile.hasSameUniqueKeysThan(f)))
304 .map(f => f.destroy(sequelizeOptions))
305 await Promise.all(destroyTasks)
306
307 // Update or add other one
308 const upsertTasks = videoFileAttributes.map(a => {
309 return VideoFileModel.upsert<VideoFileModel>(a, { returning: true, transaction: t })
310 .then(([ file ]) => file)
311 })
312
313 options.video.VideoFiles = await Promise.all(upsertTasks)
314 }
315
316 {
317 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(
318 options.video,
319 options.videoObject,
320 options.video.VideoFiles
321 )
322 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
323
324 // Remove video files that do not exist anymore
325 const destroyTasks = options.video.VideoStreamingPlaylists
326 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
327 .map(f => f.destroy(sequelizeOptions))
328 await Promise.all(destroyTasks)
329
330 // Update or add other one
331 const upsertTasks = streamingPlaylistAttributes.map(a => {
332 return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
333 .then(([ streamingPlaylist ]) => streamingPlaylist)
334 })
335
336 options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
337 }
338
339 {
340 // Update Tags
341 const tags = options.videoObject.tag.map(tag => tag.name)
342 const tagInstances = await TagModel.findOrCreateTags(tags, t)
343 await options.video.$set('Tags', tagInstances, sequelizeOptions)
344 }
345
346 {
347 // Update captions
348 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(options.video.id, t)
349
350 const videoCaptionsPromises = options.videoObject.subtitleLanguage.map(c => {
351 return VideoCaptionModel.insertOrReplaceLanguage(options.video.id, c.identifier, t)
352 })
353 options.video.VideoCaptions = await Promise.all(videoCaptionsPromises)
354 }
355 })
356
357 // Notify our users?
358 if (wasPrivateVideo || wasUnlistedVideo) {
359 Notifier.Instance.notifyOnNewVideo(options.video)
360 }
361
362 logger.info('Remote video with uuid %s updated', options.videoObject.uuid)
363 } catch (err) {
364 if (options.video !== undefined && videoFieldsSave !== undefined) {
365 resetSequelizeInstance(options.video, videoFieldsSave)
366 }
367
368 // This is just a debug because we will retry the insert
369 logger.debug('Cannot update the remote video.', { err })
370 throw err
371 }
372 }
373
374 async function refreshVideoIfNeeded (options: {
375 video: VideoModel,
376 fetchedType: VideoFetchByUrlType,
377 syncParam: SyncParam
378 }): Promise<VideoModel> {
379 if (!options.video.isOutdated()) return options.video
380
381 // We need more attributes if the argument video was fetched with not enough joints
382 const video = options.fetchedType === 'all' ? options.video : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
383
384 try {
385 const { response, videoObject } = await fetchRemoteVideo(video.url)
386 if (response.statusCode === 404) {
387 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
388
389 // Video does not exist anymore
390 await video.destroy()
391 return undefined
392 }
393
394 if (videoObject === undefined) {
395 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
396
397 await video.setAsRefreshed()
398 return video
399 }
400
401 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
402 const account = await AccountModel.load(channelActor.VideoChannel.accountId)
403
404 const updateOptions = {
405 video,
406 videoObject,
407 account,
408 channel: channelActor.VideoChannel
409 }
410 await retryTransactionWrapper(updateVideoFromAP, updateOptions)
411 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
412
413 return video
414 } catch (err) {
415 logger.warn('Cannot refresh video %s.', options.video.url, { err })
416
417 // Don't refresh in loop
418 await video.setAsRefreshed()
419 return video
420 }
421 }
422
423 export {
424 updateVideoFromAP,
425 refreshVideoIfNeeded,
426 federateVideoIfNeeded,
427 fetchRemoteVideo,
428 getOrCreateVideoAndAccountAndChannel,
429 fetchRemoteVideoStaticFile,
430 fetchRemoteVideoDescription,
431 getOrCreateVideoChannelFromVideoObject
432 }
433
434 // ---------------------------------------------------------------------------
435
436 function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
437 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
438
439 const urlMediaType = url.mediaType || url.mimeType
440 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
441 }
442
443 function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
444 const urlMediaType = url.mediaType || url.mimeType
445
446 return urlMediaType === 'application/x-mpegURL'
447 }
448
449 function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
450 const urlMediaType = tag.mediaType || tag.mimeType
451
452 return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
453 }
454
455 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
456 logger.debug('Adding remote video %s.', videoObject.id)
457
458 const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
459 const video = VideoModel.build(videoData)
460
461 const promiseThumbnail = createVideoMiniatureFromUrl(videoObject.icon.url, video, ThumbnailType.MINIATURE)
462
463 let thumbnailModel: ThumbnailModel
464 if (waitThumbnail === true) {
465 thumbnailModel = await promiseThumbnail
466 }
467
468 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
469 const sequelizeOptions = { transaction: t }
470
471 const videoCreated = await video.save(sequelizeOptions)
472 videoCreated.VideoChannel = channelActor.VideoChannel
473
474 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
475
476 // FIXME: use icon URL instead
477 const previewUrl = buildRemoteBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName()))
478 const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
479 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
480
481 // Process files
482 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
483 if (videoFileAttributes.length === 0) {
484 throw new Error('Cannot find valid files for video %s ' + videoObject.url)
485 }
486
487 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
488 const videoFiles = await Promise.all(videoFilePromises)
489
490 const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
491 const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
492 await Promise.all(playlistPromises)
493
494 // Process tags
495 const tags = videoObject.tag
496 .filter(t => t.type === 'Hashtag')
497 .map(t => t.name)
498 const tagInstances = await TagModel.findOrCreateTags(tags, t)
499 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
500
501 // Process captions
502 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
503 return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, t)
504 })
505 await Promise.all(videoCaptionsPromises)
506
507 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
508
509 return videoCreated
510 })
511
512 if (waitThumbnail === false) {
513 promiseThumbnail.then(thumbnailModel => {
514 thumbnailModel = videoCreated.id
515
516 return thumbnailModel.save()
517 })
518 }
519
520 return videoCreated
521 }
522
523 async function videoActivityObjectToDBAttributes (
524 videoChannel: VideoChannelModel,
525 videoObject: VideoTorrentObject,
526 to: string[] = []
527 ) {
528 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED
529 const duration = videoObject.duration.replace(/[^\d]+/, '')
530
531 let language: string | undefined
532 if (videoObject.language) {
533 language = videoObject.language.identifier
534 }
535
536 let category: number | undefined
537 if (videoObject.category) {
538 category = parseInt(videoObject.category.identifier, 10)
539 }
540
541 let licence: number | undefined
542 if (videoObject.licence) {
543 licence = parseInt(videoObject.licence.identifier, 10)
544 }
545
546 const description = videoObject.content || null
547 const support = videoObject.support || null
548
549 return {
550 name: videoObject.name,
551 uuid: videoObject.uuid,
552 url: videoObject.id,
553 category,
554 licence,
555 language,
556 description,
557 support,
558 nsfw: videoObject.sensitive,
559 commentsEnabled: videoObject.commentsEnabled,
560 downloadEnabled: videoObject.downloadEnabled,
561 waitTranscoding: videoObject.waitTranscoding,
562 state: videoObject.state,
563 channelId: videoChannel.id,
564 duration: parseInt(duration, 10),
565 createdAt: new Date(videoObject.published),
566 publishedAt: new Date(videoObject.published),
567 originallyPublishedAt: videoObject.originallyPublishedAt ? new Date(videoObject.originallyPublishedAt) : null,
568 // FIXME: updatedAt does not seems to be considered by Sequelize
569 updatedAt: new Date(videoObject.updated),
570 views: videoObject.views,
571 likes: 0,
572 dislikes: 0,
573 remote: true,
574 privacy
575 }
576 }
577
578 function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
579 const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
580
581 if (fileUrls.length === 0) {
582 throw new Error('Cannot find video files for ' + video.url)
583 }
584
585 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
586 for (const fileUrl of fileUrls) {
587 // Fetch associated magnet uri
588 const magnet = videoObject.url.find(u => {
589 const mediaType = u.mediaType || u.mimeType
590 return mediaType === 'application/x-bittorrent;x-scheme-handler/magnet' && (u as any).height === fileUrl.height
591 })
592
593 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
594
595 const parsed = magnetUtil.decode(magnet.href)
596 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
597 throw new Error('Cannot parse magnet URI ' + magnet.href)
598 }
599
600 const mediaType = fileUrl.mediaType || fileUrl.mimeType
601 const attribute = {
602 extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
603 infoHash: parsed.infoHash,
604 resolution: fileUrl.height,
605 size: fileUrl.size,
606 videoId: video.id,
607 fps: fileUrl.fps || -1
608 }
609
610 attributes.push(attribute)
611 }
612
613 return attributes
614 }
615
616 function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject, videoFiles: VideoFileModel[]) {
617 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
618 if (playlistUrls.length === 0) return []
619
620 const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
621 for (const playlistUrlObject of playlistUrls) {
622 const segmentsSha256UrlObject = playlistUrlObject.tag
623 .find(t => {
624 return isAPPlaylistSegmentHashesUrlObject(t)
625 }) as ActivityPlaylistSegmentHashesObject
626 if (!segmentsSha256UrlObject) {
627 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
628 continue
629 }
630
631 const attribute = {
632 type: VideoStreamingPlaylistType.HLS,
633 playlistUrl: playlistUrlObject.href,
634 segmentsSha256Url: segmentsSha256UrlObject.href,
635 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, videoFiles),
636 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
637 videoId: video.id
638 }
639
640 attributes.push(attribute)
641 }
642
643 return attributes
644 }