a197ef629c4cb455c458aa576a2e9b7bdea3d0a2
[oweals/peertube.git] / server / lib / job-queue / handlers / video-import.ts
1 import * as Bull from 'bull'
2 import { move, remove, stat } from 'fs-extra'
3 import { extname } from 'path'
4 import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
5 import { isPostImportVideoAccepted } from '@server/lib/moderation'
6 import { Hooks } from '@server/lib/plugins/hooks'
7 import { getVideoFilePath } from '@server/lib/video-paths'
8 import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/typings/models/video/video-import'
9 import {
10   VideoImportPayload,
11   VideoImportTorrentPayload,
12   VideoImportTorrentPayloadType,
13   VideoImportYoutubeDLPayload,
14   VideoImportYoutubeDLPayloadType,
15   VideoState
16 } from '../../../../shared'
17 import { VideoImportState } from '../../../../shared/models/videos'
18 import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
19 import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
20 import { logger } from '../../../helpers/logger'
21 import { getSecureTorrentName } from '../../../helpers/utils'
22 import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
23 import { downloadYoutubeDLVideo } from '../../../helpers/youtube-dl'
24 import { CONFIG } from '../../../initializers/config'
25 import { VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants'
26 import { sequelizeTypescript } from '../../../initializers/database'
27 import { VideoModel } from '../../../models/video/video'
28 import { VideoFileModel } from '../../../models/video/video-file'
29 import { VideoImportModel } from '../../../models/video/video-import'
30 import { MThumbnail } from '../../../typings/models/video/thumbnail'
31 import { federateVideoIfNeeded } from '../../activitypub/videos'
32 import { Notifier } from '../../notifier'
33 import { generateVideoMiniature } from '../../thumbnail'
34
35 async function processVideoImport (job: Bull.Job) {
36   const payload = job.data as VideoImportPayload
37
38   if (payload.type === 'youtube-dl') return processYoutubeDLImport(job, payload)
39   if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') return processTorrentImport(job, payload)
40 }
41
42 // ---------------------------------------------------------------------------
43
44 export {
45   processVideoImport
46 }
47
48 // ---------------------------------------------------------------------------
49
50 async function processTorrentImport (job: Bull.Job, payload: VideoImportTorrentPayload) {
51   logger.info('Processing torrent video import in job %d.', job.id)
52
53   const videoImport = await getVideoImportOrDie(payload.videoImportId)
54
55   const options = {
56     type: payload.type,
57     videoImportId: payload.videoImportId,
58
59     generateThumbnail: true,
60     generatePreview: true
61   }
62   const target = {
63     torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined,
64     magnetUri: videoImport.magnetUri
65   }
66   return processFile(() => downloadWebTorrentVideo(target, VIDEO_IMPORT_TIMEOUT), videoImport, options)
67 }
68
69 async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutubeDLPayload) {
70   logger.info('Processing youtubeDL video import in job %d.', job.id)
71
72   const videoImport = await getVideoImportOrDie(payload.videoImportId)
73   const options = {
74     type: payload.type,
75     videoImportId: videoImport.id,
76
77     generateThumbnail: payload.generateThumbnail,
78     generatePreview: payload.generatePreview
79   }
80
81   return processFile(() => downloadYoutubeDLVideo(videoImport.targetUrl, payload.fileExt, VIDEO_IMPORT_TIMEOUT), videoImport, options)
82 }
83
84 async function getVideoImportOrDie (videoImportId: number) {
85   const videoImport = await VideoImportModel.loadAndPopulateVideo(videoImportId)
86   if (!videoImport || !videoImport.Video) {
87     throw new Error('Cannot import video %s: the video import or video linked to this import does not exist anymore.')
88   }
89
90   return videoImport
91 }
92
93 type ProcessFileOptions = {
94   type: VideoImportYoutubeDLPayloadType | VideoImportTorrentPayloadType
95   videoImportId: number
96
97   generateThumbnail: boolean
98   generatePreview: boolean
99 }
100 async function processFile (downloader: () => Promise<string>, videoImport: MVideoImportDefault, options: ProcessFileOptions) {
101   let tempVideoPath: string
102   let videoDestFile: string
103   let videoFile: VideoFileModel
104
105   try {
106     // Download video from youtubeDL
107     tempVideoPath = await downloader()
108
109     // Get information about this video
110     const stats = await stat(tempVideoPath)
111     const isAble = await videoImport.User.isAbleToUploadVideo({ size: stats.size })
112     if (isAble === false) {
113       throw new Error('The user video quota is exceeded with this video to import.')
114     }
115
116     const { videoFileResolution } = await getVideoFileResolution(tempVideoPath)
117     const fps = await getVideoFileFPS(tempVideoPath)
118     const duration = await getDurationFromVideoFile(tempVideoPath)
119
120     // Prepare video file object for creation in database
121     const videoFileData = {
122       extname: extname(tempVideoPath),
123       resolution: videoFileResolution,
124       size: stats.size,
125       fps,
126       videoId: videoImport.videoId
127     }
128     videoFile = new VideoFileModel(videoFileData)
129
130     const hookName = options.type === 'youtube-dl'
131       ? 'filter:api.video.post-import-url.accept.result'
132       : 'filter:api.video.post-import-torrent.accept.result'
133
134     // Check we accept this video
135     const acceptParameters = {
136       videoImport,
137       video: videoImport.Video,
138       videoFilePath: tempVideoPath,
139       videoFile,
140       user: videoImport.User
141     }
142     const acceptedResult = await Hooks.wrapFun(isPostImportVideoAccepted, acceptParameters, hookName)
143
144     if (acceptedResult.accepted !== true) {
145       logger.info('Refused imported video.', { acceptedResult, acceptParameters })
146
147       videoImport.state = VideoImportState.REJECTED
148       await videoImport.save()
149
150       throw new Error(acceptedResult.errorMessage)
151     }
152
153     // Video is accepted, resuming preparation
154     const videoWithFiles = Object.assign(videoImport.Video, { VideoFiles: [ videoFile ], VideoStreamingPlaylists: [] })
155     // To clean files if the import fails
156     const videoImportWithFiles: MVideoImportDefaultFiles = Object.assign(videoImport, { Video: videoWithFiles })
157
158     // Move file
159     videoDestFile = getVideoFilePath(videoImportWithFiles.Video, videoFile)
160     await move(tempVideoPath, videoDestFile)
161     tempVideoPath = null // This path is not used anymore
162
163     // Process thumbnail
164     let thumbnailModel: MThumbnail
165     if (options.generateThumbnail) {
166       thumbnailModel = await generateVideoMiniature(videoImportWithFiles.Video, videoFile, ThumbnailType.MINIATURE)
167     }
168
169     // Process preview
170     let previewModel: MThumbnail
171     if (options.generatePreview) {
172       previewModel = await generateVideoMiniature(videoImportWithFiles.Video, videoFile, ThumbnailType.PREVIEW)
173     }
174
175     // Create torrent
176     await createTorrentAndSetInfoHash(videoImportWithFiles.Video, videoFile)
177
178     const { videoImportUpdated, video } = await sequelizeTypescript.transaction(async t => {
179       const videoImportToUpdate = videoImportWithFiles as MVideoImportVideo
180
181       // Refresh video
182       const video = await VideoModel.load(videoImportToUpdate.videoId, t)
183       if (!video) throw new Error('Video linked to import ' + videoImportToUpdate.videoId + ' does not exist anymore.')
184
185       const videoFileCreated = await videoFile.save({ transaction: t })
186       videoImportToUpdate.Video = Object.assign(video, { VideoFiles: [ videoFileCreated ] })
187
188       // Update video DB object
189       video.duration = duration
190       video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
191       await video.save({ transaction: t })
192
193       if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
194       if (previewModel) await video.addAndSaveThumbnail(previewModel, t)
195
196       // Now we can federate the video (reload from database, we need more attributes)
197       const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
198       await federateVideoIfNeeded(videoForFederation, true, t)
199
200       // Update video import object
201       videoImportToUpdate.state = VideoImportState.SUCCESS
202       const videoImportUpdated = await videoImportToUpdate.save({ transaction: t }) as MVideoImportVideo
203       videoImportUpdated.Video = video
204
205       logger.info('Video %s imported.', video.uuid)
206
207       return { videoImportUpdated, video: videoForFederation }
208     })
209
210     Notifier.Instance.notifyOnFinishedVideoImport(videoImportUpdated, true)
211
212     if (video.isBlacklisted()) {
213       const videoBlacklist = Object.assign(video.VideoBlacklist, { Video: video })
214
215       Notifier.Instance.notifyOnVideoAutoBlacklist(videoBlacklist)
216     } else {
217       Notifier.Instance.notifyOnNewVideoIfNeeded(video)
218     }
219
220     // Create transcoding jobs?
221     if (video.state === VideoState.TO_TRANSCODE) {
222       await addOptimizeOrMergeAudioJob(videoImportUpdated.Video, videoFile)
223     }
224
225   } catch (err) {
226     try {
227       if (tempVideoPath) await remove(tempVideoPath)
228     } catch (errUnlink) {
229       logger.warn('Cannot cleanup files after a video import error.', { err: errUnlink })
230     }
231
232     videoImport.error = err.message
233     if (videoImport.state !== VideoImportState.REJECTED) {
234       videoImport.state = VideoImportState.FAILED
235     }
236     await videoImport.save()
237
238     Notifier.Instance.notifyOnFinishedVideoImport(videoImport, false)
239
240     throw err
241   }
242 }