aboutsummaryrefslogblamecommitdiffhomepage
path: root/server/tools/peertube-import-videos.ts
blob: b3c3aee3e496a975bf8a4ad6c2e26df7022ebf73 (plain) (tree)
1
2
3
4
5
6
7
8
9


                                                              


                                                   
                                    
                           
                                                            
                                                              
                                                                                                                       
                                 
                                
                                          
                                 
                                              
                                                                                    
                                                                                                                   
 



                  

                        

                     
 
                     
                        



                                          


                                                  


                                                                                                                           


                                                                             

                      

                                         


                                          

                                                  
 



                                                                                                  
     
 

                                                                     
 
                                       
 

                     
                      

        
 
                                                  

                                          
   
 

                                            
                                                                   
                                                                                           
              
                            
     
 
                        
 
                            




                                                        
     


                                                                       
 
                                   
                          
                                 



                         

     
                                                                                       



                   







                                                    
                                   
                                              
 
                                                    
                                           
 

                                                                                           
                                                                                  





                                                                                           
                                                                                 




                                                           
                                                                            
 
                                                                              
 
                                                                 
                                                                                   


                  
                                                          
 
                                                          
 
                                                                                      
         
                                                

                                                                                     
                        


                      
                                   






                                                


                    
                            
                  
     


    








                                                             
                                                               
                                               

                                      
                         


                                                                                                                              
   
 

                            
                                                                   






                                  
                                                                     
 
                             




                                                   

             
                            

                                       

   







                                                                                                  
 
                                                                       


                                                        
       
                                                        
                 
                                            
                                                                                         
 
                                                        
 
                                                          
            
                            

     
 

                                                
 
                                                          

 

                                                                
                                                                

                                   
                                        


                                                     
                                           


                                                    
                                                  






                                                                                               

                                



                                                                              







                                       
                            

                                    
                                       
            
                           




               




                                               
                                              









                                                                                 


                                                                       
                                
                                                  



                                                      

                             
                                               
 

                                         
                                
 































                                                                  
                                                                          

   


                                              
                                                                                                     

                                   
                         
                              
                                                                        






                                          





                                                     
import { registerTSPaths } from '../helpers/register-ts-paths'
registerTSPaths()

// FIXME: https://github.com/nodejs/node/pull/16853
require('tls').DEFAULT_ECDH_CURVE = 'auto'

import * as program from 'commander'
import { join } from 'path'
import { doRequestAndSaveToFile } from '../helpers/requests'
import { CONSTRAINTS_FIELDS } from '../initializers/constants'
import { getClient, getVideoCategories, login, searchVideoWithSort, uploadVideo } from '../../shared/extra-utils/index'
import { truncate } from 'lodash'
import * as prompt from 'prompt'
import { accessSync, constants } from 'fs'
import { remove } from 'fs-extra'
import { sha256 } from '../helpers/core-utils'
import { buildOriginallyPublishedAt, safeGetYoutubeDL } from '../helpers/youtube-dl'
import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getServerCredentials, getLogger } from './cli'

type UserInfo = {
  username: string
  password: string
}

const processOptions = {
  maxBuffer: Infinity
}

let command = program
  .name('import-videos')

command = buildCommonVideoOptions(command)

command
  .option('-u, --url <url>', 'Server url')
  .option('-U, --username <username>', 'Username')
  .option('-p, --password <token>', 'Password')
  .option('--target-url <targetUrl>', 'Video target URL')
  .option('--since <since>', 'Publication date (inclusive) since which the videos can be imported (YYYY-MM-DD)', parseDate)
  .option('--until <until>', 'Publication date (inclusive) until which the videos can be imported (YYYY-MM-DD)', parseDate)
  .option('--first <first>', 'Process first n elements of returned playlist')
  .option('--last <last>', 'Process last n elements of returned playlist')
  .option('-T, --tmpdir <tmpdir>', 'Working directory', __dirname)
  .parse(process.argv)

let log = getLogger(program[ 'verbose' ])

getServerCredentials(command)
  .then(({ url, username, password }) => {
    if (!program[ 'targetUrl' ]) {
      exitError('--target-url field is required.')
    }

    try {
      accessSync(program[ 'tmpdir' ], constants.R_OK | constants.W_OK)
    } catch (e) {
      exitError('--tmpdir %s: directory does not exist or is not accessible', program[ 'tmpdir' ])
    }

    url = removeEndSlashes(url)
    program[ 'targetUrl' ] = removeEndSlashes(program[ 'targetUrl' ])

    const user = { username, password }

    run(url, user)
      .catch(err => {
        exitError(err)
      })
  })

async function run (url: string, user: UserInfo) {
  if (!user.password) {
    user.password = await promptPassword()
  }

  const youtubeDL = await safeGetYoutubeDL()

  const options = [ '-j', '--flat-playlist', '--playlist-reverse' ]
  youtubeDL.getInfo(program[ 'targetUrl' ], options, processOptions, async (err, info) => {
    if (err) {
      exitError(err.message)
    }

    let infoArray: any[]

    // Normalize utf8 fields
    infoArray = [].concat(info);
    if (program[ 'first' ]) {
      infoArray = infoArray.slice(0, program[ 'first' ])
    } else if (program[ 'last' ]) {
      infoArray = infoArray.slice(- program[ 'last' ])
    }
    infoArray = infoArray.map(i => normalizeObject(i))

    log.info('Will download and upload %d videos.\n', infoArray.length)

    for (const info of infoArray) {
      await processVideo({
        cwd: program[ 'tmpdir' ],
        url,
        user,
        youtubeInfo: info
      })
    }

    log.info('Video/s for user %s imported: %s', user.username, program[ 'targetUrl' ])
    process.exit(0)
  })
}

function processVideo (parameters: {
  cwd: string,
  url: string,
  user: { username: string, password: string },
  youtubeInfo: any
}) {
  const { youtubeInfo, cwd, url, user } = parameters

  return new Promise(async res => {
    log.debug('Fetching object.', youtubeInfo)

    const videoInfo = await fetchObject(youtubeInfo)
    log.debug('Fetched object.', videoInfo)

    if (program[ 'since' ]) {
      if (buildOriginallyPublishedAt(videoInfo).getTime() < program[ 'since' ].getTime()) {
        log.info('Video "%s" has been published before "%s", don\'t upload it.\n',
          videoInfo.title, formatDate(program[ 'since' ]));
        return res();
      }
    }
    if (program[ 'until' ]) {
      if (buildOriginallyPublishedAt(videoInfo).getTime() > program[ 'until' ].getTime()) {
        log.info('Video "%s" has been published after "%s", don\'t upload it.\n',
          videoInfo.title, formatDate(program[ 'until' ]));
        return res();
      }
    }

    const result = await searchVideoWithSort(url, videoInfo.title, '-match')

    log.info('############################################################\n')

    if (result.body.data.find(v => v.name === videoInfo.title)) {
      log.info('Video "%s" already exists, don\'t reupload it.\n', videoInfo.title)
      return res()
    }

    const path = join(cwd, sha256(videoInfo.url) + '.mp4')

    log.info('Downloading video "%s"...', videoInfo.title)

    const options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ]
    try {
      const youtubeDL = await safeGetYoutubeDL()
      youtubeDL.exec(videoInfo.url, options, processOptions, async (err, output) => {
        if (err) {
          log.error(err)
          return res()
        }

        log.info(output.join('\n'))
        await uploadVideoOnPeerTube({
          cwd,
          url,
          user,
          videoInfo: normalizeObject(videoInfo),
          videoPath: path
        })
        return res()
      })
    } catch (err) {
      log.error(err.message)
      return res()
    }
  })
}

async function uploadVideoOnPeerTube (parameters: {
  videoInfo: any,
  videoPath: string,
  cwd: string,
  url: string,
  user: { username: string; password: string }
}) {
  const { videoInfo, videoPath, cwd, url, user } = parameters

  const category = await getCategory(videoInfo.categories, url)
  const licence = getLicence(videoInfo.license)
  let tags = []
  if (Array.isArray(videoInfo.tags)) {
    tags = videoInfo.tags
                    .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
                    .map(t => t.normalize())
                    .slice(0, 5)
  }

  let thumbnailfile
  if (videoInfo.thumbnail) {
    thumbnailfile = join(cwd, sha256(videoInfo.thumbnail) + '.jpg')

    await doRequestAndSaveToFile({
      method: 'GET',
      uri: videoInfo.thumbnail
    }, thumbnailfile)
  }

  const originallyPublishedAt = buildOriginallyPublishedAt(videoInfo)

  const defaultAttributes = {
    name: truncate(videoInfo.title, {
      'length': CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
      'separator': /,? +/,
      'omission': ' […]'
    }),
    category,
    licence,
    nsfw: isNSFW(videoInfo),
    description: videoInfo.description,
    tags
  }

  const videoAttributes = await buildVideoAttributesFromCommander(url, program, defaultAttributes)

  Object.assign(videoAttributes, {
    originallyPublishedAt: originallyPublishedAt ? originallyPublishedAt.toISOString() : null,
    thumbnailfile,
    previewfile: thumbnailfile,
    fixture: videoPath
  })

  log.info('\nUploading on PeerTube video "%s".', videoAttributes.name)

  let accessToken = await getAccessTokenOrDie(url, user)

  try {
    await uploadVideo(url, accessToken, videoAttributes)
  } catch (err) {
    if (err.message.indexOf('401') !== -1) {
      log.info('Got 401 Unauthorized, token may have expired, renewing token and retry.')

      accessToken = await getAccessTokenOrDie(url, user)

      await uploadVideo(url, accessToken, videoAttributes)
    } else {
      exitError(err.message)
    }
  }

  await remove(videoPath)
  if (thumbnailfile) await remove(thumbnailfile)

  log.warn('Uploaded video "%s"!\n', videoAttributes.name)
}

/* ---------------------------------------------------------- */

async function getCategory (categories: string[], url: string) {
  if (!categories) return undefined

  const categoryString = categories[ 0 ]

  if (categoryString === 'News & Politics') return 11

  const res = await getVideoCategories(url)
  const categoriesServer = res.body

  for (const key of Object.keys(categoriesServer)) {
    const categoryServer = categoriesServer[ key ]
    if (categoryString.toLowerCase() === categoryServer.toLowerCase()) return parseInt(key, 10)
  }

  return undefined
}

function getLicence (licence: string) {
  if (!licence) return undefined

  if (licence.indexOf('Creative Commons Attribution licence') !== -1) return 1

  return undefined
}

function normalizeObject (obj: any) {
  const newObj: any = {}

  for (const key of Object.keys(obj)) {
    // Deprecated key
    if (key === 'resolution') continue

    const value = obj[ key ]

    if (typeof value === 'string') {
      newObj[ key ] = value.normalize()
    } else {
      newObj[ key ] = value
    }
  }

  return newObj
}

function fetchObject (info: any) {
  const url = buildUrl(info)

  return new Promise<any>(async (res, rej) => {
    const youtubeDL = await safeGetYoutubeDL()
    youtubeDL.getInfo(url, undefined, processOptions, async (err, videoInfo) => {
      if (err) return rej(err)

      const videoInfoWithUrl = Object.assign(videoInfo, { url })
      return res(normalizeObject(videoInfoWithUrl))
    })
  })
}

function buildUrl (info: any) {
  const webpageUrl = info.webpage_url as string
  if (webpageUrl && webpageUrl.match(/^https?:\/\//)) return webpageUrl

  const url = info.url as string
  if (url && url.match(/^https?:\/\//)) return url

  // It seems youtube-dl does not return the video url
  return 'https://www.youtube.com/watch?v=' + info.id
}

function isNSFW (info: any) {
  return info.age_limit && info.age_limit >= 16
}

function removeEndSlashes (url: string) {
  return url.replace(/\/+$/, '')
}

async function promptPassword () {
  return new Promise<string>((res, rej) => {
    prompt.start()
    const schema = {
      properties: {
        password: {
          hidden: true,
          required: true
        }
      }
    }
    prompt.get(schema, function (err, result) {
      if (err) {
        return rej(err)
      }
      return res(result.password)
    })
  })
}

async function getAccessTokenOrDie (url: string, user: UserInfo) {
  const resClient = await getClient(url)
  const client = {
    id: resClient.body.client_id,
    secret: resClient.body.client_secret
  }

  try {
    const res = await login(url, client, user)
    return res.body.access_token
  } catch (err) {
    exitError('Cannot authenticate. Please check your username/password.')
  }
}

function parseDate (dateAsStr: string): Date {
  if (!/\d{4}-\d{2}-\d{2}/.test(dateAsStr)) {
    exitError(`Invalid date passed: ${dateAsStr}. Expected format: YYYY-MM-DD. See help for usage.`);
  }
  const date = new Date(dateAsStr);
  date.setHours(0, 0, 0);
  if (isNaN(date.getTime())) {
    exitError(`Invalid date passed: ${dateAsStr}. See help for usage.`);
  }
  return date;
}

function formatDate (date: Date): string {
  return date.toISOString().split('T')[0];
}

function exitError (message:string, ...meta: any[]) {
  // use console.error instead of log.error here
  console.error(message, ...meta)
  process.exit(-1)
}