chromium/ui/file_manager/file_manager/foreground/js/list_thumbnail_loader.ts

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert} from 'chrome://resources/js/assert.js';

import type {VolumeManager} from '../../background/js/volume_manager.js';
import type {ChangeEvent, SpliceEvent} from '../../common/js/array_data_model.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {type CustomEventMap, FilesEventTarget} from '../../common/js/files_event_target.js';
import {LruCache} from '../../common/js/lru_cache.js';
import {isNullOrUndefined} from '../../common/js/util.js';
import {Source, VolumeType} from '../../common/js/volume_manager_types.js';

import type {DirectoryModel} from './directory_model.js';
import type {FileListModel} from './file_list_model.js';
import type {ThumbnailModel} from './metadata/thumbnail_model.js';
import {FillMode, LoadTarget, ThumbnailLoader} from './thumbnail_loader.js';

type ListThumbnailLoaderVolumeType = VolumeType|string;

/**
 * Thumbnail loaded event.
 */
export type ThumbnailLoadedEvent = CustomEvent<{
  index: number,
  fileUrl: string,
  dataUrl?: string,
  width?: number,
  height?: number,
}>;

interface ListThumbnailLoaderEventMap extends CustomEventMap {
  'thumbnailLoaded': ThumbnailLoadedEvent;
}

/**
 * A thumbnail loader for list style UI.
 *
 * ListThumbnailLoader is a thumbnail loader designed for list style ui. List
 * thumbnail loader loads thumbnail in a viewport of the UI. ListThumbnailLoader
 * is responsible to return dataUrls of thumbnails and fetch them with proper
 * priority.
 */
export class ListThumbnailLoader extends
    FilesEventTarget<ListThumbnailLoaderEventMap> {
  private thumbnailLoaderConstructor_: typeof ThumbnailLoader;
  private active_: Record<string, ListThumbnailLoaderTask> = {};
  /**
   * Cache size. Cache size must be larger than sum of high priority range size
   * and number of prefetch tasks.
   */
  private cache_: LruCache<ThumbnailData>;
  private beginIndex_ = 0;
  private endIndex_ = 0;
  private cursor_ = 0;
  /**
   * Current volume type.
   */
  private currentVolumeType_: ListThumbnailLoaderVolumeType|null = null;
  private dataModel_: FileListModel;
  /**
   * Number of maximum active tasks for testing.
   */
  private numOfMaxActiveTasksForTest_ = 2;

  /**
   * @param directoryModel A directory model.
   * @param thumbnailModel Thumbnail metadata model.
   * @param volumeManager Volume manager.
   * @param opt_thumbnailLoaderConstructor A constructor of thumbnail loader.
   *     This argument is used for testing.
   */
  constructor(
      private directoryModel_: DirectoryModel,
      private thumbnailModel_: ThumbnailModel,
      private volumeManager_: VolumeManager,
      thumbnailLoaderConstructor?: typeof ThumbnailLoader, cacheSize?: number) {
    super();

    /**
     * Constructor of thumbnail loader.
     */
    this.thumbnailLoaderConstructor_ =
        thumbnailLoaderConstructor || ThumbnailLoader;

    this.dataModel_ = this.directoryModel_.getFileList();

    /**
     * Cache size. Cache size must be larger than sum of high priority range
     * size and number of prefetch tasks.
     */
    this.cache_ = new LruCache(cacheSize ?? 500);


    this.directoryModel_.addEventListener(
        'cur-dir-scan-completed', this.onScanCompleted_.bind(this));
    this.dataModel_.addEventListener(
        'splice', this.onSplice_.bind(this) as EventListener);
    this.dataModel_.addEventListener('sorted', this.onSorted_.bind(this));
    this.dataModel_.addEventListener(
        'change', this.onChange_.bind(this) as EventListener);
  }

  /**
   * Gets number of prefetch requests. This number changes based on current
   * volume type.
   * @return Number of prefetch requests.
   */
  private getNumOfPrefetch_(): number {
    switch (this.currentVolumeType_) {
      case VolumeType.MTP:
        return 0;
      case TEST_VOLUME_TYPE:
        return 1;
      default:
        return 20;
    }
  }

  /**
   * Gets maximum number of active thumbnail fetch tasks. This number changes
   * based on current volume type.
   * @return Maximum number of active thumbnail fetch tasks.
   */
  private getNumOfMaxActiveTasks_(): number {
    switch (this.currentVolumeType_) {
      case VolumeType.MTP:
        return 1;
      case TEST_VOLUME_TYPE:
        return this.numOfMaxActiveTasksForTest_;
      default:
        return 10;
    }
  }

  /**
   * An event handler for scan-completed event of directory model. When
   * directory scan is running, we don't fetch thumbnail in order not to block
   * IO for directory scan. i.e. modification events during directory scan is
   * ignored. We need to check thumbnail loadings after directory scan is
   * completed.
   */
  private onScanCompleted_(_event: Event) {
    this.cursor_ = this.beginIndex_;
    this.continue_();
  }

  /**
   * An event handler for splice event of data model. When list is changed,
   * start to rescan items.
   */
  private onSplice_(_event: SpliceEvent) {
    this.cursor_ = this.beginIndex_;
    this.continue_();
  }

  /**
   * An event handler for sorted event of data model. When list is sorted, start
   * to rescan items.
   */
  private onSorted_(_event: Event) {
    this.cursor_ = this.beginIndex_;
    this.continue_();
  }

  /**
   * An event handler for change event of data model.
   */
  private onChange_(event: ChangeEvent) {
    // Mark the thumbnail in cache as invalid.
    const entry = isNullOrUndefined(event.detail.index) ?
        null :
        this.dataModel_.item(event.detail.index);
    const cachedThumbnail = this.cache_.peek(entry?.toURL() || '');
    if (cachedThumbnail) {
      cachedThumbnail.outdated = true;
    }

    this.cursor_ = this.beginIndex_;
    this.continue_();
  }

  /**
   * Sets high priority range in the list.
   *
   * @param beginIndex Begin index of the range, inclusive.
   * @param endIndex End index of the range, exclusive.
   */
  setHighPriorityRange(beginIndex: number, endIndex: number) {
    if (!(beginIndex < endIndex)) {
      return;
    }

    this.beginIndex_ = beginIndex;
    this.endIndex_ = endIndex;
    this.cursor_ = this.beginIndex_;

    this.continue_();
  }

  /**
   * Returns a thumbnail of an entry if it is in cache. This method returns
   * thumbnail even if the thumbnail is outdated.
   *
   * @return If the thumbnail is not in cache, this returns null.
   */
  getThumbnailFromCache(entry: Entry|FilesAppEntry): ThumbnailData|null {
    // Since we want to evict cache based on high priority range, we use peek
    // here instead of get.
    return this.cache_.peek(entry.toURL()) || null;
  }

  /**
   * Enqueues tasks if available.
   */
  private continue_() {
    // If directory scan is running or all items are scanned, do nothing.
    if (this.directoryModel_.isScanning() ||
        !(this.cursor_ < this.dataModel_.length)) {
      return;
    }

    const entry = this.dataModel_.item(this.cursor_)!;

    // Check volume type for optimizing the parameters.
    const volumeInfo = this.volumeManager_.getVolumeInfo(entry);
    this.currentVolumeType_ = volumeInfo ? volumeInfo.volumeType : null;

    // If tasks are running full or all items are scanned, do nothing.
    if (!(Object.keys(this.active_).length < this.getNumOfMaxActiveTasks_()) ||
        !(this.cursor_ < this.endIndex_ + this.getNumOfPrefetch_())) {
      return;
    }

    // If the entry is a directory, already in cache as valid or fetching, skip.
    const thumbnail = this.cache_.get(entry.toURL());
    if (entry.isDirectory || (thumbnail && !thumbnail.outdated) ||
        this.active_[entry.toURL()]) {
      this.cursor_++;
      this.continue_();
      return;
    }

    this.enqueue_(this.cursor_, entry);
    this.cursor_++;
    this.continue_();
  }

  /**
   * Enqueues a thumbnail fetch task for an entry.
   *
   * @param index Index of an entry in current data model.
   * @param entry An entry.
   */
  private enqueue_(index: number, entry: Entry|FilesAppEntry) {
    const task = new ListThumbnailLoaderTask(
        entry, this.volumeManager_, this.thumbnailModel_,
        this.thumbnailLoaderConstructor_);

    const url = entry.toURL();
    this.active_[url] = task;

    task.fetch().then(thumbnail => {
      delete this.active_[url];
      this.cache_.put(url, thumbnail);
      this.dispatchThumbnailLoaded_(index, thumbnail);
      this.continue_();
    });
  }

  /**
   * Dispatches thumbnail loaded event.
   *
   * @param index Index of an original image in the data model.
   * @param thumbnail Thumbnail.
   */
  private dispatchThumbnailLoaded_(index: number, thumbnail: ThumbnailData) {
    // Update index if it's already invalid, i.e. index may be invalid if some
    // change had happened in the data model during thumbnail fetch.
    const item = this.dataModel_.item(index);
    if (item && item.toURL() !== thumbnail.fileUrl) {
      index = -1;
      for (let i = 0; i < this.dataModel_.length; i++) {
        if (this.dataModel_.item(i)!.toURL() === thumbnail.fileUrl) {
          index = i;
          break;
        }
      }
    }

    if (index > -1) {
      this.dispatchEvent(new CustomEvent('thumbnailLoaded', {
        detail: {
          index,
          fileUrl: thumbnail.fileUrl,
          dataUrl: thumbnail.dataUrl,
          width: thumbnail.width,
          height: thumbnail.height,
        },
      }));
    }
  }

  set numOfMaxActiveTasksForTest(num: number) {
    this.numOfMaxActiveTasksForTest_ = num;
  }
}

/**
 * Volume type for testing.
 */
export const TEST_VOLUME_TYPE = 'test_volume_type';

/**
 * A class to represent thumbnail data.
 */
class ThumbnailData {
  outdated = false;

  /**
   * @param fileUrl File url of an original image.
   * @param dataUrl Data url of thumbnail.
   * @param width Width of thumbnail.
   * @param height Height of thumbnail.
   */
  constructor(
      public readonly fileUrl: string, public readonly dataUrl: string|null,
      public readonly width: number|null, public readonly height: number|null) {
  }
}

/**
 * A task to load thumbnail.
 */
export class ListThumbnailLoaderTask {
  /**
   *
   * @param entry An entry.
   * @param volumeManager Volume manager.
   * @param thumbnailModel Metadata cache.
   * @param thumbnailLoaderConstructor A constructor of thumbnail loader.
   */
  constructor(
      private entry_: Entry|FilesAppEntry,
      private volumeManager_: VolumeManager,
      private thumbnailModel_: ThumbnailModel,
      private thumbnailLoaderConstructor_: typeof ThumbnailLoader) {}

  /**
   * Fetches thumbnail.
   *
   * @return A promise which is resolved when thumbnail data is fetched with
   *     either a success or an error.
   */
  async fetch(): Promise<ThumbnailData> {
    let ioError = false;
    return this.thumbnailModel_.get([this.entry_])
        .then(metadatas => {
          assert(metadatas[0]);
          // When it failed to read exif header with an IO error, do not
          // generate thumbnail at this time since it may success in the second
          // try. If it failed to read at 0 byte, it would be an IO error.
          if (metadatas[0].thumbnail.urlError &&
              metadatas[0].thumbnail.urlError.errorDescription ===
                  'Error: Unexpected EOF @0') {
            ioError = true;
            return Promise.reject();
          }
          return metadatas[0];
        })
        .then(metadata => {
          const loadTargets = [
            LoadTarget.CONTENT_METADATA,
            LoadTarget.EXTERNAL_METADATA,
          ];

          // If the file is on a network filesystem, don't generate thumbnails
          // from file entry, as it could cause very high network traffic.
          // Allow Drive to do so however, as ThumbnailLoader tries to generate
          // thumbnails of Drive files from file entry only if cached locally.
          const volumeInfo = this.volumeManager_.getVolumeInfo(this.entry_);
          if (volumeInfo &&
              (volumeInfo.source !== Source.NETWORK ||
               volumeInfo.volumeType === VolumeType.DRIVE)) {
            loadTargets.push(LoadTarget.FILE_ENTRY);
          }

          return new this
              .thumbnailLoaderConstructor_(
                  this.entry_, metadata, undefined /* mediaType */, loadTargets)
              .loadAsDataUrl(FillMode.OVER_FILL);
        })
        .then(result => {
          return new ThumbnailData(
              this.entry_.toURL(), result.data ?? null, result.width ?? null,
              result.height ?? null);
        })
        .catch(() => {
          // If an error happens during generating of a thumbnail, then return
          // an empty object, so we don't retry the thumbnail over and over
          // again.
          const thumbnailData =
              new ThumbnailData(this.entry_.toURL(), null, null, null);
          if (ioError) {
            // If fetching a thumbnail from EXIF fails due to an IO error, then
            // try to refetch it in the future, but not earlier than in 3
            // second.
            setTimeout(() => {
              thumbnailData.outdated = true;
            }, EXIF_IO_ERROR_DELAY);
          }
          return thumbnailData;
        });
  }
}

/**
 * Minimum delay of milliseconds before another retry for fetching a
 * thumbnmail from EXIF after failing with an IO error. In milliseconds.
 *
 */
const EXIF_IO_ERROR_DELAY = 3000;