youtube-dl.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers/constants'
  2. import { logger } from './logger'
  3. import { generateVideoImportTmpPath } from './utils'
  4. import { join } from 'path'
  5. import { peertubeTruncate, root } from './core-utils'
  6. import { ensureDir, remove, writeFile } from 'fs-extra'
  7. import * as request from 'request'
  8. import { createWriteStream } from 'fs'
  9. import { CONFIG } from '@server/initializers/config'
  10. export type YoutubeDLInfo = {
  11. name?: string
  12. description?: string
  13. category?: number
  14. licence?: number
  15. nsfw?: boolean
  16. tags?: string[]
  17. thumbnailUrl?: string
  18. originallyPublishedAt?: Date
  19. }
  20. const processOptions = {
  21. maxBuffer: 1024 * 1024 * 10 // 10MB
  22. }
  23. function getYoutubeDLInfo (url: string, opts?: string[]): Promise<YoutubeDLInfo> {
  24. return new Promise<YoutubeDLInfo>(async (res, rej) => {
  25. const args = opts || [ '-j', '--flat-playlist' ]
  26. const youtubeDL = await safeGetYoutubeDL()
  27. youtubeDL.getInfo(url, args, processOptions, (err, info) => {
  28. if (err) return rej(err)
  29. if (info.is_live === true) return rej(new Error('Cannot download a live streaming.'))
  30. const obj = buildVideoInfo(normalizeObject(info))
  31. if (obj.name && obj.name.length < CONSTRAINTS_FIELDS.VIDEOS.NAME.min) obj.name += ' video'
  32. return res(obj)
  33. })
  34. })
  35. }
  36. function downloadYoutubeDLVideo (url: string, timeout: number) {
  37. const path = generateVideoImportTmpPath(url)
  38. let timer
  39. logger.info('Importing youtubeDL video %s', url)
  40. let options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ]
  41. if (CONFIG.IMPORT.VIDEOS.HTTP.PROXY.ENABLED) {
  42. logger.debug('Using proxy for YoutubeDL')
  43. options = [ '--proxy', CONFIG.IMPORT.VIDEOS.HTTP.PROXY.URL ].concat(options)
  44. }
  45. if (process.env.FFMPEG_PATH) {
  46. options = options.concat([ '--ffmpeg-location', process.env.FFMPEG_PATH ])
  47. }
  48. return new Promise<string>(async (res, rej) => {
  49. const youtubeDL = await safeGetYoutubeDL()
  50. youtubeDL.exec(url, options, processOptions, err => {
  51. clearTimeout(timer)
  52. if (err) {
  53. remove(path)
  54. .catch(err => logger.error('Cannot delete path on YoutubeDL error.', { err }))
  55. return rej(err)
  56. }
  57. return res(path)
  58. })
  59. timer = setTimeout(async () => {
  60. await remove(path)
  61. return rej(new Error('YoutubeDL download timeout.'))
  62. }, timeout)
  63. })
  64. }
  65. // Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js
  66. // We rewrote it to avoid sync calls
  67. async function updateYoutubeDLBinary () {
  68. logger.info('Updating youtubeDL binary.')
  69. const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin')
  70. const bin = join(binDirectory, 'youtube-dl')
  71. const detailsPath = join(binDirectory, 'details')
  72. const url = 'https://yt-dl.org/downloads/latest/youtube-dl'
  73. await ensureDir(binDirectory)
  74. return new Promise(res => {
  75. request.get(url, { followRedirect: false }, (err, result) => {
  76. if (err) {
  77. logger.error('Cannot update youtube-dl.', { err })
  78. return res()
  79. }
  80. if (result.statusCode !== 302) {
  81. logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', result.statusCode)
  82. return res()
  83. }
  84. const url = result.headers.location
  85. const downloadFile = request.get(url)
  86. const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(url)[ 1 ]
  87. downloadFile.on('response', result => {
  88. if (result.statusCode !== 200) {
  89. logger.error('Cannot update youtube-dl: new version response is not 200, it\'s %d.', result.statusCode)
  90. return res()
  91. }
  92. downloadFile.pipe(createWriteStream(bin, { mode: 493 }))
  93. })
  94. downloadFile.on('error', err => {
  95. logger.error('youtube-dl update error.', { err })
  96. return res()
  97. })
  98. downloadFile.on('end', () => {
  99. const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' })
  100. writeFile(detailsPath, details, { encoding: 'utf8' }, err => {
  101. if (err) {
  102. logger.error('youtube-dl update error: cannot write details.', { err })
  103. return res()
  104. }
  105. logger.info('youtube-dl updated to version %s.', newVersion)
  106. return res()
  107. })
  108. })
  109. })
  110. })
  111. }
  112. async function safeGetYoutubeDL () {
  113. let youtubeDL
  114. try {
  115. youtubeDL = require('youtube-dl')
  116. } catch (e) {
  117. // Download binary
  118. await updateYoutubeDLBinary()
  119. youtubeDL = require('youtube-dl')
  120. }
  121. return youtubeDL
  122. }
  123. function buildOriginallyPublishedAt (obj: any) {
  124. let originallyPublishedAt: Date = null
  125. const uploadDateMatcher = /^(\d{4})(\d{2})(\d{2})$/.exec(obj.upload_date)
  126. if (uploadDateMatcher) {
  127. originallyPublishedAt = new Date()
  128. originallyPublishedAt.setHours(0, 0, 0, 0)
  129. const year = parseInt(uploadDateMatcher[1], 10)
  130. // Month starts from 0
  131. const month = parseInt(uploadDateMatcher[2], 10) - 1
  132. const day = parseInt(uploadDateMatcher[3], 10)
  133. originallyPublishedAt.setFullYear(year, month, day)
  134. }
  135. return originallyPublishedAt
  136. }
  137. // ---------------------------------------------------------------------------
  138. export {
  139. updateYoutubeDLBinary,
  140. downloadYoutubeDLVideo,
  141. getYoutubeDLInfo,
  142. safeGetYoutubeDL,
  143. buildOriginallyPublishedAt
  144. }
  145. // ---------------------------------------------------------------------------
  146. function normalizeObject (obj: any) {
  147. const newObj: any = {}
  148. for (const key of Object.keys(obj)) {
  149. // Deprecated key
  150. if (key === 'resolution') continue
  151. const value = obj[key]
  152. if (typeof value === 'string') {
  153. newObj[key] = value.normalize()
  154. } else {
  155. newObj[key] = value
  156. }
  157. }
  158. return newObj
  159. }
  160. function buildVideoInfo (obj: any) {
  161. return {
  162. name: titleTruncation(obj.title),
  163. description: descriptionTruncation(obj.description),
  164. category: getCategory(obj.categories),
  165. licence: getLicence(obj.license),
  166. nsfw: isNSFW(obj),
  167. tags: getTags(obj.tags),
  168. thumbnailUrl: obj.thumbnail || undefined,
  169. originallyPublishedAt: buildOriginallyPublishedAt(obj)
  170. }
  171. }
  172. function titleTruncation (title: string) {
  173. return peertubeTruncate(title, {
  174. length: CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
  175. separator: /,? +/,
  176. omission: ' […]'
  177. })
  178. }
  179. function descriptionTruncation (description: string) {
  180. if (!description || description.length < CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.min) return undefined
  181. return peertubeTruncate(description, {
  182. length: CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max,
  183. separator: /,? +/,
  184. omission: ' […]'
  185. })
  186. }
  187. function isNSFW (info: any) {
  188. return info.age_limit && info.age_limit >= 16
  189. }
  190. function getTags (tags: any) {
  191. if (Array.isArray(tags) === false) return []
  192. return tags
  193. .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
  194. .map(t => t.normalize())
  195. .slice(0, 5)
  196. }
  197. function getLicence (licence: string) {
  198. if (!licence) return undefined
  199. if (licence.indexOf('Creative Commons Attribution') !== -1) return 1
  200. return undefined
  201. }
  202. function getCategory (categories: string[]) {
  203. if (!categories) return undefined
  204. const categoryString = categories[0]
  205. if (!categoryString || typeof categoryString !== 'string') return undefined
  206. if (categoryString === 'News & Politics') return 11
  207. for (const key of Object.keys(VIDEO_CATEGORIES)) {
  208. const category = VIDEO_CATEGORIES[key]
  209. if (categoryString.toLowerCase() === category.toLowerCase()) return parseInt(key, 10)
  210. }
  211. return undefined
  212. }