diff options
Diffstat (limited to 'server/lib/activitypub/videos.ts')
-rw-r--r-- | server/lib/activitypub/videos.ts | 273 |
1 files changed, 224 insertions, 49 deletions
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 8bc928b93..708f4a897 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts | |||
@@ -1,15 +1,23 @@ | |||
1 | import * as Bluebird from 'bluebird' | ||
2 | import * as magnetUtil from 'magnet-uri' | ||
1 | import { join } from 'path' | 3 | import { join } from 'path' |
2 | import * as request from 'request' | 4 | import * as request from 'request' |
3 | import { Transaction } from 'sequelize' | ||
4 | import { ActivityIconObject } from '../../../shared/index' | 5 | import { ActivityIconObject } from '../../../shared/index' |
6 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' | ||
7 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
8 | import { isVideoTorrentObjectValid } from '../../helpers/custom-validators/activitypub/videos' | ||
9 | import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' | ||
10 | import { retryTransactionWrapper } from '../../helpers/database-utils' | ||
11 | import { logger } from '../../helpers/logger' | ||
5 | import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' | 12 | import { doRequest, doRequestAndSaveToFile } from '../../helpers/requests' |
6 | import { CONFIG, REMOTE_SCHEME, STATIC_PATHS } from '../../initializers' | 13 | import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, STATIC_PATHS, VIDEO_MIMETYPE_EXT } from '../../initializers' |
7 | import { AccountModel } from '../../models/account/account' | 14 | import { ActorModel } from '../../models/activitypub/actor' |
15 | import { TagModel } from '../../models/video/tag' | ||
8 | import { VideoModel } from '../../models/video/video' | 16 | import { VideoModel } from '../../models/video/video' |
9 | import { | 17 | import { VideoChannelModel } from '../../models/video/video-channel' |
10 | sendCreateDislikeToOrigin, sendCreateDislikeToVideoFollowers, sendLikeToOrigin, sendLikeToVideoFollowers, sendUndoDislikeToOrigin, | 18 | import { VideoFileModel } from '../../models/video/video-file' |
11 | sendUndoDislikeToVideoFollowers, sendUndoLikeToOrigin, sendUndoLikeToVideoFollowers | 19 | import { VideoShareModel } from '../../models/video/video-share' |
12 | } from './send' | 20 | import { getOrCreateActorAndServerAndModel } from './actor' |
13 | 21 | ||
14 | function fetchRemoteVideoPreview (video: VideoModel, reject: Function) { | 22 | function fetchRemoteVideoPreview (video: VideoModel, reject: Function) { |
15 | // FIXME: use url | 23 | // FIXME: use url |
@@ -45,54 +53,221 @@ function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) | |||
45 | return doRequestAndSaveToFile(options, thumbnailPath) | 53 | return doRequestAndSaveToFile(options, thumbnailPath) |
46 | } | 54 | } |
47 | 55 | ||
48 | async function sendVideoRateChangeToFollowers ( | 56 | async function videoActivityObjectToDBAttributes (videoChannel: VideoChannelModel, |
49 | account: AccountModel, | 57 | videoObject: VideoTorrentObject, |
50 | video: VideoModel, | 58 | to: string[] = [], |
51 | likes: number, | 59 | cc: string[] = []) { |
52 | dislikes: number, | 60 | let privacy = VideoPrivacy.PRIVATE |
53 | t: Transaction | 61 | if (to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1) privacy = VideoPrivacy.PUBLIC |
54 | ) { | 62 | else if (cc.indexOf(ACTIVITY_PUB.PUBLIC) !== -1) privacy = VideoPrivacy.UNLISTED |
55 | const actor = account.Actor | 63 | |
56 | 64 | const duration = videoObject.duration.replace(/[^\d]+/, '') | |
57 | // Keep the order: first we undo and then we create | 65 | let language = null |
58 | 66 | if (videoObject.language) { | |
59 | // Undo Like | 67 | language = parseInt(videoObject.language.identifier, 10) |
60 | if (likes < 0) await sendUndoLikeToVideoFollowers(actor, video, t) | 68 | } |
61 | // Undo Dislike | 69 | |
62 | if (dislikes < 0) await sendUndoDislikeToVideoFollowers(actor, video, t) | 70 | let category = null |
63 | 71 | if (videoObject.category) { | |
64 | // Like | 72 | category = parseInt(videoObject.category.identifier, 10) |
65 | if (likes > 0) await sendLikeToVideoFollowers(actor, video, t) | 73 | } |
66 | // Dislike | 74 | |
67 | if (dislikes > 0) await sendCreateDislikeToVideoFollowers(actor, video, t) | 75 | let licence = null |
76 | if (videoObject.licence) { | ||
77 | licence = parseInt(videoObject.licence.identifier, 10) | ||
78 | } | ||
79 | |||
80 | let description = null | ||
81 | if (videoObject.content) { | ||
82 | description = videoObject.content | ||
83 | } | ||
84 | |||
85 | return { | ||
86 | name: videoObject.name, | ||
87 | uuid: videoObject.uuid, | ||
88 | url: videoObject.id, | ||
89 | category, | ||
90 | licence, | ||
91 | language, | ||
92 | description, | ||
93 | nsfw: videoObject.nsfw, | ||
94 | commentsEnabled: videoObject.commentsEnabled, | ||
95 | channelId: videoChannel.id, | ||
96 | duration: parseInt(duration, 10), | ||
97 | createdAt: new Date(videoObject.published), | ||
98 | // FIXME: updatedAt does not seems to be considered by Sequelize | ||
99 | updatedAt: new Date(videoObject.updated), | ||
100 | views: videoObject.views, | ||
101 | likes: 0, | ||
102 | dislikes: 0, | ||
103 | remote: true, | ||
104 | privacy | ||
105 | } | ||
106 | } | ||
107 | |||
108 | function videoFileActivityUrlToDBAttributes (videoCreated: VideoModel, videoObject: VideoTorrentObject) { | ||
109 | const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT) | ||
110 | const fileUrls = videoObject.url.filter(u => { | ||
111 | return mimeTypes.indexOf(u.mimeType) !== -1 && u.mimeType.startsWith('video/') | ||
112 | }) | ||
113 | |||
114 | if (fileUrls.length === 0) { | ||
115 | throw new Error('Cannot find video files for ' + videoCreated.url) | ||
116 | } | ||
117 | |||
118 | const attributes = [] | ||
119 | for (const fileUrl of fileUrls) { | ||
120 | // Fetch associated magnet uri | ||
121 | const magnet = videoObject.url.find(u => { | ||
122 | return u.mimeType === 'application/x-bittorrent;x-scheme-handler/magnet' && u.width === fileUrl.width | ||
123 | }) | ||
124 | |||
125 | if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.url) | ||
126 | |||
127 | const parsed = magnetUtil.decode(magnet.url) | ||
128 | if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) throw new Error('Cannot parse magnet URI ' + magnet.url) | ||
129 | |||
130 | const attribute = { | ||
131 | extname: VIDEO_MIMETYPE_EXT[ fileUrl.mimeType ], | ||
132 | infoHash: parsed.infoHash, | ||
133 | resolution: fileUrl.width, | ||
134 | size: fileUrl.size, | ||
135 | videoId: videoCreated.id | ||
136 | } | ||
137 | attributes.push(attribute) | ||
138 | } | ||
139 | |||
140 | return attributes | ||
141 | } | ||
142 | |||
143 | async function getOrCreateVideo (videoObject: VideoTorrentObject, channelActor: ActorModel) { | ||
144 | logger.debug('Adding remote video %s.', videoObject.id) | ||
145 | |||
146 | return sequelizeTypescript.transaction(async t => { | ||
147 | const sequelizeOptions = { | ||
148 | transaction: t | ||
149 | } | ||
150 | const videoFromDatabase = await VideoModel.loadByUUIDOrURLAndPopulateAccount(videoObject.uuid, videoObject.id, t) | ||
151 | if (videoFromDatabase) return videoFromDatabase | ||
152 | |||
153 | const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to, videoObject.cc) | ||
154 | const video = VideoModel.build(videoData) | ||
155 | |||
156 | // Don't block on request | ||
157 | generateThumbnailFromUrl(video, videoObject.icon) | ||
158 | .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, err)) | ||
159 | |||
160 | const videoCreated = await video.save(sequelizeOptions) | ||
161 | |||
162 | const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject) | ||
163 | if (videoFileAttributes.length === 0) { | ||
164 | throw new Error('Cannot find valid files for video %s ' + videoObject.url) | ||
165 | } | ||
166 | |||
167 | const tasks: Bluebird<any>[] = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) | ||
168 | await Promise.all(tasks) | ||
169 | |||
170 | const tags = videoObject.tag.map(t => t.name) | ||
171 | const tagInstances = await TagModel.findOrCreateTags(tags, t) | ||
172 | await videoCreated.$set('Tags', tagInstances, sequelizeOptions) | ||
173 | |||
174 | logger.info('Remote video with uuid %s inserted.', videoObject.uuid) | ||
175 | |||
176 | videoCreated.VideoChannel = channelActor.VideoChannel | ||
177 | return videoCreated | ||
178 | }) | ||
68 | } | 179 | } |
69 | 180 | ||
70 | async function sendVideoRateChangeToOrigin ( | 181 | async function getOrCreateAccountAndVideoAndChannel (videoObject: VideoTorrentObject | string, actor?: ActorModel) { |
71 | account: AccountModel, | 182 | if (typeof videoObject === 'string') { |
72 | video: VideoModel, | 183 | const videoFromDatabase = await VideoModel.loadByUrlAndPopulateAccount(videoObject) |
73 | likes: number, | 184 | if (videoFromDatabase) { |
74 | dislikes: number, | 185 | return { |
75 | t: Transaction | 186 | video: videoFromDatabase, |
76 | ) { | 187 | actor: videoFromDatabase.VideoChannel.Account.Actor, |
77 | const actor = account.Actor | 188 | channelActor: videoFromDatabase.VideoChannel.Actor |
78 | 189 | } | |
79 | // Keep the order: first we undo and then we create | 190 | } |
80 | 191 | ||
81 | // Undo Like | 192 | videoObject = await fetchRemoteVideo(videoObject) |
82 | if (likes < 0) await sendUndoLikeToOrigin(actor, video, t) | 193 | if (!videoObject) throw new Error('Cannot fetch remote video') |
83 | // Undo Dislike | 194 | } |
84 | if (dislikes < 0) await sendUndoDislikeToOrigin(actor, video, t) | 195 | |
85 | 196 | if (!actor) { | |
86 | // Like | 197 | const actorObj = videoObject.attributedTo.find(a => a.type === 'Person') |
87 | if (likes > 0) await sendLikeToOrigin(actor, video, t) | 198 | if (!actorObj) throw new Error('Cannot find associated actor to video ' + videoObject.url) |
88 | // Dislike | 199 | |
89 | if (dislikes > 0) await sendCreateDislikeToOrigin(actor, video, t) | 200 | actor = await getOrCreateActorAndServerAndModel(actorObj.id) |
201 | } | ||
202 | |||
203 | const channel = videoObject.attributedTo.find(a => a.type === 'Group') | ||
204 | if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) | ||
205 | |||
206 | const channelActor = await getOrCreateActorAndServerAndModel(channel.id) | ||
207 | |||
208 | const options = { | ||
209 | arguments: [ videoObject, channelActor ], | ||
210 | errorMessage: 'Cannot insert the remote video with many retries.' | ||
211 | } | ||
212 | |||
213 | const video = await retryTransactionWrapper(getOrCreateVideo, options) | ||
214 | |||
215 | return { actor, channelActor, video } | ||
216 | } | ||
217 | |||
218 | async function addVideoShares (instance: VideoModel, shareUrls: string[]) { | ||
219 | for (const shareUrl of shareUrls) { | ||
220 | // Fetch url | ||
221 | const { body } = await doRequest({ | ||
222 | uri: shareUrl, | ||
223 | json: true, | ||
224 | activityPub: true | ||
225 | }) | ||
226 | const actorUrl = body.actor | ||
227 | if (!actorUrl) continue | ||
228 | |||
229 | const actor = await getOrCreateActorAndServerAndModel(actorUrl) | ||
230 | |||
231 | const entry = { | ||
232 | actorId: actor.id, | ||
233 | videoId: instance.id | ||
234 | } | ||
235 | |||
236 | await VideoShareModel.findOrCreate({ | ||
237 | where: entry, | ||
238 | defaults: entry | ||
239 | }) | ||
240 | } | ||
90 | } | 241 | } |
91 | 242 | ||
92 | export { | 243 | export { |
244 | getOrCreateAccountAndVideoAndChannel, | ||
93 | fetchRemoteVideoPreview, | 245 | fetchRemoteVideoPreview, |
94 | fetchRemoteVideoDescription, | 246 | fetchRemoteVideoDescription, |
95 | generateThumbnailFromUrl, | 247 | generateThumbnailFromUrl, |
96 | sendVideoRateChangeToFollowers, | 248 | videoActivityObjectToDBAttributes, |
97 | sendVideoRateChangeToOrigin | 249 | videoFileActivityUrlToDBAttributes, |
250 | getOrCreateVideo, | ||
251 | addVideoShares} | ||
252 | |||
253 | // --------------------------------------------------------------------------- | ||
254 | |||
255 | async function fetchRemoteVideo (videoUrl: string): Promise<VideoTorrentObject> { | ||
256 | const options = { | ||
257 | uri: videoUrl, | ||
258 | method: 'GET', | ||
259 | json: true, | ||
260 | activityPub: true | ||
261 | } | ||
262 | |||
263 | logger.info('Fetching remote video %s.', videoUrl) | ||
264 | |||
265 | const { body } = await doRequest(options) | ||
266 | |||
267 | if (isVideoTorrentObjectValid(body) === false) { | ||
268 | logger.debug('Remote video JSON is not valid.', { body }) | ||
269 | return undefined | ||
270 | } | ||
271 | |||
272 | return body | ||
98 | } | 273 | } |