import.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import express from 'express'
  2. import { move, readFile } from 'fs-extra'
  3. import { decode } from 'magnet-uri'
  4. import parseTorrent, { Instance } from 'parse-torrent'
  5. import { join } from 'path'
  6. import { ServerConfigManager } from '@server/lib/server-config-manager'
  7. import { setVideoTags } from '@server/lib/video'
  8. import { FilteredModelAttributes } from '@server/types'
  9. import {
  10. MChannelAccountDefault,
  11. MThumbnail,
  12. MUser,
  13. MVideoAccountDefault,
  14. MVideoCaption,
  15. MVideoTag,
  16. MVideoThumbnail,
  17. MVideoWithBlacklistLight
  18. } from '@server/types/models'
  19. import { MVideoImportFormattable } from '@server/types/models/video/video-import'
  20. import { ServerErrorCode, VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
  21. import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
  22. import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
  23. import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
  24. import { isArray } from '../../../helpers/custom-validators/misc'
  25. import { cleanUpReqFiles, createReqFiles } from '../../../helpers/express-utils'
  26. import { logger } from '../../../helpers/logger'
  27. import { getSecureTorrentName } from '../../../helpers/utils'
  28. import { YoutubeDL, YoutubeDLInfo } from '../../../helpers/youtube-dl'
  29. import { CONFIG } from '../../../initializers/config'
  30. import { MIMETYPES } from '../../../initializers/constants'
  31. import { sequelizeTypescript } from '../../../initializers/database'
  32. import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url'
  33. import { JobQueue } from '../../../lib/job-queue/job-queue'
  34. import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail'
  35. import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
  36. import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
  37. import { VideoModel } from '../../../models/video/video'
  38. import { VideoCaptionModel } from '../../../models/video/video-caption'
  39. import { VideoImportModel } from '../../../models/video/video-import'
  40. const auditLogger = auditLoggerFactory('video-imports')
  41. const videoImportsRouter = express.Router()
  42. const reqVideoFileImport = createReqFiles(
  43. [ 'thumbnailfile', 'previewfile', 'torrentfile' ],
  44. Object.assign({}, MIMETYPES.TORRENT.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
  45. {
  46. thumbnailfile: CONFIG.STORAGE.TMP_DIR,
  47. previewfile: CONFIG.STORAGE.TMP_DIR,
  48. torrentfile: CONFIG.STORAGE.TMP_DIR
  49. }
  50. )
  51. videoImportsRouter.post('/imports',
  52. authenticate,
  53. reqVideoFileImport,
  54. asyncMiddleware(videoImportAddValidator),
  55. asyncRetryTransactionMiddleware(addVideoImport)
  56. )
  57. // ---------------------------------------------------------------------------
  58. export {
  59. videoImportsRouter
  60. }
  61. // ---------------------------------------------------------------------------
  62. function addVideoImport (req: express.Request, res: express.Response) {
  63. if (req.body.targetUrl) return addYoutubeDLImport(req, res)
  64. const file = req.files?.['torrentfile']?.[0]
  65. if (req.body.magnetUri || file) return addTorrentImport(req, res, file)
  66. }
  67. async function addTorrentImport (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
  68. const body: VideoImportCreate = req.body
  69. const user = res.locals.oauth.token.User
  70. let videoName: string
  71. let torrentName: string
  72. let magnetUri: string
  73. if (torrentfile) {
  74. const result = await processTorrentOrAbortRequest(req, res, torrentfile)
  75. if (!result) return
  76. videoName = result.name
  77. torrentName = result.torrentName
  78. } else {
  79. const result = processMagnetURI(body)
  80. magnetUri = result.magnetUri
  81. videoName = result.name
  82. }
  83. const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName })
  84. const thumbnailModel = await processThumbnail(req, video)
  85. const previewModel = await processPreview(req, video)
  86. const videoImport = await insertIntoDB({
  87. video,
  88. thumbnailModel,
  89. previewModel,
  90. videoChannel: res.locals.videoChannel,
  91. tags: body.tags || undefined,
  92. user,
  93. videoImportAttributes: {
  94. magnetUri,
  95. torrentName,
  96. state: VideoImportState.PENDING,
  97. userId: user.id
  98. }
  99. })
  100. // Create job to import the video
  101. const payload = {
  102. type: torrentfile
  103. ? 'torrent-file' as 'torrent-file'
  104. : 'magnet-uri' as 'magnet-uri',
  105. videoImportId: videoImport.id,
  106. magnetUri
  107. }
  108. await JobQueue.Instance.createJobWithPromise({ type: 'video-import', payload })
  109. auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
  110. return res.json(videoImport.toFormattedJSON()).end()
  111. }
  112. async function addYoutubeDLImport (req: express.Request, res: express.Response) {
  113. const body: VideoImportCreate = req.body
  114. const targetUrl = body.targetUrl
  115. const user = res.locals.oauth.token.User
  116. const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
  117. // Get video infos
  118. let youtubeDLInfo: YoutubeDLInfo
  119. try {
  120. youtubeDLInfo = await youtubeDL.getYoutubeDLInfo()
  121. } catch (err) {
  122. logger.info('Cannot fetch information from import for URL %s.', targetUrl, { err })
  123. return res.fail({
  124. message: 'Cannot fetch remote information of this URL.',
  125. data: {
  126. targetUrl
  127. }
  128. })
  129. }
  130. const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo)
  131. // Process video thumbnail from request.files
  132. let thumbnailModel = await processThumbnail(req, video)
  133. // Process video thumbnail from url if processing from request.files failed
  134. if (!thumbnailModel && youtubeDLInfo.thumbnailUrl) {
  135. thumbnailModel = await processThumbnailFromUrl(youtubeDLInfo.thumbnailUrl, video)
  136. }
  137. // Process video preview from request.files
  138. let previewModel = await processPreview(req, video)
  139. // Process video preview from url if processing from request.files failed
  140. if (!previewModel && youtubeDLInfo.thumbnailUrl) {
  141. previewModel = await processPreviewFromUrl(youtubeDLInfo.thumbnailUrl, video)
  142. }
  143. const videoImport = await insertIntoDB({
  144. video,
  145. thumbnailModel,
  146. previewModel,
  147. videoChannel: res.locals.videoChannel,
  148. tags: body.tags || youtubeDLInfo.tags,
  149. user,
  150. videoImportAttributes: {
  151. targetUrl,
  152. state: VideoImportState.PENDING,
  153. userId: user.id
  154. }
  155. })
  156. // Get video subtitles
  157. await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
  158. // Create job to import the video
  159. const payload = {
  160. type: 'youtube-dl' as 'youtube-dl',
  161. videoImportId: videoImport.id,
  162. fileExt: `.${youtubeDLInfo.ext || 'mp4'}`
  163. }
  164. await JobQueue.Instance.createJobWithPromise({ type: 'video-import', payload })
  165. auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
  166. return res.json(videoImport.toFormattedJSON()).end()
  167. }
  168. function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo): MVideoThumbnail {
  169. const videoData = {
  170. name: body.name || importData.name || 'Unknown name',
  171. remote: false,
  172. category: body.category || importData.category,
  173. licence: body.licence || importData.licence,
  174. language: body.language || importData.language,
  175. commentsEnabled: body.commentsEnabled !== false, // If the value is not "false", the default is "true"
  176. downloadEnabled: body.downloadEnabled !== false,
  177. waitTranscoding: body.waitTranscoding || false,
  178. state: VideoState.TO_IMPORT,
  179. nsfw: body.nsfw || importData.nsfw || false,
  180. description: body.description || importData.description,
  181. support: body.support || null,
  182. privacy: body.privacy || VideoPrivacy.PRIVATE,
  183. duration: 0, // duration will be set by the import job
  184. channelId: channelId,
  185. originallyPublishedAt: body.originallyPublishedAt
  186. ? new Date(body.originallyPublishedAt)
  187. : importData.originallyPublishedAt
  188. }
  189. const video = new VideoModel(videoData)
  190. video.url = getLocalVideoActivityPubUrl(video)
  191. return video
  192. }
  193. async function processThumbnail (req: express.Request, video: MVideoThumbnail) {
  194. const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined
  195. if (thumbnailField) {
  196. const thumbnailPhysicalFile = thumbnailField[0]
  197. return updateVideoMiniatureFromExisting({
  198. inputPath: thumbnailPhysicalFile.path,
  199. video,
  200. type: ThumbnailType.MINIATURE,
  201. automaticallyGenerated: false
  202. })
  203. }
  204. return undefined
  205. }
  206. async function processPreview (req: express.Request, video: MVideoThumbnail): Promise<MThumbnail> {
  207. const previewField = req.files ? req.files['previewfile'] : undefined
  208. if (previewField) {
  209. const previewPhysicalFile = previewField[0]
  210. return updateVideoMiniatureFromExisting({
  211. inputPath: previewPhysicalFile.path,
  212. video,
  213. type: ThumbnailType.PREVIEW,
  214. automaticallyGenerated: false
  215. })
  216. }
  217. return undefined
  218. }
  219. async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) {
  220. try {
  221. return updateVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.MINIATURE })
  222. } catch (err) {
  223. logger.warn('Cannot generate video thumbnail %s for %s.', url, video.url, { err })
  224. return undefined
  225. }
  226. }
  227. async function processPreviewFromUrl (url: string, video: MVideoThumbnail) {
  228. try {
  229. return updateVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.PREVIEW })
  230. } catch (err) {
  231. logger.warn('Cannot generate video preview %s for %s.', url, video.url, { err })
  232. return undefined
  233. }
  234. }
  235. async function insertIntoDB (parameters: {
  236. video: MVideoThumbnail
  237. thumbnailModel: MThumbnail
  238. previewModel: MThumbnail
  239. videoChannel: MChannelAccountDefault
  240. tags: string[]
  241. videoImportAttributes: FilteredModelAttributes<VideoImportModel>
  242. user: MUser
  243. }): Promise<MVideoImportFormattable> {
  244. const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters
  245. const videoImport = await sequelizeTypescript.transaction(async t => {
  246. const sequelizeOptions = { transaction: t }
  247. // Save video object in database
  248. const videoCreated = await video.save(sequelizeOptions) as (MVideoAccountDefault & MVideoWithBlacklistLight & MVideoTag)
  249. videoCreated.VideoChannel = videoChannel
  250. if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
  251. if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
  252. await autoBlacklistVideoIfNeeded({
  253. video: videoCreated,
  254. user,
  255. notify: false,
  256. isRemote: false,
  257. isNew: true,
  258. transaction: t
  259. })
  260. await setVideoTags({ video: videoCreated, tags, transaction: t })
  261. // Create video import object in database
  262. const videoImport = await VideoImportModel.create(
  263. Object.assign({ videoId: videoCreated.id }, videoImportAttributes),
  264. sequelizeOptions
  265. ) as MVideoImportFormattable
  266. videoImport.Video = videoCreated
  267. return videoImport
  268. })
  269. return videoImport
  270. }
  271. async function processTorrentOrAbortRequest (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
  272. const torrentName = torrentfile.originalname
  273. // Rename the torrent to a secured name
  274. const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName))
  275. await move(torrentfile.path, newTorrentPath, { overwrite: true })
  276. torrentfile.path = newTorrentPath
  277. const buf = await readFile(torrentfile.path)
  278. const parsedTorrent = parseTorrent(buf) as Instance
  279. if (parsedTorrent.files.length !== 1) {
  280. cleanUpReqFiles(req)
  281. res.fail({
  282. type: ServerErrorCode.INCORRECT_FILES_IN_TORRENT,
  283. message: 'Torrents with only 1 file are supported.'
  284. })
  285. return undefined
  286. }
  287. return {
  288. name: extractNameFromArray(parsedTorrent.name),
  289. torrentName
  290. }
  291. }
  292. function processMagnetURI (body: VideoImportCreate) {
  293. const magnetUri = body.magnetUri
  294. const parsed = decode(magnetUri)
  295. return {
  296. name: extractNameFromArray(parsed.name),
  297. magnetUri
  298. }
  299. }
  300. function extractNameFromArray (name: string | string[]) {
  301. return isArray(name) ? name[0] : name
  302. }
  303. async function processYoutubeSubtitles (youtubeDL: YoutubeDL, targetUrl: string, videoId: number) {
  304. try {
  305. const subtitles = await youtubeDL.getYoutubeDLSubs()
  306. logger.info('Will create %s subtitles from youtube import %s.', subtitles.length, targetUrl)
  307. for (const subtitle of subtitles) {
  308. const videoCaption = new VideoCaptionModel({
  309. videoId,
  310. language: subtitle.language,
  311. filename: VideoCaptionModel.generateCaptionName(subtitle.language)
  312. }) as MVideoCaption
  313. // Move physical file
  314. await moveAndProcessCaptionFile(subtitle, videoCaption)
  315. await sequelizeTypescript.transaction(async t => {
  316. await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
  317. })
  318. }
  319. } catch (err) {
  320. logger.warn('Cannot get video subtitles.', { err })
  321. }
  322. }