aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-06-05 13:22:16 +0200
committerChocobozzz <me@florianbigard.com>2023-06-29 10:18:00 +0200
commitd896fef7e21cacfb44967eb9059fe543a66f5dd0 (patch)
tree153f0810cb4137b8df04bd94053f6a4f0db24b56 /server/models
parent638a2950215af1f11be8e8bdd136ca12e5176c32 (diff)
downloadPeerTube-d896fef7e21cacfb44967eb9059fe543a66f5dd0.tar.gz
PeerTube-d896fef7e21cacfb44967eb9059fe543a66f5dd0.tar.zst
PeerTube-d896fef7e21cacfb44967eb9059fe543a66f5dd0.zip
Refactor video formatter
Diffstat (limited to 'server/models')
-rw-r--r--server/models/video/formatter/index.ts2
-rw-r--r--server/models/video/formatter/shared/index.ts1
-rw-r--r--server/models/video/formatter/shared/video-format-utils.ts7
-rw-r--r--server/models/video/formatter/video-activity-pub-format.ts295
-rw-r--r--server/models/video/formatter/video-api-format.ts304
-rw-r--r--server/models/video/formatter/video-format-utils.ts561
-rw-r--r--server/models/video/video.ts4
7 files changed, 611 insertions, 563 deletions
diff --git a/server/models/video/formatter/index.ts b/server/models/video/formatter/index.ts
new file mode 100644
index 000000000..77b406559
--- /dev/null
+++ b/server/models/video/formatter/index.ts
@@ -0,0 +1,2 @@
1export * from './video-activity-pub-format'
2export * from './video-api-format'
diff --git a/server/models/video/formatter/shared/index.ts b/server/models/video/formatter/shared/index.ts
new file mode 100644
index 000000000..d558fa7d6
--- /dev/null
+++ b/server/models/video/formatter/shared/index.ts
@@ -0,0 +1 @@
export * from './video-format-utils'
diff --git a/server/models/video/formatter/shared/video-format-utils.ts b/server/models/video/formatter/shared/video-format-utils.ts
new file mode 100644
index 000000000..df3bbdf1c
--- /dev/null
+++ b/server/models/video/formatter/shared/video-format-utils.ts
@@ -0,0 +1,7 @@
1import { MVideoFile } from '@server/types/models'
2
3export function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) {
4 if (fileA.resolution < fileB.resolution) return 1
5 if (fileA.resolution === fileB.resolution) return 0
6 return -1
7}
diff --git a/server/models/video/formatter/video-activity-pub-format.ts b/server/models/video/formatter/video-activity-pub-format.ts
new file mode 100644
index 000000000..c0d3d5f3e
--- /dev/null
+++ b/server/models/video/formatter/video-activity-pub-format.ts
@@ -0,0 +1,295 @@
1
2import { isArray } from 'lodash'
3import { generateMagnetUri } from '@server/helpers/webtorrent'
4import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
5import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
6import {
7 ActivityIconObject,
8 ActivityPlaylistUrlObject,
9 ActivityPubStoryboard,
10 ActivityTagObject,
11 ActivityTrackerUrlObject,
12 ActivityUrlObject,
13 VideoObject
14} from '@shared/models'
15import { MIMETYPES, WEBSERVER } from '../../../initializers/constants'
16import {
17 getLocalVideoCommentsActivityPubUrl,
18 getLocalVideoDislikesActivityPubUrl,
19 getLocalVideoLikesActivityPubUrl,
20 getLocalVideoSharesActivityPubUrl
21} from '../../../lib/activitypub/url'
22import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models'
23import { VideoCaptionModel } from '../video-caption'
24import { sortByResolutionDesc } from './shared'
25import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format'
26
27export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
28 const language = video.language
29 ? { identifier: video.language, name: getLanguageLabel(video.language) }
30 : undefined
31
32 const category = video.category
33 ? { identifier: video.category + '', name: getCategoryLabel(video.category) }
34 : undefined
35
36 const licence = video.licence
37 ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) }
38 : undefined
39
40 const url: ActivityUrlObject[] = [
41 // HTML url should be the first element in the array so Mastodon correctly displays the embed
42 {
43 type: 'Link',
44 mediaType: 'text/html',
45 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
46 } as ActivityUrlObject,
47
48 ...buildVideoFileUrls({ video, files: video.VideoFiles }),
49
50 ...buildStreamingPlaylistUrls(video),
51
52 ...buildTrackerUrls(video)
53 ]
54
55 return {
56 type: 'Video' as 'Video',
57 id: video.url,
58 name: video.name,
59 duration: getActivityStreamDuration(video.duration),
60 uuid: video.uuid,
61 category,
62 licence,
63 language,
64 views: video.views,
65 sensitive: video.nsfw,
66 waitTranscoding: video.waitTranscoding,
67
68 state: video.state,
69 commentsEnabled: video.commentsEnabled,
70 downloadEnabled: video.downloadEnabled,
71 published: video.publishedAt.toISOString(),
72
73 originallyPublishedAt: video.originallyPublishedAt
74 ? video.originallyPublishedAt.toISOString()
75 : null,
76
77 updated: video.updatedAt.toISOString(),
78
79 tag: buildTags(video),
80
81 mediaType: 'text/markdown',
82 content: video.description,
83 support: video.support,
84
85 subtitleLanguage: buildSubtitleLanguage(video),
86
87 icon: buildIcon(video),
88
89 preview: buildPreviewAPAttribute(video),
90
91 url,
92
93 likes: getLocalVideoLikesActivityPubUrl(video),
94 dislikes: getLocalVideoDislikesActivityPubUrl(video),
95 shares: getLocalVideoSharesActivityPubUrl(video),
96 comments: getLocalVideoCommentsActivityPubUrl(video),
97
98 attributedTo: [
99 {
100 type: 'Person',
101 id: video.VideoChannel.Account.Actor.url
102 },
103 {
104 type: 'Group',
105 id: video.VideoChannel.Actor.url
106 }
107 ],
108
109 ...buildLiveAPAttributes(video)
110 }
111}
112
113// ---------------------------------------------------------------------------
114// Private
115// ---------------------------------------------------------------------------
116
117function buildLiveAPAttributes (video: MVideoAP) {
118 if (!video.isLive) {
119 return {
120 isLiveBroadcast: false,
121 liveSaveReplay: null,
122 permanentLive: null,
123 latencyMode: null
124 }
125 }
126
127 return {
128 isLiveBroadcast: true,
129 liveSaveReplay: video.VideoLive.saveReplay,
130 permanentLive: video.VideoLive.permanentLive,
131 latencyMode: video.VideoLive.latencyMode
132 }
133}
134
135function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] {
136 if (!video.Storyboard) return undefined
137
138 const storyboard = video.Storyboard
139
140 return [
141 {
142 type: 'Image',
143 rel: [ 'storyboard' ],
144 url: [
145 {
146 mediaType: 'image/jpeg',
147
148 href: storyboard.getOriginFileUrl(video),
149
150 width: storyboard.totalWidth,
151 height: storyboard.totalHeight,
152
153 tileWidth: storyboard.spriteWidth,
154 tileHeight: storyboard.spriteHeight,
155 tileDuration: getActivityStreamDuration(storyboard.spriteDuration)
156 }
157 ]
158 }
159 ]
160}
161
162function buildVideoFileUrls (options: {
163 video: MVideo
164 files: MVideoFile[]
165 user?: MUserId
166}): ActivityUrlObject[] {
167 const { video, files } = options
168
169 if (!isArray(files)) return []
170
171 const urls: ActivityUrlObject[] = []
172
173 const trackerUrls = video.getTrackerUrls()
174 const sortedFiles = files
175 .filter(f => !f.isLive())
176 .sort(sortByResolutionDesc)
177
178 for (const file of sortedFiles) {
179 urls.push({
180 type: 'Link',
181 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any,
182 href: file.getFileUrl(video),
183 height: file.resolution,
184 size: file.size,
185 fps: file.fps
186 })
187
188 urls.push({
189 type: 'Link',
190 rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ],
191 mediaType: 'application/json' as 'application/json',
192 href: getLocalVideoFileMetadataUrl(video, file),
193 height: file.resolution,
194 fps: file.fps
195 })
196
197 if (file.hasTorrent()) {
198 urls.push({
199 type: 'Link',
200 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
201 href: file.getTorrentUrl(),
202 height: file.resolution
203 })
204
205 urls.push({
206 type: 'Link',
207 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
208 href: generateMagnetUri(video, file, trackerUrls),
209 height: file.resolution
210 })
211 }
212 }
213
214 return urls
215}
216
217// ---------------------------------------------------------------------------
218
219function buildStreamingPlaylistUrls (video: MVideoAP): ActivityPlaylistUrlObject[] {
220 if (!isArray(video.VideoStreamingPlaylists)) return []
221
222 return video.VideoStreamingPlaylists
223 .map(playlist => ({
224 type: 'Link',
225 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
226 href: playlist.getMasterPlaylistUrl(video),
227 tag: buildStreamingPlaylistTags(video, playlist)
228 }))
229}
230
231function buildStreamingPlaylistTags (video: MVideoAP, playlist: MStreamingPlaylistFiles) {
232 return [
233 ...playlist.p2pMediaLoaderInfohashes.map(i => ({ type: 'Infohash' as 'Infohash', name: i })),
234
235 {
236 type: 'Link',
237 name: 'sha256',
238 mediaType: 'application/json' as 'application/json',
239 href: playlist.getSha256SegmentsUrl(video)
240 },
241
242 ...buildVideoFileUrls({ video, files: playlist.VideoFiles })
243 ] as ActivityTagObject[]
244}
245
246// ---------------------------------------------------------------------------
247
248function buildTrackerUrls (video: MVideoAP): ActivityTrackerUrlObject[] {
249 return video.getTrackerUrls()
250 .map(trackerUrl => {
251 const rel2 = trackerUrl.startsWith('http')
252 ? 'http'
253 : 'websocket'
254
255 return {
256 type: 'Link',
257 name: `tracker-${rel2}`,
258 rel: [ 'tracker', rel2 ],
259 href: trackerUrl
260 }
261 })
262}
263
264// ---------------------------------------------------------------------------
265
266function buildTags (video: MVideoAP) {
267 if (!isArray(video.Tags)) return []
268
269 return video.Tags.map(t => ({
270 type: 'Hashtag' as 'Hashtag',
271 name: t.name
272 }))
273}
274
275function buildIcon (video: MVideoAP): ActivityIconObject[] {
276 return [ video.getMiniature(), video.getPreview() ]
277 .map(i => ({
278 type: 'Image',
279 url: i.getOriginFileUrl(video),
280 mediaType: 'image/jpeg',
281 width: i.width,
282 height: i.height
283 }))
284}
285
286function buildSubtitleLanguage (video: MVideoAP) {
287 if (!isArray(video.VideoCaptions)) return []
288
289 return video.VideoCaptions
290 .map(caption => ({
291 identifier: caption.language,
292 name: VideoCaptionModel.getLanguageLabel(caption.language),
293 url: caption.getFileUrl(video)
294 }))
295}
diff --git a/server/models/video/formatter/video-api-format.ts b/server/models/video/formatter/video-api-format.ts
new file mode 100644
index 000000000..1af51d132
--- /dev/null
+++ b/server/models/video/formatter/video-api-format.ts
@@ -0,0 +1,304 @@
1import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { tracer } from '@server/lib/opentelemetry/tracing'
3import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
4import { VideoViewsManager } from '@server/lib/views/video-views-manager'
5import { uuidToShort } from '@shared/extra-utils'
6import {
7 Video,
8 VideoAdditionalAttributes,
9 VideoDetails,
10 VideoFile,
11 VideoInclude,
12 VideosCommonQueryAfterSanitize,
13 VideoStreamingPlaylist
14} from '@shared/models'
15import { isArray } from '../../../helpers/custom-validators/misc'
16import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants'
17import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models'
18import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
19import { sortByResolutionDesc } from './shared'
20
21export type VideoFormattingJSONOptions = {
22 completeDescription?: boolean
23
24 additionalAttributes?: {
25 state?: boolean
26 waitTranscoding?: boolean
27 scheduledUpdate?: boolean
28 blacklistInfo?: boolean
29 files?: boolean
30 blockedOwner?: boolean
31 }
32}
33
34export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions {
35 if (!query?.include) return {}
36
37 return {
38 additionalAttributes: {
39 state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
40 waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
41 scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
42 blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
43 files: !!(query.include & VideoInclude.FILES),
44 blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
45 }
46 }
47}
48
49// ---------------------------------------------------------------------------
50
51export function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video {
52 const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON')
53
54 const userHistory = isArray(video.UserVideoHistories)
55 ? video.UserVideoHistories[0]
56 : undefined
57
58 const videoObject: Video = {
59 id: video.id,
60 uuid: video.uuid,
61 shortUUID: uuidToShort(video.uuid),
62
63 url: video.url,
64
65 name: video.name,
66 category: {
67 id: video.category,
68 label: getCategoryLabel(video.category)
69 },
70 licence: {
71 id: video.licence,
72 label: getLicenceLabel(video.licence)
73 },
74 language: {
75 id: video.language,
76 label: getLanguageLabel(video.language)
77 },
78 privacy: {
79 id: video.privacy,
80 label: getPrivacyLabel(video.privacy)
81 },
82 nsfw: video.nsfw,
83
84 truncatedDescription: video.getTruncatedDescription(),
85 description: options && options.completeDescription === true
86 ? video.description
87 : video.getTruncatedDescription(),
88
89 isLocal: video.isOwned(),
90 duration: video.duration,
91
92 views: video.views,
93 viewers: VideoViewsManager.Instance.getViewers(video),
94
95 likes: video.likes,
96 dislikes: video.dislikes,
97 thumbnailPath: video.getMiniatureStaticPath(),
98 previewPath: video.getPreviewStaticPath(),
99 embedPath: video.getEmbedStaticPath(),
100 createdAt: video.createdAt,
101 updatedAt: video.updatedAt,
102 publishedAt: video.publishedAt,
103 originallyPublishedAt: video.originallyPublishedAt,
104
105 isLive: video.isLive,
106
107 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
108 channel: video.VideoChannel.toFormattedSummaryJSON(),
109
110 userHistory: userHistory
111 ? { currentTime: userHistory.currentTime }
112 : undefined,
113
114 // Can be added by external plugins
115 pluginData: (video as any).pluginData,
116
117 ...buildAdditionalAttributes(video, options)
118 }
119
120 span.end()
121
122 return videoObject
123}
124
125export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails {
126 const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON')
127
128 const videoJSON = video.toFormattedJSON({
129 completeDescription: true,
130 additionalAttributes: {
131 scheduledUpdate: true,
132 blacklistInfo: true,
133 files: true
134 }
135 }) as Video & Required<Pick<Video, 'files' | 'streamingPlaylists' | 'scheduledUpdate' | 'blacklisted' | 'blacklistedReason'>>
136
137 const tags = video.Tags
138 ? video.Tags.map(t => t.name)
139 : []
140
141 const detailsJSON = {
142 ...videoJSON,
143
144 support: video.support,
145 descriptionPath: video.getDescriptionAPIPath(),
146 channel: video.VideoChannel.toFormattedJSON(),
147 account: video.VideoChannel.Account.toFormattedJSON(),
148 tags,
149 commentsEnabled: video.commentsEnabled,
150 downloadEnabled: video.downloadEnabled,
151 waitTranscoding: video.waitTranscoding,
152 state: {
153 id: video.state,
154 label: getStateLabel(video.state)
155 },
156
157 trackerUrls: video.getTrackerUrls()
158 }
159
160 span.end()
161
162 return detailsJSON
163}
164
165export function streamingPlaylistsModelToFormattedJSON (
166 video: MVideoFormattable,
167 playlists: MStreamingPlaylistRedundanciesOpt[]
168): VideoStreamingPlaylist[] {
169 if (isArray(playlists) === false) return []
170
171 return playlists
172 .map(playlist => ({
173 id: playlist.id,
174 type: playlist.type,
175
176 playlistUrl: playlist.getMasterPlaylistUrl(video),
177 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
178
179 redundancies: isArray(playlist.RedundancyVideos)
180 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
181 : [],
182
183 files: videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
184 }))
185}
186
187export function videoFilesModelToFormattedJSON (
188 video: MVideoFormattable,
189 videoFiles: MVideoFileRedundanciesOpt[],
190 options: {
191 includeMagnet?: boolean // default true
192 } = {}
193): VideoFile[] {
194 const { includeMagnet = true } = options
195
196 if (isArray(videoFiles) === false) return []
197
198 const trackerUrls = includeMagnet
199 ? video.getTrackerUrls()
200 : []
201
202 return videoFiles
203 .filter(f => !f.isLive())
204 .sort(sortByResolutionDesc)
205 .map(videoFile => {
206 return {
207 id: videoFile.id,
208
209 resolution: {
210 id: videoFile.resolution,
211 label: videoFile.resolution === 0
212 ? 'Audio'
213 : `${videoFile.resolution}p`
214 },
215
216 magnetUri: includeMagnet && videoFile.hasTorrent()
217 ? generateMagnetUri(video, videoFile, trackerUrls)
218 : undefined,
219
220 size: videoFile.size,
221 fps: videoFile.fps,
222
223 torrentUrl: videoFile.getTorrentUrl(),
224 torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
225
226 fileUrl: videoFile.getFileUrl(video),
227 fileDownloadUrl: videoFile.getFileDownloadUrl(video),
228
229 metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
230 }
231 })
232}
233
234// ---------------------------------------------------------------------------
235
236export function getCategoryLabel (id: number) {
237 return VIDEO_CATEGORIES[id] || 'Unknown'
238}
239
240export function getLicenceLabel (id: number) {
241 return VIDEO_LICENCES[id] || 'Unknown'
242}
243
244export function getLanguageLabel (id: string) {
245 return VIDEO_LANGUAGES[id] || 'Unknown'
246}
247
248export function getPrivacyLabel (id: number) {
249 return VIDEO_PRIVACIES[id] || 'Unknown'
250}
251
252export function getStateLabel (id: number) {
253 return VIDEO_STATES[id] || 'Unknown'
254}
255
256// ---------------------------------------------------------------------------
257// Private
258// ---------------------------------------------------------------------------
259
260function buildAdditionalAttributes (video: MVideoFormattable, options: VideoFormattingJSONOptions) {
261 const add = options.additionalAttributes
262
263 const result: Partial<VideoAdditionalAttributes> = {}
264
265 if (add?.state === true) {
266 result.state = {
267 id: video.state,
268 label: getStateLabel(video.state)
269 }
270 }
271
272 if (add?.waitTranscoding === true) {
273 result.waitTranscoding = video.waitTranscoding
274 }
275
276 if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) {
277 result.scheduledUpdate = {
278 updateAt: video.ScheduleVideoUpdate.updateAt,
279 privacy: video.ScheduleVideoUpdate.privacy || undefined
280 }
281 }
282
283 if (add?.blacklistInfo === true) {
284 result.blacklisted = !!video.VideoBlacklist
285 result.blacklistedReason =
286 video.VideoBlacklist
287 ? video.VideoBlacklist.reason
288 : null
289 }
290
291 if (add?.blockedOwner === true) {
292 result.blockedOwner = video.VideoChannel.Account.isBlocked()
293
294 const server = video.VideoChannel.Account.Actor.Server as MServer
295 result.blockedServer = !!(server?.isBlocked())
296 }
297
298 if (add?.files === true) {
299 result.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
300 result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
301 }
302
303 return result
304}
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}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index fd56d2423..06aec1308 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -114,13 +114,13 @@ import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated,
114import { UserModel } from '../user/user' 114import { UserModel } from '../user/user'
115import { UserVideoHistoryModel } from '../user/user-video-history' 115import { UserVideoHistoryModel } from '../user/user-video-history'
116import { VideoViewModel } from '../view/video-view' 116import { VideoViewModel } from '../view/video-view'
117import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format'
117import { 118import {
118 videoFilesModelToFormattedJSON, 119 videoFilesModelToFormattedJSON,
119 VideoFormattingJSONOptions, 120 VideoFormattingJSONOptions,
120 videoModelToActivityPubObject,
121 videoModelToFormattedDetailsJSON, 121 videoModelToFormattedDetailsJSON,
122 videoModelToFormattedJSON 122 videoModelToFormattedJSON
123} from './formatter/video-format-utils' 123} from './formatter/video-api-format'
124import { ScheduleVideoUpdateModel } from './schedule-video-update' 124import { ScheduleVideoUpdateModel } from './schedule-video-update'
125import { 125import {
126 BuildVideosListQueryOptions, 126 BuildVideosListQueryOptions,