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