Home Reference Source

src/controller/base-playlist-controller.ts

import type Hls from '../hls';
import type { NetworkComponentAPI } from '../types/component-api';
import { getSkipValue, HlsSkip, HlsUrlParameters } from '../types/level';
import { computeReloadInterval } from './level-helper';
import { logger } from '../utils/logger';
import type { LevelDetails } from '../loader/level-details';
import type { MediaPlaylist } from '../types/media-playlist';
import type {
  AudioTrackLoadedData,
  LevelLoadedData,
  TrackLoadedData,
} from '../types/events';
import { ErrorData } from '../types/events';
import * as LevelHelper from './level-helper';
import { Events } from '../events';
import { ErrorTypes } from '../errors';

export default class BasePlaylistController implements NetworkComponentAPI {
  protected hls: Hls;
  protected timer: number = -1;
  protected canLoad: boolean = false;
  protected retryCount: number = 0;
  protected log: (msg: any) => void;
  protected warn: (msg: any) => void;

  constructor(hls: Hls, logPrefix: string) {
    this.log = logger.log.bind(logger, `${logPrefix}:`);
    this.warn = logger.warn.bind(logger, `${logPrefix}:`);
    this.hls = hls;
  }

  public destroy(): void {
    this.clearTimer();
    // @ts-ignore
    this.hls = this.log = this.warn = null;
  }

  protected onError(event: Events.ERROR, data: ErrorData): void {
    if (data.fatal && data.type === ErrorTypes.NETWORK_ERROR) {
      this.clearTimer();
    }
  }

  protected clearTimer(): void {
    clearTimeout(this.timer);
    this.timer = -1;
  }

  public startLoad(): void {
    this.canLoad = true;
    this.retryCount = 0;
    this.loadPlaylist();
  }

  public stopLoad(): void {
    this.canLoad = false;
    this.clearTimer();
  }

  protected switchParams(
    playlistUri: string,
    previous?: LevelDetails
  ): HlsUrlParameters | undefined {
    const renditionReports = previous?.renditionReports;
    if (renditionReports) {
      for (let i = 0; i < renditionReports.length; i++) {
        const attr = renditionReports[i];
        const uri = '' + attr.URI;
        if (uri === playlistUri.substr(-uri.length)) {
          const msn = parseInt(attr['LAST-MSN']);
          let part = parseInt(attr['LAST-PART']);
          if (previous && this.hls.config.lowLatencyMode) {
            const currentGoal = Math.min(
              previous.age - previous.partTarget,
              previous.targetduration
            );
            if (part !== undefined && currentGoal > previous.partTarget) {
              part += 1;
            }
          }
          if (Number.isFinite(msn)) {
            return new HlsUrlParameters(
              msn,
              Number.isFinite(part) ? part : undefined,
              HlsSkip.No
            );
          }
        }
      }
    }
  }

  protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {}

  protected shouldLoadTrack(track: MediaPlaylist): boolean {
    return (
      this.canLoad &&
      track &&
      !!track.url &&
      (!track.details || track.details.live)
    );
  }

  protected playlistLoaded(
    index: number,
    data: LevelLoadedData | AudioTrackLoadedData | TrackLoadedData,
    previousDetails?: LevelDetails
  ) {
    const { details, stats } = data;

    // Set last updated date-time
    const elapsed = stats.loading.end
      ? Math.max(0, self.performance.now() - stats.loading.end)
      : 0;
    details.advancedDateTime = Date.now() - elapsed;

    // if current playlist is a live playlist, arm a timer to reload it
    if (details.live || previousDetails?.live) {
      details.reloaded(previousDetails);
      if (previousDetails) {
        this.log(
          `live playlist ${index} ${
            details.advanced
              ? 'REFRESHED ' + details.lastPartSn + '-' + details.lastPartIndex
              : 'MISSED'
          }`
        );
      }
      // Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments
      if (previousDetails && details.fragments.length > 0) {
        LevelHelper.mergeDetails(previousDetails, details);
      }
      if (!this.canLoad || !details.live) {
        return;
      }
      let msn: number | undefined = undefined;
      let part: number | undefined = undefined;
      if (details.canBlockReload && details.endSN && details.advanced) {
        // Load level with LL-HLS delivery directives
        const lowLatencyMode = this.hls.config.lowLatencyMode;
        const lastPartIndex = details.lastPartIndex;
        if (lowLatencyMode) {
          msn = lastPartIndex !== -1 ? details.lastPartSn : details.endSN + 1;
          part = lastPartIndex !== -1 ? lastPartIndex + 1 : undefined;
        } else {
          // This playlist update will be late by one part (0). There is no way to know the last part number,
          // or request just the next sn without a part in most implementations.
          msn =
            lastPartIndex !== -1 ? details.lastPartSn + 1 : details.endSN + 1;
          part = lastPartIndex !== -1 ? 0 : undefined;
        }
        // Low-Latency CDN Tune-in: "age" header and time since load indicates we're behind by more than one part
        // Update directives to obtain the Playlist that has the estimated additional duration of media
        const lastAdvanced = details.age;
        const cdnAge = lastAdvanced + details.ageHeader;
        let currentGoal = Math.min(
          cdnAge - details.partTarget,
          details.targetduration * 1.5
        );
        if (currentGoal > 0) {
          if (previousDetails && currentGoal > previousDetails.tuneInGoal) {
            // If we attempted to get the next or latest playlist update, but currentGoal increased,
            // then we either can't catchup, or the "age" header cannot be trusted.
            this.warn(
              `CDN Tune-in goal increased from: ${previousDetails.tuneInGoal} to: ${currentGoal} with playlist age: ${details.age}`
            );
            currentGoal = 0;
          } else {
            const segments = Math.floor(currentGoal / details.targetduration);
            msn += segments;
            if (part !== undefined) {
              const parts = Math.round(
                (currentGoal % details.targetduration) / details.partTarget
              );
              part += parts;
            }
            this.log(
              `CDN Tune-in age: ${
                details.ageHeader
              }s last advanced ${lastAdvanced.toFixed(
                2
              )}s goal: ${currentGoal} skip sn ${segments} to part ${part}`
            );
          }
          details.tuneInGoal = currentGoal;
        }
        const deliveryDirectives = this.getDeliveryDirectives(
          details,
          data.deliveryDirectives,
          msn,
          part
        );
        this.loadPlaylist(deliveryDirectives);
        return;
      }
      const reloadInterval = computeReloadInterval(details, stats);
      this.log(
        `reload live playlist ${index} in ${Math.round(reloadInterval)} ms`
      );
      const deliveryDirectives = this.getDeliveryDirectives(
        details,
        data.deliveryDirectives,
        msn,
        part
      );
      this.timer = self.setTimeout(
        () => this.loadPlaylist(deliveryDirectives),
        reloadInterval
      );
    } else {
      this.clearTimer();
    }
  }

  private getDeliveryDirectives(
    details: LevelDetails,
    previousDeliveryDirectives: HlsUrlParameters | null,
    msn?: number,
    part?: number
  ) {
    let skip = getSkipValue(details, msn);
    if (previousDeliveryDirectives?.skip && details.deltaUpdateFailed) {
      msn = previousDeliveryDirectives.msn;
      part = previousDeliveryDirectives.part;
      skip = HlsSkip.No;
    }
    return new HlsUrlParameters(msn, part, skip);
  }

  protected retryLoadingOrFail(errorEvent: ErrorData): boolean {
    const { config } = this.hls;
    const retry = this.retryCount < config.levelLoadingMaxRetry;
    if (retry) {
      this.retryCount++;
      if (
        errorEvent.details.indexOf('LoadTimeOut') > -1 &&
        errorEvent.context?.deliveryDirectives
      ) {
        // The LL-HLS request already timed out so retry immediately
        this.warn(
          `retry playlist loading #${this.retryCount} after "${errorEvent.details}"`
        );
        this.loadPlaylist();
      } else {
        // exponential backoff capped to max retry timeout
        const delay = Math.min(
          Math.pow(2, this.retryCount) * config.levelLoadingRetryDelay,
          config.levelLoadingMaxRetryTimeout
        );
        // Schedule level/track reload
        this.timer = self.setTimeout(() => this.loadPlaylist(), delay);
        this.warn(
          `retry playlist loading #${this.retryCount} in ${delay} ms after "${errorEvent.details}"`
        );
      }
    } else {
      this.warn(`cannot recover from error "${errorEvent.details}"`);
      // stopping live reloading timer if any
      this.clearTimer();
      // switch error to fatal
      errorEvent.fatal = true;
    }
    return retry;
  }
}