From 35361e84f10a4ef25dc75586c843b77f05fda9d5 Mon Sep 17 00:00:00 2001 From: Olivier Mouren Date: Sun, 31 Jan 2021 09:55:05 +0100 Subject: [PATCH 1/3] New process to upload a broadcast replay to IGTV --- examples/live-replay-to-igtv.example.ts | 129 ++++++++++++++++++ src/repositories/live.repository.ts | 31 ----- src/repositories/upload.repository.ts | 4 + src/responses/index.ts | 1 - .../live.add-post-live-to-igtv.response.ts | 5 - src/types/upload.photo.options.ts | 1 + 6 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 examples/live-replay-to-igtv.example.ts delete mode 100644 src/responses/live.add-post-live-to-igtv.response.ts diff --git a/examples/live-replay-to-igtv.example.ts b/examples/live-replay-to-igtv.example.ts new file mode 100644 index 000000000..151c7c898 --- /dev/null +++ b/examples/live-replay-to-igtv.example.ts @@ -0,0 +1,129 @@ +/* tslint:disable:no-console */ +import {IgApiClient, LiveEntity} from '../src'; +import Bluebird = require('bluebird'); +const pngToJpeg = require('png-to-jpeg') +const sharp = require('sharp'); +const https = require('https'); + +const ig = new IgApiClient(); + +async function login() { + ig.state.generateDevice(process.env.IG_USERNAME); + ig.state.proxyUrl = process.env.IG_PROXY; + await ig.account.login(process.env.IG_USERNAME, process.env.IG_PASSWORD); +} + +(async () => { + // basic login-procedure + await login(); + + const {broadcast_id, upload_url} = await ig.live.create({ + // create a stream in 720x1280 (9:16) + previewWidth: 720, + previewHeight: 1280, + // this message is not necessary, because it doesn't show up in the notification + message: 'My message', + }); + // (optional) get the key and url for programs such as OBS + const {stream_key, stream_url} = LiveEntity.getUrlAndKey({broadcast_id, upload_url}); + console.log(`Start your stream on ${stream_url}.\n + Your key is: ${stream_key}`); + + /** + * make sure you are streaming to the url + * the next step will send a notification / start your stream for everyone to see + */ + const startInfo = await ig.live.start(broadcast_id); + // status should be 'ok' + console.log(startInfo); + + /** + * now, your stream is running + * the next step is to get comments + * note: comments can only be requested roughly every 2s + */ + + // initial comment-timestamp = 0, get all comments + let lastCommentTs = await printComments(broadcast_id, 0); + + // enable the comments + await ig.live.unmuteComment(broadcast_id); + /** + * wait 2 seconds until the next request. + * in the real world you'd use something like setInterval() instead of Bluebird.delay() / just to simulate a delay + */ + // wait 2s + await Bluebird.delay(2000); + // now, we print the next comments + lastCommentTs = await printComments(broadcast_id, lastCommentTs); + + // now we're commenting on our stream + await ig.live.comment(broadcast_id, 'A comment'); + + /** + * now, your stream is running, you entertain your followers, but you're tired and + * we're going to stop the stream + */ + await ig.live.endBroadcast(broadcast_id); + + // Get live thumbnails, required to post on IGTV + let data = await ig.live.getPostLiveThumbnails(broadcast_id) + + // Download any thumb + let file = await new Promise((resolve) => https.get(data.thumbnails[0], (download) => { + let ds = []; + download.on("data", (d) => ds.push(d)); + download.on("end", () => resolve(Buffer.concat(ds))) + })) + + // (optional) Resize thumb to a vertical one + file = await sharp(file) + .resize(720, 1280) + .png() + .toBuffer() + + // It will be a png, it must be converted to jpg + file = await pngToJpeg({quality: 100})(file) + + // Upload the thumbnail with a broadcast id for a replay and get uploadId + let upload = await ig.upload.photo({file, broadcastId: broadcast_id}) + + let igtv = null + let currentRetry = 0 + let maxRetry = 3 + let retryDelay = 4 + while (!igtv) { + // This endpoint can return an error "202 Accepted; Transcode not finished yet" if Instagram has not finished to process the previous upload, so retry later in this case + try { + igtv = await ig.media.configureToIgtv({ + upload_id: upload.upload_id, + title: 'A title', + caption: 'A description', + igtv_share_preview_to_feed: '1', + }) + + console.log(`Live posted to IGTV : ${igtv.upload_id}`)) + } catch (e) { + currentRetry++ + if (currentRetry > maxRetry) { + throw e + } else { + await (new Promise(resolve => { + setTimeout(resolve, currentRetry * retryDelay) + })) + } + } + } + + // now you're basically done +})(); + +async function printComments(broadcastId, lastCommentTs) { + const {comments} = await ig.live.getComment({broadcastId, lastCommentTs}); + if (comments.length > 0) { + comments.forEach(comment => console.log(`${comment.user.username}: ${comment.text}`)); + return comments[comments.length - 1].created_at; + } else { + return lastCommentTs; + } +} diff --git a/src/repositories/live.repository.ts b/src/repositories/live.repository.ts index c74fff5ea..bb15fcdad 100644 --- a/src/repositories/live.repository.ts +++ b/src/repositories/live.repository.ts @@ -4,7 +4,6 @@ import { LiveSwitchCommentsResponseRootObject, LiveCreateBroadcastResponseRootObject, LiveStartBroadcastResponseRootObject, - LiveAddPostLiveToIgtvResponseRootObject, LiveCommentsResponseRootObject, LiveHeartbeatViewerCountResponseRootObject, LiveInfoResponseRootObject, @@ -281,36 +280,6 @@ export class LiveRepository extends Repository { return body; } - public async addPostLiveToIgtv({ - broadcastId, - title, - description, - coverUploadId, - igtvSharePreviewToFeed = false, - }: { - broadcastId: string; - title: string; - description: string; - coverUploadId: string; - igtvSharePreviewToFeed?: boolean; - }): Promise { - const { body } = await this.client.request.send({ - url: `/api/v1/live/add_post_live_to_igtv/`, - method: 'POST', - form: this.client.request.sign({ - _csrftoken: this.client.state.cookieCsrfToken, - _uuid: this.client.state.uuid, - broadcast_id: broadcastId, - cover_upload_id: coverUploadId, - description: description, - title: title, - internal_only: false, - igtv_share_preview_to_feed: igtvSharePreviewToFeed, - }), - }); - return body; - } - public async endBroadcast(broadcastId: string, endAfterCopyrightWarning: boolean = false) { const { body } = await this.client.request.send({ url: `/api/v1/live/${broadcastId}/end_broadcast/`, diff --git a/src/repositories/upload.repository.ts b/src/repositories/upload.repository.ts index d0e7b1aeb..b35ee2513 100644 --- a/src/repositories/upload.repository.ts +++ b/src/repositories/upload.repository.ts @@ -208,6 +208,10 @@ export class UploadRepository extends Repository { if (options.isSidecar) { ruploadParams.is_sidecar = '1'; } + if (options.broadcastId) { + ruploadParams.broadcast_id = options.broadcastId; + ruploadParams.is_post_live_igtv = '1'; + } return ruploadParams; } diff --git a/src/responses/index.ts b/src/responses/index.ts index 1b43b076b..6b15b3809 100644 --- a/src/responses/index.ts +++ b/src/responses/index.ts @@ -37,7 +37,6 @@ export * from './live.like-count.response'; export * from './live.post-live-thumbnails.response'; export * from './live.like.response'; export * from './live.start-broadcast.response'; -export * from './live.add-post-live-to-igtv.response'; export * from './live.switch-comments.response'; export * from './live.viewer-list.response'; export * from './live.add-to-post.response'; diff --git a/src/responses/live.add-post-live-to-igtv.response.ts b/src/responses/live.add-post-live-to-igtv.response.ts deleted file mode 100644 index 3d91eafe4..000000000 --- a/src/responses/live.add-post-live-to-igtv.response.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface LiveAddPostLiveToIgtvResponseRootObject { - success: boolean; - igtv_post_id: number; - status: string; -} diff --git a/src/types/upload.photo.options.ts b/src/types/upload.photo.options.ts index 45546962c..b522e899d 100644 --- a/src/types/upload.photo.options.ts +++ b/src/types/upload.photo.options.ts @@ -3,4 +3,5 @@ export interface UploadPhotoOptions { file: Buffer; isSidecar?: boolean; waterfallId?: string; + broadcastId?: string; } From 671f8da5d9253e1d396674a578d991d0fdd5183a Mon Sep 17 00:00:00 2001 From: Olivier Mouren Date: Wed, 3 Feb 2021 20:48:07 +0100 Subject: [PATCH 2/3] Handle 202 in repository, clean example --- examples/live-replay-to-igtv.example.ts | 62 ++++++++----------------- src/repositories/media.repository.ts | 53 ++++++++++++++------- 2 files changed, 54 insertions(+), 61 deletions(-) diff --git a/examples/live-replay-to-igtv.example.ts b/examples/live-replay-to-igtv.example.ts index 151c7c898..9d64ae430 100644 --- a/examples/live-replay-to-igtv.example.ts +++ b/examples/live-replay-to-igtv.example.ts @@ -3,7 +3,7 @@ import {IgApiClient, LiveEntity} from '../src'; import Bluebird = require('bluebird'); const pngToJpeg = require('png-to-jpeg') const sharp = require('sharp'); -const https = require('https'); +const axios = require('axios'); const ig = new IgApiClient(); @@ -67,54 +67,30 @@ async function login() { await ig.live.endBroadcast(broadcast_id); // Get live thumbnails, required to post on IGTV - let data = await ig.live.getPostLiveThumbnails(broadcast_id) + let data = await ig.live.getPostLiveThumbnails(broadcast_id); - // Download any thumb - let file = await new Promise((resolve) => https.get(data.thumbnails[0], (download) => { - let ds = []; - download.on("data", (d) => ds.push(d)); - download.on("end", () => resolve(Buffer.concat(ds))) - })) + // Use an HTTP client to download any thumb + let {data: file} = await axios.get(data.thumbnails[0], {responseType: 'arraybuffer'}); - // (optional) Resize thumb to a vertical one + // (optional) Resize thumb to a vertical one and convert to jpg file = await sharp(file) - .resize(720, 1280) - .png() - .toBuffer() - - // It will be a png, it must be converted to jpg - file = await pngToJpeg({quality: 100})(file) + .resize({width: 720, height: 1280}) + .jpeg({ + quality: 100, + }) + .toBuffer(); // Upload the thumbnail with a broadcast id for a replay and get uploadId - let upload = await ig.upload.photo({file, broadcastId: broadcast_id}) - - let igtv = null - let currentRetry = 0 - let maxRetry = 3 - let retryDelay = 4 - while (!igtv) { - // This endpoint can return an error "202 Accepted; Transcode not finished yet" if Instagram has not finished to process the previous upload, so retry later in this case - try { - igtv = await ig.media.configureToIgtv({ - upload_id: upload.upload_id, - title: 'A title', - caption: 'A description', - igtv_share_preview_to_feed: '1', - }) - - console.log(`Live posted to IGTV : ${igtv.upload_id}`)) - } catch (e) { - currentRetry++ - if (currentRetry > maxRetry) { - throw e - } else { - await (new Promise(resolve => { - setTimeout(resolve, currentRetry * retryDelay) - })) - } - } - } + let upload = await ig.upload.photo({file, broadcastId: broadcast_id}); + + let igtv = await ig.media.configureToIgtv({ + upload_id: upload.upload_id, + title: 'A title', + caption: 'A description', + igtv_share_preview_to_feed: '1', + }, 2000) + console.log(`Live posted to IGTV : ${igtv.upload_id}`)); // now you're basically done })(); diff --git a/src/repositories/media.repository.ts b/src/repositories/media.repository.ts index c66cd4d8f..e9fe9f080 100644 --- a/src/repositories/media.repository.ts +++ b/src/repositories/media.repository.ts @@ -30,6 +30,7 @@ import { MediaRepositoryConfigureResponseRootObject } from '../responses'; import Chance = require('chance'); import { MediaRepositoryCheckOffensiveCommentResponseRootObject } from '../responses'; import { StoryMusicQuestionResponse, StoryTextQuestionResponse } from '../types/story-response.options'; +import { IgResponseError } from "../errors"; export class MediaRepository extends Repository { public async info(mediaId: string): Promise { @@ -576,7 +577,7 @@ export class MediaRepository extends Repository { return body; } - public async configureToIgtv(options: MediaConfigureToIgtvOptions) { + public async configureToIgtv(options: MediaConfigureToIgtvOptions, retryDelay: number = 1000) { const form: MediaConfigureToIgtvOptions = defaultsDeep(options, { caption: '', date_time_original: new Date().toISOString().replace(/[-:]/g, ''), @@ -598,23 +599,39 @@ export class MediaRepository extends Repository { }); const retryContext = options.retryContext; delete form.retryContext; - const { body } = await this.client.request.send({ - url: '/api/v1/media/configure_to_igtv/', - method: 'POST', - qs: { - video: '1', - }, - headers: { - is_igtv_video: '1', - retry_context: JSON.stringify(retryContext), - }, - form: this.client.request.sign({ - ...form, - _csrftoken: this.client.state.cookieCsrfToken, - _uid: this.client.state.cookieUserId, - _uuid: this.client.state.uuid, - }), - }); + + let body = null; + let response = null; + while (!body) { + try { + response = await this.client.request.send({ + url: '/api/v1/media/configure_to_igtv/', + method: 'POST', + qs: { + video: '1', + }, + headers: { + is_igtv_video: '1', + retry_context: JSON.stringify(retryContext), + }, + form: this.client.request.sign({ + ...form, + _csrftoken: this.client.state.cookieCsrfToken, + _uid: this.client.state.cookieUserId, + _uuid: this.client.state.uuid, + }), + }); + + body = response.body; + } catch (e) { + // Endpoint can return an error "202 Accepted; Transcode not finished yet" if Instagram has not finished to process the upload, retry after a delay + if (!(e instanceof IgResponseError && e.response.statusCode === 202)) { + throw e; + } else { + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + } return body; } From 2fedb9a29f68ef0e1da40f52d192824a34a243f1 Mon Sep 17 00:00:00 2001 From: Olivier Mouren Date: Fri, 5 Feb 2021 22:16:37 +0100 Subject: [PATCH 3/3] Use publish service --- examples/live-replay-to-igtv.example.ts | 13 +++-- src/errors/ig-upload-live-igtv-error.ts | 9 ++++ src/errors/index.ts | 1 + src/repositories/media.repository.ts | 51 +++++++------------ src/services/publish.service.ts | 52 +++++++++++++++++++- src/types/media.configure-to-igtv.options.ts | 2 +- src/types/posting.live-igtv.options.ts | 8 +++ 7 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 src/errors/ig-upload-live-igtv-error.ts create mode 100644 src/types/posting.live-igtv.options.ts diff --git a/examples/live-replay-to-igtv.example.ts b/examples/live-replay-to-igtv.example.ts index 9d64ae430..12b558955 100644 --- a/examples/live-replay-to-igtv.example.ts +++ b/examples/live-replay-to-igtv.example.ts @@ -81,16 +81,15 @@ async function login() { .toBuffer(); // Upload the thumbnail with a broadcast id for a replay and get uploadId - let upload = await ig.upload.photo({file, broadcastId: broadcast_id}); - - let igtv = await ig.media.configureToIgtv({ - upload_id: upload.upload_id, + let igtv = await ig.publish.liveIgtv({ + file, + broadcastId: broadcast_id, title: 'A title', caption: 'A description', - igtv_share_preview_to_feed: '1', - }, 2000) + igtv_share_preview_to_feed: '1' + }); - console.log(`Live posted to IGTV : ${igtv.upload_id}`)); + console.log(`Live posted to IGTV : ${igtv.upload_id}`); // now you're basically done })(); diff --git a/src/errors/ig-upload-live-igtv-error.ts b/src/errors/ig-upload-live-igtv-error.ts new file mode 100644 index 000000000..8dbf2bec0 --- /dev/null +++ b/src/errors/ig-upload-live-igtv-error.ts @@ -0,0 +1,9 @@ +import { IgResponseError } from './ig-response.error'; +import { IgResponse } from '../types'; +import { UploadRepositoryVideoResponseRootObject } from '../responses'; + +export class IgUploadLiveIgtvError extends IgResponseError { + constructor(response: IgResponse) { + super(response); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index bdf3fe24d..e41d3cf41 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -22,5 +22,6 @@ export * from './ig-challenge-wrong-code.error'; export * from './ig-exact-user-not-found-error'; export * from './ig-user-id-not-found.error'; export * from './ig-upload-video-error'; +export * from './ig-upload-live-igtv-error'; export * from './ig-user-has-logged-out.error'; export * from './ig-configure-video-error'; diff --git a/src/repositories/media.repository.ts b/src/repositories/media.repository.ts index e9fe9f080..40a2a21b6 100644 --- a/src/repositories/media.repository.ts +++ b/src/repositories/media.repository.ts @@ -30,7 +30,6 @@ import { MediaRepositoryConfigureResponseRootObject } from '../responses'; import Chance = require('chance'); import { MediaRepositoryCheckOffensiveCommentResponseRootObject } from '../responses'; import { StoryMusicQuestionResponse, StoryTextQuestionResponse } from '../types/story-response.options'; -import { IgResponseError } from "../errors"; export class MediaRepository extends Repository { public async info(mediaId: string): Promise { @@ -577,7 +576,7 @@ export class MediaRepository extends Repository { return body; } - public async configureToIgtv(options: MediaConfigureToIgtvOptions, retryDelay: number = 1000) { + public async configureToIgtv(options: MediaConfigureToIgtvOptions) { const form: MediaConfigureToIgtvOptions = defaultsDeep(options, { caption: '', date_time_original: new Date().toISOString().replace(/[-:]/g, ''), @@ -600,38 +599,24 @@ export class MediaRepository extends Repository { const retryContext = options.retryContext; delete form.retryContext; - let body = null; - let response = null; - while (!body) { - try { - response = await this.client.request.send({ - url: '/api/v1/media/configure_to_igtv/', - method: 'POST', - qs: { - video: '1', - }, - headers: { - is_igtv_video: '1', - retry_context: JSON.stringify(retryContext), - }, - form: this.client.request.sign({ - ...form, - _csrftoken: this.client.state.cookieCsrfToken, - _uid: this.client.state.cookieUserId, - _uuid: this.client.state.uuid, - }), - }); + const { body } = await this.client.request.send({ + url: '/api/v1/media/configure_to_igtv/', + method: 'POST', + qs: { + video: '1', + }, + headers: { + is_igtv_video: '1', + retry_context: JSON.stringify(retryContext), + }, + form: this.client.request.sign({ + ...form, + _csrftoken: this.client.state.cookieCsrfToken, + _uid: this.client.state.cookieUserId, + _uuid: this.client.state.uuid, + }), + }); - body = response.body; - } catch (e) { - // Endpoint can return an error "202 Accepted; Transcode not finished yet" if Instagram has not finished to process the upload, retry after a delay - if (!(e instanceof IgResponseError && e.response.statusCode === 202)) { - throw e; - } else { - await new Promise(resolve => setTimeout(resolve, retryDelay)); - } - } - } return body; } diff --git a/src/services/publish.service.ts b/src/services/publish.service.ts index 7b55e3ec7..9a4bda008 100644 --- a/src/services/publish.service.ts +++ b/src/services/publish.service.ts @@ -19,9 +19,10 @@ import { UploadVideoOptions, } from '../types'; import { PostingLocation, PostingStoryOptions } from '../types/posting.options'; -import { IgConfigureVideoError, IgResponseError, IgUploadVideoError } from '../errors'; +import { IgConfigureVideoError, IgResponseError, IgUploadLiveIgtvError, IgUploadVideoError } from '../errors'; import { StatusResponse, UploadRepositoryVideoResponseRootObject } from '../responses'; import { PostingIgtvOptions } from '../types/posting.igtv.options'; +import { PostingLiveIgtvOptions } from "../types/posting.live-igtv.options"; import sizeOf = require('image-size'); import Bluebird = require('bluebird'); import Chance = require('chance'); @@ -52,6 +53,22 @@ export class PublishService extends Repository { }; } + /** + * @param transcodeDelayInMs The delay for instagram to transcode the video + */ + public static catchLiveIgtvTranscodeError(transcodeDelayInMs: number) { + 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 IgUploadLiveIgtvError(error.response as IgResponse); + } + }; + } + /** * Gets duration in ms, width and height info for a video in the mp4 container * @param buffer Buffer, containing the video-file @@ -453,6 +470,39 @@ export class PublishService extends Repository { } } + public async liveIgtv(options: PostingLiveIgtvOptions) { + const uploadedPhoto = await this.client.upload.photo({ + file: options.file, + broadcastId: options.broadcastId, + }); + + await Bluebird.try(() => + this.client.media.uploadFinish({ + upload_id: uploadedPhoto.upload_id, + source_type: '4', + }), + ).catch(IgResponseError, PublishService.catchLiveIgtvTranscodeError(options.transcodeDelay || 5000)); + + const configureOptions: MediaConfigureToIgtvOptions = { + upload_id: uploadedPhoto.upload_id, + title: options.title, + caption: options.caption, + igtv_share_preview_to_feed: options.igtv_share_preview_to_feed, + length: 0 + }; + + for (let i = 0; i < 6; i++) { + try { + return await this.client.media.configureToIgtv(configureOptions); + } catch (e) { + if (i >= 5 || e.response.statusCode >= 400) { + throw new IgConfigureVideoError(e.response, configureOptions); + } + await Bluebird.delay((i + 1) * 2 * 1000); + } + } + } + private async regularVideo(options: UploadVideoOptions) { options = defaults(options, { uploadId: Date.now(), diff --git a/src/types/media.configure-to-igtv.options.ts b/src/types/media.configure-to-igtv.options.ts index c2e83041b..aa28edd35 100644 --- a/src/types/media.configure-to-igtv.options.ts +++ b/src/types/media.configure-to-igtv.options.ts @@ -2,7 +2,7 @@ export interface MediaConfigureToIgtvOptions { upload_id: string; title: string; length: number; - extra: { source_width: number; source_height: number }; + extra?: { source_width: number; source_height: number }; caption?: string; // will be converted to a json-string feed_preview_crop?: diff --git a/src/types/posting.live-igtv.options.ts b/src/types/posting.live-igtv.options.ts new file mode 100644 index 000000000..073bcef23 --- /dev/null +++ b/src/types/posting.live-igtv.options.ts @@ -0,0 +1,8 @@ +export interface PostingLiveIgtvOptions { + file: Buffer; + title: string; + caption?: string; + broadcastId: string; + igtv_share_preview_to_feed?: '1' | '0'; + transcodeDelay?: number +} \ No newline at end of file