adapt-authoring-assets/lib/AbstractAsset.js

import { App } from 'adapt-authoring-core'
import ffmpeg from 'fluent-ffmpeg'
import ffmpegStatic from 'ffmpeg-static'
import ffprobeStatic from 'ffprobe-static'
import mime from 'mime-types'
import path from 'path'

ffmpeg.setFfprobePath(ffprobeStatic.path)
ffmpeg.setFfmpegPath(ffmpegStatic)
/**
 * Base class for handling an asset
 * @memberof assets
 */
class AbstractAsset {
  /**
   * Name of the asset type
   * @type {string}
   */
  static get name () {
    return 'local'
  }

  constructor (data) {
    this.assets = App.instance.dependencyloader.instances['adapt-authoring-assets']
    this.root = this.assetRoot
    this.setData(data)
  }

  /**
   * Reference to the ffmpeg module
   * @type {*}
   */
  get ffmpeg () {
    return ffmpeg
  }

  /**
   * Reference to the ffprobe module
   * @type {*}
   */
  get ffprobe () {
    return ffmpeg.ffprobe
  }

  /**
   * The root location for this asset type
   * @type {string}
   */
  get assetRoot () {
    throw App.instance.errors.FUNC_NOT_OVERRIDDEN.setData({ name: `${this.constructor.name}#assetRoot` })
  }

  /**
   * The asset path
   * @type {string}
   */
  get path () {
    return this.data.path ? this.resolvePath(this.data.path) : undefined
  }

  /**
   * Whether the asset has a thumbnail
   */
  get hasThumb () {
    return this.data.hasThumb
  }

  /**
   * Whether the asset is an audio file
   */
  get isAudio () {
    return this.data.type === 'audio'
  }

  /**
   * Whether the asset is an image file
   */
  get isImage () {
    return this.data.type === 'image'
  }

  /**
   * Whether the asset is an video file
   */
  get isVideo () {
    return this.data.type === 'video'
  }

  /**
   * Access to the thumbnail asset
   * @return {LocalAsset} The thumb asset
   */
  get thumb () {
    if (!this._thumb) {
      const id = this.data?._id?.toString() ?? this.path.replace(path.extname(this.path), '')
      this._thumb = this.assets.createFsWrapper({
        repo: 'local',
        path: id + this.assets.getConfig('thumbnailExt'),
        root: this.assets.getConfig('thumbnailDir')
      })
    }
    return this._thumb
  }

  setData (data) {
    if (data.root) {
      this.root = data.root
      delete data.root
    }
    if (!this.data) this.data = {}
    Object.assign(this.data, JSON.parse(JSON.stringify(data)))
    return this.data
  }

  /**
   * Returns the expected file type from a MIME subtype
   * @param {FormidableFile} file File data
   * @returns {String}
   */
  getFileExtension (file) {
    const subtype = file.mimetype.split('/')[1]
    const originalExtension = path.extname(file.originalFilename)
    switch (subtype) {
      case 'svg+xml':
        return originalExtension.startsWith('.svg') ? originalExtension : 'svg'
      default:
        return subtype
    }
  }

  /**
   * Generate a thumbnail for an existing asset
   * @param {object} options Optional settings
   * @param {string} options.regenerate Will regenerate the thumbnail if one already exists
   */
  async generateThumb (options = { regenerate: false }) {
    if (!this.hasThumb) {
      return
    }
    await this.thumb.ensureDir(this.assets.getConfig('thumbnailDir'))
    try {
      await this.thumb.ensureExists()
      if (!options.regenerate) return
    } catch (e) {
      // thumb doesn't exist, continue
    }
    /**
     * ffmpeg doesn't work with streams in all cases, so we need to
     * temporarily download the asset locally before processing
     */
    const { default: LocalAsset } = await import('./LocalAsset.js')
    const tempAsset = new LocalAsset({ path: path.join(this.thumb.dirname, `TEMP_${this.filename}`) })
    await tempAsset.write(await this.read(), tempAsset.path)
    const ff = this.ffmpeg(tempAsset.path)
    const size = `${this.assets.getConfig('thumbnailWidth')}x?`
    try {
      await new Promise((resolve, reject) => {
        ff.on('end', () => resolve())
        ff.on('error', error => reject(App.instance.errors.GENERATE_THUMB_FAIL.setData({ file: this.path, error })))

        if (this.isVideo || this.data.subtype === 'gif') {
          const timemarks = [this.isVideo ? '25%' : '0']
          ff.screenshots({ size, timemarks, folder: this.thumb.dirname, filename: this.thumb.filename })
        } else if (this.isImage) {
          ff.size(size).save(this.thumb.path)
        }
      })
    } finally { // remove temp asset
      await tempAsset.delete()
    }
  }

  /**
   * Performs the required file operations when uploading/replacing an asset
   * @param {FormidableFile} file Uploaded file data
   * @returns {object} The update data
   */
  async updateFile (file) {
    if (!file.mimetype) file.mimetype = mime.lookup(file.originalFilename)
    const [type, subtype] = file.mimetype.split('/')
    // remove old file and set new path
    await this.delete()
    this.setData({
      path: `${this.data._id}.${this.getFileExtension(file)}`,
      repo: this.data.repo,
      size: file.size,
      subtype,
      type,
      hasThumb: (type === 'image' && subtype !== 'svg+xml') || type === 'video'
    })
    // perform filesystem operations
    const localAsset = this.assets.createFsWrapper({ repo: 'local', path: file.filepath })
    await this.write(await localAsset.read(), this.path)
    await localAsset.delete()
    await this.generateThumb({ regenerate: true })
    // generate metadata
    return this.setData(await this.generateMetadata(localAsset))
  }

  /**
   * Resolves a relative path to the root directory. Must be overridden by subclasses.
   * @param {string} filePath
   * @returns {string} The resolved path
   */
  resolvePath (filePath) {
  }

  /**
   * Ensures a directory exists, creating it if not. Must be overridden by subclasses.
   * @param {string} dir Directory to check
   * @return {Promise}
   */
  async ensureDir (dir) {
  }

  /**
   * Checks if a file exists. Must be overridden by subclasses.
   * @return {Promise} Rejects if not found
   */
  async ensureExists () {
  }

  /**
   * Sets metadata on an existing asset
   * @typedef
   * @return {AssetMetadata} The metadata
   */
  async generateMetadata () {
  }

  /**
   * Read a file. Must be overridden by subclasses.
   * @return {external:stream~Readable}
   */
  async read () {
  }

  /**
   * Write a file to the repository. Must be overridden by subclasses.
   * @param {external:stream~Readable} inputStream The file read stream
   * @param {string} outputPath Path at which to store the file
   * @return {Promise}
   */
  async write (inputStream, outputPath) {
  }

  /**
   *
   * @param {string} newPath New path for file
   * @return {Promise}
   */
  async move (newPath) {
  }

  /**
   * Removes a file from the repository
   * @return {Promise}
   */
  async delete () {
    if (this.hasThumb) await this.thumb.delete()
  }
}

export default AbstractAsset