peertube-import-videos.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import { registerTSPaths } from '../helpers/register-ts-paths'
  2. registerTSPaths()
  3. // FIXME: https://github.com/nodejs/node/pull/16853
  4. require('tls').DEFAULT_ECDH_CURVE = 'auto'
  5. import * as program from 'commander'
  6. import { join } from 'path'
  7. import { doRequestAndSaveToFile } from '../helpers/requests'
  8. import { CONSTRAINTS_FIELDS } from '../initializers/constants'
  9. import { getClient, getVideoCategories, login, searchVideoWithSort, uploadVideo } from '../../shared/extra-utils/index'
  10. import { truncate } from 'lodash'
  11. import * as prompt from 'prompt'
  12. import { accessSync, constants } from 'fs'
  13. import { remove } from 'fs-extra'
  14. import { sha256 } from '../helpers/core-utils'
  15. import { buildOriginallyPublishedAt, safeGetYoutubeDL } from '../helpers/youtube-dl'
  16. import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getServerCredentials, getLogger } from './cli'
  17. type UserInfo = {
  18. username: string
  19. password: string
  20. }
  21. const processOptions = {
  22. maxBuffer: Infinity
  23. }
  24. let command = program
  25. .name('import-videos')
  26. command = buildCommonVideoOptions(command)
  27. command
  28. .option('-u, --url <url>', 'Server url')
  29. .option('-U, --username <username>', 'Username')
  30. .option('-p, --password <token>', 'Password')
  31. .option('--target-url <targetUrl>', 'Video target URL')
  32. .option('--since <since>', 'Publication date (inclusive) since which the videos can be imported (YYYY-MM-DD)', parseDate)
  33. .option('--until <until>', 'Publication date (inclusive) until which the videos can be imported (YYYY-MM-DD)', parseDate)
  34. .option('--first <first>', 'Process first n elements of returned playlist')
  35. .option('--last <last>', 'Process last n elements of returned playlist')
  36. .option('-T, --tmpdir <tmpdir>', 'Working directory', __dirname)
  37. .parse(process.argv)
  38. let log = getLogger(program[ 'verbose' ])
  39. getServerCredentials(command)
  40. .then(({ url, username, password }) => {
  41. if (!program[ 'targetUrl' ]) {
  42. exitError('--target-url field is required.')
  43. }
  44. try {
  45. accessSync(program[ 'tmpdir' ], constants.R_OK | constants.W_OK)
  46. } catch (e) {
  47. exitError('--tmpdir %s: directory does not exist or is not accessible', program[ 'tmpdir' ])
  48. }
  49. url = normalizeTargetUrl(url)
  50. program[ 'targetUrl' ] = normalizeTargetUrl(program[ 'targetUrl' ])
  51. const user = { username, password }
  52. run(url, user)
  53. .catch(err => {
  54. exitError(err)
  55. })
  56. })
  57. async function run (url: string, user: UserInfo) {
  58. if (!user.password) {
  59. user.password = await promptPassword()
  60. }
  61. const youtubeDL = await safeGetYoutubeDL()
  62. const options = [ '-j', '--flat-playlist', '--playlist-reverse' ]
  63. youtubeDL.getInfo(program[ 'targetUrl' ], options, processOptions, async (err, info) => {
  64. if (err) {
  65. exitError(err.message)
  66. }
  67. let infoArray: any[]
  68. // Normalize utf8 fields
  69. infoArray = [].concat(info)
  70. if (program[ 'first' ]) {
  71. infoArray = infoArray.slice(0, program[ 'first' ])
  72. } else if (program[ 'last' ]) {
  73. infoArray = infoArray.slice(-program[ 'last' ])
  74. }
  75. infoArray = infoArray.map(i => normalizeObject(i))
  76. log.info('Will download and upload %d videos.\n', infoArray.length)
  77. for (const info of infoArray) {
  78. await processVideo({
  79. cwd: program[ 'tmpdir' ],
  80. url,
  81. user,
  82. youtubeInfo: info
  83. })
  84. }
  85. log.info('Video/s for user %s imported: %s', user.username, program[ 'targetUrl' ])
  86. process.exit(0)
  87. })
  88. }
  89. function processVideo (parameters: {
  90. cwd: string,
  91. url: string,
  92. user: { username: string, password: string },
  93. youtubeInfo: any
  94. }) {
  95. const { youtubeInfo, cwd, url, user } = parameters
  96. return new Promise(async res => {
  97. log.debug('Fetching object.', youtubeInfo)
  98. const videoInfo = await fetchObject(youtubeInfo)
  99. log.debug('Fetched object.', videoInfo)
  100. if (program[ 'since' ]) {
  101. if (buildOriginallyPublishedAt(videoInfo).getTime() < program[ 'since' ].getTime()) {
  102. log.info('Video "%s" has been published before "%s", don\'t upload it.\n',
  103. videoInfo.title, formatDate(program[ 'since' ]))
  104. return res()
  105. }
  106. }
  107. if (program[ 'until' ]) {
  108. if (buildOriginallyPublishedAt(videoInfo).getTime() > program[ 'until' ].getTime()) {
  109. log.info('Video "%s" has been published after "%s", don\'t upload it.\n',
  110. videoInfo.title, formatDate(program[ 'until' ]))
  111. return res()
  112. }
  113. }
  114. const result = await searchVideoWithSort(url, videoInfo.title, '-match')
  115. log.info('############################################################\n')
  116. if (result.body.data.find(v => v.name === videoInfo.title)) {
  117. log.info('Video "%s" already exists, don\'t reupload it.\n', videoInfo.title)
  118. return res()
  119. }
  120. const path = join(cwd, sha256(videoInfo.url) + '.mp4')
  121. log.info('Downloading video "%s"...', videoInfo.title)
  122. const options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ]
  123. try {
  124. const youtubeDL = await safeGetYoutubeDL()
  125. youtubeDL.exec(videoInfo.url, options, processOptions, async (err, output) => {
  126. if (err) {
  127. log.error(err)
  128. return res()
  129. }
  130. log.info(output.join('\n'))
  131. await uploadVideoOnPeerTube({
  132. cwd,
  133. url,
  134. user,
  135. videoInfo: normalizeObject(videoInfo),
  136. videoPath: path
  137. })
  138. return res()
  139. })
  140. } catch (err) {
  141. log.error(err.message)
  142. return res()
  143. }
  144. })
  145. }
  146. async function uploadVideoOnPeerTube (parameters: {
  147. videoInfo: any,
  148. videoPath: string,
  149. cwd: string,
  150. url: string,
  151. user: { username: string; password: string }
  152. }) {
  153. const { videoInfo, videoPath, cwd, url, user } = parameters
  154. const category = await getCategory(videoInfo.categories, url)
  155. const licence = getLicence(videoInfo.license)
  156. let tags = []
  157. if (Array.isArray(videoInfo.tags)) {
  158. tags = videoInfo.tags
  159. .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
  160. .map(t => t.normalize())
  161. .slice(0, 5)
  162. }
  163. let thumbnailfile
  164. if (videoInfo.thumbnail) {
  165. thumbnailfile = join(cwd, sha256(videoInfo.thumbnail) + '.jpg')
  166. await doRequestAndSaveToFile({
  167. method: 'GET',
  168. uri: videoInfo.thumbnail
  169. }, thumbnailfile)
  170. }
  171. const originallyPublishedAt = buildOriginallyPublishedAt(videoInfo)
  172. const defaultAttributes = {
  173. name: truncate(videoInfo.title, {
  174. 'length': CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
  175. 'separator': /,? +/,
  176. 'omission': ' […]'
  177. }),
  178. category,
  179. licence,
  180. nsfw: isNSFW(videoInfo),
  181. description: videoInfo.description,
  182. tags
  183. }
  184. const videoAttributes = await buildVideoAttributesFromCommander(url, program, defaultAttributes)
  185. Object.assign(videoAttributes, {
  186. originallyPublishedAt: originallyPublishedAt ? originallyPublishedAt.toISOString() : null,
  187. thumbnailfile,
  188. previewfile: thumbnailfile,
  189. fixture: videoPath
  190. })
  191. log.info('\nUploading on PeerTube video "%s".', videoAttributes.name)
  192. let accessToken = await getAccessTokenOrDie(url, user)
  193. try {
  194. await uploadVideo(url, accessToken, videoAttributes)
  195. } catch (err) {
  196. if (err.message.indexOf('401') !== -1) {
  197. log.info('Got 401 Unauthorized, token may have expired, renewing token and retry.')
  198. accessToken = await getAccessTokenOrDie(url, user)
  199. await uploadVideo(url, accessToken, videoAttributes)
  200. } else {
  201. exitError(err.message)
  202. }
  203. }
  204. await remove(videoPath)
  205. if (thumbnailfile) await remove(thumbnailfile)
  206. log.warn('Uploaded video "%s"!\n', videoAttributes.name)
  207. }
  208. /* ---------------------------------------------------------- */
  209. async function getCategory (categories: string[], url: string) {
  210. if (!categories) return undefined
  211. const categoryString = categories[ 0 ]
  212. if (categoryString === 'News & Politics') return 11
  213. const res = await getVideoCategories(url)
  214. const categoriesServer = res.body
  215. for (const key of Object.keys(categoriesServer)) {
  216. const categoryServer = categoriesServer[ key ]
  217. if (categoryString.toLowerCase() === categoryServer.toLowerCase()) return parseInt(key, 10)
  218. }
  219. return undefined
  220. }
  221. function getLicence (licence: string) {
  222. if (!licence) return undefined
  223. if (licence.indexOf('Creative Commons Attribution licence') !== -1) return 1
  224. return undefined
  225. }
  226. function normalizeObject (obj: any) {
  227. const newObj: any = {}
  228. for (const key of Object.keys(obj)) {
  229. // Deprecated key
  230. if (key === 'resolution') continue
  231. const value = obj[ key ]
  232. if (typeof value === 'string') {
  233. newObj[ key ] = value.normalize()
  234. } else {
  235. newObj[ key ] = value
  236. }
  237. }
  238. return newObj
  239. }
  240. function fetchObject (info: any) {
  241. const url = buildUrl(info)
  242. return new Promise<any>(async (res, rej) => {
  243. const youtubeDL = await safeGetYoutubeDL()
  244. youtubeDL.getInfo(url, undefined, processOptions, async (err, videoInfo) => {
  245. if (err) return rej(err)
  246. const videoInfoWithUrl = Object.assign(videoInfo, { url })
  247. return res(normalizeObject(videoInfoWithUrl))
  248. })
  249. })
  250. }
  251. function buildUrl (info: any) {
  252. const webpageUrl = info.webpage_url as string
  253. if (webpageUrl && webpageUrl.match(/^https?:\/\//)) return webpageUrl
  254. const url = info.url as string
  255. if (url && url.match(/^https?:\/\//)) return url
  256. // It seems youtube-dl does not return the video url
  257. return 'https://www.youtube.com/watch?v=' + info.id
  258. }
  259. function isNSFW (info: any) {
  260. return info.age_limit && info.age_limit >= 16
  261. }
  262. function normalizeTargetUrl (url: string) {
  263. let normalizedUrl = url.replace(/\/+$/, '')
  264. if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
  265. normalizedUrl = 'https://' + normalizedUrl
  266. }
  267. return normalizedUrl
  268. }
  269. async function promptPassword () {
  270. return new Promise<string>((res, rej) => {
  271. prompt.start()
  272. const schema = {
  273. properties: {
  274. password: {
  275. hidden: true,
  276. required: true
  277. }
  278. }
  279. }
  280. prompt.get(schema, function (err, result) {
  281. if (err) {
  282. return rej(err)
  283. }
  284. return res(result.password)
  285. })
  286. })
  287. }
  288. async function getAccessTokenOrDie (url: string, user: UserInfo) {
  289. const resClient = await getClient(url)
  290. const client = {
  291. id: resClient.body.client_id,
  292. secret: resClient.body.client_secret
  293. }
  294. try {
  295. const res = await login(url, client, user)
  296. return res.body.access_token
  297. } catch (err) {
  298. exitError('Cannot authenticate. Please check your username/password.')
  299. }
  300. }
  301. function parseDate (dateAsStr: string): Date {
  302. if (!/\d{4}-\d{2}-\d{2}/.test(dateAsStr)) {
  303. exitError(`Invalid date passed: ${dateAsStr}. Expected format: YYYY-MM-DD. See help for usage.`)
  304. }
  305. const date = new Date(dateAsStr)
  306. date.setHours(0, 0, 0)
  307. if (isNaN(date.getTime())) {
  308. exitError(`Invalid date passed: ${dateAsStr}. See help for usage.`)
  309. }
  310. return date
  311. }
  312. function formatDate (date: Date): string {
  313. return date.toISOString().split('T')[ 0 ]
  314. }
  315. function exitError (message: string, ...meta: any[]) {
  316. // use console.error instead of log.error here
  317. console.error(message, ...meta)
  318. process.exit(-1)
  319. }