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