diff --git a/README.md b/README.md index 1d8cdf9ac..e43ff2cce 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,16 @@ OSS, Object Storage Service. Equal to well known Amazon [S3](http://aws.amazon.c - [.multipartUpload*(name, file[, options])](#multipartuploadname-file-options) - [.listUploads*(query[, options])](#listuploadsquery-options) - [.abortMultipartUpload*(name, uploadId[, options])](#abortmultipartuploadname-uploadid-options) +- [RTMP Operations](#rtmp-operations) + - [.putChannel*(id, conf[, options])](#putchannelid-conf-options) + - [.getChannel*(id[, options])](#getchannelid-options) + - [.deleteChannel*(id[, options])](#deletechannelid-options) + - [.putChannelStatus*(id, status[, options])](#putchannelstatusid-status-options) + - [.getChannelStatus*(id[, options])](#getchannelstatusid-options) + - [.listChannels*(query[, options])](#listchannelsquery-options) + - [.getChannelHistory*(id[, options])](#getchannelhistoryid-options) + - [.createVod*(id, name, time[, options])](#createvodid-name-time-options) + - [.getRtmpUrl(channelId[, options])](#getrtmpurlchannelid-options) - [Create A Image Service Instance](#create-a-image-service-instance) - [#oss.ImageClient(options)](#ossimageclientoptions) - [Image Operations](#image-operations) @@ -1660,6 +1670,323 @@ var result = yield store.abortMultipartUpload('object', 'upload-id'); console.log(result); ``` +## RTMP Operations + +All operations function is [generator], except `getRtmpUrl`. + +generator function format: `functionName*(...)`. + +### .putChannel*(id, conf[, options]) + +Create a live channel. + +parameters: + +- id {String} the channel id +- conf {Object} the channel config + - [Description] {String} the channel description + - [Status] {String} the channel status: 'enabled' or 'disabled' + - [Target] {Object} + - [Type] {String} the data type for the channel, only 'HLS' is supported now + - [FragDuration] {Number} duration of a 'ts' segment + - [FragCount] {Number} the number of 'ts' segments in a 'm3u8' + - [PlaylistName] {String} the 'm3u8' name +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the channel information. + +object: + +- publishUrls {Array} the publish urls +- playUrls {Array} the play urls +- res {Object} response info + +example: + +- Create a live channel + +```js +var cid = 'my-channel'; +var conf = { + Description: 'this is channel 1', + Status: 'enabled', + Target: { + Type: 'HLS', + FragDuration: '10', + FragCount: '5', + PlaylistName: 'playlist.m3u8' + } +}; + +var r = yield this.store.putChannel(cid, conf); +console.log(r); +``` + +### .getChannel*(id[, options]) + +Get live channel info. + +parameters: + +- id {String} the channel id +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the channel information. + +object: + +- data {Object} channel info, same as conf in [.putChannel](#putchannelid-conf-options) +- res {Object} response info + +example: + +- Get live channel info + +```js +var cid = 'my-channel'; + +var r = yield this.store.getChannel(cid); +console.log(r); +``` + +### .deleteChannel*(id[, options]) + +Delete a live channel. + +parameters: + +- id {String} the channel id +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the response infomation. + +object: + +- res {Object} response info + +example: + +- Delete a live channel + +```js +var cid = 'my-channel'; + +var r = yield this.store.deleteChannel(cid); +console.log(r); +``` + +### .putChannelStatus*(id, status[, options]) + +Change the live channel status. + +parameters: + +- id {String} the channel id +- status {String} the status: 'enabled' or 'disabled' +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the response information. + +object: + +- res {Object} response info + +example: + +- Disable a live channel + +```js +var cid = 'my-channel'; + +var r = yield this.store.putChannelStatus(cid, 'disabled'); +console.log(r); +``` + +### .getChannelStatus*(id[, options]) + +Get the live channel status. + +parameters: + +- id {String} the channel id +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the channel status information. + +object: + +- data {Object} + - Status {String} the channel status: 'Live' or 'Idle' + - [ConnectedTime] {String} the connected time of rtmp pushing + - [RemoteAddr] {String} the remote addr of rtmp pushing + - [Video] {Object} the video parameters (Width/Height/FrameRate/Bandwidth/Codec) + - [Audio] {Object} the audio parameters (Bandwidth/SampleRate/Codec) +- res {Object} response info + +example: + +- Get a live channel status + +```js +var cid = 'my-channel'; + +var r = yield this.store.getChannelStatus(cid); +console.log(r); + +// { Status: 'Live', +// ConnectedTime: '2016-04-12T11:51:03.000Z', +// RemoteAddr: '42.120.74.98:53931', +// Video: +// { Width: '672', +// Height: '378', +// FrameRate: '29', +// Bandwidth: '60951', +// Codec: 'H264' }, +// Audio: { Bandwidth: '5959', SampleRate: '22050', Codec: 'AAC' } +// } +``` + +### .listChannels*(query[, options]) + +List channels. + +parameters: + +- query {Object} parameters for list + - prefix {String}: the channel id prefix (returns channels with this prefix) + - marker {String}: the channle id marker (returns channels after this id) + - max-keys {Number}: max number of channels to return +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the channel list. + +object: + +- channels {Array} the channels, each in the structure: + - Name {String} the channel id + - Description {String} the channel description + - Status {String} the channel status + - LastModified {String} the last modification time of the channel + - PublishUrls {Array} the publish urls for the channel + - PlayUrls {Array} the play urls for the channel +- nextMarker: result.data.NextMarker || null, +- isTruncated: result.data.IsTruncated === 'true' +- res {Object} response info + +example: + +- List live channels + +```js +var r = yield this.store.listChannels({ + prefix: 'my-channel', + 'max-keys': 3 +}); +console.log(r); +``` + +### .getChannelHistory*(id[, options]) + +Get the live channel history. + +parameters: + +- id {String} the channel id +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the history information. + +object: + +- records {Object} the pushing records, each in the structure: + - StartTime {String} the start time + - EndTime {String} the end time + - RemoteAddr {String} the remote addr +- res {Object} response info + +example: + +- Get the live channel history + +```js +var cid = 'my-channel'; + +var r = yield this.store.getChannelHistory(cid); +console.log(r); +``` + +### .createVod*(id, name, time[, options]) + +Create a VOD playlist for the channel. + +parameters: + +- id {String} the channel id +- name {String} the playlist name +- time {Object} the duration time + - startTime {Number} the start time in epoch seconds + - endTime {Number} the end time in epoch seconds +- [options] {Object} optional parameters + - [timeout] {Number} the operation timeout + +Success will return the response information. + +object: + +- res {Object} response info + +example: + +- Create a vod playlist of a live channel + +```js +var cid = 'my-channel'; + +var r = yield this.store.createVod(cid, 're-play', { + startTime: 1460464870, + endTime: 1460465877 +}); +console.log(r); +``` + +### .getRtmpUrl(channelId[, options]) + +Get signatured rtmp url for publishing. + +parameters: + +- channelId {String} the channel id +- [options] {Object} optional parameters + - [expires] {Number} the expire time in seconds of the url + - [params] {Object} the additional paramters for url, e.g.: {playlistName: 'play.m3u8'} + - [timeout] {Number} the operation timeout + +Success will return the rtmp url. + +example: + +- Get a rtmp url. + +```js +var cid = 'my-channel'; + +var url = this.store.getRtmpUrl(this.cid, { + params: { + playlistName: 'play.m3u8' + }, + expires: 3600 +}); +console.log(url); +// rtmp://ossliveshow.oss-cn-hangzhou.aliyuncs.com/live/tl-channel?OSSAccessKeyId=T0cqQWBk2ThfRS6m&Expires=1460466188&Signature=%2BnzTtpyxUWDuQn924jdS6b51vT8%3D +``` + ## Create A Image Service Instance Each Image Service instance required `accessKeyId`, `accessKeySecret`, `bucket` and `imageHost`. diff --git a/lib/client.js b/lib/client.js index 06bc60bca..a7d2d789e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -107,6 +107,10 @@ merge(proto, require('./bucket')); * Multipart operations */ merge(proto, require('./multipart')); +/** + * RTMP operations + */ +merge(proto, require('./rtmp')); /** * ImageClient class diff --git a/lib/rtmp.js b/lib/rtmp.js new file mode 100644 index 000000000..8df2c7ac1 --- /dev/null +++ b/lib/rtmp.js @@ -0,0 +1,297 @@ +/** + * Copyright(c) ali-sdk and other contributors. + * MIT Licensed + * + * Authors: + * rockuw (http://rockuw.com) + */ + +'use strict'; + +/** + * Module dependencies. + */ + +var debug = require('debug')('ali-oss:rtmp'); +var jstoxml = require('jstoxml'); +var utility = require('utility'); +var copy = require('copy-to'); +var urlutil = require('url'); + +var proto = exports; + +/** + * RTMP operations + */ + +/** + * Create a live channel + * @param {String} id the channel id + * @param {Object} conf the channel configuration + * @param {Object} options + * @return {Object} + */ +proto.putChannel = function* (id, conf, options) { + options = options || {}; + options.subres = 'live'; + + var params = this._objectRequestParams('PUT', id, options); + params.xmlResponse = true; + params.content = jstoxml.toXML({ + LiveChannelConfiguration: conf + }); + params.successStatuses = [200]; + + var result = yield this.request(params); + + var publishUrls = result.data.PublishUrls.Url; + if (!Array.isArray(publishUrls)) { + publishUrls = [publishUrls]; + } + var playUrls = result.data.PlayUrls.Url; + if (!Array.isArray(playUrls)) { + playUrls = [playUrls]; + } + + return { + publishUrls: publishUrls, + playUrls: playUrls, + res: result.res + }; +}; + +/** + * Get the channel info + * @param {String} id the channel id + * @param {Object} options + * @return {Object} + */ +proto.getChannel = function* (id, options) { + options = options || {}; + options.subres = 'live'; + + var params = this._objectRequestParams('GET', id, options); + params.xmlResponse = true; + params.successStatuses = [200]; + + var result = yield this.request(params); + + return { + data: result.data, + res: result.res + }; +}; + +/** + * Delete the channel + * @param {String} id the channel id + * @param {Object} options + * @return {Object} + */ +proto.deleteChannel = function* (id, options) { + options = options || {}; + options.subres = 'live'; + + var params = this._objectRequestParams('DELETE', id, options); + params.successStatuses = [204]; + + var result = yield this.request(params); + + return { + res: result.res + }; +}; + +/** + * Set the channel status + * @param {String} id the channel id + * @param {String} status the channel status + * @param {Object} options + * @return {Object} + */ +proto.putChannelStatus = function* (id, status, options) { + options = options || {}; + options.subres = { + 'live': null, + 'status': status + }; + + var params = this._objectRequestParams('PUT', id, options); + params.successStatuses = [200]; + + var result = yield this.request(params); + + return { + res: result.res, + }; +}; + +/** + * Get the channel status + * @param {String} id the channel id + * @param {Object} options + * @return {Object} + */ +proto.getChannelStatus = function* (id, options) { + options = options || {}; + options.subres = { + 'live': null, + 'comp': 'stat' + }; + + var params = this._objectRequestParams('GET', id, options); + params.xmlResponse = true; + params.successStatuses = [200]; + + var result = yield this.request(params); + + return { + data: result.data, + res: result.res + }; +}; + +/** + * List the channels + * @param {Object} query the query parameters + * filter options: + * - prefix {String}: the channel id prefix (returns channels with this prefix) + * - marker {String}: the channle id marker (returns channels after this id) + * - max-keys {Number}: max number of channels to return + * @param {Object} options + * @return {Object} + */ +proto.listChannels = function* (query, options) { + // prefix, marker, max-keys + + options = options || {}; + options.subres = 'live'; + + var params = this._objectRequestParams('GET', '', options); + params.query = query; + params.xmlResponse = true; + params.successStatuses = [200]; + + var result = yield this.request(params); + + var channels = result.data.LiveChannel || []; + if (!Array.isArray(channels)) { + channels = [channels]; + } + + channels = channels.map(function (x) { + x.PublishUrls = x.PublishUrls.Url; + if (!Array.isArray(x.PublishUrls)) { + x.PublishUrls = [x.PublishUrls]; + } + x.PlayUrls = x.PlayUrls.Url; + if (!Array.isArray(x.PlayUrls)) { + x.PlayUrls = [x.PlayUrls]; + } + + return x; + }); + + return { + channels: channels, + nextMarker: result.data.NextMarker || null, + isTruncated: result.data.IsTruncated === 'true', + res: result.res + }; +}; + +/** + * Get the channel history + * @param {String} id the channel id + * @param {Object} options + * @return {Object} + */ +proto.getChannelHistory = function* (id, options) { + options = options || {}; + options.subres = { + 'live': null, + 'comp': 'history' + }; + + var params = this._objectRequestParams('GET', id, options); + params.xmlResponse = true; + params.successStatuses = [200]; + + var result = yield this.request(params); + + var records = result.data.LiveRecord || []; + if (!Array.isArray(records)) { + records = [records]; + } + return { + records: records, + res: result.res + }; +}; + +/** + * Create vod playlist + * @param {String} id the channel id + * @param {String} name the playlist name + * @param {Object} time the begin and end time + * time: + * - startTime {Number}: the begin time in epoch seconds + * - endTime {Number}: the end time in epoch seconds + * @param {Object} options + * @return {Object} + */ +proto.createVod = function* (id, name, time, options) { + options = options || {}; + options.subres = { + 'vod': null + } + copy(time).to(options.subres); + + var params = this._objectRequestParams('POST', id + '/' + name, options); + params.query = time; + params.successStatuses = [200]; + + var result = yield this.request(params); + + return { + res: result.res + }; +}; + +/** + * Get RTMP Url + * @param {String} channelId the channel id + * @param {Object} options + * options: + * - expires {Number}: expire time in seconds + * - params {Object}: the parameters such as 'playlistName' + * @return {String} the RTMP url + */ +proto.getRtmpUrl = function (channelId, options) { + options = options || {}; + var expires = utility.timestamp() + (options.expires || 1800); + var res = { + bucket: this.options.bucket, + object: this._objectName('live/' + channelId) + }; + var resource = '/' + res.bucket + '/' + channelId; + + options.params = options.params || {}; + var query = Object.keys(options.params).sort().map(function (x) { + return x + ':' + options.params[x] + '\n'; + }).join(''); + + var stringToSign = expires + '\n' + query + resource; + var signature = this.signature(stringToSign); + + var url = urlutil.parse(this._getReqUrl(res)); + url.protocol = 'rtmp:'; + url.query = { + OSSAccessKeyId: this.options.accessKeyId, + Expires: expires, + Signature: signature + } + copy(options.params).to(url.query); + + return url.format(); +} diff --git a/package.json b/package.json index 5a0802943..1fbdde644 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "get-ready": "^1.0.0", "humanize-ms": "^1.2.0", "is-type-of": "^1.0.0", + "jstoxml": "^0.2.3", "merge-descriptors": "^1.0.1", "mime": "^1.3.4", "platform": "^1.3.1", diff --git a/test/rtmp.test.js b/test/rtmp.test.js new file mode 100644 index 000000000..b076fea45 --- /dev/null +++ b/test/rtmp.test.js @@ -0,0 +1,228 @@ +/**! + * Copyright(c) ali-sdk and other contributors. + * MIT Licensed + * + * Authors: + * rockuw (http://rockuw.com) + */ + +'use strict'; + +/** + * Module dependencies. + */ + +var assert = require('assert'); +var utils = require('./utils'); +var is = require('is-type-of'); +var oss = require('../'); +var config = require('./config').oss; + +describe('test/rtmp.test.js', function () { + var prefix = utils.prefix; + + before(function* () { + this.store = oss(config); + this.bucket = 'ali-oss-test-bucket-' + prefix.replace(/[\/\.]/g, '-'); + this.bucket = this.bucket.substring(0, this.bucket.length - 1); + this.store.useBucket(this.bucket); + + var result = yield this.store.putBucket(this.bucket, this.region); + assert.equal(result.bucket, this.bucket); + assert.equal(result.res.status, 200); + + this.cid = 'channel-1'; + this.conf = { + Description: 'this is channel 1', + Status: 'enabled', + Target: { + Type: 'HLS', + FragDuration: '10', + FragCount: '5', + PlaylistName: 'playlist.m3u8' + } + }; + }); + + after(function* () { + yield utils.cleanBucket(this.store, this.bucket, this.region); + }); + + describe('put/get/deleteChannel()', function () { + it('should create a new channel', function* () { + var cid = this.cid; + var conf = this.conf; + + var result = yield this.store.putChannel(cid, conf); + assert.equal(result.res.status, 200); + assert(is.array(result.publishUrls)); + assert(result.publishUrls.length > 0); + assert(is.array(result.playUrls)); + assert(result.playUrls.length > 0); + + var result = yield this.store.getChannel(cid); + assert.equal(result.res.status, 200); + assert.deepEqual(result.data, conf); + + var result = yield this.store.deleteChannel(cid); + assert.equal(result.res.status, 204); + + yield utils.throws(function* () { + var result = yield this.store.getChannel(cid); + }.bind(this), function (err) { + assert.equal(err.status, 404); + }); + }); + }); + + describe('put/getChannelStatus()', function () { + before(function* () { + this.cid = 'live channel 2'; + this.conf.Description = 'this is live channel 2'; + yield this.store.putChannel(this.cid, this.conf); + }); + + after(function* () { + yield this.store.deleteChannel(this.cid); + }); + + it('should disable channel', function* () { + var result = yield this.store.getChannelStatus(this.cid); + assert.equal(result.res.status, 200); + assert.equal(result.data.Status, 'Idle'); + + // TODO: verify ConnectedTime/RemoteAddr/Video/Audio when not idle + + var result = yield this.store.putChannelStatus(this.cid, 'disabled'); + assert.equal(result.res.status, 200); + + var result = yield this.store.getChannelStatus(this.cid); + assert.equal(result.res.status, 200); + assert.equal(result.data.Status, 'Disabled'); + }); + }); + + describe('listChannels()', function () { + before(function* () { + this.channelNum = 10; + this.channelPrefix = 'channel-list-'; + + for (var i = 0; i < this.channelNum; i ++) { + this.conf.Description = i; + yield this.store.putChannel(this.channelPrefix + i, this.conf); + } + }); + + after(function* () { + for (var i = 0; i < this.channelNum; i ++) { + yield this.store.deleteChannel(this.channelPrefix + i); + } + }); + + it('list channels using prefix/marker/max-keys', function* () { + var query = { + prefix: 'channel-list-', + marker: 'channel-list-4', + 'max-keys': 3 + }; + + var result = yield this.store.listChannels(query); + + assert.equal(result.res.status, 200); + assert.equal(result.nextMarker, 'channel-list-7'); + assert.equal(result.isTruncated, true); + + var channels = result.channels; + assert.equal(channels.length, 3); + assert.equal(channels[0].Name, this.channelPrefix + 5) + assert.equal(channels[1].Name, this.channelPrefix + 6) + assert.equal(channels[2].Name, this.channelPrefix + 7) + }); + }); + + describe('getChannelHistory()', function () { + before(function* () { + this.cid = 'channel-3'; + this.conf.Description = 'this is live channel 3'; + yield this.store.putChannel(this.cid, this.conf); + }); + + after(function* () { + yield this.store.deleteChannel(this.cid); + }); + + it('should get channel history', function* () { + var result = yield this.store.getChannelHistory(this.cid); + + assert.equal(result.res.status, 200); + assert(is.array(result.records)); + assert.equal(result.records.length, 0); + + // TODO: verify LiveRecord when history exists + //verify wish OBS or ffmpeg + }); + }); + + describe('createVod()', function () { + before(function* () { + this.cid = 'channel-4'; + this.conf.Description = 'this is live channel 4'; + var result = yield this.store.putChannel(this.cid, this.conf); + console.log(result); + var url = this.store.getRtmpUrl(this.cid, { + params: { + playlistName: 'vod.m3u8' + }, + expires: 3600 + }); + console.log(url); + }); + + after(function* () { + yield this.store.deleteChannel(this.cid); + }); + + // this case need have data in server + it.skip('should create vod playlist', function* () { + var name = 'vod.m3u8'; + var now = Date.now(); + + try { + var result = yield this.store.createVod(this.cid, name, { + startTime: Math.floor((now - 100) / 1000), + endTime: Math.floor(now / 1000) + }); + + assert.equal(result.res.status, 200); + } catch (err) { + console.error(err); + } + + }); + }); + + describe('getRtmpUrl()', function () { + before(function* () { + this.cid = 'channel-5'; + this.conf.Description = 'this is live channel 5'; + var result = yield this.store.putChannel(this.cid, this.conf); + console.log(result); + }); + + after(function* () { + yield this.store.deleteChannel(this.cid); + }); + + it('should get rtmp url', function* () { + var name = 'vod.m3u8'; + var url = this.store.getRtmpUrl(this.cid, { + params: { + playlistName: name + }, + expires: 3600 + }); + console.log(url); + //verify the url is ok used by OBS or ffmpeg + }); + }); +});