peertube-import-videos.ts 12 KB

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