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