Home Reference Source

src/controller/level-helper.ts

/**
 * @module LevelHelper
 * Providing methods dealing with playlist sliding and drift
 * */

import { logger } from '../utils/logger';
import { Fragment, Part } from '../loader/fragment';
import { LevelDetails } from '../loader/level-details';
import type { Level } from '../types/level';
import type { LoaderStats } from '../types/loader';
import type { MediaPlaylist } from '../types/media-playlist';

type FragmentIntersection = (oldFrag: Fragment, newFrag: Fragment) => void;
type PartIntersection = (oldPart: Part, newPart: Part) => void;

export function addGroupId(level: Level, type: string, id: string): void {
  switch (type) {
    case 'audio':
      if (!level.audioGroupIds) {
        level.audioGroupIds = [];
      }
      level.audioGroupIds.push(id);
      break;
    case 'text':
      if (!level.textGroupIds) {
        level.textGroupIds = [];
      }
      level.textGroupIds.push(id);
      break;
  }
}

export function assignTrackIdsByGroup(tracks: MediaPlaylist[]): void {
  const groups = {};
  tracks.forEach((track) => {
    const groupId = track.groupId || '';
    track.id = groups[groupId] = groups[groupId] || 0;
    groups[groupId]++;
  });
}

export function updatePTS(
  fragments: Fragment[],
  fromIdx: number,
  toIdx: number
): void {
  const fragFrom = fragments[fromIdx];
  const fragTo = fragments[toIdx];
  updateFromToPTS(fragFrom, fragTo);
}

function updateFromToPTS(fragFrom: Fragment, fragTo: Fragment) {
  const fragToPTS = fragTo.startPTS as number;
  // if we know startPTS[toIdx]
  if (Number.isFinite(fragToPTS)) {
    // update fragment duration.
    // it helps to fix drifts between playlist reported duration and fragment real duration
    let duration: number = 0;
    let frag: Fragment;
    if (fragTo.sn > fragFrom.sn) {
      duration = fragToPTS - fragFrom.start;
      frag = fragFrom;
    } else {
      duration = fragFrom.start - fragToPTS;
      frag = fragTo;
    }
    // TODO? Drift can go either way, or the playlist could be completely accurate
    // console.assert(duration > 0,
    //   `duration of ${duration} computed for frag ${frag.sn}, level ${frag.level}, there should be some duration drift between playlist and fragment!`);
    if (frag.duration !== duration) {
      frag.duration = duration;
    }
    // we dont know startPTS[toIdx]
  } else if (fragTo.sn > fragFrom.sn) {
    const contiguous = fragFrom.cc === fragTo.cc;
    // TODO: With part-loading end/durations we need to confirm the whole fragment is loaded before using (or setting) minEndPTS
    if (contiguous && fragFrom.minEndPTS) {
      fragTo.start = fragFrom.start + (fragFrom.minEndPTS - fragFrom.start);
    } else {
      fragTo.start = fragFrom.start + fragFrom.duration;
    }
  } else {
    fragTo.start = Math.max(fragFrom.start - fragTo.duration, 0);
  }
}

export function updateFragPTSDTS(
  details: LevelDetails | undefined,
  frag: Fragment,
  startPTS: number,
  endPTS: number,
  startDTS: number,
  endDTS: number
): number {
  const parsedMediaDuration = endPTS - startPTS;
  if (parsedMediaDuration <= 0) {
    logger.warn('Fragment should have a positive duration', frag);
    endPTS = startPTS + frag.duration;
    endDTS = startDTS + frag.duration;
  }
  let maxStartPTS = startPTS;
  let minEndPTS = endPTS;
  const fragStartPts = frag.startPTS as number;
  const fragEndPts = frag.endPTS as number;
  if (Number.isFinite(fragStartPts)) {
    // delta PTS between audio and video
    const deltaPTS = Math.abs(fragStartPts - startPTS);
    if (!Number.isFinite(frag.deltaPTS as number)) {
      frag.deltaPTS = deltaPTS;
    } else {
      frag.deltaPTS = Math.max(deltaPTS, frag.deltaPTS as number);
    }

    maxStartPTS = Math.max(startPTS, fragStartPts);
    startPTS = Math.min(startPTS, fragStartPts);
    startDTS = Math.min(startDTS, frag.startDTS);

    minEndPTS = Math.min(endPTS, fragEndPts);
    endPTS = Math.max(endPTS, fragEndPts);
    endDTS = Math.max(endDTS, frag.endDTS);
  }
  frag.duration = endPTS - startPTS;

  const drift = startPTS - frag.start;
  frag.appendedPTS = endPTS;
  frag.start = frag.startPTS = startPTS;
  frag.maxStartPTS = maxStartPTS;
  frag.startDTS = startDTS;
  frag.endPTS = endPTS;
  frag.minEndPTS = minEndPTS;
  frag.endDTS = endDTS;

  const sn = frag.sn as number; // 'initSegment'
  // exit if sn out of range
  if (!details || sn < details.startSN || sn > details.endSN) {
    return 0;
  }
  let i;
  const fragIdx = sn - details.startSN;
  const fragments = details.fragments;
  // update frag reference in fragments array
  // rationale is that fragments array might not contain this frag object.
  // this will happen if playlist has been refreshed between frag loading and call to updateFragPTSDTS()
  // if we don't update frag, we won't be able to propagate PTS info on the playlist
  // resulting in invalid sliding computation
  fragments[fragIdx] = frag;
  // adjust fragment PTS/duration from seqnum-1 to frag 0
  for (i = fragIdx; i > 0; i--) {
    updateFromToPTS(fragments[i], fragments[i - 1]);
  }

  // adjust fragment PTS/duration from seqnum to last frag
  for (i = fragIdx; i < fragments.length - 1; i++) {
    updateFromToPTS(fragments[i], fragments[i + 1]);
  }
  if (details.fragmentHint) {
    updateFromToPTS(fragments[fragments.length - 1], details.fragmentHint);
  }

  details.PTSKnown = details.alignedSliding = true;
  return drift;
}

export function mergeDetails(
  oldDetails: LevelDetails,
  newDetails: LevelDetails
): void {
  // potentially retrieve cached initsegment
  if (newDetails.initSegment && oldDetails.initSegment) {
    newDetails.initSegment = oldDetails.initSegment;
  }

  if (oldDetails.fragmentHint) {
    // prevent PTS and duration from being adjusted on the next hint
    delete oldDetails.fragmentHint.endPTS;
  }
  // check if old/new playlists have fragments in common
  // loop through overlapping SN and update startPTS , cc, and duration if any found
  let ccOffset = 0;
  let PTSFrag;
  mapFragmentIntersection(
    oldDetails,
    newDetails,
    (oldFrag: Fragment, newFrag: Fragment) => {
      if (oldFrag.relurl) {
        // Do not compare CC if the old fragment has no url. This is a level.fragmentHint used by LL-HLS parts.
        // It maybe be off by 1 if it was created before any parts or discontinuity tags were appended to the end
        // of the playlist.
        ccOffset = oldFrag.cc - newFrag.cc;
      }
      if (
        Number.isFinite(oldFrag.startPTS) &&
        Number.isFinite(oldFrag.endPTS)
      ) {
        newFrag.start = newFrag.startPTS = oldFrag.startPTS as number;
        newFrag.startDTS = oldFrag.startDTS;
        newFrag.appendedPTS = oldFrag.appendedPTS;
        newFrag.maxStartPTS = oldFrag.maxStartPTS;

        newFrag.endPTS = oldFrag.endPTS;
        newFrag.endDTS = oldFrag.endDTS;
        newFrag.minEndPTS = oldFrag.minEndPTS;
        newFrag.duration =
          (oldFrag.endPTS as number) - (oldFrag.startPTS as number);

        if (newFrag.duration) {
          PTSFrag = newFrag;
        }

        // PTS is known when any segment has startPTS and endPTS
        newDetails.PTSKnown = newDetails.alignedSliding = true;
      }
      newFrag.elementaryStreams = oldFrag.elementaryStreams;
      newFrag.loader = oldFrag.loader;
      newFrag.stats = oldFrag.stats;
      newFrag.urlId = oldFrag.urlId;
    }
  );

  if (newDetails.skippedSegments) {
    newDetails.deltaUpdateFailed = newDetails.fragments.some((frag) => !frag);
    if (newDetails.deltaUpdateFailed) {
      logger.warn(
        '[level-helper] Previous playlist missing segments skipped in delta playlist'
      );
      for (let i = newDetails.skippedSegments; i--; ) {
        newDetails.fragments.shift();
      }
      newDetails.startSN = newDetails.fragments[0].sn as number;
      newDetails.startCC = newDetails.fragments[0].cc;
    }
  }

  const newFragments = newDetails.fragments;
  if (ccOffset) {
    logger.warn('discontinuity sliding from playlist, take drift into account');
    for (let i = 0; i < newFragments.length; i++) {
      newFragments[i].cc += ccOffset;
    }
  }
  if (newDetails.skippedSegments) {
    if (!newDetails.initSegment) {
      newDetails.initSegment = oldDetails.initSegment;
    }
    newDetails.startCC = newDetails.fragments[0].cc;
  }

  // Merge parts
  mapPartIntersection(
    oldDetails.partList,
    newDetails.partList,
    (oldPart: Part, newPart: Part) => {
      newPart.elementaryStreams = oldPart.elementaryStreams;
      newPart.stats = oldPart.stats;
    }
  );

  // if at least one fragment contains PTS info, recompute PTS information for all fragments
  if (PTSFrag) {
    updateFragPTSDTS(
      newDetails,
      PTSFrag,
      PTSFrag.startPTS,
      PTSFrag.endPTS,
      PTSFrag.startDTS,
      PTSFrag.endDTS
    );
  } else {
    // ensure that delta is within oldFragments range
    // also adjust sliding in case delta is 0 (we could have old=[50-60] and new=old=[50-61])
    // in that case we also need to adjust start offset of all fragments
    adjustSliding(oldDetails, newDetails);
  }

  if (newFragments.length) {
    newDetails.totalduration = newDetails.edge - newFragments[0].start;
  }

  newDetails.driftStartTime = oldDetails.driftStartTime;
  newDetails.driftStart = oldDetails.driftStart;
  const advancedDateTime = newDetails.advancedDateTime;
  if (newDetails.advanced && advancedDateTime) {
    const edge = newDetails.edge;
    if (!newDetails.driftStart) {
      newDetails.driftStartTime = advancedDateTime;
      newDetails.driftStart = edge;
    }
    newDetails.driftEndTime = advancedDateTime;
    newDetails.driftEnd = edge;
  } else {
    newDetails.driftEndTime = oldDetails.driftEndTime;
    newDetails.driftEnd = oldDetails.driftEnd;
    newDetails.advancedDateTime = oldDetails.advancedDateTime;
  }
}

export function mapPartIntersection(
  oldParts: Part[] | null,
  newParts: Part[] | null,
  intersectionFn: PartIntersection
) {
  if (oldParts && newParts) {
    let delta = 0;
    for (let i = 0, len = oldParts.length; i <= len; i++) {
      const oldPart = oldParts[i];
      const newPart = newParts[i + delta];
      if (
        oldPart &&
        newPart &&
        oldPart.index === newPart.index &&
        oldPart.fragment.sn === newPart.fragment.sn
      ) {
        intersectionFn(oldPart, newPart);
      } else {
        delta--;
      }
    }
  }
}

export function mapFragmentIntersection(
  oldDetails: LevelDetails,
  newDetails: LevelDetails,
  intersectionFn: FragmentIntersection
): void {
  const skippedSegments = newDetails.skippedSegments;
  const start =
    Math.max(oldDetails.startSN, newDetails.startSN) - newDetails.startSN;
  const end =
    (oldDetails.fragmentHint ? 1 : 0) +
    (skippedSegments
      ? newDetails.endSN
      : Math.min(oldDetails.endSN, newDetails.endSN)) -
    newDetails.startSN;
  const delta = newDetails.startSN - oldDetails.startSN;
  const newFrags = newDetails.fragmentHint
    ? newDetails.fragments.concat(newDetails.fragmentHint)
    : newDetails.fragments;
  const oldFrags = oldDetails.fragmentHint
    ? oldDetails.fragments.concat(oldDetails.fragmentHint)
    : oldDetails.fragments;

  for (let i = start; i <= end; i++) {
    const oldFrag = oldFrags[delta + i];
    let newFrag = newFrags[i];
    if (skippedSegments && !newFrag && i < skippedSegments) {
      // Fill in skipped segments in delta playlist
      newFrag = newDetails.fragments[i] = oldFrag;
    }
    if (oldFrag && newFrag) {
      intersectionFn(oldFrag, newFrag);
    }
  }
}

export function adjustSliding(
  oldDetails: LevelDetails,
  newDetails: LevelDetails
): void {
  const delta =
    newDetails.startSN + newDetails.skippedSegments - oldDetails.startSN;
  const oldFragments = oldDetails.fragments;
  const newFragments = newDetails.fragments;
  if (delta < 0 || delta >= oldFragments.length) {
    return;
  }
  const playlistStartOffset = oldFragments[delta].start;
  if (playlistStartOffset) {
    for (let i = newDetails.skippedSegments; i < newFragments.length; i++) {
      newFragments[i].start += playlistStartOffset;
    }
    if (newDetails.fragmentHint) {
      newDetails.fragmentHint.start += playlistStartOffset;
    }
  }
}

export function computeReloadInterval(
  newDetails: LevelDetails,
  stats: LoaderStats
): number {
  const reloadInterval = 1000 * newDetails.levelTargetDuration;
  const reloadIntervalAfterMiss = reloadInterval / 2;
  const timeSinceLastModified = newDetails.age;
  const useLastModified =
    timeSinceLastModified > 0 && timeSinceLastModified < reloadInterval * 3;
  const roundTrip = stats.loading.end - stats.loading.start;

  let estimatedTimeUntilUpdate;
  let availabilityDelay = newDetails.availabilityDelay;
  // let estimate = 'average';

  if (newDetails.updated === false) {
    if (useLastModified) {
      // estimate = 'miss round trip';
      // We should have had a hit so try again in the time it takes to get a response,
      // but no less than 1/3 second.
      const minRetry = 333 * newDetails.misses;
      estimatedTimeUntilUpdate = Math.max(
        Math.min(reloadIntervalAfterMiss, roundTrip * 2),
        minRetry
      );
      newDetails.availabilityDelay =
        (newDetails.availabilityDelay || 0) + estimatedTimeUntilUpdate;
    } else {
      // estimate = 'miss half average';
      // follow HLS Spec, If the client reloads a Playlist file and finds that it has not
      // changed then it MUST wait for a period of one-half the target
      // duration before retrying.
      estimatedTimeUntilUpdate = reloadIntervalAfterMiss;
    }
  } else if (useLastModified) {
    // estimate = 'next modified date';
    // Get the closest we've been to timeSinceLastModified on update
    availabilityDelay = Math.min(
      availabilityDelay || reloadInterval / 2,
      timeSinceLastModified
    );
    newDetails.availabilityDelay = availabilityDelay;
    estimatedTimeUntilUpdate =
      availabilityDelay + reloadInterval - timeSinceLastModified;
  } else {
    estimatedTimeUntilUpdate = reloadInterval - roundTrip;
  }

  // console.log(`[computeReloadInterval] live reload ${newDetails.updated ? 'REFRESHED' : 'MISSED'}`,
  //   '\n  method', estimate,
  //   '\n  estimated time until update =>', estimatedTimeUntilUpdate,
  //   '\n  average target duration', reloadInterval,
  //   '\n  time since modified', timeSinceLastModified,
  //   '\n  time round trip', roundTrip,
  //   '\n  availability delay', availabilityDelay);

  return Math.round(estimatedTimeUntilUpdate);
}

export function getFragmentWithSN(level: Level, sn: number): Fragment | null {
  if (!level || !level.details) {
    return null;
  }
  const levelDetails = level.details;
  let fragment: Fragment | undefined =
    levelDetails.fragments[sn - levelDetails.startSN];
  if (fragment) {
    return fragment;
  }
  fragment = levelDetails.fragmentHint;
  if (fragment && fragment.sn === sn) {
    return fragment;
  }
  return null;
}

export function getPartWith(
  level: Level,
  sn: number,
  partIndex: number
): Part | null {
  if (!level || !level.details) {
    return null;
  }
  const partList = level.details.partList;
  if (partList) {
    for (let i = partList.length; i--; ) {
      const part = partList[i];
      if (part.index === partIndex && part.fragment.sn === sn) {
        return part;
      }
    }
  }
  return null;
}