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'
15 VideosCommonQueryAfterSanitize,
16 VideoStreamingPlaylist
17 } from '@shared/models'
18 import { isArray } from '../../../helpers/custom-validators/misc'
27 } from '../../../initializers/constants'
29 getLocalVideoCommentsActivityPubUrl,
30 getLocalVideoDislikesActivityPubUrl,
31 getLocalVideoLikesActivityPubUrl,
32 getLocalVideoSharesActivityPubUrl
33 } from '../../../lib/activitypub/url'
36 MStreamingPlaylistRedundanciesOpt,
42 MVideoFormattableDetails
43 } from '../../../types/models'
44 import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
45 import { VideoCaptionModel } from '../video-caption'
47 export type VideoFormattingJSONOptions = {
48 completeDescription?: boolean
50 additionalAttributes?: {
52 waitTranscoding?: boolean
53 scheduledUpdate?: boolean
54 blacklistInfo?: boolean
56 blockedOwner?: boolean
60 function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions {
61 if (!query || !query.include) return {}
64 additionalAttributes: {
65 state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
66 waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
67 scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
68 blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
69 files: !!(query.include & VideoInclude.FILES),
70 blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
75 function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video {
76 const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON')
78 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
80 const videoObject: Video = {
83 shortUUID: uuidToShort(video.uuid),
90 label: getCategoryLabel(video.category)
94 label: getLicenceLabel(video.licence)
98 label: getLanguageLabel(video.language)
102 label: getPrivacyLabel(video.privacy)
106 description: options && options.completeDescription === true
108 : video.getTruncatedDescription(),
110 isLocal: video.isOwned(),
111 duration: video.duration,
114 viewers: VideoViewsManager.Instance.getViewers(video),
117 dislikes: video.dislikes,
118 thumbnailPath: video.getMiniatureStaticPath(),
119 previewPath: video.getPreviewStaticPath(),
120 embedPath: video.getEmbedStaticPath(),
121 createdAt: video.createdAt,
122 updatedAt: video.updatedAt,
123 publishedAt: video.publishedAt,
124 originallyPublishedAt: video.originallyPublishedAt,
126 isLive: video.isLive,
128 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
129 channel: video.VideoChannel.toFormattedSummaryJSON(),
131 userHistory: userHistory
132 ? { currentTime: userHistory.currentTime }
135 // Can be added by external plugins
136 pluginData: (video as any).pluginData
139 const add = options.additionalAttributes
140 if (add?.state === true) {
141 videoObject.state = {
143 label: getStateLabel(video.state)
147 if (add?.waitTranscoding === true) {
148 videoObject.waitTranscoding = video.waitTranscoding
151 if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) {
152 videoObject.scheduledUpdate = {
153 updateAt: video.ScheduleVideoUpdate.updateAt,
154 privacy: video.ScheduleVideoUpdate.privacy || undefined
158 if (add?.blacklistInfo === true) {
159 videoObject.blacklisted = !!video.VideoBlacklist
160 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
163 if (add?.blockedOwner === true) {
164 videoObject.blockedOwner = video.VideoChannel.Account.isBlocked()
166 const server = video.VideoChannel.Account.Actor.Server as MServer
167 videoObject.blockedServer = !!(server?.isBlocked())
170 if (add?.files === true) {
171 videoObject.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
172 videoObject.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
180 function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails {
181 const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON')
183 const videoJSON = video.toFormattedJSON({
184 additionalAttributes: {
185 scheduledUpdate: true,
189 }) as Video & Required<Pick<Video, 'files' | 'streamingPlaylists'>>
191 const tags = video.Tags ? video.Tags.map(t => t.name) : []
193 const detailsJSON = {
194 support: video.support,
195 descriptionPath: video.getDescriptionAPIPath(),
196 channel: video.VideoChannel.toFormattedJSON(),
197 account: video.VideoChannel.Account.toFormattedJSON(),
199 commentsEnabled: video.commentsEnabled,
200 downloadEnabled: video.downloadEnabled,
201 waitTranscoding: video.waitTranscoding,
204 label: getStateLabel(video.state)
207 trackerUrls: video.getTrackerUrls()
212 return Object.assign(videoJSON, detailsJSON)
215 function streamingPlaylistsModelToFormattedJSON (
216 video: MVideoFormattable,
217 playlists: MStreamingPlaylistRedundanciesOpt[]
218 ): VideoStreamingPlaylist[] {
219 if (isArray(playlists) === false) return []
223 const redundancies = isArray(playlist.RedundancyVideos)
224 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
227 const files = videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
232 playlistUrl: playlist.getMasterPlaylistUrl(video),
233 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
240 function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) {
241 if (fileA.resolution < fileB.resolution) return 1
242 if (fileA.resolution === fileB.resolution) return 0
246 function videoFilesModelToFormattedJSON (
247 video: MVideoFormattable,
248 videoFiles: MVideoFileRedundanciesOpt[],
250 includeMagnet?: boolean // default true
253 const { includeMagnet = true } = options
255 const trackerUrls = includeMagnet
256 ? video.getTrackerUrls()
259 return (videoFiles || [])
260 .filter(f => !f.isLive())
261 .sort(sortByResolutionDesc)
267 id: videoFile.resolution,
268 label: videoFile.resolution === 0 ? 'Audio' : `${videoFile.resolution}p`
271 magnetUri: includeMagnet && videoFile.hasTorrent()
272 ? generateMagnetUri(video, videoFile, trackerUrls)
275 size: videoFile.size,
278 torrentUrl: videoFile.getTorrentUrl(),
279 torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
281 fileUrl: videoFile.getFileUrl(video),
282 fileDownloadUrl: videoFile.getFileDownloadUrl(video),
284 metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
289 function addVideoFilesInAPAcc (options: {
290 acc: ActivityUrlObject[] | ActivityTagObject[]
295 const { acc, video, files } = options
297 const trackerUrls = video.getTrackerUrls()
299 const sortedFiles = (files || [])
300 .filter(f => !f.isLive())
301 .sort(sortByResolutionDesc)
303 for (const file of sortedFiles) {
306 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any,
307 href: file.getFileUrl(video),
308 height: file.resolution,
315 rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ],
316 mediaType: 'application/json' as 'application/json',
317 href: getLocalVideoFileMetadataUrl(video, file),
318 height: file.resolution,
322 if (file.hasTorrent()) {
325 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
326 href: file.getTorrentUrl(),
327 height: file.resolution
332 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
333 href: generateMagnetUri(video, file, trackerUrls),
334 height: file.resolution
340 function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
341 if (!video.Tags) video.Tags = []
343 const tag = video.Tags.map(t => ({
344 type: 'Hashtag' as 'Hashtag',
349 if (video.language) {
351 identifier: video.language,
352 name: getLanguageLabel(video.language)
357 if (video.category) {
359 identifier: video.category + '',
360 name: getCategoryLabel(video.category)
367 identifier: video.licence + '',
368 name: getLicenceLabel(video.licence)
372 const url: ActivityUrlObject[] = [
373 // HTML url should be the first element in the array so Mastodon correctly displays the embed
376 mediaType: 'text/html',
377 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
381 addVideoFilesInAPAcc({ acc: url, video, files: video.VideoFiles || [] })
383 for (const playlist of (video.VideoStreamingPlaylists || [])) {
384 const tag = playlist.p2pMediaLoaderInfohashes
385 .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[]
389 mediaType: 'application/json' as 'application/json',
390 href: playlist.getSha256SegmentsUrl(video)
393 addVideoFilesInAPAcc({ acc: tag, video, files: playlist.VideoFiles || [] })
397 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
398 href: playlist.getMasterPlaylistUrl(video),
403 for (const trackerUrl of video.getTrackerUrls()) {
404 const rel2 = trackerUrl.startsWith('http')
410 name: `tracker-${rel2}`,
411 rel: [ 'tracker', rel2 ],
416 const subtitleLanguage = []
417 for (const caption of video.VideoCaptions) {
418 subtitleLanguage.push({
419 identifier: caption.language,
420 name: VideoCaptionModel.getLanguageLabel(caption.language),
421 url: caption.getFileUrl(video)
425 const icons = [ video.getMiniature(), video.getPreview() ]
428 type: 'Video' as 'Video',
431 duration: getActivityStreamDuration(video.duration),
438 sensitive: video.nsfw,
439 waitTranscoding: video.waitTranscoding,
442 commentsEnabled: video.commentsEnabled,
443 downloadEnabled: video.downloadEnabled,
444 published: video.publishedAt.toISOString(),
446 originallyPublishedAt: video.originallyPublishedAt
447 ? video.originallyPublishedAt.toISOString()
450 updated: video.updatedAt.toISOString(),
452 mediaType: 'text/markdown',
453 content: video.description,
454 support: video.support,
458 icon: icons.map(i => ({
460 url: i.getFileUrl(video),
461 mediaType: 'image/jpeg',
468 likes: getLocalVideoLikesActivityPubUrl(video),
469 dislikes: getLocalVideoDislikesActivityPubUrl(video),
470 shares: getLocalVideoSharesActivityPubUrl(video),
471 comments: getLocalVideoCommentsActivityPubUrl(video),
476 id: video.VideoChannel.Account.Actor.url
480 id: video.VideoChannel.Actor.url
484 ...buildLiveAPAttributes(video)
488 function getCategoryLabel (id: number) {
489 return VIDEO_CATEGORIES[id] || 'Misc'
492 function getLicenceLabel (id: number) {
493 return VIDEO_LICENCES[id] || 'Unknown'
496 function getLanguageLabel (id: string) {
497 return VIDEO_LANGUAGES[id] || 'Unknown'
500 function getPrivacyLabel (id: number) {
501 return VIDEO_PRIVACIES[id] || 'Unknown'
504 function getStateLabel (id: number) {
505 return VIDEO_STATES[id] || 'Unknown'
509 videoModelToFormattedJSON,
510 videoModelToFormattedDetailsJSON,
511 videoFilesModelToFormattedJSON,
512 videoModelToActivityPubObject,
514 guessAdditionalAttributesFromQuery,
523 // ---------------------------------------------------------------------------
525 function buildLiveAPAttributes (video: MVideoAP) {
528 isLiveBroadcast: false,
529 liveSaveReplay: null,
536 isLiveBroadcast: true,
537 liveSaveReplay: video.VideoLive.saveReplay,
538 permanentLive: video.VideoLive.permanentLive,
539 latencyMode: video.VideoLive.latencyMode