aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video/formatter/video-format-utils.ts
diff options
context:
space:
mode:
Diffstat (limited to 'server/models/video/formatter/video-format-utils.ts')
-rw-r--r--server/models/video/formatter/video-format-utils.ts561
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 @@
1import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
3import { tracer } from '@server/lib/opentelemetry/tracing'
4import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
5import { VideoViewsManager } from '@server/lib/views/video-views-manager'
6import { uuidToShort } from '@shared/extra-utils'
7import {
8 ActivityPubStoryboard,
9 ActivityTagObject,
10 ActivityUrlObject,
11 Video,
12 VideoDetails,
13 VideoFile,
14 VideoInclude,
15 VideoObject,
16 VideosCommonQueryAfterSanitize,
17 VideoStreamingPlaylist
18} from '@shared/models'
19import { isArray } from '../../../helpers/custom-validators/misc'
20import {
21 MIMETYPES,
22 VIDEO_CATEGORIES,
23 VIDEO_LANGUAGES,
24 VIDEO_LICENCES,
25 VIDEO_PRIVACIES,
26 VIDEO_STATES,
27 WEBSERVER
28} from '../../../initializers/constants'
29import {
30 getLocalVideoCommentsActivityPubUrl,
31 getLocalVideoDislikesActivityPubUrl,
32 getLocalVideoLikesActivityPubUrl,
33 getLocalVideoSharesActivityPubUrl
34} from '../../../lib/activitypub/url'
35import {
36 MServer,
37 MStreamingPlaylistRedundanciesOpt,
38 MUserId,
39 MVideo,
40 MVideoAP,
41 MVideoFile,
42 MVideoFormattable,
43 MVideoFormattableDetails
44} from '../../../types/models'
45import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
46import { VideoCaptionModel } from '../video-caption'
47
48export 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
61function 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
76function 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
182function 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
218function 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
243function 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
249function 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
292function 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
343function 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
481function getCategoryLabel (id: number) {
482 return VIDEO_CATEGORIES[id] || 'Unknown'
483}
484
485function getLicenceLabel (id: number) {
486 return VIDEO_LICENCES[id] || 'Unknown'
487}
488
489function getLanguageLabel (id: string) {
490 return VIDEO_LANGUAGES[id] || 'Unknown'
491}
492
493function getPrivacyLabel (id: number) {
494 return VIDEO_PRIVACIES[id] || 'Unknown'
495}
496
497function getStateLabel (id: number) {
498 return VIDEO_STATES[id] || 'Unknown'
499}
500
501export {
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
518function 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
536function 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}