bangumi.js

/**
 * Bangumi API client library for node.js.
 * @module bangumi
 * @version 1.2.0
 */
const VERSION = '1.2.0';
const protocols = {
  http: require('http'),
  https: require('https'),
};
const querystring = require('querystring');
const utils = require('./utils');
const PromiseProvider = require('./promise_provider');
const nodeBangumiAppId = 'bgm2305af4de0f06a38'; //for debug


/**
 * Represents the Bangumi API client.
 * @class Bangumi
 * @param {Object} [options] - Configuration options.
 * @param {string} [options.app_id] - App ID used to identify the source (deprecated).
 * @param {string} [options.access_token] - Access token used to identify the user.
 * @param {string} [options.protocol] - HTTP protocol used for the API (e.g., 'http' or 'https').
 * @param {string} [options.user_agent] - Custom user agent. See {@link https://github.com/bangumi/api/blob/master/docs-raw/user%20agent.md} for more information.
 * @example
 * const Bangumi = require('bangumi');
 * const bgm = new Bangumi({
 *   access_token: "123456"
 * });
 */

function Bangumi(options){
  if (!(this instanceof Bangumi)) return new Bangumi(options);
  const defaults = {
    headers: {
      'Accept': '*/*',
      'Content-Type': 'application/x-www-form-urlencoded',
      'Connection': 'close',
      'User-Agent': 'markni/node-bangumi/' + VERSION + ' (https://github.com/markni/node-bangumi)'
    },

    callback_url: null,
    rest_base: 'api.bgm.tv',
    cookie_options: {},
    cookie_secret: null
  };
  this.options = utils.merge(defaults, options);

  if (this.options.user_agent) {
    this.options.headers['User-Agent'] = this.options.user_agent;
  }
}

Bangumi.VERSION = VERSION;
module.exports = Bangumi;

/**
 * Sets a custom promise provider. By default, the Node.js ES6 promise provider is used.
 * @param {function} provider - Custom promise provider.
 * @example
 * // Use Bluebird as the promise provider, so you can use features like .finally()
 * bgm.setPromiseProvider(require('bluebird'));
 */
Bangumi.prototype.setPromiseProvider = function(provider) {
  PromiseProvider.set(provider);
};

/**
 * Updates the access token in case it's refreshed.
 * @param {string} access_token - The new access token.
 * @example
 * bgm.setAccessToken('123456');
 */
Bangumi.prototype.setAccessToken = function(access_token) {
  this.options['access_token'] = access_token;
};


Bangumi.prototype._get = function(url, params, callback) {
  if (typeof params === 'function') {
    callback = params;
    params = {};
  }


  if ( typeof callback !== 'function' ) {
    throw 'FAIL: INVALID CALLBACK.';
  }

  if (url.charAt(0) !== '/')        {
    throw 'FAIL: INVALID URL.';
  }

  if (Object.prototype.hasOwnProperty.call(this.options, 'app_id')) params = utils.merge({source:this.options.app_id}, params);


  const protocolName = this.options.protocol || 'https';


  const options = {
    host: this.options.rest_base,
    path: encodeURI(url + (querystring.stringify(params) ? '?' : '') + querystring.stringify(params)),
    method: 'GET',
    headers: this.options.headers
  };

  if (Object.prototype.hasOwnProperty.call(this.options, 'access_token')) options.headers['Authorization'] = 'Bearer ' + this.options.access_token;

  const req = protocols[protocolName].request(options, function (res) {
    let data = '';
    res.setEncoding('utf8');

    res.on('data', function (chunk) {
      data += chunk;
    });

    res.on('end', function () {
      try {
        const json = JSON.parse(data);
        if (Object.prototype.hasOwnProperty.call(json, 'error')) callback(json, {});
        else callback(null, json);
      } catch (err) {
        callback(err, {});
      }
    });

    res.on('error', function (err) {
      callback(err, {});
    });
  });

  req.end();
};

/**
 * A general GET request method for Bangumi API
 * @param {string} url - Base GET request path
 * @param {object} params - Parameters used for GET query string
 * @param {function} [callback] - Optional callback function
 * @returns {Promise|undefined} - Returns a promise if no callback is provided, otherwise undefined
 */
Bangumi.prototype.get = function(url, params, callback) {
  const self = this;
  return utils.promiseOrCallback(callback, function(cb){
    self._get(url, params, cb);
  });
};

Bangumi.prototype._post = function(url, params, callback) {
  if (typeof params === 'function') {
    callback = params;
    params = {};
  }

  if ( typeof callback !== 'function' ) {
    throw 'FAIL: INVALID CALLBACK.';
  }

  if (url.charAt(0) !== '/')        {
    throw 'FAIL: INVALID URL.';
  }

  const protocolName = this.options.protocol || 'https';

  const post_data = querystring.stringify(params);

  const headers = utils.merge({'Content-Length': post_data.length}, this.options.headers);

  const options = {
    host: this.options.rest_base,
    path: encodeURI(url),
    method: 'POST',
    headers: headers
  };

  if (Object.prototype.hasOwnProperty.call(this.options, 'access_token')) options.headers['Authorization'] = 'Bearer ' + this.options.access_token;
  if (Object.prototype.hasOwnProperty.call(this.options, 'app_id')) options.path += '?' + querystring.stringify({source: this.options.app_id});

  const req = protocols[protocolName].request(options, function (res) {
    let data = '';
    res.on('data', function (chunk) {
      data += chunk;
    });

    res.on('end', function () {
      try {
        const json = JSON.parse(data);
        if (Object.prototype.hasOwnProperty.call(json, 'error')) callback(json, {});
        else callback(null, json);
      } catch (err) {
        callback(err, {});
      }
    });

    res.on('error', function (err) {
      callback(err, {});
    });
  });

  req.write(post_data);
  req.end();
};

/**
 * A general POST request method for Bangumi API
 * @param {string} url - Base POST request path
 * @param {object} params - Parameters used for POST body
 * @param {function} [callback] - Optional callback function
 * @returns {Promise|undefined} - Returns a promise if no callback is provided
 */

Bangumi.prototype.post = function(url, params, callback) {
  const self = this;
  return utils.promiseOrCallback(callback, function(cb){
    self._post(url, params, cb);
  });
};


/**
 * A GET method that gets current weekly shows' seclude
 * @param {function} [callback] - Optional callback function
 * @returns {Promise|undefined} - Returns a promise if no callback is provided
 */
Bangumi.prototype.fetchCalendar = function(callback) {
  const url = '/calendar';
  return this.get(url, callback);
};

/**
 * A GET method that gets target user's profile
 * @param {string|number} username - Username or UID of the target user
 * @param {function} [callback] - Optional callback function
 * @returns {Promise|undefined} - Returns a promise if no callback is provided
 */
Bangumi.prototype.fetchUser = function(username, callback){
  const url = '/user/' + username;
  return this.get(url, callback);
};

/**
 * @typedef {Object} SubjectEp
 * @property {number} id - Episode ID
 * @property {string} url - Episode URL
 * @property {number} type - Episode type
 * @property {number} sort - Episode sort order
 * @property {string} name - Episode name
 * @property {string} name_cn - Episode Chinese name
 * @property {string} duration - Episode duration
 * @property {string} airdate - Episode air date
 * @property {number} comment - Number of comments
 * @property {string} desc - Episode description
 * @property {string} status - Episode status
 */

/**
 * @typedef {Object} SubjectCrt
 * @property {number} id - Character ID
 * @property {string} url - Character URL
 * @property {string} name - Character name
 * @property {string} name_cn - Character Chinese name
 * @property {string} role_name - Character role name
 * @property {Object} images - Character images
 * @property {number} comment - Number of comments
 * @property {number} collects - Number of collections
 * @property {Object} info - Additional character information
 * @property {Array<Object>} actors - List of voice actors
 */

/**
 * @typedef {Object} SubjectStaff
 * @property {number} id - Staff ID
 * @property {string} url - Staff URL
 * @property {string} name - Staff name
 * @property {string} name_cn - Staff Chinese name
 * @property {string} role_name - Staff role name
 * @property {Object} images - Staff images
 * @property {number} comment - Number of comments
 * @property {number} collects - Number of collections
 * @property {Object} info - Additional staff information
 * @property {Array<string>} jobs - List of jobs
 */

/**
 * @typedef {Object} SubjectTopic
 * @property {number} id - Topic ID
 * @property {string} url - Topic URL
 * @property {string} title - Topic title
 * @property {number} main_id - Main subject ID
 * @property {number} timestamp - Topic creation timestamp
 * @property {number} lastpost - Last post timestamp
 * @property {number} replies - Number of replies
 * @property {Object} user - User who created the topic
 */

/**
 * @typedef {Object} SubjectBlog
 * @property {number} id - Blog ID
 * @property {string} url - Blog URL
 * @property {string} title - Blog title
 * @property {string} summary - Blog summary
 * @property {string} image - Blog image URL
 * @property {number} replies - Number of replies
 * @property {number} timestamp - Blog creation timestamp
 * @property {string} dateline - Formatted date
 * @property {Object} user - User who created the blog
 */

/**
 * @typedef {Object} Subject
 * @property {number} id - ID of the subject
 * @property {string} url - URL of the subject page
 * @property {number} type - Type of the subject
 * @property {string} name - Name of the subject
 * @property {string} name_cn - Chinese name of the subject
 * @property {string} summary - Summary or description of the subject
 * @property {number} eps - Number of episodes
 * @property {number} eps_count - Count of episodes
 * @property {string} air_date - Air date of the subject
 * @property {number} air_weekday - Air weekday of the subject
 * @property {Object} rating - Rating information
 * @property {number} rating.total - Total number of ratings
 * @property {Object} rating.count - Count of ratings for each score
 * @property {number} rating.score - Average score
 * @property {number} rank - Rank of the subject
 * @property {Object} images - Image URLs for different sizes
 * @property {string} images.large - URL for large image
 * @property {string} images.common - URL for common image
 * @property {string} images.medium - URL for medium image
 * @property {string} images.small - URL for small image
 * @property {string} images.grid - URL for grid image
 * @property {Object} collection - Collection statistics
 * @property {number} collection.wish - Number of users who wish to watch
 * @property {number} collection.collect - Number of users who have collected
 * @property {number} collection.doing - Number of users currently watching
 * @property {number} collection.on_hold - Number of users who have put on hold
 * @property {number} collection.dropped - Number of users who have dropped
 * @property {Array<SubjectEp>} [eps] - Array of episode objects
 * @property {Array<SubjectCrt>} [crt] - Array of character objects
 * @property {Array<SubjectStaff>} [staff] - Array of staff objects
 * @property {Array<SubjectTopic>} [topic] - Array of topic objects
 * @property {Array<SubjectBlog>} [blog] - Array of blog objects
 */

/**
 * A GET method that gets details of a subject by id
 * @param {number} subject_id - ID of the target subject
 * @param {Object} [params] - Optional parameters for the request
 * @param {string} [params.responseGroup] - Accepts [small|medium|large]
 * @param {function} [callback] - Optional callback function
 * @returns {Promise<Subject>} - Returns a promise that resolves to the subject details
 */
Bangumi.prototype.fetchSubject = function(subject_id, params, callback) {
  const url = '/subject/' + subject_id;
  return this.get(url, params, callback);
};

/**
 * A GET method that gets a list of episodes of a subject by subject id
 * @param {number} subject_id - ID of the target subject
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @deprecated use fetchSubjectEps
 */

Bangumi.prototype.fetchEps = function(subject_id, callback){
  return this.fetchSubjectEps(subject_id, callback);
};

/**
 * A GET method that gets a list of episodes of a subject by subject id
 * @param {number} subject_id - ID of the target subject
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.fetchSubjectEps(211567);
 */
Bangumi.prototype.fetchSubjectEps = function(subject_id, callback){
  const url = '/subject/' + subject_id + '/ep';
  return this.get(url, callback);
};

/**
 * A GET method that gets details of user's collection by user id
 * @param {string|number} username - Username or UID of the target user
 * @param {object} params - Parameters for the request
 * @param {string} params.cat - Type of the collections, accepts [watching|all_watching]
 * @param {string} [params.responseGroup] - Type of the collections, accepts [medium|small]
 * @param {string} [params.ids] - IDs of subjects the user wants to fetch, this is very poorly implemented at the moment
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.fetchUserCollection(1, {cat:'all_watching', responseGroup: 'small'}).then(console.log)
 */
Bangumi.prototype.fetchUserCollection = function(username, params, callback){
  const url = '/user/' + username + '/collection';
  return this.get(url, params, callback);
};

/**
 * A GET method that gets overview of user's collection by username and subject type
 * @param {string|number} username - Username or UID of the target user
 * @param {string} subject_type - Type of subject
 * @param {object} [params] - Optional parameters for the request
 * @param {number} [params.max_results] - Upper limit of the amount of subjects the client can fetch (max 25)
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.fetchUserCollections('sai', 'real', {max_results: 1}).then(console.log).catch(console.error)
 */
Bangumi.prototype.fetchUserCollections = function(username, subject_type, params, callback){
  //note here using our appid due to an api bug: https://github.com/bangumi/api/issues/24
  if(typeof params === 'object' && !Object.prototype.hasOwnProperty.call(params, 'app_id')) params.app_id = nodeBangumiAppId;
  if(typeof params === 'undefined') params = {app_id : nodeBangumiAppId};

  const url = '/user/' + username + '/collections' + (subject_type ? '/' : '') + subject_type;
  return this.get(url, params, callback);
};


/**
 * A GET method that gets overview of user's collection statics by username
 * @param {string|number} username - Username or UID of the target user
 * @param {object} [params] - Optional parameters for the request
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.fetchUserCollectionsStatus('sai').then(console.log).catch(console.error)
 */
Bangumi.prototype.fetchUserCollectionsStatus = function(username, params, callback){
  //note here using our appid due to an api bug: https://github.com/bangumi/api/issues/24
  if(typeof params === 'object' && !Object.prototype.hasOwnProperty.call(params, 'app_id')) params.app_id = nodeBangumiAppId;
  if(typeof params === 'undefined') params = {app_id : nodeBangumiAppId};

  const url = '/user/' + username + '/collections/status';
  return this.get(url, params, callback);
};


/**
 * A GET method that searches and returns a list of subjects by keywords
 * @param {string} keywords - Query string for search
 * @param {object} [params] - Optional parameters for the request
 * @param {string} [params.responseGroup] - Accepts [small|medium|large]
 * @param {number} [params.type] - Accepts [1|2|3|4|6] (1.book 2.anime 3.music 4.game 5.live action)
 * @param {number} [params.start] - Result start index, used for paging
 * @param {number} [params.max_results] - The number of entries to return, max 25
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.search('天元突破');
 */
Bangumi.prototype.search = function(keywords, params, callback){
  const url =  '/search/subject/' + keywords;
  return this.get(url, params, callback);
};



/**
 * A GET method that get lists of episodes that user already watched, sorted by subject id
 * @param {string|number} username - Username or UID of the target user
 * @param {object} [params] - Optional parameters for the request
 * @param {number} [params.subject_id] - The ID of the subject the user wants to fetch progress for
 * @param {string} [params.auth] - Authentication string used for validation of user identity, deprecated
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.fetchProgress(1, {subject_id:899}).then(console.log);
 */
Bangumi.prototype.fetchProgress = function(username, params, callback){
  const url = '/user/' + username + '/progress';
  return this.get(url, params, callback);
};

/**
 * A POST method that login user with username and password, and returns auth string
 * @param {object} params - Parameters for the request
 * @param {string} params.username - Username or UID of the target user
 * @param {string} params.password - Password for the target user
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @deprecated since version 1.0.0, newer version of api will use oauth.
 */

Bangumi.prototype.auth = function(params, callback){
  console.warn('auth() will be deprecated in the near future');
  const url = '/auth';
  return this.post(url, params, callback);
};

/**
 * A GET method that get current user's collection details on specific subject by subject id
 * @param {number} subject_id - ID of the target subject
 * @param {object} [params] - Optional parameters for the request
 * @param {string} [params.auth] - Authentication string used for validation of user identity, deprecated
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.fetchCollection(1);
 */
Bangumi.prototype.fetchCollection = function(subject_id, params, callback){
  const url =  '/collection/' + subject_id;
  return this.get(url, params, callback);
};

/**
 * A POST method that updates user's status on specific subject by subject id
 * @param {number} subject_id - ID of the target subject
 * @param {object} [params] - Optional parameters for the request
 * @param {string} [params.status=wish] - Accepts [wish|collect|do|on_hold|dropped]
 * @param {string} [params.comment] - Comment on the target subject status update
 * @param {string} [params.tags] - Tags on the target subject status update, separated by comma
 * @param {number} [params.rating] - Star rating on the target subject, accepts [0~10]
 * @param {number} [params.privacy] - Privacy control of this action, accepts [0|1] (0.open, 1.private)
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.createCollection(211567, {status: 'do', rating: 10});
 */
Bangumi.prototype.createCollection = function(subject_id, params, callback){
  const url = '/collection/' + subject_id + '/update';
  return this.post(url, params, callback);
};

/**
 * A POST method that updates user's status on specific episode by episode id
 * @param {number} ep_id - ID of the target episode
 * @param {string} status - Accepts [watched|queue|drop|remove]
 * @param {object} [params] - Optional parameters for the request
 * @param {string} [params.ep_id] - A list of episode IDs for batch processing, separated by comma
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.updateEpStatus(7036,'drop');
 */
Bangumi.prototype.updateEpStatus = function(ep_id, status, params, callback){
  const url = '/ep/' + ep_id + '/status/' + status;
  return this.post(url, params, callback);
};


/**
 * A POST method that marks episode 1 to target number as watched
 * @param {number} subject_id - ID of the target subject
 * @param {object} params - Parameters for the request
 * @param {string} params.watched_eps - The number of episodes the user currently has watched
 * @param {string} [params.watched_vols] - The number of volumes the user currently has watched
 * @param {function} [callback] - Optional callback function
 * @returns {Promise} - Returns a promise if no callback is provided
 * @example
 * bgm.updateWatchedEps(18462, {watched_eps: 10, watched_vols: 2});
 */
Bangumi.prototype.updateWatchedEps = function(subject_id, params, callback){
  const url = '/subject/' + subject_id + '/update/watched_eps';
  return this.post(url, params, callback);
};