diff options
Diffstat (limited to 'server/models/video/formatter')
-rw-r--r-- | server/models/video/formatter/video-format-utils.ts | 434 |
1 files changed, 434 insertions, 0 deletions
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts new file mode 100644 index 000000000..5ddbf74da --- /dev/null +++ b/server/models/video/formatter/video-format-utils.ts | |||
@@ -0,0 +1,434 @@ | |||
1 | import { generateMagnetUri } from '@server/helpers/webtorrent' | ||
2 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-paths' | ||
3 | import { VideoFile } from '@shared/models/videos/video-file.model' | ||
4 | import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../../shared/models/activitypub/objects' | ||
5 | import { Video, VideoDetails } from '../../../../shared/models/videos' | ||
6 | import { VideoStreamingPlaylist } from '../../../../shared/models/videos/video-streaming-playlist.model' | ||
7 | import { isArray } from '../../../helpers/custom-validators/misc' | ||
8 | import { MIMETYPES, WEBSERVER } from '../../../initializers/constants' | ||
9 | import { | ||
10 | getLocalVideoCommentsActivityPubUrl, | ||
11 | getLocalVideoDislikesActivityPubUrl, | ||
12 | getLocalVideoLikesActivityPubUrl, | ||
13 | getLocalVideoSharesActivityPubUrl | ||
14 | } from '../../../lib/activitypub/url' | ||
15 | import { | ||
16 | MStreamingPlaylistRedundanciesOpt, | ||
17 | MVideo, | ||
18 | MVideoAP, | ||
19 | MVideoFile, | ||
20 | MVideoFormattable, | ||
21 | MVideoFormattableDetails | ||
22 | } from '../../../types/models' | ||
23 | import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file' | ||
24 | import { VideoModel } from '../video' | ||
25 | import { VideoCaptionModel } from '../video-caption' | ||
26 | |||
27 | export type VideoFormattingJSONOptions = { | ||
28 | completeDescription?: boolean | ||
29 | additionalAttributes: { | ||
30 | state?: boolean | ||
31 | waitTranscoding?: boolean | ||
32 | scheduledUpdate?: boolean | ||
33 | blacklistInfo?: boolean | ||
34 | } | ||
35 | } | ||
36 | |||
37 | function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { | ||
38 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined | ||
39 | |||
40 | const videoObject: Video = { | ||
41 | id: video.id, | ||
42 | uuid: video.uuid, | ||
43 | name: video.name, | ||
44 | category: { | ||
45 | id: video.category, | ||
46 | label: VideoModel.getCategoryLabel(video.category) | ||
47 | }, | ||
48 | licence: { | ||
49 | id: video.licence, | ||
50 | label: VideoModel.getLicenceLabel(video.licence) | ||
51 | }, | ||
52 | language: { | ||
53 | id: video.language, | ||
54 | label: VideoModel.getLanguageLabel(video.language) | ||
55 | }, | ||
56 | privacy: { | ||
57 | id: video.privacy, | ||
58 | label: VideoModel.getPrivacyLabel(video.privacy) | ||
59 | }, | ||
60 | nsfw: video.nsfw, | ||
61 | |||
62 | description: options && options.completeDescription === true | ||
63 | ? video.description | ||
64 | : video.getTruncatedDescription(), | ||
65 | |||
66 | isLocal: video.isOwned(), | ||
67 | duration: video.duration, | ||
68 | views: video.views, | ||
69 | likes: video.likes, | ||
70 | dislikes: video.dislikes, | ||
71 | thumbnailPath: video.getMiniatureStaticPath(), | ||
72 | previewPath: video.getPreviewStaticPath(), | ||
73 | embedPath: video.getEmbedStaticPath(), | ||
74 | createdAt: video.createdAt, | ||
75 | updatedAt: video.updatedAt, | ||
76 | publishedAt: video.publishedAt, | ||
77 | originallyPublishedAt: video.originallyPublishedAt, | ||
78 | |||
79 | isLive: video.isLive, | ||
80 | |||
81 | account: video.VideoChannel.Account.toFormattedSummaryJSON(), | ||
82 | channel: video.VideoChannel.toFormattedSummaryJSON(), | ||
83 | |||
84 | userHistory: userHistory | ||
85 | ? { currentTime: userHistory.currentTime } | ||
86 | : undefined, | ||
87 | |||
88 | // Can be added by external plugins | ||
89 | pluginData: (video as any).pluginData | ||
90 | } | ||
91 | |||
92 | if (options) { | ||
93 | if (options.additionalAttributes.state === true) { | ||
94 | videoObject.state = { | ||
95 | id: video.state, | ||
96 | label: VideoModel.getStateLabel(video.state) | ||
97 | } | ||
98 | } | ||
99 | |||
100 | if (options.additionalAttributes.waitTranscoding === true) { | ||
101 | videoObject.waitTranscoding = video.waitTranscoding | ||
102 | } | ||
103 | |||
104 | if (options.additionalAttributes.scheduledUpdate === true && video.ScheduleVideoUpdate) { | ||
105 | videoObject.scheduledUpdate = { | ||
106 | updateAt: video.ScheduleVideoUpdate.updateAt, | ||
107 | privacy: video.ScheduleVideoUpdate.privacy || undefined | ||
108 | } | ||
109 | } | ||
110 | |||
111 | if (options.additionalAttributes.blacklistInfo === true) { | ||
112 | videoObject.blacklisted = !!video.VideoBlacklist | ||
113 | videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null | ||
114 | } | ||
115 | } | ||
116 | |||
117 | return videoObject | ||
118 | } | ||
119 | |||
120 | function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails { | ||
121 | const formattedJson = video.toFormattedJSON({ | ||
122 | additionalAttributes: { | ||
123 | scheduledUpdate: true, | ||
124 | blacklistInfo: true | ||
125 | } | ||
126 | }) | ||
127 | |||
128 | const tags = video.Tags ? video.Tags.map(t => t.name) : [] | ||
129 | |||
130 | const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists) | ||
131 | |||
132 | const detailsJson = { | ||
133 | support: video.support, | ||
134 | descriptionPath: video.getDescriptionAPIPath(), | ||
135 | channel: video.VideoChannel.toFormattedJSON(), | ||
136 | account: video.VideoChannel.Account.toFormattedJSON(), | ||
137 | tags, | ||
138 | commentsEnabled: video.commentsEnabled, | ||
139 | downloadEnabled: video.downloadEnabled, | ||
140 | waitTranscoding: video.waitTranscoding, | ||
141 | state: { | ||
142 | id: video.state, | ||
143 | label: VideoModel.getStateLabel(video.state) | ||
144 | }, | ||
145 | |||
146 | trackerUrls: video.getTrackerUrls(), | ||
147 | |||
148 | files: [], | ||
149 | streamingPlaylists | ||
150 | } | ||
151 | |||
152 | // Format and sort video files | ||
153 | detailsJson.files = videoFilesModelToFormattedJSON(video, video.VideoFiles) | ||
154 | |||
155 | return Object.assign(formattedJson, detailsJson) | ||
156 | } | ||
157 | |||
158 | function streamingPlaylistsModelToFormattedJSON ( | ||
159 | video: MVideoFormattableDetails, | ||
160 | playlists: MStreamingPlaylistRedundanciesOpt[] | ||
161 | ): VideoStreamingPlaylist[] { | ||
162 | if (isArray(playlists) === false) return [] | ||
163 | |||
164 | return playlists | ||
165 | .map(playlist => { | ||
166 | const redundancies = isArray(playlist.RedundancyVideos) | ||
167 | ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl })) | ||
168 | : [] | ||
169 | |||
170 | const files = videoFilesModelToFormattedJSON(video, playlist.VideoFiles) | ||
171 | |||
172 | return { | ||
173 | id: playlist.id, | ||
174 | type: playlist.type, | ||
175 | playlistUrl: playlist.playlistUrl, | ||
176 | segmentsSha256Url: playlist.segmentsSha256Url, | ||
177 | redundancies, | ||
178 | files | ||
179 | } | ||
180 | }) | ||
181 | } | ||
182 | |||
183 | function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) { | ||
184 | if (fileA.resolution < fileB.resolution) return 1 | ||
185 | if (fileA.resolution === fileB.resolution) return 0 | ||
186 | return -1 | ||
187 | } | ||
188 | |||
189 | function videoFilesModelToFormattedJSON ( | ||
190 | video: MVideoFormattableDetails, | ||
191 | videoFiles: MVideoFileRedundanciesOpt[], | ||
192 | includeMagnet = true | ||
193 | ): VideoFile[] { | ||
194 | const trackerUrls = includeMagnet | ||
195 | ? video.getTrackerUrls() | ||
196 | : [] | ||
197 | |||
198 | return [ ...videoFiles ] | ||
199 | .filter(f => !f.isLive()) | ||
200 | .sort(sortByResolutionDesc) | ||
201 | .map(videoFile => { | ||
202 | return { | ||
203 | resolution: { | ||
204 | id: videoFile.resolution, | ||
205 | label: videoFile.resolution + 'p' | ||
206 | }, | ||
207 | |||
208 | magnetUri: includeMagnet && videoFile.hasTorrent() | ||
209 | ? generateMagnetUri(video, videoFile, trackerUrls) | ||
210 | : undefined, | ||
211 | |||
212 | size: videoFile.size, | ||
213 | fps: videoFile.fps, | ||
214 | |||
215 | torrentUrl: videoFile.getTorrentUrl(), | ||
216 | torrentDownloadUrl: videoFile.getTorrentDownloadUrl(), | ||
217 | |||
218 | fileUrl: videoFile.getFileUrl(video), | ||
219 | fileDownloadUrl: videoFile.getFileDownloadUrl(video), | ||
220 | |||
221 | metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile) | ||
222 | } as VideoFile | ||
223 | }) | ||
224 | } | ||
225 | |||
226 | function addVideoFilesInAPAcc ( | ||
227 | acc: ActivityUrlObject[] | ActivityTagObject[], | ||
228 | video: MVideo, | ||
229 | files: MVideoFile[] | ||
230 | ) { | ||
231 | const trackerUrls = video.getTrackerUrls() | ||
232 | |||
233 | const sortedFiles = [ ...files ] | ||
234 | .filter(f => !f.isLive()) | ||
235 | .sort(sortByResolutionDesc) | ||
236 | |||
237 | for (const file of sortedFiles) { | ||
238 | acc.push({ | ||
239 | type: 'Link', | ||
240 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any, | ||
241 | href: file.getFileUrl(video), | ||
242 | height: file.resolution, | ||
243 | size: file.size, | ||
244 | fps: file.fps | ||
245 | }) | ||
246 | |||
247 | acc.push({ | ||
248 | type: 'Link', | ||
249 | rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ], | ||
250 | mediaType: 'application/json' as 'application/json', | ||
251 | href: getLocalVideoFileMetadataUrl(video, file), | ||
252 | height: file.resolution, | ||
253 | fps: file.fps | ||
254 | }) | ||
255 | |||
256 | if (file.hasTorrent()) { | ||
257 | acc.push({ | ||
258 | type: 'Link', | ||
259 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', | ||
260 | href: file.getTorrentUrl(), | ||
261 | height: file.resolution | ||
262 | }) | ||
263 | |||
264 | acc.push({ | ||
265 | type: 'Link', | ||
266 | mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', | ||
267 | href: generateMagnetUri(video, file, trackerUrls), | ||
268 | height: file.resolution | ||
269 | }) | ||
270 | } | ||
271 | } | ||
272 | } | ||
273 | |||
274 | function videoModelToActivityPubObject (video: MVideoAP): VideoObject { | ||
275 | if (!video.Tags) video.Tags = [] | ||
276 | |||
277 | const tag = video.Tags.map(t => ({ | ||
278 | type: 'Hashtag' as 'Hashtag', | ||
279 | name: t.name | ||
280 | })) | ||
281 | |||
282 | let language | ||
283 | if (video.language) { | ||
284 | language = { | ||
285 | identifier: video.language, | ||
286 | name: VideoModel.getLanguageLabel(video.language) | ||
287 | } | ||
288 | } | ||
289 | |||
290 | let category | ||
291 | if (video.category) { | ||
292 | category = { | ||
293 | identifier: video.category + '', | ||
294 | name: VideoModel.getCategoryLabel(video.category) | ||
295 | } | ||
296 | } | ||
297 | |||
298 | let licence | ||
299 | if (video.licence) { | ||
300 | licence = { | ||
301 | identifier: video.licence + '', | ||
302 | name: VideoModel.getLicenceLabel(video.licence) | ||
303 | } | ||
304 | } | ||
305 | |||
306 | const url: ActivityUrlObject[] = [ | ||
307 | // HTML url should be the first element in the array so Mastodon correctly displays the embed | ||
308 | { | ||
309 | type: 'Link', | ||
310 | mediaType: 'text/html', | ||
311 | href: WEBSERVER.URL + '/videos/watch/' + video.uuid | ||
312 | } | ||
313 | ] | ||
314 | |||
315 | addVideoFilesInAPAcc(url, video, video.VideoFiles || []) | ||
316 | |||
317 | for (const playlist of (video.VideoStreamingPlaylists || [])) { | ||
318 | const tag = playlist.p2pMediaLoaderInfohashes | ||
319 | .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[] | ||
320 | tag.push({ | ||
321 | type: 'Link', | ||
322 | name: 'sha256', | ||
323 | mediaType: 'application/json' as 'application/json', | ||
324 | href: playlist.segmentsSha256Url | ||
325 | }) | ||
326 | |||
327 | addVideoFilesInAPAcc(tag, video, playlist.VideoFiles || []) | ||
328 | |||
329 | url.push({ | ||
330 | type: 'Link', | ||
331 | mediaType: 'application/x-mpegURL' as 'application/x-mpegURL', | ||
332 | href: playlist.playlistUrl, | ||
333 | tag | ||
334 | }) | ||
335 | } | ||
336 | |||
337 | for (const trackerUrl of video.getTrackerUrls()) { | ||
338 | const rel2 = trackerUrl.startsWith('http') | ||
339 | ? 'http' | ||
340 | : 'websocket' | ||
341 | |||
342 | url.push({ | ||
343 | type: 'Link', | ||
344 | name: `tracker-${rel2}`, | ||
345 | rel: [ 'tracker', rel2 ], | ||
346 | href: trackerUrl | ||
347 | }) | ||
348 | } | ||
349 | |||
350 | const subtitleLanguage = [] | ||
351 | for (const caption of video.VideoCaptions) { | ||
352 | subtitleLanguage.push({ | ||
353 | identifier: caption.language, | ||
354 | name: VideoCaptionModel.getLanguageLabel(caption.language), | ||
355 | url: caption.getFileUrl(video) | ||
356 | }) | ||
357 | } | ||
358 | |||
359 | const icons = [ video.getMiniature(), video.getPreview() ] | ||
360 | |||
361 | return { | ||
362 | type: 'Video' as 'Video', | ||
363 | id: video.url, | ||
364 | name: video.name, | ||
365 | duration: getActivityStreamDuration(video.duration), | ||
366 | uuid: video.uuid, | ||
367 | tag, | ||
368 | category, | ||
369 | licence, | ||
370 | language, | ||
371 | views: video.views, | ||
372 | sensitive: video.nsfw, | ||
373 | waitTranscoding: video.waitTranscoding, | ||
374 | isLiveBroadcast: video.isLive, | ||
375 | |||
376 | liveSaveReplay: video.isLive | ||
377 | ? video.VideoLive.saveReplay | ||
378 | : null, | ||
379 | |||
380 | permanentLive: video.isLive | ||
381 | ? video.VideoLive.permanentLive | ||
382 | : null, | ||
383 | |||
384 | state: video.state, | ||
385 | commentsEnabled: video.commentsEnabled, | ||
386 | downloadEnabled: video.downloadEnabled, | ||
387 | published: video.publishedAt.toISOString(), | ||
388 | |||
389 | originallyPublishedAt: video.originallyPublishedAt | ||
390 | ? video.originallyPublishedAt.toISOString() | ||
391 | : null, | ||
392 | |||
393 | updated: video.updatedAt.toISOString(), | ||
394 | mediaType: 'text/markdown', | ||
395 | content: video.description, | ||
396 | support: video.support, | ||
397 | subtitleLanguage, | ||
398 | icon: icons.map(i => ({ | ||
399 | type: 'Image', | ||
400 | url: i.getFileUrl(video), | ||
401 | mediaType: 'image/jpeg', | ||
402 | width: i.width, | ||
403 | height: i.height | ||
404 | })), | ||
405 | url, | ||
406 | likes: getLocalVideoLikesActivityPubUrl(video), | ||
407 | dislikes: getLocalVideoDislikesActivityPubUrl(video), | ||
408 | shares: getLocalVideoSharesActivityPubUrl(video), | ||
409 | comments: getLocalVideoCommentsActivityPubUrl(video), | ||
410 | attributedTo: [ | ||
411 | { | ||
412 | type: 'Person', | ||
413 | id: video.VideoChannel.Account.Actor.url | ||
414 | }, | ||
415 | { | ||
416 | type: 'Group', | ||
417 | id: video.VideoChannel.Actor.url | ||
418 | } | ||
419 | ] | ||
420 | } | ||
421 | } | ||
422 | |||
423 | function getActivityStreamDuration (duration: number) { | ||
424 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration | ||
425 | return 'PT' + duration + 'S' | ||
426 | } | ||
427 | |||
428 | export { | ||
429 | videoModelToFormattedJSON, | ||
430 | videoModelToFormattedDetailsJSON, | ||
431 | videoFilesModelToFormattedJSON, | ||
432 | videoModelToActivityPubObject, | ||
433 | getActivityStreamDuration | ||
434 | } | ||