From edfceec42c604d541ffc8cefd9b987dd46e9db4e Mon Sep 17 00:00:00 2001 From: Jack Willis-Craig Date: Sun, 25 Nov 2018 15:23:38 +1000 Subject: [PATCH] feat: add methods for starting and ending live streams --- README.md | 88 +++++++++++++++- src/index.ts | 71 +++++++++++++ src/live-stream.ts | 10 ++ src/types/live-stream.d.ts | 33 ++++++ test/live-stream.spec.ts | 123 ++++++++++++++++++++++ test/testdata/createLiveStreamRoom.json | 21 ++++ test/testdata/updateLiveStreamStatus.json | 6 ++ 7 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 src/live-stream.ts create mode 100644 test/testdata/createLiveStreamRoom.json create mode 100644 test/testdata/updateLiveStreamStatus.json diff --git a/README.md b/README.md index c8e8799..161e8d3 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ const api = new TikTokAPI(params, { signURL }); * [.joinLiveStream(id)](#joinlivestreamid) * [.leaveLiveStream(id)](#leavelivestreamid) * [.canStartLiveStream()](#canstartlivestream) +* [.startLiveStream(title, [contactsAuthorized])](#startlivestreamtitle-contactsauthorized) +* [.endLiveStream(roomId, streamId)](#endlivestreamroomid-streamid) +* [.createLiveStreamRoom(title, [contactsAuthorized])](#createlivestreamroomtitle-contactsauthorized) +* [.updateLiveStreamStatus(params)](#updatelivestreamstatusparams) #### .loginWithEmail(email, password) @@ -467,7 +471,7 @@ api.joinLiveStream('') ``` -See the [live stream types](src/types/live-stream.d.ts) for the complete request/response objects. +See the [live stream types](src/types/live-stream.d.ts) for the response data. #### .leaveLiveStream(id) @@ -483,8 +487,6 @@ api.leaveLiveStream('') ``` -See the [live stream types](src/types/live-stream.d.ts) for the complete request/response objects. - #### .canStartLiveStream() Determines if the current user is allowed to start a live stream. @@ -499,6 +501,86 @@ api.canStartLiveStream() ``` +See the [live stream types](src/types/live-stream.d.ts) for the response data. + +#### .startLiveStream(title, [contactsAuthorized]) + +Starts a live stream by calling [`createLiveStreamRoom`](#createlivestreamroomtitle-contactsauthorized) +then [`updateLiveStreamStatus`](#updatelivestreamstatusparams). + +Keep note of the `room_id` and `stream_id` properties because you will need them to end the live stream. + +The `rtmp_push_url` value can be used with streaming applications such as OBS. + +```javascript +api.startLiveStream('title') + .then(res => console.log(res.data.room)) + .catch(console.log); + +// Outputs: +// { create_time: 1000000000, owner: {...}, stream_url: {...}, title: 'Example', user_count: 1000, ... } + +``` + +See the [live stream types](src/types/live-stream.d.ts) for the response data. + +#### .endLiveStream(roomId, streamId) + +Ends a live stream. + +You **must** call this method to so you are no longer marked as "live" in the app. + +```javascript +api.endLiveStream('', '') + .then(res => console.log(res.data.status_code)) + .catch(console.log); + +// Outputs: +// 0 + +``` + +#### .createLiveStreamRoom(title, [contactsAuthorized]) + +Creates a room to host a live stream. + +The `rtmp_push_url` value can be used with streaming applications such as OBS. + +**Note:** This method only creates the room for the live stream. You'll need to call +[`updateLiveStreamStatus`](#updatelivestreamstatusparams) to mark the stream as started. +See [`startLiveStream`](#startlivestreamtitle-contactsauthorized) for a helper method that makes these calls for you. + +```javascript +api.startLiveStream('title') + .then(res => console.log(res.data.room)) + .catch(console.log); + +// Outputs: +// { create_time: 1000000000, owner: {...}, stream_url: {...}, title: 'Example', user_count: 1000, ... } + +``` + +See the [live stream types](src/types/live-stream.d.ts) for the response data. + +#### .updateLiveStreamStatus(params) + +Updates the status of a live stream. + +```javascript +api.updateLiveStreamStatus({ + room_id: '', + stream_id: '', + status: LiveStreamStatus.Ended, + reason_no: LiveStreamStatusChangedReason.InitiatedByUser, +}) + .then(res => console.log(res.data.status_code)) + .catch(console.log); + +// Outputs: +// 0 + +``` + See the [live stream types](src/types/live-stream.d.ts) for the complete request/response objects. ## Resources diff --git a/src/index.ts b/src/index.ts index 5dfed84..739c6c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { CookieJar } from 'tough-cookie'; import * as API from './types'; import { encryptWithXOR } from './cryptography'; import { FeedType, PullType } from './feed'; +import { LiveStreamStatus, LiveStreamStatusChangedReason } from './live-stream'; import { paramsOrder, paramsSerializer, withDefaultListParams } from './params'; export default class TikTokAPI { @@ -424,6 +425,76 @@ export default class TikTokAPI { canStartLiveStream = () => this.request.get('aweme/v1/live/podcast/') + /** + * Creates a live stream room and sets the status to started. + * + * @param title + * @param contactsAuthorized + */ + startLiveStream = (title: string, contactsAuthorized = 0) => + this.createLiveStreamRoom(title, contactsAuthorized) + .then((createRoomRes) => { + if (createRoomRes.data.status_code !== 0) { + throw new Error(`The live stream room could not be created: ${JSON.stringify(createRoomRes.data)}`); + } + + const { room } = createRoomRes.data as API.CreateLiveStreamRoomResponse; + return this.updateLiveStreamStatus({ + room_id: room.room_id, + stream_id: room.stream_id, + status: LiveStreamStatus.Started, + reason_no: LiveStreamStatusChangedReason.InitiatedByApp, + }).then((updateStatusRes) => { + if (updateStatusRes.data.status_code !== 0) { + throw new Error(`The live stream could not be started: ${JSON.stringify(updateStatusRes.data)}`); + } + + return createRoomRes; + }); + }) + + /** + * Ends a live stream. + * + * @param roomId + * @param streamId + */ + endLiveStream = (roomId: string, streamId: string) => + this.updateLiveStreamStatus({ + room_id: roomId, + stream_id: streamId, + status: LiveStreamStatus.Ended, + reason_no: LiveStreamStatusChangedReason.InitiatedByUser, + }) + + /** + * Creates a room to host a live stream. + * + * @param title + * @param contactsAuthorized + */ + createLiveStreamRoom = (title: string, contactsAuthorized = 0) => + this.request.post('aweme/v1/room/create/', { + params: { + title, + contacts_authorized: contactsAuthorized, + }, + }) + + /** + * Updates the status of a live stream. + * + * @param params + */ + updateLiveStreamStatus = (params: API.UpdateLiveStreamStatusRequest) => + this.request.get('aweme/v1/room/update/status/', { + params: { + status: LiveStreamStatus.Ended, + reason_no: LiveStreamStatusChangedReason.InitiatedByUser, + ...params, + }, + }) + /** * Transform using JSONBig to store big numbers accurately (e.g. user IDs) as strings. * diff --git a/src/live-stream.ts b/src/live-stream.ts new file mode 100644 index 0000000..4cc256a --- /dev/null +++ b/src/live-stream.ts @@ -0,0 +1,10 @@ +export enum LiveStreamStatus { + Created = 1, + Started = 2, + Ended = 4, +} + +export enum LiveStreamStatusChangedReason { + InitiatedByApp = 0, + InitiatedByUser = 1, +} diff --git a/src/types/live-stream.d.ts b/src/types/live-stream.d.ts index 2f12e84..0b16383 100644 --- a/src/types/live-stream.d.ts +++ b/src/types/live-stream.d.ts @@ -1,5 +1,6 @@ import { BaseResponseData } from './request'; import { CommonUserDetails } from './user'; +import { LiveStreamStatus, LiveStreamStatusChangedReason } from '../live-stream'; export interface LiveStreamRequest { /** The ID of the live stream to join or leave */ @@ -16,6 +17,35 @@ export interface CanStartLiveStreamResponse extends BaseResponseData { can_be_live_podcast: boolean; } +export interface CreateLiveStreamRoomRequest { + /** The name of the live stream */ + title: string; + + /** 1 if the user has given the app permission to read their contacts */ + contacts_authorized: 0 | 1; +} + +export interface CreateLiveStreamRoomResponse extends BaseResponseData { + room: LiveStream; +} + +export interface UpdateLiveStreamStatusRequest { + /** The ID of the stream */ + room_id: string; + + /** The ID used in the stream URL */ + stream_id: string; + + /** The status to update to */ + status: LiveStreamStatus; + + /** Why the status is being updated */ + reason_no: LiveStreamStatusChangedReason; +} + +export interface UpdateLiveStreamStatusResponse extends BaseResponseData { +} + export interface LiveStream { /** The timestamp in seconds when the stream was created */ create_time: number; @@ -40,6 +70,9 @@ export interface LiveStream { /** A link to the stream source */ rtmp_pull_url: string; + /** A link used to publish to the stream (only present if you own the live stream) */ + rtmp_push_url?: string; + /** The ID used in the stream URL */ sid: string; }; diff --git a/test/live-stream.spec.ts b/test/live-stream.spec.ts index d169183..468ed71 100644 --- a/test/live-stream.spec.ts +++ b/test/live-stream.spec.ts @@ -6,7 +6,9 @@ import TikTokAPI, { BaseResponseData, CanStartLiveStreamResponse, CommonUserDetails, + CreateLiveStreamRoomResponse, JoinLiveStreamResponse, + UpdateLiveStreamStatusResponse, } from '../src'; import { loadTestData, @@ -88,3 +90,124 @@ describe('#canStartLiveStream()', () => { assert.deepStrictEqual(res.data, expected); }); }); + +describe('#createLiveStreamRoom()', () => { + it('a successful response should match the interface', async () => { + const api = new TikTokAPI(mockParams, mockConfig); + const mock = new MockAdapter(api.request); + mock + .onPost(new RegExp('aweme/v1/room/create/\?.*')) + .reply(200, loadTestData('createLiveStreamRoom.json'), {}); + + const roomId = '9999999999999999999'; + const streamId = '9000000000000000000'; + const title = 'TITLE'; + + const res = await api.createLiveStreamRoom(title); + const expected: CreateLiveStreamRoomResponse = { + extra: { + now: 1000000000000, + }, + room: { + title, + create_time: 1000000000, + finish_time: 1000000000, + owner: {} as CommonUserDetails, + room_id: roomId, + status: 0, + stream_id: streamId, + stream_url: { + rtmp_pull_url: `http://pull-flv-l1-mus.pstatp.com/hudong/stream-${streamId}.flv`, + rtmp_push_url: `rtmp://push-rtmp-l1-mus.pstatp.com/hudong/stream-${streamId}?wsSecret=1234&wsTime=1a1a1a1a`, + sid: streamId, + }, + user_count: 0, + }, + status_code: 0, + }; + assert.deepStrictEqual(res.data, expected); + }); +}); + +describe('#startLiveStream()', () => { + it('a successful response should match the interface', async () => { + const api = new TikTokAPI(mockParams, mockConfig); + const mock = new MockAdapter(api.request); + mock + .onPost(new RegExp('aweme/v1/room/create/\?.*')) + .reply(200, loadTestData('createLiveStreamRoom.json'), {}); + mock + .onGet(new RegExp('aweme/v1/room/update/status/\?.*')) + .reply(200, loadTestData('updateLiveStreamStatus.json'), {}); + + const roomId = '9999999999999999999'; + const streamId = '9000000000000000000'; + const title = 'TITLE'; + + const res = await api.startLiveStream(title); + const expected: CreateLiveStreamRoomResponse = { + extra: { + now: 1000000000000, + }, + room: { + title, + create_time: 1000000000, + finish_time: 1000000000, + owner: {} as CommonUserDetails, + room_id: roomId, + status: 0, + stream_id: streamId, + stream_url: { + rtmp_pull_url: `http://pull-flv-l1-mus.pstatp.com/hudong/stream-${streamId}.flv`, + rtmp_push_url: `rtmp://push-rtmp-l1-mus.pstatp.com/hudong/stream-${streamId}?wsSecret=1234&wsTime=1a1a1a1a`, + sid: streamId, + }, + user_count: 0, + }, + status_code: 0, + }; + assert.deepStrictEqual(res.data, expected); + }); + + it('should throw an error if the live stream room could not be created', async () => { + const api = new TikTokAPI(mockParams, mockConfig); + const mock = new MockAdapter(api.request); + mock + .onPost(new RegExp('aweme/v1/room/create/\?.*')) + .reply(200, { status_code: 3 }, {}); + + assert.isRejected(api.startLiveStream('TITLE'), /could not be created/); + }); + + it('should throw an error if the live stream could not be started', async () => { + const api = new TikTokAPI(mockParams, mockConfig); + const mock = new MockAdapter(api.request); + mock + .onPost(new RegExp('aweme/v1/room/create/\?.*')) + .reply(200, loadTestData('createLiveStreamRoom.json'), {}); + mock + .onGet(new RegExp('aweme/v1/room/update/status/\?.*')) + .reply(200, { status_code: 3 }, {}); + + assert.isRejected(api.startLiveStream('TITLE'), /could not be started/); + }); +}); + +describe('#endLiveStream()', () => { + it('a successful response should match the interface', async () => { + const api = new TikTokAPI(mockParams, mockConfig); + const mock = new MockAdapter(api.request); + mock + .onGet(new RegExp('aweme/v1/room/update/status/\?.*')) + .reply(200, loadTestData('updateLiveStreamStatus.json'), {}); + + const res = await api.endLiveStream('9999999999999999999', '9000000000000000000'); + const expected: UpdateLiveStreamStatusResponse = { + extra: { + now: 1000000000000, + }, + status_code: 0, + }; + assert.deepStrictEqual(res.data, expected); + }); +}); diff --git a/test/testdata/createLiveStreamRoom.json b/test/testdata/createLiveStreamRoom.json new file mode 100644 index 0000000..63f21ef --- /dev/null +++ b/test/testdata/createLiveStreamRoom.json @@ -0,0 +1,21 @@ +{ + "extra": { + "now": 1000000000000 + }, + "room": { + "create_time": 1000000000, + "finish_time": 1000000000, + "owner": {}, + "room_id": 9999999999999999999, + "status": 0, + "stream_id": 9000000000000000000, + "stream_url": { + "rtmp_pull_url": "http://pull-flv-l1-mus.pstatp.com/hudong/stream-9000000000000000000.flv", + "rtmp_push_url": "rtmp://push-rtmp-l1-mus.pstatp.com/hudong/stream-9000000000000000000?wsSecret=1234&wsTime=1a1a1a1a", + "sid": 9000000000000000000 + }, + "title": "TITLE", + "user_count": 0 + }, + "status_code": 0 +} diff --git a/test/testdata/updateLiveStreamStatus.json b/test/testdata/updateLiveStreamStatus.json new file mode 100644 index 0000000..dd250da --- /dev/null +++ b/test/testdata/updateLiveStreamStatus.json @@ -0,0 +1,6 @@ +{ + "extra": { + "now": 1000000000000 + }, + "status_code": 0 +}