ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

346 lines
11 KiB

  1. import {
  2. ThumbnailType,
  3. ThumbnailType_Type,
  4. VideoImportCreate,
  5. VideoImportPayload,
  6. VideoImportState,
  7. VideoPrivacy,
  8. VideoState
  9. } from '@peertube/peertube-models'
  10. import { isVTTFileValid } from '@server/helpers/custom-validators/video-captions.js'
  11. import { isVideoFileExtnameValid } from '@server/helpers/custom-validators/videos.js'
  12. import { isResolvingToUnicastOnly } from '@server/helpers/dns.js'
  13. import { logger } from '@server/helpers/logger.js'
  14. import { YoutubeDLInfo, YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js'
  15. import { CONFIG } from '@server/initializers/config.js'
  16. import { sequelizeTypescript } from '@server/initializers/database.js'
  17. import { Hooks } from '@server/lib/plugins/hooks.js'
  18. import { ServerConfigManager } from '@server/lib/server-config-manager.js'
  19. import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
  20. import { buildCommentsPolicy, setVideoTags } from '@server/lib/video.js'
  21. import { VideoImportModel } from '@server/models/video/video-import.js'
  22. import { VideoPasswordModel } from '@server/models/video/video-password.js'
  23. import { VideoModel } from '@server/models/video/video.js'
  24. import { FilteredModelAttributes } from '@server/types/index.js'
  25. import {
  26. MChannelAccountDefault,
  27. MChannelSync,
  28. MThumbnail,
  29. MUser,
  30. MVideo,
  31. MVideoAccountDefault, MVideoImportFormattable,
  32. MVideoTag,
  33. MVideoThumbnail,
  34. MVideoWithBlacklistLight
  35. } from '@server/types/models/index.js'
  36. import { remove } from 'fs-extra/esm'
  37. import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
  38. import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
  39. import { createLocalCaption } from './video-captions.js'
  40. import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.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. commentsPolicy: buildCommentsPolicy(importDataOverride),
  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. generateTranscription: importDataOverride.generateTranscription ?? true,
  230. // If part of a sync process, there is a parent job that will aggregate children results
  231. preventException: !!channelSync
  232. }
  233. return {
  234. videoImport,
  235. job: { type: 'video-import' as 'video-import', payload }
  236. }
  237. }
  238. // ---------------------------------------------------------------------------
  239. export {
  240. YoutubeDlImportError, buildVideoFromImport, buildYoutubeDLImport, insertFromImportIntoDB
  241. }
  242. // ---------------------------------------------------------------------------
  243. async function forgeThumbnail ({ inputPath, video, downloadUrl, type }: {
  244. inputPath?: string
  245. downloadUrl?: string
  246. video: MVideoThumbnail
  247. type: ThumbnailType_Type
  248. }): Promise<MThumbnail> {
  249. if (inputPath) {
  250. return updateLocalVideoMiniatureFromExisting({
  251. inputPath,
  252. video,
  253. type,
  254. automaticallyGenerated: false
  255. })
  256. }
  257. if (downloadUrl) {
  258. try {
  259. return await updateLocalVideoMiniatureFromUrl({ downloadUrl, video, type })
  260. } catch (err) {
  261. logger.warn('Cannot process thumbnail %s from youtube-dl.', downloadUrl, { err })
  262. }
  263. }
  264. return null
  265. }
  266. async function processYoutubeSubtitles (youtubeDL: YoutubeDLWrapper, targetUrl: string, video: MVideo) {
  267. try {
  268. const subtitles = await youtubeDL.getSubtitles()
  269. logger.info('Found %s subtitles candidates from youtube-dl import %s.', subtitles.length, targetUrl)
  270. for (const subtitle of subtitles) {
  271. if (!await isVTTFileValid(subtitle.path)) {
  272. logger.info('%s is not a valid youtube-dl subtitle, skipping', subtitle.path)
  273. await remove(subtitle.path)
  274. continue
  275. }
  276. await createLocalCaption({
  277. language: subtitle.language,
  278. path: subtitle.path,
  279. video,
  280. automaticallyGenerated: false
  281. })
  282. logger.info('Added %s youtube-dl subtitle', subtitle.path)
  283. }
  284. } catch (err) {
  285. logger.warn('Cannot get video subtitles.', { err })
  286. }
  287. }
  288. async function hasUnicastURLsOnly (youtubeDLInfo: YoutubeDLInfo) {
  289. const hosts = youtubeDLInfo.urls.map(u => new URL(u).hostname)
  290. const uniqHosts = new Set(hosts)
  291. for (const h of uniqHosts) {
  292. if (await isResolvingToUnicastOnly(h) !== true) {
  293. return false
  294. }
  295. }
  296. return true
  297. }