diff options
Diffstat (limited to 'server/models/video/formatter/video-format-utils.ts')
-rw-r--r-- | server/models/video/formatter/video-format-utils.ts | 561 |
1 files changed, 0 insertions, 561 deletions
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts deleted file mode 100644 index 4179545b8..000000000 --- a/server/models/video/formatter/video-format-utils.ts +++ /dev/null | |||
@@ -1,561 +0,0 @@ | |||
1 | import { generateMagnetUri } from '@server/helpers/webtorrent' | ||
2 | import { getActivityStreamDuration } from '@server/lib/activitypub/activity' | ||
3 | import { tracer } from '@server/lib/opentelemetry/tracing' | ||
4 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' | ||
5 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' | ||
6 | import { uuidToShort } from '@shared/extra-utils' | ||
7 | import { | ||
8 | ActivityPubStoryboard, | ||
9 | ActivityTagObject, | ||
10 | ActivityUrlObject, | ||
11 | Video, | ||
12 | VideoDetails, | ||
13 | VideoFile, | ||
14 | VideoInclude, | ||
15 | VideoObject, | ||
16 | VideosCommonQueryAfterSanitize, | ||
17 | VideoStreamingPlaylist | ||
18 | } from '@shared/models' | ||
19 | import { isArray } from '../../../helpers/custom-validators/misc' | ||
20 | import { | ||
21 | MIMETYPES, | ||
22 | VIDEO_CATEGORIES, | ||
23 | VIDEO_LANGUAGES, | ||
24 | VIDEO_LICENCES, | ||
25 | VIDEO_PRIVACIES, | ||
26 | VIDEO_STATES, | ||
27 | WEBSERVER | ||
28 | } from '../../../initializers/constants' | ||
29 | import { | ||
30 | getLocalVideoCommentsActivityPubUrl, | ||
31 | getLocalVideoDislikesActivityPubUrl, | ||
32 | getLocalVideoLikesActivityPubUrl, | ||
33 | getLocalVideoSharesActivityPubUrl | ||
34 | } from '../../../lib/activitypub/url' | ||
35 | import { | ||
36 | MServer, | ||
37 | MStreamingPlaylistRedundanciesOpt, | ||
38 | MUserId, | ||
39 | MVideo, | ||
40 | MVideoAP, | ||
41 | MVideoFile, | ||
42 | MVideoFormattable, | ||
43 | MVideoFormattableDetails | ||
44 | } from '../../../types/models' | ||
45 | import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file' | ||
46 | import { VideoCaptionModel } from '../video-caption' | ||
47 | |||
48 | export type VideoFormattingJSONOptions = { | ||
49 | completeDescription?: boolean | ||
50 | |||
51 | additionalAttributes?: { | ||
52 | state?: boolean | ||
53 | waitTranscoding?: boolean | ||
54 | scheduledUpdate?: boolean | ||
55 | blacklistInfo?: boolean | ||
56 | files?: boolean | ||
57 | blockedOwner?: boolean | ||
58 | } | ||
59 | } | ||
60 | |||
61 | function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions { | ||
62 | if (!query?.include) return {} | ||
63 | |||
64 | return { | ||
65 | additionalAttributes: { | ||
66 | state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), | ||
67 | waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), | ||
68 | scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), | ||
69 | blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED), | ||
70 | files: !!(query.include & VideoInclude.FILES), | ||
71 | blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER) | ||
72 | } | ||
73 | } | ||
74 | } | ||
75 | |||
76 | function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video { | ||
77 | const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON') | ||
78 | |||
79 | const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined | ||
80 | |||
81 | const videoObject: Video = { | ||
82 | id: video.id, | ||
83 | uuid: video.uuid, | ||
84 | shortUUID: uuidToShort(video.uuid), | ||
85 | |||
86 | url: video.url, | ||
87 | |||
88 | name: video.name, | ||
89 | category: { | ||
90 | id: video.category, | ||
91 | label: getCategoryLabel(video.category) | ||
92 | }, | ||
93 | licence: { | ||
94 | id: video.licence, | ||
95 | label: getLicenceLabel(video.licence) | ||
96 | }, | ||
97 | language: { | ||
98 | id: video.language, | ||
99 | label: getLanguageLabel(video.language) | ||
100 | }, | ||
101 | privacy: { | ||
102 | id: video.privacy, | ||
103 | label: getPrivacyLabel(video.privacy) | ||
104 | }, | ||
105 | nsfw: video.nsfw, | ||
106 | |||
107 | truncatedDescription: video.getTruncatedDescription(), | ||
108 | description: options && options.completeDescription === true | ||
109 | ? video.description | ||
110 | : video.getTruncatedDescription(), | ||
111 | |||
112 | isLocal: video.isOwned(), | ||
113 | duration: video.duration, | ||
114 | |||
115 | views: video.views, | ||
116 | viewers: VideoViewsManager.Instance.getViewers(video), | ||
117 | |||
118 | likes: video.likes, | ||
119 | dislikes: video.dislikes, | ||
120 | thumbnailPath: video.getMiniatureStaticPath(), | ||
121 | previewPath: video.getPreviewStaticPath(), | ||
122 | embedPath: video.getEmbedStaticPath(), | ||
123 | createdAt: video.createdAt, | ||
124 | updatedAt: video.updatedAt, | ||
125 | publishedAt: video.publishedAt, | ||
126 | originallyPublishedAt: video.originallyPublishedAt, | ||
127 | |||
128 | isLive: video.isLive, | ||
129 | |||
130 | account: video.VideoChannel.Account.toFormattedSummaryJSON(), | ||
131 | channel: video.VideoChannel.toFormattedSummaryJSON(), | ||
132 | |||
133 | userHistory: userHistory | ||
134 | ? { currentTime: userHistory.currentTime } | ||
135 | : undefined, | ||
136 | |||
137 | // Can be added by external plugins | ||
138 | pluginData: (video as any).pluginData | ||
139 | } | ||
140 | |||
141 | const add = options.additionalAttributes | ||
142 | if (add?.state === true) { | ||
143 | videoObject.state = { | ||
144 | id: video.state, | ||
145 | label: getStateLabel(video.state) | ||
146 | } | ||
147 | } | ||
148 | |||
149 | if (add?.waitTranscoding === true) { | ||
150 | videoObject.waitTranscoding = video.waitTranscoding | ||
151 | } | ||
152 | |||
153 | if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) { | ||
154 | videoObject.scheduledUpdate = { | ||
155 | updateAt: video.ScheduleVideoUpdate.updateAt, | ||
156 | privacy: video.ScheduleVideoUpdate.privacy || undefined | ||
157 | } | ||
158 | } | ||
159 | |||
160 | if (add?.blacklistInfo === true) { | ||
161 | videoObject.blacklisted = !!video.VideoBlacklist | ||
162 | videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null | ||
163 | } | ||
164 | |||
165 | if (add?.blockedOwner === true) { | ||
166 | videoObject.blockedOwner = video.VideoChannel.Account.isBlocked() | ||
167 | |||
168 | const server = video.VideoChannel.Account.Actor.Server as MServer | ||
169 | videoObject.blockedServer = !!(server?.isBlocked()) | ||
170 | } | ||
171 | |||
172 | if (add?.files === true) { | ||
173 | videoObject.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists) | ||
174 | videoObject.files = videoFilesModelToFormattedJSON(video, video.VideoFiles) | ||
175 | } | ||
176 | |||
177 | span.end() | ||
178 | |||
179 | return videoObject | ||
180 | } | ||
181 | |||
182 | function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails { | ||
183 | const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON') | ||
184 | |||
185 | const videoJSON = video.toFormattedJSON({ | ||
186 | completeDescription: true, | ||
187 | additionalAttributes: { | ||
188 | scheduledUpdate: true, | ||
189 | blacklistInfo: true, | ||
190 | files: true | ||
191 | } | ||
192 | }) as Video & Required<Pick<Video, 'files' | 'streamingPlaylists'>> | ||
193 | |||
194 | const tags = video.Tags ? video.Tags.map(t => t.name) : [] | ||
195 | |||
196 | const detailsJSON = { | ||
197 | support: video.support, | ||
198 | descriptionPath: video.getDescriptionAPIPath(), | ||
199 | channel: video.VideoChannel.toFormattedJSON(), | ||
200 | account: video.VideoChannel.Account.toFormattedJSON(), | ||
201 | tags, | ||
202 | commentsEnabled: video.commentsEnabled, | ||
203 | downloadEnabled: video.downloadEnabled, | ||
204 | waitTranscoding: video.waitTranscoding, | ||
205 | state: { | ||
206 | id: video.state, | ||
207 | label: getStateLabel(video.state) | ||
208 | }, | ||
209 | |||
210 | trackerUrls: video.getTrackerUrls() | ||
211 | } | ||
212 | |||
213 | span.end() | ||
214 | |||
215 | return Object.assign(videoJSON, detailsJSON) | ||
216 | } | ||
217 | |||
218 | function streamingPlaylistsModelToFormattedJSON ( | ||
219 | video: MVideoFormattable, | ||
220 | playlists: MStreamingPlaylistRedundanciesOpt[] | ||
221 | ): VideoStreamingPlaylist[] { | ||
222 | if (isArray(playlists) === false) return [] | ||
223 | |||
224 | return playlists | ||
225 | .map(playlist => { | ||
226 | const redundancies = isArray(playlist.RedundancyVideos) | ||
227 | ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl })) | ||
228 | : [] | ||
229 | |||
230 | const files = videoFilesModelToFormattedJSON(video, playlist.VideoFiles) | ||
231 | |||
232 | return { | ||
233 | id: playlist.id, | ||
234 | type: playlist.type, | ||
235 | playlistUrl: playlist.getMasterPlaylistUrl(video), | ||
236 | segmentsSha256Url: playlist.getSha256SegmentsUrl(video), | ||
237 | redundancies, | ||
238 | files | ||
239 | } | ||
240 | }) | ||
241 | } | ||
242 | |||
243 | function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) { | ||
244 | if (fileA.resolution < fileB.resolution) return 1 | ||
245 | if (fileA.resolution === fileB.resolution) return 0 | ||
246 | return -1 | ||
247 | } | ||
248 | |||
249 | function videoFilesModelToFormattedJSON ( | ||
250 | video: MVideoFormattable, | ||
251 | videoFiles: MVideoFileRedundanciesOpt[], | ||
252 | options: { | ||
253 | includeMagnet?: boolean // default true | ||
254 | } = {} | ||
255 | ): VideoFile[] { | ||
256 | const { includeMagnet = true } = options | ||
257 | |||
258 | const trackerUrls = includeMagnet | ||
259 | ? video.getTrackerUrls() | ||
260 | : [] | ||
261 | |||
262 | return (videoFiles || []) | ||
263 | .filter(f => !f.isLive()) | ||
264 | .sort(sortByResolutionDesc) | ||
265 | .map(videoFile => { | ||
266 | return { | ||
267 | id: videoFile.id, | ||
268 | |||
269 | resolution: { | ||
270 | id: videoFile.resolution, | ||
271 | label: videoFile.resolution === 0 ? 'Audio' : `${videoFile.resolution}p` | ||
272 | }, | ||
273 | |||
274 | magnetUri: includeMagnet && videoFile.hasTorrent() | ||
275 | ? generateMagnetUri(video, videoFile, trackerUrls) | ||
276 | : undefined, | ||
277 | |||
278 | size: videoFile.size, | ||
279 | fps: videoFile.fps, | ||
280 | |||
281 | torrentUrl: videoFile.getTorrentUrl(), | ||
282 | torrentDownloadUrl: videoFile.getTorrentDownloadUrl(), | ||
283 | |||
284 | fileUrl: videoFile.getFileUrl(video), | ||
285 | fileDownloadUrl: videoFile.getFileDownloadUrl(video), | ||
286 | |||
287 | metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile) | ||
288 | } as VideoFile | ||
289 | }) | ||
290 | } | ||
291 | |||
292 | function addVideoFilesInAPAcc (options: { | ||
293 | acc: ActivityUrlObject[] | ActivityTagObject[] | ||
294 | video: MVideo | ||
295 | files: MVideoFile[] | ||
296 | user?: MUserId | ||
297 | }) { | ||
298 | const { acc, video, files } = options | ||
299 | |||
300 | const trackerUrls = video.getTrackerUrls() | ||
301 | |||
302 | const sortedFiles = (files || []) | ||
303 | .filter(f => !f.isLive()) | ||
304 | .sort(sortByResolutionDesc) | ||
305 | |||
306 | for (const file of sortedFiles) { | ||
307 | acc.push({ | ||
308 | type: 'Link', | ||
309 | mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any, | ||
310 | href: file.getFileUrl(video), | ||
311 | height: file.resolution, | ||
312 | size: file.size, | ||
313 | fps: file.fps | ||
314 | }) | ||
315 | |||
316 | acc.push({ | ||
317 | type: 'Link', | ||
318 | rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ], | ||
319 | mediaType: 'application/json' as 'application/json', | ||
320 | href: getLocalVideoFileMetadataUrl(video, file), | ||
321 | height: file.resolution, | ||
322 | fps: file.fps | ||
323 | }) | ||
324 | |||
325 | if (file.hasTorrent()) { | ||
326 | acc.push({ | ||
327 | type: 'Link', | ||
328 | mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', | ||
329 | href: file.getTorrentUrl(), | ||
330 | height: file.resolution | ||
331 | }) | ||
332 | |||
333 | acc.push({ | ||
334 | type: 'Link', | ||
335 | mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', | ||
336 | href: generateMagnetUri(video, file, trackerUrls), | ||
337 | height: file.resolution | ||
338 | }) | ||
339 | } | ||
340 | } | ||
341 | } | ||
342 | |||
343 | function videoModelToActivityPubObject (video: MVideoAP): VideoObject { | ||
344 | if (!video.Tags) video.Tags = [] | ||
345 | |||
346 | const tag = video.Tags.map(t => ({ | ||
347 | type: 'Hashtag' as 'Hashtag', | ||
348 | name: t.name | ||
349 | })) | ||
350 | |||
351 | const language = video.language | ||
352 | ? { identifier: video.language, name: getLanguageLabel(video.language) } | ||
353 | : undefined | ||
354 | |||
355 | const category = video.category | ||
356 | ? { identifier: video.category + '', name: getCategoryLabel(video.category) } | ||
357 | : undefined | ||
358 | |||
359 | const licence = video.licence | ||
360 | ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) } | ||
361 | : undefined | ||
362 | |||
363 | const url: ActivityUrlObject[] = [ | ||
364 | // HTML url should be the first element in the array so Mastodon correctly displays the embed | ||
365 | { | ||
366 | type: 'Link', | ||
367 | mediaType: 'text/html', | ||
368 | href: WEBSERVER.URL + '/videos/watch/' + video.uuid | ||
369 | } | ||
370 | ] | ||
371 | |||
372 | addVideoFilesInAPAcc({ acc: url, video, files: video.VideoFiles || [] }) | ||
373 | |||
374 | for (const playlist of (video.VideoStreamingPlaylists || [])) { | ||
375 | const tag = playlist.p2pMediaLoaderInfohashes | ||
376 | .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[] | ||
377 | tag.push({ | ||
378 | type: 'Link', | ||
379 | name: 'sha256', | ||
380 | mediaType: 'application/json' as 'application/json', | ||
381 | href: playlist.getSha256SegmentsUrl(video) | ||
382 | }) | ||
383 | |||
384 | addVideoFilesInAPAcc({ acc: tag, video, files: playlist.VideoFiles || [] }) | ||
385 | |||
386 | url.push({ | ||
387 | type: 'Link', | ||
388 | mediaType: 'application/x-mpegURL' as 'application/x-mpegURL', | ||
389 | href: playlist.getMasterPlaylistUrl(video), | ||
390 | tag | ||
391 | }) | ||
392 | } | ||
393 | |||
394 | for (const trackerUrl of video.getTrackerUrls()) { | ||
395 | const rel2 = trackerUrl.startsWith('http') | ||
396 | ? 'http' | ||
397 | : 'websocket' | ||
398 | |||
399 | url.push({ | ||
400 | type: 'Link', | ||
401 | name: `tracker-${rel2}`, | ||
402 | rel: [ 'tracker', rel2 ], | ||
403 | href: trackerUrl | ||
404 | }) | ||
405 | } | ||
406 | |||
407 | const subtitleLanguage = [] | ||
408 | for (const caption of video.VideoCaptions) { | ||
409 | subtitleLanguage.push({ | ||
410 | identifier: caption.language, | ||
411 | name: VideoCaptionModel.getLanguageLabel(caption.language), | ||
412 | url: caption.getFileUrl(video) | ||
413 | }) | ||
414 | } | ||
415 | |||
416 | const icons = [ video.getMiniature(), video.getPreview() ] | ||
417 | |||
418 | return { | ||
419 | type: 'Video' as 'Video', | ||
420 | id: video.url, | ||
421 | name: video.name, | ||
422 | duration: getActivityStreamDuration(video.duration), | ||
423 | uuid: video.uuid, | ||
424 | tag, | ||
425 | category, | ||
426 | licence, | ||
427 | language, | ||
428 | views: video.views, | ||
429 | sensitive: video.nsfw, | ||
430 | waitTranscoding: video.waitTranscoding, | ||
431 | |||
432 | state: video.state, | ||
433 | commentsEnabled: video.commentsEnabled, | ||
434 | downloadEnabled: video.downloadEnabled, | ||
435 | published: video.publishedAt.toISOString(), | ||
436 | |||
437 | originallyPublishedAt: video.originallyPublishedAt | ||
438 | ? video.originallyPublishedAt.toISOString() | ||
439 | : null, | ||
440 | |||
441 | updated: video.updatedAt.toISOString(), | ||
442 | |||
443 | mediaType: 'text/markdown', | ||
444 | content: video.description, | ||
445 | support: video.support, | ||
446 | |||
447 | subtitleLanguage, | ||
448 | |||
449 | icon: icons.map(i => ({ | ||
450 | type: 'Image', | ||
451 | url: i.getOriginFileUrl(video), | ||
452 | mediaType: 'image/jpeg', | ||
453 | width: i.width, | ||
454 | height: i.height | ||
455 | })), | ||
456 | |||
457 | preview: buildPreviewAPAttribute(video), | ||
458 | |||
459 | url, | ||
460 | |||
461 | likes: getLocalVideoLikesActivityPubUrl(video), | ||
462 | dislikes: getLocalVideoDislikesActivityPubUrl(video), | ||
463 | shares: getLocalVideoSharesActivityPubUrl(video), | ||
464 | comments: getLocalVideoCommentsActivityPubUrl(video), | ||
465 | |||
466 | attributedTo: [ | ||
467 | { | ||
468 | type: 'Person', | ||
469 | id: video.VideoChannel.Account.Actor.url | ||
470 | }, | ||
471 | { | ||
472 | type: 'Group', | ||
473 | id: video.VideoChannel.Actor.url | ||
474 | } | ||
475 | ], | ||
476 | |||
477 | ...buildLiveAPAttributes(video) | ||
478 | } | ||
479 | } | ||
480 | |||
481 | function getCategoryLabel (id: number) { | ||
482 | return VIDEO_CATEGORIES[id] || 'Unknown' | ||
483 | } | ||
484 | |||
485 | function getLicenceLabel (id: number) { | ||
486 | return VIDEO_LICENCES[id] || 'Unknown' | ||
487 | } | ||
488 | |||
489 | function getLanguageLabel (id: string) { | ||
490 | return VIDEO_LANGUAGES[id] || 'Unknown' | ||
491 | } | ||
492 | |||
493 | function getPrivacyLabel (id: number) { | ||
494 | return VIDEO_PRIVACIES[id] || 'Unknown' | ||
495 | } | ||
496 | |||
497 | function getStateLabel (id: number) { | ||
498 | return VIDEO_STATES[id] || 'Unknown' | ||
499 | } | ||
500 | |||
501 | export { | ||
502 | videoModelToFormattedJSON, | ||
503 | videoModelToFormattedDetailsJSON, | ||
504 | videoFilesModelToFormattedJSON, | ||
505 | videoModelToActivityPubObject, | ||
506 | |||
507 | guessAdditionalAttributesFromQuery, | ||
508 | |||
509 | getCategoryLabel, | ||
510 | getLicenceLabel, | ||
511 | getLanguageLabel, | ||
512 | getPrivacyLabel, | ||
513 | getStateLabel | ||
514 | } | ||
515 | |||
516 | // --------------------------------------------------------------------------- | ||
517 | |||
518 | function buildLiveAPAttributes (video: MVideoAP) { | ||
519 | if (!video.isLive) { | ||
520 | return { | ||
521 | isLiveBroadcast: false, | ||
522 | liveSaveReplay: null, | ||
523 | permanentLive: null, | ||
524 | latencyMode: null | ||
525 | } | ||
526 | } | ||
527 | |||
528 | return { | ||
529 | isLiveBroadcast: true, | ||
530 | liveSaveReplay: video.VideoLive.saveReplay, | ||
531 | permanentLive: video.VideoLive.permanentLive, | ||
532 | latencyMode: video.VideoLive.latencyMode | ||
533 | } | ||
534 | } | ||
535 | |||
536 | function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] { | ||
537 | if (!video.Storyboard) return undefined | ||
538 | |||
539 | const storyboard = video.Storyboard | ||
540 | |||
541 | return [ | ||
542 | { | ||
543 | type: 'Image', | ||
544 | rel: [ 'storyboard' ], | ||
545 | url: [ | ||
546 | { | ||
547 | mediaType: 'image/jpeg', | ||
548 | |||
549 | href: storyboard.getOriginFileUrl(video), | ||
550 | |||
551 | width: storyboard.totalWidth, | ||
552 | height: storyboard.totalHeight, | ||
553 | |||
554 | tileWidth: storyboard.spriteWidth, | ||
555 | tileHeight: storyboard.spriteHeight, | ||
556 | tileDuration: getActivityStreamDuration(storyboard.spriteDuration) | ||
557 | } | ||
558 | ] | ||
559 | } | ||
560 | ] | ||
561 | } | ||