aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/activitypub/videos/shared
diff options
context:
space:
mode:
Diffstat (limited to 'server/lib/activitypub/videos/shared')
-rw-r--r--server/lib/activitypub/videos/shared/index.ts4
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts234
-rw-r--r--server/lib/activitypub/videos/shared/trackers.ts43
-rw-r--r--server/lib/activitypub/videos/shared/video-create.ts167
-rw-r--r--server/lib/activitypub/videos/shared/video-sync-attributes.ts75
5 files changed, 523 insertions, 0 deletions
diff --git a/server/lib/activitypub/videos/shared/index.ts b/server/lib/activitypub/videos/shared/index.ts
new file mode 100644
index 000000000..4d24fbc6a
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/index.ts
@@ -0,0 +1,4 @@
1export * from './object-to-model-attributes'
2export * from './trackers'
3export * from './video-create'
4export * from './video-sync-attributes'
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
new file mode 100644
index 000000000..8a8105500
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -0,0 +1,234 @@
1import { maxBy, minBy } from 'lodash'
2import * as magnetUtil from 'magnet-uri'
3import { basename } from 'path'
4import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos'
5import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos'
6import { logger } from '@server/helpers/logger'
7import { getExtFromMimetype } from '@server/helpers/video'
8import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants'
9import { generateTorrentFileName } from '@server/lib/video-paths'
10import { VideoFileModel } from '@server/models/video/video-file'
11import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
12import { FilteredModelAttributes } from '@server/types'
13import { MChannelId, MStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoId } from '@server/types/models'
14import {
15 ActivityHashTagObject,
16 ActivityMagnetUrlObject,
17 ActivityPlaylistSegmentHashesObject,
18 ActivityPlaylistUrlObject,
19 ActivityTagObject,
20 ActivityUrlObject,
21 ActivityVideoUrlObject,
22 VideoObject,
23 VideoPrivacy,
24 VideoStreamingPlaylistType
25} from '@shared/models'
26
27function getThumbnailFromIcons (videoObject: VideoObject) {
28 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
29 // Fallback if there are not valid icons
30 if (validIcons.length === 0) validIcons = videoObject.icon
31
32 return minBy(validIcons, 'width')
33}
34
35function getPreviewFromIcons (videoObject: VideoObject) {
36 const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
37
38 return maxBy(validIcons, 'width')
39}
40
41function getTagsFromObject (videoObject: VideoObject) {
42 return videoObject.tag
43 .filter(isAPHashTagObject)
44 .map(t => t.name)
45}
46
47function videoFileActivityUrlToDBAttributes (
48 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
49 urls: (ActivityTagObject | ActivityUrlObject)[]
50) {
51 const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
52
53 if (fileUrls.length === 0) return []
54
55 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
56 for (const fileUrl of fileUrls) {
57 // Fetch associated magnet uri
58 const magnet = urls.filter(isAPMagnetUrlObject)
59 .find(u => u.height === fileUrl.height)
60
61 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
62
63 const parsed = magnetUtil.decode(magnet.href)
64 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
65 throw new Error('Cannot parse magnet URI ' + magnet.href)
66 }
67
68 const torrentUrl = Array.isArray(parsed.xs)
69 ? parsed.xs[0]
70 : parsed.xs
71
72 // Fetch associated metadata url, if any
73 const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
74 .find(u => {
75 return u.height === fileUrl.height &&
76 u.fps === fileUrl.fps &&
77 u.rel.includes(fileUrl.mediaType)
78 })
79
80 const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
81 const resolution = fileUrl.height
82 const videoId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id
83 const videoStreamingPlaylistId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? videoOrPlaylist.id : null
84
85 const attribute = {
86 extname,
87 infoHash: parsed.infoHash,
88 resolution,
89 size: fileUrl.size,
90 fps: fileUrl.fps || -1,
91 metadataUrl: metadata?.href,
92
93 // Use the name of the remote file because we don't proxify video file requests
94 filename: basename(fileUrl.href),
95 fileUrl: fileUrl.href,
96
97 torrentUrl,
98 // Use our own torrent name since we proxify torrent requests
99 torrentFilename: generateTorrentFileName(videoOrPlaylist, resolution),
100
101 // This is a video file owned by a video or by a streaming playlist
102 videoId,
103 videoStreamingPlaylistId
104 }
105
106 attributes.push(attribute)
107 }
108
109 return attributes
110}
111
112function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
113 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
114 if (playlistUrls.length === 0) return []
115
116 const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
117 for (const playlistUrlObject of playlistUrls) {
118 const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
119
120 let files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
121
122 // FIXME: backward compatibility introduced in v2.1.0
123 if (files.length === 0) files = videoFiles
124
125 if (!segmentsSha256UrlObject) {
126 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
127 continue
128 }
129
130 const attribute = {
131 type: VideoStreamingPlaylistType.HLS,
132 playlistUrl: playlistUrlObject.href,
133 segmentsSha256Url: segmentsSha256UrlObject.href,
134 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
135 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
136 videoId: video.id,
137 tagAPObject: playlistUrlObject.tag
138 }
139
140 attributes.push(attribute)
141 }
142
143 return attributes
144}
145
146function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
147 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
148 ? VideoPrivacy.PUBLIC
149 : VideoPrivacy.UNLISTED
150
151 const duration = videoObject.duration.replace(/[^\d]+/, '')
152 const language = videoObject.language?.identifier
153
154 const category = videoObject.category
155 ? parseInt(videoObject.category.identifier, 10)
156 : undefined
157
158 const licence = videoObject.licence
159 ? parseInt(videoObject.licence.identifier, 10)
160 : undefined
161
162 const description = videoObject.content || null
163 const support = videoObject.support || null
164
165 return {
166 name: videoObject.name,
167 uuid: videoObject.uuid,
168 url: videoObject.id,
169 category,
170 licence,
171 language,
172 description,
173 support,
174 nsfw: videoObject.sensitive,
175 commentsEnabled: videoObject.commentsEnabled,
176 downloadEnabled: videoObject.downloadEnabled,
177 waitTranscoding: videoObject.waitTranscoding,
178 isLive: videoObject.isLiveBroadcast,
179 state: videoObject.state,
180 channelId: videoChannel.id,
181 duration: parseInt(duration, 10),
182 createdAt: new Date(videoObject.published),
183 publishedAt: new Date(videoObject.published),
184
185 originallyPublishedAt: videoObject.originallyPublishedAt
186 ? new Date(videoObject.originallyPublishedAt)
187 : null,
188
189 updatedAt: new Date(videoObject.updated),
190 views: videoObject.views,
191 likes: 0,
192 dislikes: 0,
193 remote: true,
194 privacy
195 }
196}
197
198// ---------------------------------------------------------------------------
199
200export {
201 getThumbnailFromIcons,
202 getPreviewFromIcons,
203
204 getTagsFromObject,
205
206 videoActivityObjectToDBAttributes,
207
208 videoFileActivityUrlToDBAttributes,
209 streamingPlaylistActivityUrlToDBAttributes
210}
211
212// ---------------------------------------------------------------------------
213
214function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
215 const urlMediaType = url.mediaType
216
217 return MIMETYPES.VIDEO.MIMETYPE_EXT[urlMediaType] && urlMediaType.startsWith('video/')
218}
219
220function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
221 return url && url.mediaType === 'application/x-mpegURL'
222}
223
224function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
225 return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
226}
227
228function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
229 return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
230}
231
232function isAPHashTagObject (url: any): url is ActivityHashTagObject {
233 return url && url.type === 'Hashtag'
234}
diff --git a/server/lib/activitypub/videos/shared/trackers.ts b/server/lib/activitypub/videos/shared/trackers.ts
new file mode 100644
index 000000000..fcb2a5091
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/trackers.ts
@@ -0,0 +1,43 @@
1import { Transaction } from 'sequelize/types'
2import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub'
3import { isAPVideoTrackerUrlObject } from '@server/helpers/custom-validators/activitypub/videos'
4import { isArray } from '@server/helpers/custom-validators/misc'
5import { REMOTE_SCHEME } from '@server/initializers/constants'
6import { TrackerModel } from '@server/models/server/tracker'
7import { MVideo, MVideoWithHost } from '@server/types/models'
8import { ActivityTrackerUrlObject, VideoObject } from '@shared/models'
9
10function getTrackerUrls (object: VideoObject, video: MVideoWithHost) {
11 let wsFound = false
12
13 const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u))
14 .map((u: ActivityTrackerUrlObject) => {
15 if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true
16
17 return u.href
18 })
19
20 if (wsFound) return trackers
21
22 return [
23 buildRemoteVideoBaseUrl(video, '/tracker/socket', REMOTE_SCHEME.WS),
24 buildRemoteVideoBaseUrl(video, '/tracker/announce')
25 ]
26}
27
28async function setVideoTrackers (options: {
29 video: MVideo
30 trackers: string[]
31 transaction?: Transaction
32}) {
33 const { video, trackers, transaction } = options
34
35 const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction)
36
37 await video.$set('Trackers', trackerInstances, { transaction })
38}
39
40export {
41 getTrackerUrls,
42 setVideoTrackers
43}
diff --git a/server/lib/activitypub/videos/shared/video-create.ts b/server/lib/activitypub/videos/shared/video-create.ts
new file mode 100644
index 000000000..80cc2ab37
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/video-create.ts
@@ -0,0 +1,167 @@
1import { logger } from '@server/helpers/logger'
2import { sequelizeTypescript } from '@server/initializers/database'
3import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail'
4import { setVideoTags } from '@server/lib/video'
5import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
6import { VideoModel } from '@server/models/video/video'
7import { VideoCaptionModel } from '@server/models/video/video-caption'
8import { VideoFileModel } from '@server/models/video/video-file'
9import { VideoLiveModel } from '@server/models/video/video-live'
10import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
11import {
12 MChannelAccountLight,
13 MStreamingPlaylistFilesVideo,
14 MThumbnail,
15 MVideoCaption,
16 MVideoFullLight,
17 MVideoThumbnail
18} from '@server/types/models'
19import { ThumbnailType, VideoObject } from '@shared/models'
20import {
21 getPreviewFromIcons,
22 getTagsFromObject,
23 getThumbnailFromIcons,
24 streamingPlaylistActivityUrlToDBAttributes,
25 videoActivityObjectToDBAttributes,
26 videoFileActivityUrlToDBAttributes
27} from './object-to-model-attributes'
28import { getTrackerUrls, setVideoTrackers } from './trackers'
29
30async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) {
31 logger.debug('Adding remote video %s.', videoObject.id)
32
33 const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to)
34 const video = VideoModel.build(videoData) as MVideoThumbnail
35
36 const promiseThumbnail = createVideoMiniatureFromUrl({
37 downloadUrl: getThumbnailFromIcons(videoObject).url,
38 video,
39 type: ThumbnailType.MINIATURE
40 }).catch(err => {
41 logger.error('Cannot create miniature from url.', { err })
42 return undefined
43 })
44
45 let thumbnailModel: MThumbnail
46 if (waitThumbnail === true) {
47 thumbnailModel = await promiseThumbnail
48 }
49
50 const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
51 try {
52 const sequelizeOptions = { transaction: t }
53
54 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
55 videoCreated.VideoChannel = channel
56
57 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
58
59 const previewIcon = getPreviewFromIcons(videoObject)
60 if (previewIcon) {
61 const previewModel = createPlaceholderThumbnail({
62 fileUrl: previewIcon.url,
63 video: videoCreated,
64 type: ThumbnailType.PREVIEW,
65 size: previewIcon
66 })
67
68 await videoCreated.addAndSaveThumbnail(previewModel, t)
69 }
70
71 // Process files
72 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject.url)
73
74 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
75 const videoFiles = await Promise.all(videoFilePromises)
76
77 const streamingPlaylistsAttributes = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
78 videoCreated.VideoStreamingPlaylists = []
79
80 for (const playlistAttributes of streamingPlaylistsAttributes) {
81 const playlist = await VideoStreamingPlaylistModel.create(playlistAttributes, { transaction: t }) as MStreamingPlaylistFilesVideo
82 playlist.Video = videoCreated
83
84 const playlistFiles = videoFileActivityUrlToDBAttributes(playlist, playlistAttributes.tagAPObject)
85 const videoFilePromises = playlistFiles.map(f => VideoFileModel.create(f, { transaction: t }))
86 playlist.VideoFiles = await Promise.all(videoFilePromises)
87
88 videoCreated.VideoStreamingPlaylists.push(playlist)
89 }
90
91 // Process tags
92 const tags = getTagsFromObject(videoObject)
93 await setVideoTags({ video: videoCreated, tags, transaction: t })
94
95 // Process captions
96 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
97 const caption = new VideoCaptionModel({
98 videoId: videoCreated.id,
99 filename: VideoCaptionModel.generateCaptionName(c.identifier),
100 language: c.identifier,
101 fileUrl: c.url
102 }) as MVideoCaption
103
104 return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
105 })
106 await Promise.all(videoCaptionsPromises)
107
108 // Process trackers
109 {
110 const trackers = getTrackerUrls(videoObject, videoCreated)
111 await setVideoTrackers({ video: videoCreated, trackers, transaction: t })
112 }
113
114 videoCreated.VideoFiles = videoFiles
115
116 if (videoCreated.isLive) {
117 const videoLive = new VideoLiveModel({
118 streamKey: null,
119 saveReplay: videoObject.liveSaveReplay,
120 permanentLive: videoObject.permanentLive,
121 videoId: videoCreated.id
122 })
123
124 videoCreated.VideoLive = await videoLive.save({ transaction: t })
125 }
126
127 // We added a video in this channel, set it as updated
128 await channel.setAsUpdated(t)
129
130 const autoBlacklisted = await autoBlacklistVideoIfNeeded({
131 video: videoCreated,
132 user: undefined,
133 isRemote: true,
134 isNew: true,
135 transaction: t
136 })
137
138 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
139
140 return { autoBlacklisted, videoCreated }
141 } catch (err) {
142 // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
143 // Remove thumbnail
144 if (thumbnailModel) await thumbnailModel.removeThumbnail()
145
146 throw err
147 }
148 })
149
150 if (waitThumbnail === false) {
151 // Error is already caught above
152 // eslint-disable-next-line @typescript-eslint/no-floating-promises
153 promiseThumbnail.then(thumbnailModel => {
154 if (!thumbnailModel) return
155
156 thumbnailModel = videoCreated.id
157
158 return thumbnailModel.save()
159 })
160 }
161
162 return { autoBlacklisted, videoCreated }
163}
164
165export {
166 createVideo
167}
diff --git a/server/lib/activitypub/videos/shared/video-sync-attributes.ts b/server/lib/activitypub/videos/shared/video-sync-attributes.ts
new file mode 100644
index 000000000..181893c68
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/video-sync-attributes.ts
@@ -0,0 +1,75 @@
1import { logger } from '@server/helpers/logger'
2import { JobQueue } from '@server/lib/job-queue'
3import { AccountVideoRateModel } from '@server/models/account/account-video-rate'
4import { VideoCommentModel } from '@server/models/video/video-comment'
5import { VideoShareModel } from '@server/models/video/video-share'
6import { MVideo } from '@server/types/models'
7import { ActivitypubHttpFetcherPayload, VideoObject } from '@shared/models'
8import { crawlCollectionPage } from '../../crawl'
9import { addVideoShares } from '../../share'
10import { addVideoComments } from '../../video-comments'
11import { createRates } from '../../video-rates'
12
13import Bluebird = require('bluebird')
14
15type SyncParam = {
16 likes: boolean
17 dislikes: boolean
18 shares: boolean
19 comments: boolean
20 thumbnail: boolean
21 refreshVideo?: boolean
22}
23
24async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoObject, syncParam: SyncParam) {
25 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
26
27 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
28
29 if (syncParam.likes === true) {
30 const handler = items => createRates(items, video, 'like')
31 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
32
33 await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
34 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.likes }))
35 } else {
36 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
37 }
38
39 if (syncParam.dislikes === true) {
40 const handler = items => createRates(items, video, 'dislike')
41 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
42
43 await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
44 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.dislikes }))
45 } else {
46 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
47 }
48
49 if (syncParam.shares === true) {
50 const handler = items => addVideoShares(items, video)
51 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
52
53 await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
54 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: fetchedVideo.shares }))
55 } else {
56 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
57 }
58
59 if (syncParam.comments === true) {
60 const handler = items => addVideoComments(items)
61 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
62
63 await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
64 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: fetchedVideo.comments }))
65 } else {
66 jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
67 }
68
69 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload }))
70}
71
72export {
73 SyncParam,
74 syncVideoExternalAttributes
75}