video-pre-import.ts 11 KB

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