Source: lib/util/id3_utils.js

/*! @license
 * Shaka Player
 * Copyright 2022 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.util.Id3Utils');

goog.require('shaka.log');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.StringUtils');


/**
 * @summary A set of Id3Utils utility functions.
 * @export
 */
shaka.util.Id3Utils = class {
  /**
   * @param {Uint8Array} data
   * @param {number} offset
   * @return {boolean}
   * @private
   */
  static isHeader_(data, offset) {
    /*
     * http://id3.org/id3v2.3.0
     * [0]     = 'I'
     * [1]     = 'D'
     * [2]     = '3'
     * [3,4]   = {Version}
     * [5]     = {Flags}
     * [6-9]   = {ID3 Size}
     *
     * An ID3v2 tag can be detected with the following pattern:
     *  $49 44 33 yy yy xx zz zz zz zz
     * Where yy is less than $FF, xx is the 'flags' byte and zz is less than $80
     */
    if (offset + 10 <= data.length) {
      // look for 'ID3' identifier
      if (data[offset] === 0x49 &&
          data[offset + 1] === 0x44 &&
          data[offset + 2] === 0x33) {
        // check version is within range
        if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) {
          // check size is within range
          if (data[offset + 6] < 0x80 &&
              data[offset + 7] < 0x80 &&
              data[offset + 8] < 0x80 &&
              data[offset + 9] < 0x80) {
            return true;
          }
        }
      }
    }

    return false;
  }

  /**
   * @param {Uint8Array} data
   * @param {number} offset
   * @return {boolean}
   * @private
   */
  static isFooter_(data, offset) {
    /*
     * The footer is a copy of the header, but with a different identifier
     */
    if (offset + 10 <= data.length) {
      // look for '3DI' identifier
      if (data[offset] === 0x33 &&
          data[offset + 1] === 0x44 &&
          data[offset + 2] === 0x49) {
        // check version is within range
        if (data[offset + 3] < 0xff && data[offset + 4] < 0xff) {
          // check size is within range
          if (data[offset + 6] < 0x80 &&
              data[offset + 7] < 0x80 &&
              data[offset + 8] < 0x80 &&
              data[offset + 9] < 0x80) {
            return true;
          }
        }
      }
    }

    return false;
  }

  /**
   * @param {Uint8Array} data
   * @param {number} offset
   * @return {number}
   * @private
   */
  static readSize_(data, offset) {
    let size = 0;
    size = (data[offset] & 0x7f) << 21;
    size |= (data[offset + 1] & 0x7f) << 14;
    size |= (data[offset + 2] & 0x7f) << 7;
    size |= data[offset + 3] & 0x7f;
    return size;
  }

  /**
   * Reads a 32-bit big-endian integer from `data` at `offset`.
   * Used for ID3v2.3 frame sizes, which are plain big-endian (not syncsafe).
   *
   * @param {Uint8Array} data
   * @param {number} offset
   * @return {number}
   * @private
   */
  static readSizeBE_(data, offset) {
    let size = 0;
    size = data[offset] << 24;
    size |= data[offset + 1] << 16;
    size |= data[offset + 2] << 8;
    size |= data[offset + 3];
    return size >>> 0;
  }

  /**
   * Decodes a string from raw bytes using the given ID3 encoding indicator.
   *
   * ID3v2 encoding values:
   *   0x00 = ISO-8859-1 (Latin-1)
   *   0x01 = UTF-16 with BOM (FF FE = LE, FE FF = BE)
   *   0x02 = UTF-16BE without BOM
   *   0x03 = UTF-8
   *
   * @param {!Uint8Array} data
   * @param {number} encoding
   * @return {string}
   * @private
   */
  static decodeString_(data, encoding) {
    const StringUtils = shaka.util.StringUtils;
    switch (encoding) {
      case shaka.util.Id3Utils.LATIN1_encoding:
        return Array.from(data).map((b) => String.fromCharCode(b)).join('');
      case shaka.util.Id3Utils.UTF16BOM_encoding:
        return StringUtils.fromBytesAutoDetect(data);
      case shaka.util.Id3Utils.UTF16BE_encoding:
        return StringUtils.fromUTF16(data, /* littleEndian= */ false);
      case shaka.util.Id3Utils.UTF8_encoding:
        return StringUtils.fromUTF8(data);
      default:
        return '';
    }
  }

  /**
   * Returns the byte width of the null terminator for a given encoding.
   * UTF-16 encodings use a two-byte null; all others use a single byte.
   *
   * @param {number} encoding
   * @return {number}
   * @private
   */
  static nullTermSize_(encoding) {
    return (encoding === shaka.util.Id3Utils.UTF16BOM_encoding ||
            encoding === shaka.util.Id3Utils.UTF16BE_encoding) ? 2 : 1;
  }

  /**
   * Finds the byte index of the null terminator in `data` starting from
   * `offset`, respecting the terminator width for the given encoding.
   *
   * For UTF-16 encodings the search steps in two-byte increments so it never
   * straddles a surrogate pair or misidentifies a code-unit as a terminator.
   *
   * @param {Uint8Array} data
   * @param {number} encoding
   * @param {number=} offset
   * @return {number}  Byte index of the null terminator, or -1 if not found.
   * @private
   */
  static findNull_(data, encoding, offset = 0) {
    if (encoding === shaka.util.Id3Utils.UTF16BOM_encoding ||
        encoding === shaka.util.Id3Utils.UTF16BE_encoding) {
      for (let i = offset; i + 1 < data.length; i += 2) {
        if (data[i] === 0 && data[i + 1] === 0) {
          return i;
        }
      }
      return -1;
    }
    return data.indexOf(0, offset);
  }

  /**
   * @param {Uint8Array} data
   * @param {number} version  ID3v2 major version (3 = v2.3, 4 = v2.4)
   * @return {shaka.extern.MetadataRawFrame}
   * @private
   */
  static getFrameData_(data, version) {
    /*
     * Frame ID       $xx xx xx xx (four characters)
     * Size           $xx xx xx xx
     * Flags          $xx xx
     */
    const type = String.fromCharCode(data[0], data[1], data[2], data[3]);
    const size = version >= 4 ? shaka.util.Id3Utils.readSize_(data, 4) :
        shaka.util.Id3Utils.readSizeBE_(data, 4);

    // skip frame id, size, and flags
    const offset = 10;

    return {
      type,
      size,
      data: data.subarray(offset, offset + size),
    };
  }

  /**
   * @param {shaka.extern.MetadataRawFrame} frame
   * @return {?shaka.extern.MetadataFrame}
   * @private
   */
  static decodeFrame_(frame) {
    const Id3Utils = shaka.util.Id3Utils;
    const BufferUtils = shaka.util.BufferUtils;
    const StringUtils = shaka.util.StringUtils;

    const metadataFrame = {
      key: frame.type,
      description: '',
      data: '',
      mimeType: null,
      pictureType: null,
    };

    if (frame.type === 'APIC') {
      /*
       * Format:
       * [0]       = {Text Encoding}
       * [1 - X]   = {MIME Type}\0 (always Latin-1, single-byte null)
       * [X+1]     = {Picture Type}
       * [X+2 - Y] = {Description}\0 (encoded per encoding byte)
       * [Y+n - ?] = {Picture Data or URL} (n = nullTermSize_(encoding))
       */
      if (frame.size < 2) {
        return null;
      }
      const encoding = frame.data[0];

      // MIME type is always Latin-1, terminated by a single null byte.
      const mimeTypeEndIndex = frame.data.subarray(1).indexOf(0);
      if (mimeTypeEndIndex === -1) {
        return null;
      }
      const mimeType = StringUtils.fromUTF8(
          BufferUtils.toUint8(frame.data, 1, mimeTypeEndIndex));

      const pictureType = frame.data[2 + mimeTypeEndIndex];

      // Description is encoding-aware and null-terminated.
      const descStart = 3 + mimeTypeEndIndex;
      const descEnd = Id3Utils.findNull_(frame.data, encoding, descStart);
      if (descEnd === -1) {
        return null;
      }
      const description = Id3Utils.decodeString_(
          frame.data.subarray(descStart, descEnd), encoding);

      const nullSize = Id3Utils.nullTermSize_(encoding);
      const picStart = descEnd + nullSize;

      let data;
      if (mimeType === '-->') {
        data = StringUtils.fromUTF8(frame.data.subarray(picStart));
      } else {
        data = BufferUtils.toArrayBuffer(frame.data.subarray(picStart));
      }

      metadataFrame.mimeType = mimeType;
      metadataFrame.pictureType = pictureType;
      metadataFrame.description = description;
      metadataFrame.data = data;
      return metadataFrame;
    } else if (frame.type === 'COMM') {
      /*
       * Format:
       * [0]     = {Text Encoding}
       * [1-3]   = {Language} (ISO 639-2, always Latin-1)
       * [4-?]   = {Short content description}\0 (encoding-aware null)
       * [?-end] = {The actual text}
       */
      if (frame.size < 5) {
        return null;
      }
      const encoding = frame.data[0];
      const language = StringUtils.fromUTF8(frame.data.subarray(1, 4))
          .replace(/\0/g, '');

      const descStart = 4;
      const descEnd = Id3Utils.findNull_(frame.data, encoding, descStart);
      if (descEnd === -1) {
        return null;
      }
      const description = Id3Utils.decodeString_(
          frame.data.subarray(descStart, descEnd), encoding);

      const nullSize = Id3Utils.nullTermSize_(encoding);
      const text = Id3Utils.decodeString_(
          frame.data.subarray(descEnd + nullSize), encoding)
          .replace(/\0*$/, '');

      // Expose the language in `description` when there is no content
      // descriptor, since it is the most useful piece of context for the
      // consumer.
      metadataFrame.description = description || language;
      metadataFrame.data = text;
      return metadataFrame;
    } else if (frame.type === 'TXXX') {
      /*
       * Format:
       * [0]   = {Text Encoding}
       * [1-?] = {Description}\0{Value}
       */
      if (frame.size < 2) {
        return null;
      }
      const encoding = frame.data[0];
      const descEnd = Id3Utils.findNull_(frame.data, encoding, 1);
      if (descEnd === -1) {
        return null;
      }
      const description = Id3Utils.decodeString_(
          frame.data.subarray(1, descEnd), encoding);

      const nullSize = Id3Utils.nullTermSize_(encoding);
      // Strip any leading BOM (\ufeff) that some taggers prepend to the value
      // field even though the encoding is already declared in byte [0].
      const data = Id3Utils.decodeString_(
          frame.data.subarray(descEnd + nullSize), encoding)
          .replace(/^\ufeff/, '')
          .replace(/\0*$/, '');

      metadataFrame.description = description;
      metadataFrame.data = data;
      return metadataFrame;
    } else if (frame.type === 'WXXX') {
      /*
       * Format:
       * [0]   = {Text Encoding}
       * [1-?] = {Description}\0{URL}
       *
       * The URL itself is always Latin-1 per spec (§4.3.2).
       */
      if (frame.size < 2) {
        return null;
      }
      const encoding = frame.data[0];
      const descEnd = Id3Utils.findNull_(frame.data, encoding, 1);
      if (descEnd === -1) {
        return null;
      }
      const description = Id3Utils.decodeString_(
          frame.data.subarray(1, descEnd), encoding);

      const nullSize = Id3Utils.nullTermSize_(encoding);
      // URLs are always Latin-1 regardless of the encoding byte.
      const data = StringUtils.fromUTF8(
          frame.data.subarray(descEnd + nullSize))
          .replace(/\0*$/, '');

      metadataFrame.description = description;
      metadataFrame.data = data;
      return metadataFrame;
    } else if (frame.type === 'PRIV') {
      /*
       * Format: <owner identifier (Latin-1)>\0<binary data>
       */
      if (frame.size < 2) {
        return null;
      }
      const textEndIndex = frame.data.indexOf(0);
      if (textEndIndex === -1) {
        return null;
      }
      const text = StringUtils.fromUTF8(
          BufferUtils.toUint8(frame.data, 0, textEndIndex));
      metadataFrame.description = text;
      if (text == 'com.apple.streaming.transportStreamTimestamp') {
        const data = frame.data.subarray(text.length + 1);
        // timestamp is 33 bit expressed as a big-endian eight-octet number,
        // with the upper 31 bits set to zero.
        const pts33Bit = data[3] & 0x1;
        let timestamp =
          (data[4] << 23) + (data[5] << 15) + (data[6] << 7) + data[7];
        timestamp /= 45;

        if (pts33Bit) {
          timestamp += 47721858.84;
        } // 2^32 / 90

        metadataFrame.data = timestamp;
      } else {
        const data = BufferUtils.toArrayBuffer(
            frame.data.subarray(text.length + 1));
        metadataFrame.data = data;
      }
      return metadataFrame;
    } else if (frame.type === 'UFID') {
      /*
       * Format: <owner identifier (Latin-1)>\0<binary identifier>
       */
      const ownerEnd = frame.data.indexOf(0);
      if (ownerEnd === -1) {
        return null;
      }
      const owner = StringUtils.fromUTF8(
          BufferUtils.toUint8(frame.data, 0, ownerEnd));
      metadataFrame.description = owner;
      metadataFrame.data = BufferUtils.toArrayBuffer(
          frame.data.subarray(ownerEnd + 1));
      return metadataFrame;
    } else if (frame.type[0] === 'T') {
      /*
       * Format:
       * [0]   = {Text Encoding}
       * [1-?] = {Value}  (ID3v2.4: multiple values separated by \0)
       */
      if (frame.size < 2) {
        return null;
      }
      const encoding = frame.data[0];
      const rawText = Id3Utils.decodeString_(frame.data.subarray(1), encoding);

      // ID3v2.4 §4.2: multiple values are separated by a null character.
      // Strip trailing nulls, then split and clean each segment.
      // BOM characters (\ufeff) can appear at the start of individual segments
      // when each UTF-16 value carries its own BOM — strip them per segment.
      const text = rawText
          .replace(/\0+$/, '')
          .split('\0')
          .map((v) => v.replace(/^\ufeff/, ''))
          .filter((v) => v.length > 0)
          .join(' / ');

      metadataFrame.data = text;
      return metadataFrame;
    } else if (frame.type[0] === 'W') {
      /*
       * Format:
       * [0-?] = {URL}  (always Latin-1, no encoding byte for plain W* frames)
       */
      const url = StringUtils.fromUTF8(frame.data)
          .replace(/\0*$/, '');
      metadataFrame.data = url;
      return metadataFrame;
    } else if (frame.data) {
      shaka.log.warning('Unrecognized ID3 frame type:', frame.type);
      metadataFrame.data = BufferUtils.toArrayBuffer(frame.data);
      return metadataFrame;
    }

    return null;
  }

  /**
   * Returns an array of ID3 frames found in all the ID3 tags in the id3Data
   * @param {Uint8Array} id3Data - The ID3 data containing one or more ID3 tags
   * @return {!Array<shaka.extern.MetadataFrame>}
   * @export
   */
  static getID3Frames(id3Data) {
    const Id3Utils = shaka.util.Id3Utils;
    let offset = 0;
    const frames = [];
    while (Id3Utils.isHeader_(id3Data, offset)) {
      // The tag-level size is always syncsafe across all v2.x versions.
      const version = id3Data[offset + 3];
      const size = Id3Utils.readSize_(id3Data, offset + 6);

      if ((id3Data[offset + 5] >> 6) & 1) {
        // skip extended header
        offset += 10;
      }
      // skip past ID3 header
      offset += 10;

      const end = offset + size;
      // loop through frames in the ID3 tag
      while (offset + 10 < end) {
        // A null byte signals the start of padding — stop parsing frames.
        if (id3Data[offset] === 0) {
          break;
        }

        const frameData =
            Id3Utils.getFrameData_(id3Data.subarray(offset), version);
        const frame = Id3Utils.decodeFrame_(frameData);
        if (frame) {
          frames.push(frame);
        }

        // skip frame header and frame data
        offset += frameData.size + 10;
      }

      if (Id3Utils.isFooter_(id3Data, offset)) {
        offset += 10;
      }
    }
    return frames;
  }

  /**
   * Returns any adjacent ID3 tags found in data starting at offset, as one
   * block of data
   * @param {Uint8Array} id3Data - The ID3 data containing one or more ID3 tags
   * @param {number=} offset - The offset at which to start searching
   * @return {!Uint8Array}
   * @export
   */
  static getID3Data(id3Data, offset = 0) {
    const Id3Utils = shaka.util.Id3Utils;
    const front = offset;
    let length = 0;

    while (Id3Utils.isHeader_(id3Data, offset)) {
      if ((id3Data[offset + 5] >> 6) & 1) {
        // skip extended header
        length += 10;
      }
      // skip past ID3 header
      length += 10;

      const size = Id3Utils.readSize_(id3Data, offset + 6);
      length += size;

      if (Id3Utils.isFooter_(id3Data, offset + 10)) {
        // ID3 footer is 10 bytes
        length += 10;
      }
      offset += length;
    }

    if (length > 0) {
      return id3Data.subarray(front, front + length);
    }
    return new Uint8Array([]);
  }

  /**
   * Returns metadata frames found in ID3v1 tags.
   *
   * @param {Uint8Array} data
   * @return {!Array<!shaka.extern.MetadataFrame>}
   */
  static getID3v1Frames(data) {
    const frames = [];
    const v1Offset = data.length - 128;

    if (v1Offset < 0 ||
        data[v1Offset] !== 0x54 ||
        data[v1Offset + 1] !== 0x41 ||
        data[v1Offset + 2] !== 0x47) {
      return frames;
    }

    const read = (start, length) => {
      return shaka.util.StringUtils.fromUTF8(
          data.subarray(v1Offset + start, v1Offset + start + length),
      ).replace(/\0/g, '').trim();
    };

    const push = (key, value) => {
      if (!value) {
        return;
      }
      frames.push({
        key,
        description: '',
        data: value,
        mimeType: null,
        pictureType: null,
      });
    };

    push('TIT2', read(3, 30));
    push('TPE1', read(33, 30));
    push('TALB', read(63, 30));
    push('TYER', read(93, 4));

    let comment = '';
    let track = null;

    if (data[v1Offset + 125] === 0) {
      comment = read(97, 28);
      track = data[v1Offset + 126];
    } else {
      comment = read(97, 30);
    }

    push('COMM', comment);

    if (track !== null) {
      push('TRCK', String(track));
    }

    push('TCON', String(data[v1Offset + 127]));

    return frames;
  }
};

/**
 * ISO-8859-1 / Latin-1 encoding byte.
 * @const {number}
 */
shaka.util.Id3Utils.LATIN1_encoding = 0x00;

/**
 * UTF-16 with BOM encoding byte.
 * @const {number}
 */
shaka.util.Id3Utils.UTF16BOM_encoding = 0x01;

/**
 * UTF-16BE without BOM encoding byte.
 * @const {number}
 */
shaka.util.Id3Utils.UTF16BE_encoding = 0x02;

/**
 * UTF-8 encoding byte.
 * @const {number}
 */
shaka.util.Id3Utils.UTF8_encoding = 0x03;