Source: lib/offline/indexeddb/v1_storage_cell.js

/**
 * @license
 * Copyright 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

goog.provide('shaka.offline.indexeddb.V1StorageCell');

goog.require('goog.asserts');
goog.require('shaka.log');
goog.require('shaka.offline.indexeddb.DBConnection');
goog.require('shaka.util.Error');
goog.require('shaka.util.ManifestParserUtils');
goog.require('shaka.util.PublicPromise');


/**
 * The V1StorageCell is for all stores that follow the shakaExterns V2 offline
 * types. This storage cell will only work for version 1 indexed db database
 * schemes.
 *
 * @implements {shakaExtern.StorageCell}
 */
shaka.offline.indexeddb.V1StorageCell = class {
  /**
   * @param {IDBDatabase} connection
   * @param {string} segmentStore
   * @param {string} manifestStore
   */
  constructor(connection, segmentStore, manifestStore) {
    /** @private {!shaka.offline.indexeddb.DBConnection} */
    this.connection_ = new shaka.offline.indexeddb.DBConnection(connection);

    /** @private {string} */
    this.segmentStore_ = segmentStore;
    /** @private {string} */
    this.manifestStore_ = manifestStore;
  }

  /**
   * @override
   */
  destroy() { return this.connection_.destroy(); }

  /**
   * @override
   */
  hasFixedKeySpace() {
    // We do not allow adding new values to V1 databases.
    return true;
  }

  /**
   * @override
   */
  addSegments(segments) { return this.rejectAdd_(this.segmentStore_); }

  /**
   * @override
   */
  removeSegments(keys, onRemove) {
    return this.remove_(this.segmentStore_, keys, onRemove);
  }

  /**
   * @override
   */
  getSegments(keys) {
    const convertSegmentData =
        shaka.offline.indexeddb.V1StorageCell.convertSegmentData_;

    return this.get_(this.segmentStore_, keys).then((segments) => {
      return segments.map(convertSegmentData);
    });
  }

  /**
   * @override
   */
  addManifests(manifests) { return this.rejectAdd_(this.manifestStore_); }

  /**
   * @override
   */
  updateManifestExpiration(key, newExpiration) {
    let op = this.connection_.startReadWriteOperation(this.manifestStore_);
    let store = op.store();

    let p = new shaka.util.PublicPromise();

    store.get(key).onsuccess = (event) => {
      // Make sure a defined value was found. Indexeddb treats "no value found"
      // as a success with an undefined result.
      let manifest = event.target.result;

      // Indexeddb does not fail when you get a value that is not in the
      // database. It will return an undefined value. However, we expect
      // the value to never be null, so something is wrong if we get a
      // falsey value.
      if (manifest) {
        // Since this store's scheme uses in-line keys, we don't need to specify
        // the key with |put|.
        goog.asserts.assert(
            manifest.key == key,
            'With in-line keys, the keys should match');

        manifest.expiration = newExpiration;
        store.put(manifest);

        p.resolve();
      } else {
        p.reject(new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.STORAGE,
            shaka.util.Error.Code.KEY_NOT_FOUND,
            'Could not find values for ' + key));
      }
    };

    // Only return our promise after the operation completes.
    return op.promise().then(() => p);
  }

  /**
   * @override
   */
  removeManifests(keys, onRemove) {
    return this.remove_(this.manifestStore_, keys, onRemove);
  }

  /**
   * @override
   */
  getManifests(keys) {
    const convertManifest =
        shaka.offline.indexeddb.V1StorageCell.convertManifest_;

    return this.get_(this.manifestStore_, keys).then((manifests) => {
      return manifests.map(convertManifest);
    });
  }

  /**
   * @override
   */
  getAllManifests() {
    const convertManifest =
        shaka.offline.indexeddb.V1StorageCell.convertManifest_;

    let op = this.connection_.startReadOnlyOperation(this.manifestStore_);
    let store = op.store();

    let values = {};

    store.openCursor().onsuccess = (event) => {
      // When we reach the end of the data that the cursor is iterating
      // over, |event.target.result| will be null to signal the end of the
      // iteration.
      // https://developer.mozilla.org/en-US/docs/Web/API/IDBCursor/continue
      let cursor = event.target.result;
      if (!cursor) {
        return;
      }

      values[cursor.key] = convertManifest(cursor.value);

      // Go to the next item in the store, which will cause |onsuccess| to be
      // called again.
      cursor.continue();
    };

    // Wait until the operation completes or else values may be missing from
    // |values|.
    return op.promise().then(() => values);
  }

  /**
   * @param {string} storeName
   * @return {!Promise}
   * @private
   */
  rejectAdd_(storeName) {
    return Promise.reject(new shaka.util.Error(
      shaka.util.Error.Severity.CRITICAL,
      shaka.util.Error.Category.STORAGE,
      shaka.util.Error.Code.NEW_KEY_OPERATION_NOT_SUPPORTED,
      'Cannot add new value to ' + storeName));
  }

  /**
   * @param {string} storeName
   * @param {!Array.<number>} keys
   * @param {function(number)} onRemove
   * @return {!Promise}
   * @private
   */
  remove_(storeName, keys, onRemove) {
    let op = this.connection_.startReadWriteOperation(storeName);
    let store = op.store();

    keys.forEach((key) => {
      store.delete(key).onsuccess = () => onRemove(key);
    });

    return op.promise();
  }

  /**
   * @param {string} storeName
   * @param {!Array.<number>} keys
   * @return {!Promise.<!Array.<T>>}
   * @template T
   * @private
   */
  get_(storeName, keys) {
    let op = this.connection_.startReadOnlyOperation(storeName);
    let store = op.store();

    let values = {};
    let missing = [];

    // Use a map to store the objects so that we can reorder the results to
    // match the order of |keys|.
    keys.forEach((key) => {
      store.get(key).onsuccess = (event) => {
        let value = event.target.result;
        // Make sure a defined value was found. Indexeddb treats no-value found
        // as a success with an undefined result.
        if (value == undefined) {
          missing.push(key);
        }

        values[key] = value;
      };
    });

    // Wait until the operation completes or else values may be missing from
    // |values|. Use the original key list to convert the map to a list so that
    // the order will match.
    return op.promise().then(() => {
      if (missing.length) {
        return Promise.reject(new shaka.util.Error(
            shaka.util.Error.Severity.CRITICAL,
            shaka.util.Error.Category.STORAGE,
            shaka.util.Error.Code.KEY_NOT_FOUND,
            'Could not find values for ' + missing
        ));
      }

      return keys.map((key) => values[key]);
    });
  }

  /**
   * @param {!Object} old
   * @return {shakaExtern.ManifestDB}
   * @private
   */
  static convertManifest_(old) {
    // Old Manifest Format:
    // {
    //   key: number,
    //   originalManifestUri: string,
    //   duration: number,
    //   size: number,
    //   expiration: number,
    //   periods: !Array.<shakaExtern.PeriodDB>,
    //   sessionIds: !Array.<string>,
    //   drmInfo: ?shakaExtern.DrmInfo,
    //   appMetadata: Object
    //  }

    goog.asserts.assert(
        old.originalManifestUri != null,
        'Old manifest format should have an originalManifestUri field');
    goog.asserts.assert(
        old.duration != null,
        'Old manifest format should have a duration field');
    goog.asserts.assert(
        old.size != null,
        'Old manifest format should have a size field');
    goog.asserts.assert(
        old.periods != null,
        'Old manifest format should have a periods field');
    goog.asserts.assert(
        old.sessionIds != null,
        'Old manifest format should have a session ids field');
    goog.asserts.assert(
        old.appMetadata != null,
        'Old manifest format should have an app metadata field');

    const convertPeriod = shaka.offline.indexeddb.V1StorageCell.convertPeriod_;

    return {
      originalManifestUri: old.originalManifestUri,
      duration: old.duration,
      size: old.size,
      expiration: old.expiration == null ? Infinity : old.expiration,
      periods: old.periods.map(convertPeriod),
      sessionIds: old.sessionIds,
      drmInfo: old.drmInfo,
      appMetadata: old.appMetadata
    };
  }

  /**
   * @param {!Object} old
   * @return {shakaExtern.PeriodDB}
   * @private
   */
  static convertPeriod_(old) {
    // Old Period Format:
    // {
    //   startTime: number,
    //   streams: !Array.<shakaExtern.StreamDB>
    // }

    goog.asserts.assert(
      old.startTime != null,
      'Old period format should have a start time field');
    goog.asserts.assert(
      old.streams != null,
      'Old period format should have a streams field');

    const convertStream = shaka.offline.indexeddb.V1StorageCell.convertStream_;
    const fillMissingVariants =
        shaka.offline.indexeddb.V1StorageCell.fillMissingVariants_;

    // In the case that this is really old (like really old, like dinosaurs
    // roaming the Earth old) there may be no variants, so we need to add those.
    fillMissingVariants(old);

    old.streams.forEach((stream) => {
      const message = 'After filling in missing variants, ' +
                      'each stream should have variant ids';
      goog.asserts.assert(stream.variantIds, message);
    });

    return {
      startTime: old.startTime,
      streams: old.streams.map(convertStream)
    };
  }

  /**
   * @param {!Object} old
   * @return {shakaExtern.StreamDB}
   * @private
   */
  static convertStream_(old) {
    // Old Stream Format
    // {
    //   id: number,
    //   primary: boolean,
    //   presentationTimeOffset: number,
    //   contentType: string,
    //   mimeType: string,
    //   codecs: string,
    //   frameRate: (number|undefined),
    //   kind: (string|undefined),
    //   language: string,
    //   label: ?string,
    //   width: ?number,
    //   height: ?number,
    //   initSegmentUri: ?string,
    //   encrypted: boolean,
    //   keyId: ?string,
    //   segments: !Array.<shakaExtern.SegmentDB>,
    //   variantIds: ?Array.<number>
    // }

    goog.asserts.assert(
        old.id != null,
        'Old stream format should have an id field');
    goog.asserts.assert(
        old.primary != null,
        'Old stream format should have a primary field');
    goog.asserts.assert(
        old.presentationTimeOffset != null,
        'Old stream format should have a presentation time offset field');
    goog.asserts.assert(
        old.contentType != null,
        'Old stream format should have a content type field');
    goog.asserts.assert(
        old.mimeType != null,
        'Old stream format should have a mime type field');
    goog.asserts.assert(
        old.codecs != null,
        'Old stream format should have a codecs field');
    goog.asserts.assert(
        old.language != null,
        'Old stream format should have a language field');
    goog.asserts.assert(
        old.encrypted != null,
        'Old stream format should have an encrypted field');
    goog.asserts.assert(
        old.segments != null,
        'Old stream format should have a segments field');


    const getKeyFromUri =
        shaka.offline.indexeddb.V1StorageCell.getKeyFromSegmentUri_;
    const convertSegment =
        shaka.offline.indexeddb.V1StorageCell.convertSegment_;

    let initSegmentKey = old.initSegmentUri ?
                      getKeyFromUri(old.initSegmentUri) :
                      null;

    return {
      id: old.id,
      primary: old.primary,
      presentationTimeOffset: old.presentationTimeOffset,
      contentType: old.contentType,
      mimeType: old.mimeType,
      codecs: old.codecs,
      frameRate: old.frameRate,
      kind: old.kind,
      language: old.language,
      label: old.label,
      width: old.width,
      height: old.height,
      initSegmentKey: initSegmentKey,
      encrypted: old.encrypted,
      keyId: old.keyId,
      segments: old.segments.map(convertSegment),
      variantIds: old.variantIds
    };
  }

  /**
   * @param {!Object} old
   * @return {shakaExtern.SegmentDB}
   * @private
   */
  static convertSegment_(old) {
    // Old Segment Format
    // {
    //   startTime: number,
    //   endTime: number,
    //   uri: string
    // }

    goog.asserts.assert(
        old.startTime != null,
        'The old segment format should have a start time field');
    goog.asserts.assert(
        old.endTime != null,
        'The old segment format should have an end time field');
    goog.asserts.assert(
        old.uri != null,
        'The old segment format should have a uri field');

    const V1StorageCell = shaka.offline.indexeddb.V1StorageCell;
    const getKeyFromUri = V1StorageCell.getKeyFromSegmentUri_;

    // Since we don't want to use the uri anymore, we need to parse the key
    // from it.
    let dataKey = getKeyFromUri(old.uri);

    return {
      startTime: old.startTime,
      endTime: old.endTime,
      dataKey: dataKey
    };
  }

  /**
   * @param {!Object} old
   * @return {shakaExtern.SegmentDataDB}
   * @private
   */
  static convertSegmentData_(old) {
    // Old Segment Format:
    // {
    //   key: number,
    //   data: ArrayBuffer
    // }

    goog.asserts.assert(
        old.key != null,
        'The old segment data format should have a key field');
    goog.asserts.assert(
        old.data != null,
        'The old segment data format should have a data field');

    return {data: old.data};
  }

  /**
   * @param {string} uri
   * @return {number}
   * @private
   */
  static getKeyFromSegmentUri_(uri) {
    let parts = null;

    // Try parsing the uri as the original Shaka Player 2.0 uri.
    parts = /^offline:[0-9]+\/[0-9]+\/([0-9]+)$/.exec(uri);
    if (parts) {
      return Number(parts[1]);
    }

    // Just before Shaka Player 2.3 the uri format was changed to remove some
    // of the un-used information from the uri and make the segment uri and
    // manifest uri follow a similar format. However the old storage system
    // was still in place, so it is possible for Storage V1 Cells to have
    // Storage V2 uris.
    parts = /^offline:segment\/([0-9]+)$/.exec(uri);
    if (parts) {
      return Number(parts[1]);
    }

    throw new shaka.util.Error(
        shaka.util.Error.Severity.CRITICAL,
        shaka.util.Error.Category.STORAGE,
        shaka.util.Error.Code.MALFORMED_OFFLINE_URI,
        'Could not parse uri ' + uri);
  }

  /**
   * Take a period and check if the streams need to have variants generated.
   * Before Shaka Player moved to its variants model, there were no variants.
   * This will fill missing variants into the given object.
   *
   * @param {!Object} period
   * @private
   */
  static fillMissingVariants_(period) {
    const AUDIO = shaka.util.ManifestParserUtils.ContentType.AUDIO;
    const VIDEO = shaka.util.ManifestParserUtils.ContentType.VIDEO;

    // There are three cases:
    //  1. All streams' variant ids are null
    //  2. All streams' variant ids are non-null
    //  3. Some streams' variant ids are null and other are non-null
    // Case 3 is invalid and should never happen in production.

    let audio = period.streams.filter((s) => s.contentType == AUDIO);
    let video = period.streams.filter((s) => s.contentType == VIDEO);

    // Case 2 - There is nothing we need to do, so let's just get out of here.
    if (audio.every((s) => s.variantIds) && video.every((s) => s.variantIds)) {
      return;
    }

    // Case 3... We don't want to be in case three.
    goog.asserts.assert(
        audio.every((s) => !s.variantIds),
        'Some audio streams have variant ids and some do not.');
    goog.asserts.assert(
        video.every((s) => !s.variantIds),
        'Some video streams have variant ids and some do not.');

    // Case 1 - Populate all the variant ids (putting us back to case 2).
    // Since all the variant ids are null, we need to first make them into
    // valid arrays.
    audio.forEach((s) => { s.variantIds = []; });
    video.forEach((s) => { s.variantIds = []; });

    let nextId = 0;

    // It is not possible in Shaka Player's pre-variant world to have audio-only
    // and video-only content mixed in with audio-video content. So we can
    // assume that there is only audio-only or video-only if one group is empty.

    // Everything is video-only content - so each video stream gets to be its
    // own variant.
    if (video.length && !audio.length) {
      shaka.log.debug('Found video-only content. Creating variants for video.');
      let variantId = nextId++;
      video.forEach((s) => { s.variantIds.push(variantId); });
    }

    // Everything is audio-only content - so each audio stream gets to be its
    // own variant.
    if (!video.length && audio.length) {
      shaka.log.debug('Found audio-only content. Creating variants for audio.');
      let variantId = nextId++;
      audio.forEach((s) => { s.variantIds.push(variantId); });
    }

    // Everything is audio-video content.
    if (video.length && audio.length) {
      shaka.log.debug('Found audio-video content. Creating variants.');
      audio.forEach((a) => {
        video.forEach((v) => {
          let variantId = nextId++;
          a.variantIds.push(variantId);
          v.variantIds.push(variantId);
        });
      });
    }
  }
};