import.ts 13 KB

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