video-pre-import.ts 11 KB

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