import type Hls from '../hls'; import type { NetworkComponentAPI } from '../types/component-api'; import { getSkipValue, HlsSkip, HlsUrlParameters, Level } from '../types/level'; import { computeReloadInterval, mergeDetails } from '../utils/level-helper'; import { ErrorData } from '../types/events'; import { getRetryDelay, isTimeoutError } from '../utils/error-helper'; import { NetworkErrorAction } from './error-controller'; 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'; export default class BasePlaylistController implements NetworkComponentAPI { protected hls: Hls; protected timer: number = -1; protected requestScheduled: number = -1; protected canLoad: boolean = false; 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 clearTimer(): void { if (this.timer !== -1) { self.clearTimeout(this.timer); this.timer = -1; } } public startLoad(): void { this.canLoad = true; this.requestScheduled = -1; this.loadPlaylist(); } public stopLoad(): void { this.canLoad = false; this.clearTimer(); } protected switchParams( playlistUri: string, previous: LevelDetails | undefined, current: LevelDetails | undefined, ): HlsUrlParameters | undefined { const renditionReports = previous?.renditionReports; if (renditionReports) { let foundIndex = -1; for (let i = 0; i < renditionReports.length; i++) { const attr = renditionReports[i]; let uri: string; try { uri = new self.URL(attr.URI, previous.url).href; } catch (error) { logger.warn( `Could not construct new URL for Rendition Report: ${error}`, ); uri = attr.URI || ''; } // Use exact match. Otherwise, the last partial match, if any, will be used // (Playlist URI includes a query string that the Rendition Report does not) if (uri === playlistUri) { foundIndex = i; break; } else if (uri === playlistUri.substring(0, uri.length)) { foundIndex = i; } } if (foundIndex !== -1) { const attr = renditionReports[foundIndex]; const msn = parseInt(attr['LAST-MSN']) || previous?.lastPartSn; let part = parseInt(attr['LAST-PART']) || previous?.lastPartIndex; if (this.hls.config.lowLatencyMode) { const currentGoal = Math.min( previous.age - previous.partTarget, previous.targetduration, ); if (part >= 0 && currentGoal > previous.partTarget) { part += 1; } } const skip = current && getSkipValue(current); return new HlsUrlParameters(msn, part >= 0 ? part : undefined, skip); } } } protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void { if (this.requestScheduled === -1) { this.requestScheduled = self.performance.now(); } // Loading is handled by the subclasses } protected shouldLoadPlaylist( playlist: Level | MediaPlaylist | null | undefined, ): boolean { return ( this.canLoad && !!playlist && !!playlist.url && (!playlist.details || playlist.details.live) ); } protected shouldReloadPlaylist( playlist: Level | MediaPlaylist | null | undefined, ): boolean { return ( this.timer === -1 && this.requestScheduled === -1 && this.shouldLoadPlaylist(playlist) ); } protected playlistLoaded( index: number, data: LevelLoadedData | AudioTrackLoadedData | TrackLoadedData, previousDetails?: LevelDetails, ) { const { details, stats } = data; // Set last updated date-time const now = self.performance.now(); const elapsed = stats.loading.first ? Math.max(0, now - stats.loading.first) : 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 : details.updated ? 'UPDATED' : 'MISSED' }`, ); } // Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments if (previousDetails && details.fragments.length > 0) { mergeDetails(previousDetails, details); } if (!this.canLoad || !details.live) { return; } let deliveryDirectives: HlsUrlParameters | undefined; 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 lastPartSn = details.lastPartSn; const endSn = details.endSN; const lastPartIndex = details.lastPartIndex; const hasParts = lastPartIndex !== -1; const lastPart = lastPartSn === endSn; // When low latency mode is disabled, we'll skip part requests once the last part index is found const nextSnStartIndex = lowLatencyMode ? 0 : lastPartIndex; if (hasParts) { msn = lastPart ? endSn + 1 : lastPartSn; part = lastPart ? nextSnStartIndex : lastPartIndex + 1; } else { msn = endSn + 1; } // 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; } deliveryDirectives = this.getDeliveryDirectives( details, data.deliveryDirectives, msn, part, ); if (lowLatencyMode || !lastPart) { this.loadPlaylist(deliveryDirectives); return; } } else if (details.canBlockReload || details.canSkipUntil) { deliveryDirectives = this.getDeliveryDirectives( details, data.deliveryDirectives, msn, part, ); } const bufferInfo = this.hls.mainForwardBufferInfo; const position = bufferInfo ? bufferInfo.end - bufferInfo.len : 0; const distanceToLiveEdgeMs = (details.edge - position) * 1000; const reloadInterval = computeReloadInterval( details, distanceToLiveEdgeMs, ); if (details.updated && now > this.requestScheduled + reloadInterval) { this.requestScheduled = stats.loading.start; } if (msn !== undefined && details.canBlockReload) { this.requestScheduled = stats.loading.first + reloadInterval - (details.partTarget * 1000 || 1000); } else if ( this.requestScheduled === -1 || this.requestScheduled + reloadInterval < now ) { this.requestScheduled = now; } else if (this.requestScheduled - now <= 0) { this.requestScheduled += reloadInterval; } let estimatedTimeUntilUpdate = this.requestScheduled - now; estimatedTimeUntilUpdate = Math.max(0, estimatedTimeUntilUpdate); this.log( `reload live playlist ${index} in ${Math.round( estimatedTimeUntilUpdate, )} ms`, ); // this.log( // `live reload ${details.updated ? 'REFRESHED' : 'MISSED'} // reload in ${estimatedTimeUntilUpdate / 1000} // round trip ${(stats.loading.end - stats.loading.start) / 1000} // diff ${ // (reloadInterval - // (estimatedTimeUntilUpdate + // stats.loading.end - // stats.loading.start)) / // 1000 // } // reload interval ${reloadInterval / 1000} // target duration ${details.targetduration} // distance to edge ${distanceToLiveEdgeMs / 1000}` // ); this.timer = self.setTimeout( () => this.loadPlaylist(deliveryDirectives), estimatedTimeUntilUpdate, ); } else { this.clearTimer(); } } private getDeliveryDirectives( details: LevelDetails, previousDeliveryDirectives: HlsUrlParameters | null, msn?: number, part?: number, ): HlsUrlParameters { let skip = getSkipValue(details); if (previousDeliveryDirectives?.skip && details.deltaUpdateFailed) { msn = previousDeliveryDirectives.msn; part = previousDeliveryDirectives.part; skip = HlsSkip.No; } return new HlsUrlParameters(msn, part, skip); } protected checkRetry(errorEvent: ErrorData): boolean { const errorDetails = errorEvent.details; const isTimeout = isTimeoutError(errorEvent); const errorAction = errorEvent.errorAction; const { action, retryCount = 0, retryConfig } = errorAction || {}; const retry = !!errorAction && !!retryConfig && (action === NetworkErrorAction.RetryRequest || (!errorAction.resolved && action === NetworkErrorAction.SendAlternateToPenaltyBox)); if (retry) { this.requestScheduled = -1; if (retryCount >= retryConfig.maxNumRetry) { return false; } if (isTimeout && errorEvent.context?.deliveryDirectives) { // The LL-HLS request already timed out so retry immediately this.warn( `Retrying playlist loading ${retryCount + 1}/${ retryConfig.maxNumRetry } after "${errorDetails}" without delivery-directives`, ); this.loadPlaylist(); } else { const delay = getRetryDelay(retryConfig, retryCount); // Schedule level/track reload this.timer = self.setTimeout(() => this.loadPlaylist(), delay); this.warn( `Retrying playlist loading ${retryCount + 1}/${ retryConfig.maxNumRetry } after "${errorDetails}" in ${delay}ms`, ); } // `levelRetry = true` used to inform other controllers that a retry is happening errorEvent.levelRetry = true; errorAction.resolved = true; } return retry; } }