Source

lib/thumbnails/getPlayerThumbnail.js

// Includes
const http = require('../util/http.js').func
const { thumbnail: settings } = require('../../settings.json')

// Args
exports.required = ['userIds']
exports.optional = ['size', 'format', 'isCircular', 'cropType', 'retryCount']

// Variables
const eligibleSizes = {
  body: {
    sizes: ['30x30', '48x48', '60x60', '75x75', '100x100', '110x110', '140x140', '150x150', '150x200', '180x180', '250x250', '352x352', '420x420', '720x720'],
    endpoint: 'avatar'
  },
  bust: {
    sizes: ['48x48', '50x50', '60x60', '75x75', '100x100', '150x150', '180x180', '352x352', '420x420'],
    endpoint: 'avatar-bust'
  },
  headshot: {
    sizes: ['48x48', '50x50', '60x60', '75x75', '100x100', '110x110', '150x150', '180x180', '352x352', '420x420', '720x720'],
    endpoint: 'avatar-headshot'
  }
}

// Docs
/**
 * ✅ Get a user's thumbnail.
 * @category User
 * @alias getPlayerThumbnail
 * @param {number | Array<number>} userIds - The id or an array ids of thumbnails to be retrieved; 100
 * @param {number | string=} [size=720x720] - The [size of the image to be returned]{@link https://noblox.js.org/thumbnailSizes.png}; defaults highest resolution
 * @param {'png' | 'jpeg'=} [format=png] - The file format of the returned thumbnails
 * @param {boolean=} [isCircular=false] - Return the circular version of the thumbnails
 * @param {'Body' | 'Bust' | 'Headshot'=} [cropType=Body] - The style of thumbnail that will be returned
 * @returns {Promise<PlayerThumbnailData[]>}
 * @example const noblox = require("noblox.js")
 * let thumbnail_default = await noblox.getPlayerThumbnail(2416399685)
 * let thumbnail_circHeadshot = await noblox.getPlayerThumbnail(2416399685, 420, "png", true, "Headshot")
 * let thumbnails_body = await noblox.getPlayerThumbnail([2416399685, 234567, 345678], "150x200", "jpeg", false, "Body")
**/

// Define
function getPlayerThumbnail (userIds, size, format = 'png', isCircular = false, cropType = 'body', retryCount = settings.maxRetries) {
  // Validate userIds
  if (Array.isArray(userIds)) {
    if (userIds.some(isNaN)) {
      throw new Error('userIds must be a number or an array of numbers')
    }
    userIds = [...new Set(userIds)] // get rid of duplicates, endpoint response does this anyway
    if (userIds.length > 100) {
      throw new Error(`too many userIds provided (${userIds.length}); maximum 100`)
    }
  } else {
    if (isNaN(userIds)) {
      throw new Error('userId is not a number')
    }
    userIds = [userIds]
  }

  // Validate cropType
  cropType = cropType.toLowerCase()
  if (!Object.keys(eligibleSizes).includes(cropType)) {
    throw new Error(`Invalid cropping type provided: ${cropType} | Use: ${Object.keys(eligibleSizes).join(', ')}`)
  }
  const { sizes, endpoint } = eligibleSizes[cropType]

  // Validate size
  size = size || sizes[sizes.length - 1]
  if (typeof size === 'number') {
    size = `${size}x${size}`
  }
  if (!sizes.includes(size)) {
    throw new Error(`Invalid size parameter provided: ${size} | [${cropType.toUpperCase()}] Use: ${sizes.join(', ')}`)
  }

  // Validate format
  if (format.toLowerCase() !== 'png' && format.toLowerCase() !== 'jpeg') {
    throw new Error(`Invalid image type provided: ${format} | Use: png, jpeg`)
  }

  return http({
    url: `https://thumbnails.roblox.com/v1/users/${endpoint}?userIds=${userIds.join(',')}&size=${size}&format=${format}&isCircular=${!!isCircular}`,
    options: {
      resolveWithFullResponse: true,
      followRedirect: true
    }
  })
    .then(async ({ statusCode, body }) => {
      let { data, errors } = JSON.parse(body)
      if (statusCode === 200) {
        if (retryCount > 0) {
          const pendingThumbnails = data.filter(obj => { return obj.state === 'Pending' }).map(obj => obj.targetId) // Get 'Pending' thumbnails as array of userIds
          if (pendingThumbnails.length > 0) {
            await timeout(settings.retryDelay) // small delay helps cache populate on Roblox's end; default 500ms
            const updatedPending = await getPlayerThumbnail(pendingThumbnails, size, format, isCircular, cropType, --retryCount) // Recursively retry for # of maxRetries attempts; default 2
            data = data.map(obj => updatedPending.find(o => o.targetId === obj.targetId) || obj) // Update primary array's values
          }
        }
        data = data.map(obj => {
          if (obj.state !== 'Completed') {
            const settingsUrl = settings.failedUrl[obj.state.toLowerCase()] // user defined settings.json default image URL for blocked or pending thumbnails; default ""
            obj.imageUrl = settingsUrl || `https://noblox.js.org/moderatedThumbnails/moderatedThumbnail_${size}.png`
          }
          return obj
        })
        return data
      } else if (statusCode === 400) {
        throw new Error(`Error Code ${errors.code}: ${errors.message} | endpoint: ${endpoint}, userIds: ${userIds.join(',')}, size: ${size}, isCircular: ${!!isCircular}`)
      } else {
        throw new Error(`An unknown error occurred with getPlayerThumbnail() | endpoint: ${endpoint}, userIds: ${userIds.join(',')}, size: ${size}, isCircular: ${!!isCircular}`)
      }
    })
}

function timeout (ms) {
  return new Promise(resolve => { setTimeout(resolve, ms) })
}

exports.func = function ({ userIds, size, format, isCircular, cropType, retryCount }) {
  return getPlayerThumbnail(userIds, size, format, isCircular, cropType, retryCount)
}