aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video/formatter
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-07-31 14:34:36 +0200
committerChocobozzz <me@florianbigard.com>2023-08-11 15:02:33 +0200
commit3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch)
treee4510b39bdac9c318fdb4b47018d08f15368b8f0 /server/models/video/formatter
parent04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff)
downloadPeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.gz
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.zst
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.zip
Migrate server to ESM
Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports)
Diffstat (limited to 'server/models/video/formatter')
-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.ts296
-rw-r--r--server/models/video/formatter/video-api-format.ts305
5 files changed, 0 insertions, 611 deletions
diff --git a/server/models/video/formatter/index.ts b/server/models/video/formatter/index.ts
deleted file mode 100644
index 77b406559..000000000
--- a/server/models/video/formatter/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
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
deleted file mode 100644
index d558fa7d6..000000000
--- a/server/models/video/formatter/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * 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
deleted file mode 100644
index df3bbdf1c..000000000
--- a/server/models/video/formatter/shared/video-format-utils.ts
+++ /dev/null
@@ -1,7 +0,0 @@
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
deleted file mode 100644
index 694c66c33..000000000
--- a/server/models/video/formatter/video-activity-pub-format.ts
+++ /dev/null
@@ -1,296 +0,0 @@
1import { isArray } from '@server/helpers/custom-validators/misc'
2import { generateMagnetUri } from '@server/helpers/webtorrent'
3import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
4import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
5import {
6 ActivityIconObject,
7 ActivityPlaylistUrlObject,
8 ActivityPubStoryboard,
9 ActivityTagObject,
10 ActivityTrackerUrlObject,
11 ActivityUrlObject,
12 VideoObject
13} from '@shared/models'
14import { MIMETYPES, WEBSERVER } from '../../../initializers/constants'
15import {
16 getLocalVideoCommentsActivityPubUrl,
17 getLocalVideoDislikesActivityPubUrl,
18 getLocalVideoLikesActivityPubUrl,
19 getLocalVideoSharesActivityPubUrl
20} from '../../../lib/activitypub/url'
21import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models'
22import { VideoCaptionModel } from '../video-caption'
23import { sortByResolutionDesc } from './shared'
24import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format'
25
26export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
27 const language = video.language
28 ? { identifier: video.language, name: getLanguageLabel(video.language) }
29 : undefined
30
31 const category = video.category
32 ? { identifier: video.category + '', name: getCategoryLabel(video.category) }
33 : undefined
34
35 const licence = video.licence
36 ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) }
37 : undefined
38
39 const url: ActivityUrlObject[] = [
40 // HTML url should be the first element in the array so Mastodon correctly displays the embed
41 {
42 type: 'Link',
43 mediaType: 'text/html',
44 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
45 } as ActivityUrlObject,
46
47 ...buildVideoFileUrls({ video, files: video.VideoFiles }),
48
49 ...buildStreamingPlaylistUrls(video),
50
51 ...buildTrackerUrls(video)
52 ]
53
54 return {
55 type: 'Video' as 'Video',
56 id: video.url,
57 name: video.name,
58 duration: getActivityStreamDuration(video.duration),
59 uuid: video.uuid,
60 category,
61 licence,
62 language,
63 views: video.views,
64 sensitive: video.nsfw,
65 waitTranscoding: video.waitTranscoding,
66
67 state: video.state,
68 commentsEnabled: video.commentsEnabled,
69 downloadEnabled: video.downloadEnabled,
70 published: video.publishedAt.toISOString(),
71
72 originallyPublishedAt: video.originallyPublishedAt
73 ? video.originallyPublishedAt.toISOString()
74 : null,
75
76 updated: video.updatedAt.toISOString(),
77
78 uploadDate: video.inputFileUpdatedAt?.toISOString(),
79
80 tag: buildTags(video),
81
82 mediaType: 'text/markdown',
83 content: video.description,
84 support: video.support,
85
86 subtitleLanguage: buildSubtitleLanguage(video),
87
88 icon: buildIcon(video),
89
90 preview: buildPreviewAPAttribute(video),
91
92 url,
93
94 likes: getLocalVideoLikesActivityPubUrl(video),
95 dislikes: getLocalVideoDislikesActivityPubUrl(video),
96 shares: getLocalVideoSharesActivityPubUrl(video),
97 comments: getLocalVideoCommentsActivityPubUrl(video),
98
99 attributedTo: [
100 {
101 type: 'Person',
102 id: video.VideoChannel.Account.Actor.url
103 },
104 {
105 type: 'Group',
106 id: video.VideoChannel.Actor.url
107 }
108 ],
109
110 ...buildLiveAPAttributes(video)
111 }
112}
113
114// ---------------------------------------------------------------------------
115// Private
116// ---------------------------------------------------------------------------
117
118function buildLiveAPAttributes (video: MVideoAP) {
119 if (!video.isLive) {
120 return {
121 isLiveBroadcast: false,
122 liveSaveReplay: null,
123 permanentLive: null,
124 latencyMode: null
125 }
126 }
127
128 return {
129 isLiveBroadcast: true,
130 liveSaveReplay: video.VideoLive.saveReplay,
131 permanentLive: video.VideoLive.permanentLive,
132 latencyMode: video.VideoLive.latencyMode
133 }
134}
135
136function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] {
137 if (!video.Storyboard) return undefined
138
139 const storyboard = video.Storyboard
140
141 return [
142 {
143 type: 'Image',
144 rel: [ 'storyboard' ],
145 url: [
146 {
147 mediaType: 'image/jpeg',
148
149 href: storyboard.getOriginFileUrl(video),
150
151 width: storyboard.totalWidth,
152 height: storyboard.totalHeight,
153
154 tileWidth: storyboard.spriteWidth,
155 tileHeight: storyboard.spriteHeight,
156 tileDuration: getActivityStreamDuration(storyboard.spriteDuration)
157 }
158 ]
159 }
160 ]
161}
162
163function buildVideoFileUrls (options: {
164 video: MVideo
165 files: MVideoFile[]
166 user?: MUserId
167}): ActivityUrlObject[] {
168 const { video, files } = options
169
170 if (!isArray(files)) return []
171
172 const urls: ActivityUrlObject[] = []
173
174 const trackerUrls = video.getTrackerUrls()
175 const sortedFiles = files
176 .filter(f => !f.isLive())
177 .sort(sortByResolutionDesc)
178
179 for (const file of sortedFiles) {
180 urls.push({
181 type: 'Link',
182 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any,
183 href: file.getFileUrl(video),
184 height: file.resolution,
185 size: file.size,
186 fps: file.fps
187 })
188
189 urls.push({
190 type: 'Link',
191 rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ],
192 mediaType: 'application/json' as 'application/json',
193 href: getLocalVideoFileMetadataUrl(video, file),
194 height: file.resolution,
195 fps: file.fps
196 })
197
198 if (file.hasTorrent()) {
199 urls.push({
200 type: 'Link',
201 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
202 href: file.getTorrentUrl(),
203 height: file.resolution
204 })
205
206 urls.push({
207 type: 'Link',
208 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
209 href: generateMagnetUri(video, file, trackerUrls),
210 height: file.resolution
211 })
212 }
213 }
214
215 return urls
216}
217
218// ---------------------------------------------------------------------------
219
220function buildStreamingPlaylistUrls (video: MVideoAP): ActivityPlaylistUrlObject[] {
221 if (!isArray(video.VideoStreamingPlaylists)) return []
222
223 return video.VideoStreamingPlaylists
224 .map(playlist => ({
225 type: 'Link',
226 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
227 href: playlist.getMasterPlaylistUrl(video),
228 tag: buildStreamingPlaylistTags(video, playlist)
229 }))
230}
231
232function buildStreamingPlaylistTags (video: MVideoAP, playlist: MStreamingPlaylistFiles) {
233 return [
234 ...playlist.p2pMediaLoaderInfohashes.map(i => ({ type: 'Infohash' as 'Infohash', name: i })),
235
236 {
237 type: 'Link',
238 name: 'sha256',
239 mediaType: 'application/json' as 'application/json',
240 href: playlist.getSha256SegmentsUrl(video)
241 },
242
243 ...buildVideoFileUrls({ video, files: playlist.VideoFiles })
244 ] as ActivityTagObject[]
245}
246
247// ---------------------------------------------------------------------------
248
249function buildTrackerUrls (video: MVideoAP): ActivityTrackerUrlObject[] {
250 return video.getTrackerUrls()
251 .map(trackerUrl => {
252 const rel2 = trackerUrl.startsWith('http')
253 ? 'http'
254 : 'websocket'
255
256 return {
257 type: 'Link',
258 name: `tracker-${rel2}`,
259 rel: [ 'tracker', rel2 ],
260 href: trackerUrl
261 }
262 })
263}
264
265// ---------------------------------------------------------------------------
266
267function buildTags (video: MVideoAP) {
268 if (!isArray(video.Tags)) return []
269
270 return video.Tags.map(t => ({
271 type: 'Hashtag' as 'Hashtag',
272 name: t.name
273 }))
274}
275
276function buildIcon (video: MVideoAP): ActivityIconObject[] {
277 return [ video.getMiniature(), video.getPreview() ]
278 .map(i => ({
279 type: 'Image',
280 url: i.getOriginFileUrl(video),
281 mediaType: 'image/jpeg',
282 width: i.width,
283 height: i.height
284 }))
285}
286
287function buildSubtitleLanguage (video: MVideoAP) {
288 if (!isArray(video.VideoCaptions)) return []
289
290 return video.VideoCaptions
291 .map(caption => ({
292 identifier: caption.language,
293 name: VideoCaptionModel.getLanguageLabel(caption.language),
294 url: caption.getFileUrl(video)
295 }))
296}
diff --git a/server/models/video/formatter/video-api-format.ts b/server/models/video/formatter/video-api-format.ts
deleted file mode 100644
index 7a58f5d3c..000000000
--- a/server/models/video/formatter/video-api-format.ts
+++ /dev/null
@@ -1,305 +0,0 @@
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 inputFileUpdatedAt: video.inputFileUpdatedAt,
153 state: {
154 id: video.state,
155 label: getStateLabel(video.state)
156 },
157
158 trackerUrls: video.getTrackerUrls()
159 }
160
161 span.end()
162
163 return detailsJSON
164}
165
166export function streamingPlaylistsModelToFormattedJSON (
167 video: MVideoFormattable,
168 playlists: MStreamingPlaylistRedundanciesOpt[]
169): VideoStreamingPlaylist[] {
170 if (isArray(playlists) === false) return []
171
172 return playlists
173 .map(playlist => ({
174 id: playlist.id,
175 type: playlist.type,
176
177 playlistUrl: playlist.getMasterPlaylistUrl(video),
178 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
179
180 redundancies: isArray(playlist.RedundancyVideos)
181 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
182 : [],
183
184 files: videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
185 }))
186}
187
188export function videoFilesModelToFormattedJSON (
189 video: MVideoFormattable,
190 videoFiles: MVideoFileRedundanciesOpt[],
191 options: {
192 includeMagnet?: boolean // default true
193 } = {}
194): VideoFile[] {
195 const { includeMagnet = true } = options
196
197 if (isArray(videoFiles) === false) return []
198
199 const trackerUrls = includeMagnet
200 ? video.getTrackerUrls()
201 : []
202
203 return videoFiles
204 .filter(f => !f.isLive())
205 .sort(sortByResolutionDesc)
206 .map(videoFile => {
207 return {
208 id: videoFile.id,
209
210 resolution: {
211 id: videoFile.resolution,
212 label: videoFile.resolution === 0
213 ? 'Audio'
214 : `${videoFile.resolution}p`
215 },
216
217 magnetUri: includeMagnet && videoFile.hasTorrent()
218 ? generateMagnetUri(video, videoFile, trackerUrls)
219 : undefined,
220
221 size: videoFile.size,
222 fps: videoFile.fps,
223
224 torrentUrl: videoFile.getTorrentUrl(),
225 torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
226
227 fileUrl: videoFile.getFileUrl(video),
228 fileDownloadUrl: videoFile.getFileDownloadUrl(video),
229
230 metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
231 }
232 })
233}
234
235// ---------------------------------------------------------------------------
236
237export function getCategoryLabel (id: number) {
238 return VIDEO_CATEGORIES[id] || 'Unknown'
239}
240
241export function getLicenceLabel (id: number) {
242 return VIDEO_LICENCES[id] || 'Unknown'
243}
244
245export function getLanguageLabel (id: string) {
246 return VIDEO_LANGUAGES[id] || 'Unknown'
247}
248
249export function getPrivacyLabel (id: number) {
250 return VIDEO_PRIVACIES[id] || 'Unknown'
251}
252
253export function getStateLabel (id: number) {
254 return VIDEO_STATES[id] || 'Unknown'
255}
256
257// ---------------------------------------------------------------------------
258// Private
259// ---------------------------------------------------------------------------
260
261function buildAdditionalAttributes (video: MVideoFormattable, options: VideoFormattingJSONOptions) {
262 const add = options.additionalAttributes
263
264 const result: Partial<VideoAdditionalAttributes> = {}
265
266 if (add?.state === true) {
267 result.state = {
268 id: video.state,
269 label: getStateLabel(video.state)
270 }
271 }
272
273 if (add?.waitTranscoding === true) {
274 result.waitTranscoding = video.waitTranscoding
275 }
276
277 if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) {
278 result.scheduledUpdate = {
279 updateAt: video.ScheduleVideoUpdate.updateAt,
280 privacy: video.ScheduleVideoUpdate.privacy || undefined
281 }
282 }
283
284 if (add?.blacklistInfo === true) {
285 result.blacklisted = !!video.VideoBlacklist
286 result.blacklistedReason =
287 video.VideoBlacklist
288 ? video.VideoBlacklist.reason
289 : null
290 }
291
292 if (add?.blockedOwner === true) {
293 result.blockedOwner = video.VideoChannel.Account.isBlocked()
294
295 const server = video.VideoChannel.Account.Actor.Server as MServer
296 result.blockedServer = !!(server?.isBlocked())
297 }
298
299 if (add?.files === true) {
300 result.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
301 result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
302 }
303
304 return result
305}