diff options
author | Chocobozzz <me@florianbigard.com> | 2023-07-31 14:34:36 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2023-08-11 15:02:33 +0200 |
commit | 3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch) | |
tree | e4510b39bdac9c318fdb4b47018d08f15368b8f0 /server/server/lib/video-pre-import.ts | |
parent | 04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff) | |
download | PeerTube-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/server/lib/video-pre-import.ts')
-rw-r--r-- | server/server/lib/video-pre-import.ts | 331 |
1 files changed, 331 insertions, 0 deletions
diff --git a/server/server/lib/video-pre-import.ts b/server/server/lib/video-pre-import.ts new file mode 100644 index 000000000..0298e121e --- /dev/null +++ b/server/server/lib/video-pre-import.ts | |||
@@ -0,0 +1,331 @@ | |||
1 | import { remove } from 'fs-extra/esm' | ||
2 | import { | ||
3 | ThumbnailType, | ||
4 | ThumbnailType_Type, | ||
5 | VideoImportCreate, | ||
6 | VideoImportPayload, | ||
7 | VideoImportState, | ||
8 | VideoPrivacy, | ||
9 | VideoState | ||
10 | } from '@peertube/peertube-models' | ||
11 | import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js' | ||
12 | import { isVTTFileValid } from '@server/helpers/custom-validators/video-captions.js' | ||
13 | import { isVideoFileExtnameValid } from '@server/helpers/custom-validators/videos.js' | ||
14 | import { isResolvingToUnicastOnly } from '@server/helpers/dns.js' | ||
15 | import { logger } from '@server/helpers/logger.js' | ||
16 | import { YoutubeDLInfo, YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js' | ||
17 | import { CONFIG } from '@server/initializers/config.js' | ||
18 | import { sequelizeTypescript } from '@server/initializers/database.js' | ||
19 | import { Hooks } from '@server/lib/plugins/hooks.js' | ||
20 | import { ServerConfigManager } from '@server/lib/server-config-manager.js' | ||
21 | import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js' | ||
22 | import { setVideoTags } from '@server/lib/video.js' | ||
23 | import { VideoCaptionModel } from '@server/models/video/video-caption.js' | ||
24 | import { VideoImportModel } from '@server/models/video/video-import.js' | ||
25 | import { VideoPasswordModel } from '@server/models/video/video-password.js' | ||
26 | import { VideoModel } from '@server/models/video/video.js' | ||
27 | import { FilteredModelAttributes } from '@server/types/index.js' | ||
28 | import { | ||
29 | MChannelAccountDefault, | ||
30 | MChannelSync, | ||
31 | MThumbnail, | ||
32 | MUser, | ||
33 | MVideoAccountDefault, | ||
34 | MVideoCaption, | ||
35 | MVideoImportFormattable, | ||
36 | MVideoTag, | ||
37 | MVideoThumbnail, | ||
38 | MVideoWithBlacklistLight | ||
39 | } from '@server/types/models/index.js' | ||
40 | import { getLocalVideoActivityPubUrl } from './activitypub/url.js' | ||
41 | import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js' | ||
42 | |||
43 | class YoutubeDlImportError extends Error { | ||
44 | code: YoutubeDlImportError.CODE | ||
45 | cause?: Error // Property to remove once ES2022 is used | ||
46 | constructor ({ message, code }) { | ||
47 | super(message) | ||
48 | this.code = code | ||
49 | } | ||
50 | |||
51 | static fromError (err: Error, code: YoutubeDlImportError.CODE, message?: string) { | ||
52 | const ytDlErr = new this({ message: message ?? err.message, code }) | ||
53 | ytDlErr.cause = err | ||
54 | ytDlErr.stack = err.stack // Useless once ES2022 is used | ||
55 | return ytDlErr | ||
56 | } | ||
57 | } | ||
58 | |||
59 | namespace YoutubeDlImportError { | ||
60 | export enum CODE { | ||
61 | FETCH_ERROR, | ||
62 | NOT_ONLY_UNICAST_URL | ||
63 | } | ||
64 | } | ||
65 | |||
66 | // --------------------------------------------------------------------------- | ||
67 | |||
68 | async function insertFromImportIntoDB (parameters: { | ||
69 | video: MVideoThumbnail | ||
70 | thumbnailModel: MThumbnail | ||
71 | previewModel: MThumbnail | ||
72 | videoChannel: MChannelAccountDefault | ||
73 | tags: string[] | ||
74 | videoImportAttributes: FilteredModelAttributes<VideoImportModel> | ||
75 | user: MUser | ||
76 | videoPasswords?: string[] | ||
77 | }): Promise<MVideoImportFormattable> { | ||
78 | const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters | ||
79 | |||
80 | const videoImport = await sequelizeTypescript.transaction(async t => { | ||
81 | const sequelizeOptions = { transaction: t } | ||
82 | |||
83 | // Save video object in database | ||
84 | const videoCreated = await video.save(sequelizeOptions) as (MVideoAccountDefault & MVideoWithBlacklistLight & MVideoTag) | ||
85 | videoCreated.VideoChannel = videoChannel | ||
86 | |||
87 | if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) | ||
88 | if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) | ||
89 | |||
90 | if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) { | ||
91 | await VideoPasswordModel.addPasswords(videoPasswords, video.id, t) | ||
92 | } | ||
93 | |||
94 | await autoBlacklistVideoIfNeeded({ | ||
95 | video: videoCreated, | ||
96 | user, | ||
97 | notify: false, | ||
98 | isRemote: false, | ||
99 | isNew: true, | ||
100 | isNewFile: true, | ||
101 | transaction: t | ||
102 | }) | ||
103 | |||
104 | await setVideoTags({ video: videoCreated, tags, transaction: t }) | ||
105 | |||
106 | // Create video import object in database | ||
107 | const videoImport = await VideoImportModel.create( | ||
108 | Object.assign({ videoId: videoCreated.id }, videoImportAttributes), | ||
109 | sequelizeOptions | ||
110 | ) as MVideoImportFormattable | ||
111 | videoImport.Video = videoCreated | ||
112 | |||
113 | return videoImport | ||
114 | }) | ||
115 | |||
116 | return videoImport | ||
117 | } | ||
118 | |||
119 | async function buildVideoFromImport ({ channelId, importData, importDataOverride, importType }: { | ||
120 | channelId: number | ||
121 | importData: YoutubeDLInfo | ||
122 | importDataOverride?: Partial<VideoImportCreate> | ||
123 | importType: 'url' | 'torrent' | ||
124 | }): Promise<MVideoThumbnail> { | ||
125 | let videoData = { | ||
126 | name: importDataOverride?.name || importData.name || 'Unknown name', | ||
127 | remote: false, | ||
128 | category: importDataOverride?.category || importData.category, | ||
129 | licence: importDataOverride?.licence ?? importData.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE, | ||
130 | language: importDataOverride?.language || importData.language, | ||
131 | commentsEnabled: importDataOverride?.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED, | ||
132 | downloadEnabled: importDataOverride?.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED, | ||
133 | waitTranscoding: importDataOverride?.waitTranscoding ?? true, | ||
134 | state: VideoState.TO_IMPORT, | ||
135 | nsfw: importDataOverride?.nsfw || importData.nsfw || false, | ||
136 | description: importDataOverride?.description || importData.description, | ||
137 | support: importDataOverride?.support || null, | ||
138 | privacy: importDataOverride?.privacy || VideoPrivacy.PRIVATE, | ||
139 | duration: 0, // duration will be set by the import job | ||
140 | channelId, | ||
141 | originallyPublishedAt: importDataOverride?.originallyPublishedAt | ||
142 | ? new Date(importDataOverride?.originallyPublishedAt) | ||
143 | : importData.originallyPublishedAtWithoutTime | ||
144 | } | ||
145 | |||
146 | videoData = await Hooks.wrapObject( | ||
147 | videoData, | ||
148 | importType === 'url' | ||
149 | ? 'filter:api.video.import-url.video-attribute.result' | ||
150 | : 'filter:api.video.import-torrent.video-attribute.result' | ||
151 | ) | ||
152 | |||
153 | const video = new VideoModel(videoData) | ||
154 | video.url = getLocalVideoActivityPubUrl(video) | ||
155 | |||
156 | return video | ||
157 | } | ||
158 | |||
159 | async function buildYoutubeDLImport (options: { | ||
160 | targetUrl: string | ||
161 | channel: MChannelAccountDefault | ||
162 | user: MUser | ||
163 | channelSync?: MChannelSync | ||
164 | importDataOverride?: Partial<VideoImportCreate> | ||
165 | thumbnailFilePath?: string | ||
166 | previewFilePath?: string | ||
167 | }) { | ||
168 | const { targetUrl, channel, channelSync, importDataOverride, thumbnailFilePath, previewFilePath, user } = options | ||
169 | |||
170 | const youtubeDL = new YoutubeDLWrapper( | ||
171 | targetUrl, | ||
172 | ServerConfigManager.Instance.getEnabledResolutions('vod'), | ||
173 | CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION | ||
174 | ) | ||
175 | |||
176 | // Get video infos | ||
177 | let youtubeDLInfo: YoutubeDLInfo | ||
178 | try { | ||
179 | youtubeDLInfo = await youtubeDL.getInfoForDownload() | ||
180 | } catch (err) { | ||
181 | throw YoutubeDlImportError.fromError( | ||
182 | err, YoutubeDlImportError.CODE.FETCH_ERROR, `Cannot fetch information from import for URL ${targetUrl}` | ||
183 | ) | ||
184 | } | ||
185 | |||
186 | if (!await hasUnicastURLsOnly(youtubeDLInfo)) { | ||
187 | throw new YoutubeDlImportError({ | ||
188 | message: 'Cannot use non unicast IP as targetUrl.', | ||
189 | code: YoutubeDlImportError.CODE.NOT_ONLY_UNICAST_URL | ||
190 | }) | ||
191 | } | ||
192 | |||
193 | const video = await buildVideoFromImport({ | ||
194 | channelId: channel.id, | ||
195 | importData: youtubeDLInfo, | ||
196 | importDataOverride, | ||
197 | importType: 'url' | ||
198 | }) | ||
199 | |||
200 | const thumbnailModel = await forgeThumbnail({ | ||
201 | inputPath: thumbnailFilePath, | ||
202 | downloadUrl: youtubeDLInfo.thumbnailUrl, | ||
203 | video, | ||
204 | type: ThumbnailType.MINIATURE | ||
205 | }) | ||
206 | |||
207 | const previewModel = await forgeThumbnail({ | ||
208 | inputPath: previewFilePath, | ||
209 | downloadUrl: youtubeDLInfo.thumbnailUrl, | ||
210 | video, | ||
211 | type: ThumbnailType.PREVIEW | ||
212 | }) | ||
213 | |||
214 | const videoImport = await insertFromImportIntoDB({ | ||
215 | video, | ||
216 | thumbnailModel, | ||
217 | previewModel, | ||
218 | videoChannel: channel, | ||
219 | tags: importDataOverride?.tags || youtubeDLInfo.tags, | ||
220 | user, | ||
221 | videoImportAttributes: { | ||
222 | targetUrl, | ||
223 | state: VideoImportState.PENDING, | ||
224 | userId: user.id, | ||
225 | videoChannelSyncId: channelSync?.id | ||
226 | }, | ||
227 | videoPasswords: importDataOverride.videoPasswords | ||
228 | }) | ||
229 | |||
230 | // Get video subtitles | ||
231 | await processYoutubeSubtitles(youtubeDL, targetUrl, video.id) | ||
232 | |||
233 | let fileExt = `.${youtubeDLInfo.ext}` | ||
234 | if (!isVideoFileExtnameValid(fileExt)) fileExt = '.mp4' | ||
235 | |||
236 | const payload: VideoImportPayload = { | ||
237 | type: 'youtube-dl' as 'youtube-dl', | ||
238 | videoImportId: videoImport.id, | ||
239 | fileExt, | ||
240 | // If part of a sync process, there is a parent job that will aggregate children results | ||
241 | preventException: !!channelSync | ||
242 | } | ||
243 | |||
244 | return { | ||
245 | videoImport, | ||
246 | job: { type: 'video-import' as 'video-import', payload } | ||
247 | } | ||
248 | } | ||
249 | |||
250 | // --------------------------------------------------------------------------- | ||
251 | |||
252 | export { | ||
253 | buildYoutubeDLImport, | ||
254 | YoutubeDlImportError, | ||
255 | insertFromImportIntoDB, | ||
256 | buildVideoFromImport | ||
257 | } | ||
258 | |||
259 | // --------------------------------------------------------------------------- | ||
260 | |||
261 | async function forgeThumbnail ({ inputPath, video, downloadUrl, type }: { | ||
262 | inputPath?: string | ||
263 | downloadUrl?: string | ||
264 | video: MVideoThumbnail | ||
265 | type: ThumbnailType_Type | ||
266 | }): Promise<MThumbnail> { | ||
267 | if (inputPath) { | ||
268 | return updateLocalVideoMiniatureFromExisting({ | ||
269 | inputPath, | ||
270 | video, | ||
271 | type, | ||
272 | automaticallyGenerated: false | ||
273 | }) | ||
274 | } | ||
275 | |||
276 | if (downloadUrl) { | ||
277 | try { | ||
278 | return await updateLocalVideoMiniatureFromUrl({ downloadUrl, video, type }) | ||
279 | } catch (err) { | ||
280 | logger.warn('Cannot process thumbnail %s from youtube-dl.', downloadUrl, { err }) | ||
281 | } | ||
282 | } | ||
283 | |||
284 | return null | ||
285 | } | ||
286 | |||
287 | async function processYoutubeSubtitles (youtubeDL: YoutubeDLWrapper, targetUrl: string, videoId: number) { | ||
288 | try { | ||
289 | const subtitles = await youtubeDL.getSubtitles() | ||
290 | |||
291 | logger.info('Found %s subtitles candidates from youtube-dl import %s.', subtitles.length, targetUrl) | ||
292 | |||
293 | for (const subtitle of subtitles) { | ||
294 | if (!await isVTTFileValid(subtitle.path)) { | ||
295 | logger.info('%s is not a valid youtube-dl subtitle, skipping', subtitle.path) | ||
296 | await remove(subtitle.path) | ||
297 | continue | ||
298 | } | ||
299 | |||
300 | const videoCaption = new VideoCaptionModel({ | ||
301 | videoId, | ||
302 | language: subtitle.language, | ||
303 | filename: VideoCaptionModel.generateCaptionName(subtitle.language) | ||
304 | }) as MVideoCaption | ||
305 | |||
306 | // Move physical file | ||
307 | await moveAndProcessCaptionFile(subtitle, videoCaption) | ||
308 | |||
309 | await sequelizeTypescript.transaction(async t => { | ||
310 | await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t) | ||
311 | }) | ||
312 | |||
313 | logger.info('Added %s youtube-dl subtitle', subtitle.path) | ||
314 | } | ||
315 | } catch (err) { | ||
316 | logger.warn('Cannot get video subtitles.', { err }) | ||
317 | } | ||
318 | } | ||
319 | |||
320 | async function hasUnicastURLsOnly (youtubeDLInfo: YoutubeDLInfo) { | ||
321 | const hosts = youtubeDLInfo.urls.map(u => new URL(u).hostname) | ||
322 | const uniqHosts = new Set(hosts) | ||
323 | |||
324 | for (const h of uniqHosts) { | ||
325 | if (await isResolvingToUnicastOnly(h) !== true) { | ||
326 | return false | ||
327 | } | ||
328 | } | ||
329 | |||
330 | return true | ||
331 | } | ||