import { Repository } from '../core/repository' ; import { IgResponse , MediaConfigureStoryBaseOptions , MediaConfigureTimelineOptions , MediaConfigureTimelineVideoOptions , MediaConfigureToIgtvOptions , PostingAlbumItem , PostingAlbumOptions , PostingAlbumPhotoItem , PostingAlbumVideoItem , PostingPhotoOptions , PostingStoryPhotoOptions , PostingStoryVideoOptions , PostingVideoOptions , SEGMENT_DIVIDERS , UploadRetryContext , UploadSegmentedVideoOptions , UploadVideoOptions , } from '../types' ; import { PostingLocation , PostingStoryOptions } from '../types/posting.options' ; import { IgConfigureVideoError , IgResponseError , IgUploadVideoError } from '../errors' ; import { StatusResponse , UploadRepositoryVideoResponseRootObject } from '../responses' ; import { PostingIgtvOptions } from '../types/posting.igtv.options' ; import sizeOf = require ( 'image-size' ) ; import Bluebird = require ( 'bluebird' ) ; import Chance = require ( 'chance' ) ; import { random , defaults } from 'lodash' ; import { UploadRepository } from '../repositories/upload.repository' ; import debug from 'debug' ; import { StickerBuilder } from '../sticker-builder' ; export class PublishService extends Repository { private static publishDebug = debug ( 'ig:publish' ) ; private chance = new Chance ( ) ; param videoInfo param transcodeDelayInMs public static catchTranscodeError ( videoInfo , transcodeDelayInMs ) { return error => { if ( error . response . statusCode === 202 ) { PublishService . publishDebug ( `Received trancode error: ${ JSON . stringify ( error . response . body ) }, waiting ${ transcodeDelayInMs }ms` ) ; return Bluebird . delay ( transcodeDelayInMs ) ; } else { throw new IgUploadVideoError ( error . response as IgResponse < UploadRepositoryVideoResponseRootObject > , videoInfo ) ; } } ; } param buffer returns public static getVideoInfo ( buffer ) { const width = PublishService . read16 ( buffer , [ 'moov' , 'trak' , 'stbl' , 'avc1' ] , 24 ) ; const height = PublishService . read16 ( buffer , [ 'moov' , 'trak' , 'stbl' , 'avc1' ] , 26 ) ; return { duration : PublishService . getMP4Duration ( buffer ) , width , height , } ; } param buffer public static getMP4Duration ( buffer ) { const timescale = PublishService . read32 ( buffer , [ 'moov' , 'mvhd' ] , 12 ) ; const length = PublishService . read32 ( buffer , [ 'moov' , 'mvhd' ] , 12 + 4 ) ; return Math . floor ( ( length / timescale ) * 1000 ) ; } private static makeLocationOptions ( location ? ) { const options = { } ; if ( typeof location !== 'undefined' ) { const { lat , lng , external_id_source , external_id , name , address } = location ; options . location = { name , lat , lng , address , external_source : external_id_source , external_id , } ; options . location [ external_id_source + '_id' ] = external_id ; options . geotag_enabled = '1' ; options . media_latitude = lat . toString ( ) ; options . media_longitude = lng . toString ( ) ; options . posting_latitude = lat . toString ( ) ; options . posting_longitude = lng . toString ( ) ; } return options ; } param buffer param keys param offset private static read32 ( buffer , keys , offset ) { let start = 0 ; for ( const key of keys ) { start = buffer . indexOf ( Buffer . from ( key ) , start ) + key . length ; } return buffer . readUInt32BE ( start + offset ) ; } param buffer param keys param offset private static read16 ( buffer , keys , offset ) { let start = 0 ; for ( const key of keys ) { start = buffer . indexOf ( Buffer . from ( key ) , start ) + key . length ; } return buffer . readUInt16BE ( start + offset ) ; } param options public async photo ( options ) { const uploadedPhoto = await this . client . upload . photo ( { file : options . file , } ) ; const imageSize = await sizeOf ( options . file ) ; const configureOptions = { upload_id : uploadedPhoto . upload_id , width : imageSize . width , height : imageSize . height , caption : options . caption , ... PublishService . makeLocationOptions ( options . location ) , } ; if ( typeof options . usertags !== 'undefined' ) { configureOptions . usertags = options . usertags ; } return await this . client . media . configure ( configureOptions ) ; } public async video ( options ) { const uploadId = Date . now ( ) . toString ( ) ; const videoInfo = PublishService . getVideoInfo ( options . video ) ; PublishService . publishDebug ( `Publishing video to timeline: ${ JSON . stringify ( videoInfo ) }` ) ; await Bluebird . try ( ( ) => this . regularVideo ( { video : options . video , uploadId , ... videoInfo , } ) ) . catch ( IgResponseError , error => { throw new IgUploadVideoError ( error . response as IgResponse < UploadRepositoryVideoResponseRootObject > , videoInfo ) ; } ) ; await this . client . upload . photo ( { file : options . coverImage , uploadId : uploadId . toString ( ) , } ) ; await Bluebird . try ( ( ) => this . client . media . uploadFinish ( { upload_id : uploadId , source_type : '4' , video : { length : videoInfo . duration / 1000.0 } , } ) ) . catch ( IgResponseError , PublishService . catchTranscodeError ( videoInfo , options . transcodeDelay || 5000 ) ) ; const configureOptions = { upload_id : uploadId . toString ( ) , caption : options . caption , length : videoInfo . duration / 1000.0 , width : videoInfo . width , height : videoInfo . height , clips : [ { length : videoInfo . duration / 1000.0 , source_type : '4' , } , ] , ... PublishService . makeLocationOptions ( options . location ) , } ; if ( typeof options . usertags !== 'undefined' ) { configureOptions . usertags = options . usertags ; } for ( let i = 0 ; i < 6 ; i ++ ) { try { return await this . client . media . configureVideo ( configureOptions ) ; } catch ( e ) { if ( i >= 5 || e . response . statusCode >= 400 ) { throw new IgConfigureVideoError ( e . response , configureOptions ) ; } await Bluebird . delay ( ( i + 1 ) * 2 * 1000 ) ; } } } public async album ( options ) { const isPhoto = ( arg ) : arg is PostingAlbumPhotoItem => ( arg as PostingAlbumPhotoItem ) . file !== undefined ; const isVideo = ( arg ) : arg is PostingAlbumVideoItem => ( arg as PostingAlbumVideoItem ) . video !== undefined ; for ( const item of options . items ) { if ( isPhoto ( item ) ) { const uploadedPhoto = await this . client . upload . photo ( { file : item . file , uploadId : item . uploadId , isSidecar : true , } ) ; const { width , height } = await sizeOf ( item . file ) ; item . width = width ; item . height = height ; item . uploadId = uploadedPhoto . upload_id ; } else if ( isVideo ( item ) ) { item . videoInfo = PublishService . getVideoInfo ( item . video ) ; item . uploadId = Date . now ( ) . toString ( ) ; PublishService . publishDebug ( `Adding video to album: ${ JSON . stringify ( item . videoInfo ) }` ) ; await Bluebird . try ( ( ) => this . regularVideo ( { video : item . video , uploadId : item . uploadId , isSidecar : true , ... item . videoInfo , } ) ) . catch ( IgResponseError , error => { throw new IgConfigureVideoError ( error . response as IgResponse < UploadRepositoryVideoResponseRootObject > , item . videoInfo ) ; } ) ; await this . client . upload . photo ( { file : item . coverImage , uploadId : item . uploadId , isSidecar : true , } ) ; await Bluebird . try ( ( ) => this . client . media . uploadFinish ( { upload_id : item . uploadId , source_type : '4' , video : { length : item . videoInfo . duration / 1000.0 } , } ) ) . catch ( IgResponseError , PublishService . catchTranscodeError ( item . videoInfo , item . transcodeDelay ) ) ; } } return await this . client . media . configureSidecar ( { caption : options . caption , children_metadata : options . items . map ( item => { if ( isVideo ( item ) ) { return { upload_id : item . uploadId , width : item . videoInfo . width , height : item . videoInfo . height , length : item . videoInfo . duration , usertags : item . usertags , } ; } else if ( isPhoto ( item ) ) { return { upload_id : item . uploadId , width : item . width , height : item . height , usertags : item . usertags , } ; } } ) , ... PublishService . makeLocationOptions ( options . location ) , } ) ; } public async story ( options ) { const isPhoto = ( arg ) : arg is PostingStoryPhotoOptions => ( arg as PostingStoryPhotoOptions ) . file !== undefined ; if ( options . stickerConfig instanceof StickerBuilder ) { options . stickerConfig = options . stickerConfig . build ( ) ; } const storyStickerIds = [ ] ; const configureOptions = { configure_mode : '1' , ... ( options . stickerConfig ? options . stickerConfig : { } ) , } ; const uploadAndConfigure = ( ) => isPhoto ( options ) ? this . uploadAndConfigureStoryPhoto ( options , configureOptions ) : this . uploadAndConfigureStoryVideo ( options , configureOptions ) ; const threadIds = typeof options . threadIds !== 'undefined' ; const recipients = typeof options . recipientUsers !== 'undefined' ; if ( recipients || threadIds ) { configureOptions . configure_mode = '2' ; configureOptions . view_mode = options . viewMode ; configureOptions . reply_type = options . replyType ; configureOptions . client_context = this . chance . guid ( ) ; if ( recipients ) { configureOptions . recipient_users = options . recipientUsers ; } configureOptions . thread_ids = threadIds ? options . threadIds : [ ] ; return uploadAndConfigure ( ) ; } if ( options . toBesties ) { configureOptions . audience = 'besties' ; } if ( typeof options . hashtags !== 'undefined' && options . hashtags . length > 0 ) { if ( typeof options . caption === 'undefined' ) { options . caption = '' ; } options . hashtags . forEach ( hashtag => { if ( hashtag . tag_name . includes ( '#' ) ) { hashtag . tag_name = hashtag . tag_name . replace ( '#' , '' ) ; } if ( ! options . caption . includes ( hashtag . tag_name ) ) { options . caption = `${ options . caption } ${ hashtag . tag_name }` ; } } ) ; configureOptions . story_hashtags = options . hashtags ; configureOptions . mas_opt_in = 'NOT_PROMPTED' ; } if ( typeof options . location !== 'undefined' ) { const { latitude , longitude } = options . location ; configureOptions . geotag_enabled = '1' ; configureOptions . posting_latitude = latitude ; configureOptions . posting_longitude = longitude ; configureOptions . media_latitude = latitude ; configureOptions . media_longitude = longitude ; configureOptions . story_locations = [ options . location . sticker ] ; configureOptions . mas_opt_in = 'NOT_PROMPTED' ; } if ( typeof options . mentions !== 'undefined' && options . mentions . length > 0 ) { if ( typeof options . caption === 'undefined' ) { options . caption = '' ; } else { options . caption = options . caption . replace ( ' ' , '+' ) + '+' ; } configureOptions . reel_mentions = options . mentions ; configureOptions . mas_opt_in = 'NOT_PROMPTED' ; } if ( typeof options . poll !== 'undefined' ) { configureOptions . story_polls = [ options . poll ] ; configureOptions . internal_features = 'polling_sticker' ; configureOptions . mas_opt_in = 'NOT_PROMPTED' ; } if ( typeof options . slider !== 'undefined' ) { configureOptions . story_sliders = [ options . slider ] ; storyStickerIds . push ( `emoji_slider_${ options . slider . emoji }` ) ; } if ( typeof options . question !== 'undefined' ) { configureOptions . story_questions = [ options . question ] ; storyStickerIds . push ( 'question_sticker_ma' ) ; } if ( typeof options . countdown !== 'undefined' ) { configureOptions . story_countdowns = [ options . countdown ] ; storyStickerIds . push ( 'countdown_sticker_time' ) ; } if ( typeof options . media !== 'undefined' ) { configureOptions . attached_media = [ options . media ] ; storyStickerIds . push ( `media_simple_${ options . media . media_id }` ) ; } if ( typeof options . chat !== 'undefined' ) { configureOptions . story_chats = [ options . chat ] ; storyStickerIds . push ( 'chat_sticker_id' ) ; } if ( typeof options . quiz !== 'undefined' ) { configureOptions . story_quizs = [ options . quiz ] ; storyStickerIds . push ( 'quiz_story_sticker_default' ) ; } if ( typeof options . link !== 'undefined' && options . link . length > 0 ) { configureOptions . story_cta = [ { links : [ { webUri : options . link } ] , } , ] ; } if ( storyStickerIds . length > 0 ) { configureOptions . story_sticker_ids = storyStickerIds . join ( ',' ) ; } return uploadAndConfigure ( ) ; } public async igtvVideo ( options ) { const videoInfo = PublishService . getVideoInfo ( options . video ) ; PublishService . publishDebug ( `Publishing video to igtv: ${ JSON . stringify ( videoInfo ) }` ) ; const uploadId = Date . now ( ) . toString ( ) ; const uploadResult = await this . segmentedVideo ( { video : options . video , isIgtvVideo : true , ... videoInfo , uploadId , ... options . uploadOptions , } ) ; await this . client . upload . photo ( { uploadId , file : options . coverFrame } ) ; const form = { upload_id : uploadId , title : options . title , caption : options . caption , audio_muted : options . audioMuted , length : videoInfo . duration / 1000.0 , extra : { source_width : videoInfo . width , source_height : videoInfo . height , } , retryContext : uploadResult . retryContext , } ; if ( options . shareToFeed ) { form . igtv_share_preview_to_feed = '1' ; if ( options . feedPreviewCrop ) { const { left , right , top , bottom } = options . feedPreviewCrop ; form . feed_preview_crop = { crop_left : left , crop_bottom : bottom , crop_right : right , crop_top : top } ; } else { const ratio = videoInfo . width / videoInfo . height ; if ( ratio > 1 ) { throw new Error ( 'Received invalid video ratio. Try specifying feedPreviewCrop directly.' ) ; } form . feed_preview_crop = { crop_left : 0.0 , crop_right : 1.0 , crop_top : ( 1 - ratio ) / 2 , crop_bottom : ratio + ( 1 - ratio ) / 2 , } ; } } const finalInput = { ... form , ... options . configureOptions } ; for ( let i = 0 ; i < 6 ; i ++ ) { try { return await this . client . media . configureToIgtv ( finalInput ) ; } catch ( e ) { if ( i >= 6 ) { throw new IgConfigureVideoError ( e . response , finalInput ) ; } await Bluebird . delay ( ( i + 1 ) * 2 * 1000 ) ; } } } private async regularVideo ( options ) { options = defaults ( options , { uploadId : Date . now ( ) , waterfallId : this . chance . guid ( { version : 4 } ) , } ) ; options . uploadName = options . uploadName || `${ options . uploadId }_0_${ random ( 1000000000 , 9999999999 ) }` ; const ruploadParams = UploadRepository . createVideoRuploadParams ( options , options . uploadId ) ; const { offset } = await this . client . upload . initVideo ( { name : options . uploadName , ruploadParams , waterfallId : options . waterfallId , } ) ; return this . client . upload . video ( { offset , ... options } ) ; } private async segmentedVideo ( options ) { const uploadId = options . uploadId || Date . now ( ) . toString ( ) ; const retryContext = options . retryContext || { num_step_auto_retry : 0 , num_reupload : 0 , num_step_manual_retry : 0 } ; const ruploadParams = UploadRepository . createVideoRuploadParams ( options , uploadId , retryContext ) ; const waterfallId = options . waterfallId || random ( 1000000000 , 9999999999 ) . toString ( ) ; const { stream_id : streamId } = await this . client . upload . startSegmentedVideo ( ruploadParams ) ; const segments = options . segments || ( options . segmentDivider || SEGMENT_DIVIDERS . sectionSize ( Math . pow ( 2 , 24 ) ) ) ( { buffer : options . video , client : this . client , } ) ; PublishService . publishDebug ( `Uploading ${ segments . length } segments.` ) ; let startOffset = 0 ; for ( const segment of segments ) { const transferId = `${ this . chance . guid ( { version : 4 } ) . replace ( '-' , '' ) }-0-${ segment . byteLength }` ; const { offset : streamOffset } = await this . client . upload . videoSegmentInit ( { waterfallId , streamId , startOffset , ruploadParams , transferId , } ) ; if ( streamOffset !== 0 ) { throw new Error ( `Offset != 0 isn't implemented. Open an issue including your network config and other setup information to reproduce.` ) ; } await this . client . upload . videoSegmentTransfer ( { waterfallId , streamId , startOffset , ruploadParams , transferId , segment , } ) ; startOffset += segment . byteLength ; } const end = await this . client . upload . endSegmentedVideo ( { ruploadParams , streamId } ) ; return { ... end , retryContext , uploadId , waterfallId } ; } private async uploadAndConfigureStoryPhoto ( options , configureOptions ) { const uploadId = Date . now ( ) . toString ( ) ; const imageSize = await sizeOf ( options . file ) ; await this . client . upload . photo ( { file : options . file , uploadId , } ) ; return await this . client . media . configureToStory ( { ... configureOptions , upload_id : uploadId , width : imageSize . width , height : imageSize . height , } ) ; } private async uploadAndConfigureStoryVideo ( options , configureOptions ) { const uploadId = random ( 100000000000 , 999999999999 ) . toString ( ) ; const videoInfo = PublishService . getVideoInfo ( options . video ) ; PublishService . publishDebug ( `Publishing video to story: ${ JSON . stringify ( videoInfo ) }` ) ; const waterfallId = this . chance . guid ( { version : 4 } ) ; await Bluebird . try ( ( ) => this . regularVideo ( { video : options . video , uploadId , forDirectStory : configureOptions . configure_mode === '2' , waterfallId , forAlbum : true , ... videoInfo , } ) ) . catch ( IgResponseError , error => { throw new IgConfigureVideoError ( error . response as IgResponse < UploadRepositoryVideoResponseRootObject > , videoInfo ) ; } ) ; await this . client . upload . photo ( { file : options . coverImage , waterfallId , uploadId , } ) ; await Bluebird . try ( ( ) => this . client . media . uploadFinish ( { upload_id : uploadId , source_type : '3' , video : { length : videoInfo . duration / 1000.0 } , } ) ) . catch ( IgResponseError , PublishService . catchTranscodeError ( videoInfo , options . transcodeDelay ) ) ; return Bluebird . try ( ( ) => this . client . media . configureToStoryVideo ( { upload_id : uploadId , length : videoInfo . duration / 1000.0 , width : videoInfo . width , height : videoInfo . height , ... configureOptions , } ) ) . catch ( IgResponseError , error => { throw new IgConfigureVideoError ( error . response as IgResponse < UploadRepositoryVideoResponseRootObject > , videoInfo ) ; } ) ; } }