aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/server/lib/video-pre-import.ts
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/server/lib/video-pre-import.ts
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/server/lib/video-pre-import.ts')
-rw-r--r--server/server/lib/video-pre-import.ts331
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 @@
1import { remove } from 'fs-extra/esm'
2import {
3 ThumbnailType,
4 ThumbnailType_Type,
5 VideoImportCreate,
6 VideoImportPayload,
7 VideoImportState,
8 VideoPrivacy,
9 VideoState
10} from '@peertube/peertube-models'
11import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils.js'
12import { isVTTFileValid } from '@server/helpers/custom-validators/video-captions.js'
13import { isVideoFileExtnameValid } from '@server/helpers/custom-validators/videos.js'
14import { isResolvingToUnicastOnly } from '@server/helpers/dns.js'
15import { logger } from '@server/helpers/logger.js'
16import { YoutubeDLInfo, YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js'
17import { CONFIG } from '@server/initializers/config.js'
18import { sequelizeTypescript } from '@server/initializers/database.js'
19import { Hooks } from '@server/lib/plugins/hooks.js'
20import { ServerConfigManager } from '@server/lib/server-config-manager.js'
21import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
22import { setVideoTags } from '@server/lib/video.js'
23import { VideoCaptionModel } from '@server/models/video/video-caption.js'
24import { VideoImportModel } from '@server/models/video/video-import.js'
25import { VideoPasswordModel } from '@server/models/video/video-password.js'
26import { VideoModel } from '@server/models/video/video.js'
27import { FilteredModelAttributes } from '@server/types/index.js'
28import {
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'
40import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
41import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
42
43class 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
59namespace YoutubeDlImportError {
60 export enum CODE {
61 FETCH_ERROR,
62 NOT_ONLY_UNICAST_URL
63 }
64}
65
66// ---------------------------------------------------------------------------
67
68async 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
119async 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
159async 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
252export {
253 buildYoutubeDLImport,
254 YoutubeDlImportError,
255 insertFromImportIntoDB,
256 buildVideoFromImport
257}
258
259// ---------------------------------------------------------------------------
260
261async 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
287async 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
320async 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}