Add more CLI tests
[oweals/peertube.git] / server / tools / peertube-import-videos.ts
1 // FIXME: https://github.com/nodejs/node/pull/16853
2 require('tls').DEFAULT_ECDH_CURVE = 'auto'
3
4 import * as program from 'commander'
5 import { join } from 'path'
6 import { VideoPrivacy } from '../../shared/models/videos'
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 { remove } from 'fs-extra'
13 import { sha256 } from '../helpers/core-utils'
14 import { buildOriginallyPublishedAt, safeGetYoutubeDL } from '../helpers/youtube-dl'
15 import { getNetrc, getRemoteObjectOrDie, getSettings } from './cli'
16
17 type UserInfo = {
18   username: string
19   password: string
20 }
21
22 const processOptions = {
23   cwd: __dirname,
24   maxBuffer: Infinity
25 }
26
27 program
28   .name('import-videos')
29   .option('-u, --url <url>', 'Server url')
30   .option('-U, --username <username>', 'Username')
31   .option('-p, --password <token>', 'Password')
32   .option('-t, --target-url <targetUrl>', 'Video target URL')
33   .option('-C, --channel-id <channel_id>', 'Channel ID')
34   .option('-l, --language <languageCode>', 'Language ISO 639 code (fr or en...)')
35   .option('-v, --verbose', 'Verbose mode')
36   .parse(process.argv)
37
38 Promise.all([ getSettings(), getNetrc() ])
39        .then(([ settings, netrc ]) => {
40          const { url, username, password } = getRemoteObjectOrDie(program, settings, netrc)
41
42          if (!program[ 'targetUrl' ]) {
43            console.error('--targetUrl field is required.')
44
45            process.exit(-1)
46          }
47
48          removeEndSlashes(url)
49          removeEndSlashes(program[ 'targetUrl' ])
50
51          const user = { username, password }
52
53          run(url, user)
54            .catch(err => {
55              console.error(err)
56              process.exit(-1)
57            })
58        })
59
60 async function run (url: string, user: UserInfo) {
61   if (!user.password) {
62     user.password = await promptPassword()
63   }
64
65   const youtubeDL = await safeGetYoutubeDL()
66
67   const options = [ '-j', '--flat-playlist', '--playlist-reverse' ]
68   youtubeDL.getInfo(program[ 'targetUrl' ], options, processOptions, async (err, info) => {
69     if (err) {
70       console.log(err.message)
71       process.exit(1)
72     }
73
74     let infoArray: any[]
75
76     // Normalize utf8 fields
77     if (Array.isArray(info) === true) {
78       infoArray = info.map(i => normalizeObject(i))
79     } else {
80       infoArray = [ normalizeObject(info) ]
81     }
82     console.log('Will download and upload %d videos.\n', infoArray.length)
83
84     for (const info of infoArray) {
85       await processVideo({
86         cwd: processOptions.cwd,
87         url,
88         user,
89         youtubeInfo: info
90       })
91     }
92
93     console.log('Video/s for user %s imported: %s', program[ 'username' ], program[ 'targetUrl' ])
94     process.exit(0)
95   })
96 }
97
98 function processVideo (parameters: {
99   cwd: string,
100   url: string,
101   user: { username: string, password: string },
102   youtubeInfo: any
103 }) {
104   const { youtubeInfo, cwd, url, user } = parameters
105
106   return new Promise(async res => {
107     if (program[ 'verbose' ]) console.log('Fetching object.', youtubeInfo)
108
109     const videoInfo = await fetchObject(youtubeInfo)
110     if (program[ 'verbose' ]) console.log('Fetched object.', videoInfo)
111
112     const result = await searchVideoWithSort(url, videoInfo.title, '-match')
113
114     console.log('############################################################\n')
115
116     if (result.body.data.find(v => v.name === videoInfo.title)) {
117       console.log('Video "%s" already exists, don\'t reupload it.\n', videoInfo.title)
118       return res()
119     }
120
121     const path = join(cwd, sha256(videoInfo.url) + '.mp4')
122
123     console.log('Downloading video "%s"...', videoInfo.title)
124
125     const options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ]
126     try {
127       const youtubeDL = await safeGetYoutubeDL()
128       youtubeDL.exec(videoInfo.url, options, processOptions, async (err, output) => {
129         if (err) {
130           console.error(err)
131           return res()
132         }
133
134         console.log(output.join('\n'))
135         await uploadVideoOnPeerTube({
136           cwd,
137           url,
138           user,
139           videoInfo: normalizeObject(videoInfo),
140           videoPath: path
141         })
142         return res()
143       })
144     } catch (err) {
145       console.log(err.message)
146       return res()
147     }
148   })
149 }
150
151 async function uploadVideoOnPeerTube (parameters: {
152   videoInfo: any,
153   videoPath: string,
154   cwd: string,
155   url: string,
156   user: { username: string; password: string }
157 }) {
158   const { videoInfo, videoPath, cwd, url, user } = parameters
159
160   const category = await getCategory(videoInfo.categories, url)
161   const licence = getLicence(videoInfo.license)
162   let tags = []
163   if (Array.isArray(videoInfo.tags)) {
164     tags = videoInfo.tags
165                     .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
166                     .map(t => t.normalize())
167                     .slice(0, 5)
168   }
169
170   let thumbnailfile
171   if (videoInfo.thumbnail) {
172     thumbnailfile = join(cwd, sha256(videoInfo.thumbnail) + '.jpg')
173
174     await doRequestAndSaveToFile({
175       method: 'GET',
176       uri: videoInfo.thumbnail
177     }, thumbnailfile)
178   }
179
180   const originallyPublishedAt = buildOriginallyPublishedAt(videoInfo)
181
182   const videoAttributes = {
183     name: truncate(videoInfo.title, {
184       'length': CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
185       'separator': /,? +/,
186       'omission': ' […]'
187     }),
188     category,
189     licence,
190     language: program[ 'language' ],
191     nsfw: isNSFW(videoInfo),
192     waitTranscoding: true,
193     commentsEnabled: true,
194     downloadEnabled: true,
195     description: videoInfo.description || undefined,
196     support: undefined,
197     tags,
198     privacy: VideoPrivacy.PUBLIC,
199     fixture: videoPath,
200     thumbnailfile,
201     previewfile: thumbnailfile,
202     originallyPublishedAt: originallyPublishedAt ? originallyPublishedAt.toISOString() : null
203   }
204
205   if (program[ 'channelId' ]) {
206     Object.assign(videoAttributes, { channelId: program['channelId'] })
207   }
208
209   console.log('\nUploading on PeerTube video "%s".', videoAttributes.name)
210
211   let accessToken = await getAccessTokenOrDie(url, user)
212
213   try {
214     await uploadVideo(url, accessToken, videoAttributes)
215   } catch (err) {
216     if (err.message.indexOf('401') !== -1) {
217       console.log('Got 401 Unauthorized, token may have expired, renewing token and retry.')
218
219       accessToken = await getAccessTokenOrDie(url, user)
220
221       await uploadVideo(url, accessToken, videoAttributes)
222     } else {
223       console.log(err.message)
224       process.exit(1)
225     }
226   }
227
228   await remove(videoPath)
229   if (thumbnailfile) await remove(thumbnailfile)
230
231   console.log('Uploaded video "%s"!\n', videoAttributes.name)
232 }
233
234 /* ---------------------------------------------------------- */
235
236 async function getCategory (categories: string[], url: string) {
237   if (!categories) return undefined
238
239   const categoryString = categories[ 0 ]
240
241   if (categoryString === 'News & Politics') return 11
242
243   const res = await getVideoCategories(url)
244   const categoriesServer = res.body
245
246   for (const key of Object.keys(categoriesServer)) {
247     const categoryServer = categoriesServer[ key ]
248     if (categoryString.toLowerCase() === categoryServer.toLowerCase()) return parseInt(key, 10)
249   }
250
251   return undefined
252 }
253
254 function getLicence (licence: string) {
255   if (!licence) return undefined
256
257   if (licence.indexOf('Creative Commons Attribution licence') !== -1) return 1
258
259   return undefined
260 }
261
262 function normalizeObject (obj: any) {
263   const newObj: any = {}
264
265   for (const key of Object.keys(obj)) {
266     // Deprecated key
267     if (key === 'resolution') continue
268
269     const value = obj[ key ]
270
271     if (typeof value === 'string') {
272       newObj[ key ] = value.normalize()
273     } else {
274       newObj[ key ] = value
275     }
276   }
277
278   return newObj
279 }
280
281 function fetchObject (info: any) {
282   const url = buildUrl(info)
283
284   return new Promise<any>(async (res, rej) => {
285     const youtubeDL = await safeGetYoutubeDL()
286     youtubeDL.getInfo(url, undefined, processOptions, async (err, videoInfo) => {
287       if (err) return rej(err)
288
289       const videoInfoWithUrl = Object.assign(videoInfo, { url })
290       return res(normalizeObject(videoInfoWithUrl))
291     })
292   })
293 }
294
295 function buildUrl (info: any) {
296   const webpageUrl = info.webpage_url as string
297   if (webpageUrl && webpageUrl.match(/^https?:\/\//)) return webpageUrl
298
299   const url = info.url as string
300   if (url && url.match(/^https?:\/\//)) return url
301
302   // It seems youtube-dl does not return the video url
303   return 'https://www.youtube.com/watch?v=' + info.id
304 }
305
306 function isNSFW (info: any) {
307   return info.age_limit && info.age_limit >= 16
308 }
309
310 function removeEndSlashes (url: string) {
311   while (url.endsWith('/')) {
312     url.slice(0, -1)
313   }
314 }
315
316 async function promptPassword () {
317   return new Promise<string>((res, rej) => {
318     prompt.start()
319     const schema = {
320       properties: {
321         password: {
322           hidden: true,
323           required: true
324         }
325       }
326     }
327     prompt.get(schema, function (err, result) {
328       if (err) {
329         return rej(err)
330       }
331       return res(result.password)
332     })
333   })
334 }
335
336 async function getAccessTokenOrDie (url: string, user: UserInfo) {
337   const resClient = await getClient(url)
338   const client = {
339     id: resClient.body.client_id,
340     secret: resClient.body.client_secret
341   }
342
343   try {
344     const res = await login(url, client, user)
345     return res.body.access_token
346   } catch (err) {
347     console.error('Cannot authenticate. Please check your username/password.')
348     process.exit(-1)
349   }
350 }