Fix Live join following Interstitial preroll (#7846)

* Fix live join following preroll, including `_HLS_start_offset` on following midroll asset-list requests
Fixes #7845

* Make live preroll resumption offset relative to live start
Added rolling playlist update to test
This commit is contained in:
Rob Walch
2026-05-14 12:35:32 -07:00
committed by GitHub
parent 987320e051
commit 1a6cdfe0ef
4 changed files with 387 additions and 17 deletions
+7 -2
View File
@@ -193,6 +193,12 @@ export default class BaseStreamController
public get startPositionValue(): number {
const { nextLoadPosition, startPosition } = this;
if (startPosition === -1 && nextLoadPosition) {
const details = this.getLevelDetails();
if (details?.live) {
const liveSyncPosition = this.hls.liveSyncPosition;
const liveStart = liveSyncPosition || details.fragmentStart;
return liveStart + this.timelineOffset;
}
return nextLoadPosition;
}
return startPosition;
@@ -2229,8 +2235,7 @@ export default class BaseStreamController
// in that case, reset startFragRequested flag
if (!this.hls.hasEnoughToStart) {
this.startFragRequested = false;
const level = this.levelLastLoaded;
const details = level ? level.details : null;
const details = this.getLevelDetails();
if (details?.live) {
// Update the start position and return to IDLE to recover live start
this.log(`resetting startPosition for live start`);
+16 -3
View File
@@ -1564,9 +1564,12 @@ export default class InterstitialsController
main,
};
this.mediaSelection = currentSelection;
const startPosition = this.hls.startPosition;
this.schedule.parseInterstitialDateRanges(
currentSelection,
this.hls.config.interstitialAppendInPlace,
startPosition === -1 ? 0 : startPosition,
);
if (!this.effectivePlayingItem && this.schedule.items) {
@@ -1888,7 +1891,13 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
if (!mediaSelection) {
return;
}
this.schedule?.updateSchedule(mediaSelection, [], forceUpdate);
const liveSyncPosition = this.hls?.liveSyncPosition || 0;
this.schedule?.updateSchedule(
mediaSelection,
[],
liveSyncPosition,
forceUpdate,
);
}
// Schedule buffer control
@@ -2127,8 +2136,8 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
const playOnce = interstitial.cue.once;
if (neverLoaded) {
const timelineStart = interstitial.timelineStart;
const playingItem = this.playingItem;
if (interstitial.appendInPlace) {
const playingItem = this.playingItem;
if (
!this.isInterstitial(playingItem) &&
playingItem?.nextEvent?.identifier === interstitial.identifier
@@ -2138,7 +2147,11 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
}
let hlsStartOffset;
let liveStartPosition = 0;
if (!this.playingItem && this.primaryLive) {
if (
(!playingItem ||
(this.isInterstitial(playingItem) && playingItem.event.cue.pre)) &&
this.primaryLive
) {
liveStartPosition = this.hls.startPosition;
if (liveStartPosition === -1) {
liveStartPosition = this.hls.liveSyncPosition || 0;
+16 -4
View File
@@ -243,6 +243,7 @@ export class InterstitialsSchedule extends Logger {
public parseInterstitialDateRanges(
mediaSelection: MediaSelection,
enableAppendInPlace: boolean,
startPosition: number = 0,
) {
const details = mediaSelection.main.details!;
const { dateRanges } = details;
@@ -297,18 +298,23 @@ export class InterstitialsSchedule extends Logger {
this.removeEvent(interstitial);
});
this.updateSchedule(mediaSelection, removedInterstitials);
this.updateSchedule(mediaSelection, removedInterstitials, startPosition);
}
public updateSchedule(
mediaSelection: MediaSelection,
removedInterstitials: InterstitialEvent[] = [],
startPosition: number = 0,
forceUpdate: boolean = false,
) {
const events = this.events || [];
if (events.length || removedInterstitials.length || this.length < 2) {
const currentItems = this.items;
const updatedItems = this.parseSchedule(events, mediaSelection);
const updatedItems = this.parseSchedule(
events,
mediaSelection,
startPosition,
);
const updated =
forceUpdate ||
removedInterstitials.length ||
@@ -360,6 +366,7 @@ export class InterstitialsSchedule extends Logger {
private parseSchedule(
interstitialEvents: InterstitialEvent[],
mediaSelection: MediaSelection,
startPosition: number = 0,
): InterstitialScheduleItem[] {
const schedule: InterstitialScheduleItem[] = [];
const details = mediaSelection.main.details!;
@@ -391,7 +398,12 @@ export class InterstitialsSchedule extends Logger {
interstitial.timelineOccupancy === TimelineOccupancy.Range
? interstitialDuration
: 0;
const resumptionOffset = interstitial.resumptionOffset;
const livePrerollResumption =
preroll && details.live
? startPosition + (interstitial.resumeOffset || 0)
: 0;
const resumptionOffset =
livePrerollResumption || interstitial.resumptionOffset;
const inSameStartTimeSequence = previousEvent?.startTime === eventStart;
const start = eventStart + interstitial.cumulativeDuration;
let end = appendInPlace
@@ -475,7 +487,7 @@ export class InterstitialsSchedule extends Logger {
// Interstitial starts after end of primary VOD - not included in schedule
return;
}
const resumeTime = interstitial.resumeTime;
const resumeTime = livePrerollResumption || interstitial.resumeTime;
if (postroll || resumeTime > primaryDuration) {
primaryPosition = primaryDuration;
} else {
@@ -2,6 +2,10 @@ import { config as chaiConfig, expect, use } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import InterstitialsController from '../../../src/controller/interstitials-controller';
import {
type InterstitialScheduleItem,
segmentToString,
} from '../../../src/controller/interstitials-schedule';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { TimelineOccupancy } from '../../../src/loader/interstitial-event';
@@ -13,7 +17,6 @@ import { AttrList } from '../../../src/utils/attr-list';
import { MockMediaElement } from '../../mocks/mock-media';
import type { HlsConfig } from '../../../src/config';
import type AbrController from '../../../src/controller/abr-controller';
import type { InterstitialScheduleItem } from '../../../src/controller/interstitials-schedule';
import type LatencyController from '../../../src/controller/latency-controller';
import type LevelController from '../../../src/controller/level-controller';
import type StreamController from '../../../src/controller/stream-controller';
@@ -76,16 +79,17 @@ function expectItemToHaveProperties(
schedule: InterstitialScheduleItem[],
itemIndex: number,
expected: Record<string, number | string | object | null>,
note?: string,
) {
const item = schedule[itemIndex];
Object.keys(expected).forEach((key) => {
// Use deep equals on all properties except for InterstitialEvents ('event' and 'nextEvent')
if (key === 'event' || key === 'nextEvent' || key === 'previousEvent') {
expect(item, 'Schedule Index ' + itemIndex)
expect(item, `${note || 'properties'}: Schedule Index ${itemIndex}`)
.to.be.an('object')
.which.has.property(key);
const debug =
`Schedule Index ${itemIndex} ['${key}']:` +
`${note || 'properties'}: Schedule Index ${itemIndex} ['${key}']:` +
JSON.stringify(
item,
(key, value) =>
@@ -101,12 +105,12 @@ function expectItemToHaveProperties(
expect(item[key], debug).includes(expectedValue);
}
} else {
expect(item, 'Schedule Index ' + itemIndex)
expect(item, `${note || 'properties'}: Schedule Index ${itemIndex}`)
.to.be.an('object')
.which.has.property(key)
.which.deep.equals(
expected[key],
`Schedule Index ${itemIndex} ['${key}']:` +
`${note || 'properties'}: Schedule Index ${itemIndex} ['${key}']:` +
JSON.stringify(
item,
(key, value) =>
@@ -114,7 +118,11 @@ function expectItemToHaveProperties(
key === 'previousEvent' ||
key === 'event'
? `${key} <${value ? value.identifier : value}>`
: value,
: typeof value !== 'number'
? value
: Number.isFinite(value)
? value
: `<${value}>`,
2,
),
);
@@ -125,13 +133,19 @@ function expectItemToHaveProperties(
function expectScheduleToInclude(
schedule: InterstitialScheduleItem[],
itemAssertions: Record<string, number | string | object | null>[],
note?: string,
) {
expect(schedule).to.have.lengthOf(itemAssertions.length);
note = `${note || 'schedule-length'}: ${scheduleToString(schedule)}`;
expect(schedule, note).to.have.lengthOf(itemAssertions.length);
itemAssertions.forEach((assertion, i) => {
expectItemToHaveProperties(schedule, i, assertion);
expectItemToHaveProperties(schedule, i, assertion, note);
});
}
function scheduleToString(schedule: InterstitialScheduleItem[]) {
return schedule.map((item) => segmentToString(item)).join(', ');
}
describe('InterstitialsController', function () {
const sandbox = sinon.createSandbox();
let hls: HlsTestable;
@@ -2183,4 +2197,330 @@ media_w507366714_268.ts`;
expect(interstitials.primary.currentTime).to.equal(0, 'timelinePos b');
});
});
describe('#7845 Live start folloing preroll', function () {
describe('resumes at live-edge', function () {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
#EXT-X-DATERANGE:ID="pre-resume-at-live",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",CUE="PRE,ONCE",DURATION=15,X-ASSET-URI="https://example.com/pre.m3u8?_HLS_interstitial_id=pre-resume-at-live"
#EXT-X-MAP:URI="fileSequence0.mp4"
#EXTINF:6.000,
fileSequence1.mp4
#EXTINF:6.000,
fileSequence2.mp4
#EXTINF:6.000,
fileSequence3.mp4
#EXTINF:6.000,
fileSequence4.mp4
#EXT-X-DATERANGE:ID="mid-starts-at-24",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:24.000Z",X-TIMELINE-OCCUPIES="RANGE",DURATION=10,X-ASSET-LIST="https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-24"
#EXTINF:6.000,
fileSequence5.mp4
#EXTINF:6.000,
fileSequence6.mp4
#EXTINF:6.000,
fileSequence7.mp4
#EXTINF:6.000,
fileSequence8.mp4`;
it('should include _HLS_start_offset in midroll asset-list request, following preroll with implicit resumption offset', function () {
setLoadedLevelDetails(playlist);
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(interstitials.bufferingIndex).to.equal(-1, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(-1, 'playingIndex');
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
expectScheduleToInclude(
interstitials.schedule,
[
{
event: {
identifier: 'pre-resume-at-live',
},
start: 0,
end: 30,
playout: { start: 0, end: 15 },
integrated: { start: 0, end: 0 },
},
{
event: {
identifier: 'mid-starts-at-24',
},
start: 24,
end: 34,
playout: { start: 15, end: 25 },
integrated: { start: 0, end: 10 },
},
{
previousEvent: {
identifier: 'mid-starts-at-24',
},
nextEvent: null,
start: 34,
end: Infinity,
playout: { start: 25, end: Infinity },
integrated: { start: 10, end: Infinity },
},
],
'before-preroll',
);
attachMediaToHls();
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex');
expect(
interstitials.interstitialPlayer,
`interstitialPlayer`,
).to.include({
playingIndex: 0,
currentTime: 0,
duration: 15,
});
expect(
interstitials.interstitialPlayer?.scheduleItem?.event,
`interstitialPlayer.scheduleItem`,
).to.include({ identifier: 'pre-resume-at-live' });
expect(interstitials.events[1].assetListLoader).to.equal(undefined);
// Capture asset-list request
const loadSpy = sandbox.spy(hls.config.loader.prototype, 'load');
const primaryId = hls.sessionId;
// skip preroll to advance schedule
interstitials.skip();
expect(loadSpy).calledOnce;
const assetListUrl = loadSpy.getCalls()[0].args[0].url;
expect(
assetListUrl,
'_HLS_primary_id and _HLS_start_offset match',
).to.equal(
`https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-24&_HLS_primary_id=${primaryId}&_HLS_start_offset=6`,
);
// Removing the CUE="ONCE" interstitial changes the `schedule` items, but does not remove it from `events`
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
expectScheduleToInclude(
interstitials.schedule,
[
{
previousEvent: null,
nextEvent: {
identifier: 'mid-starts-at-24',
},
start: 0,
end: 24,
playout: { start: 0, end: 24 },
integrated: { start: 0, end: 24 },
},
{
event: {
identifier: 'mid-starts-at-24',
},
start: 24,
end: 34,
playout: { start: 24, end: 34 },
integrated: { start: 24, end: 34 },
},
{
previousEvent: {
identifier: 'mid-starts-at-24',
},
nextEvent: null,
start: 34,
end: Infinity,
playout: { start: 34, end: Infinity },
integrated: { start: 34, end: Infinity },
},
],
'after-preroll',
);
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(30, 'timelinePos b');
});
});
describe('resumes at X-RESUME-OFFSET=15 (t + 15)', function () {
const playlist = `#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
#EXT-X-DATERANGE:ID="pre-resume-at-t15",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",CUE="PRE,ONCE",DURATION=15,X-RESUME-OFFSET=15,X-ASSET-URI="https://example.com/pre.m3u8?_HLS_interstitial_id=pre-resume-at-t15"
#EXT-X-MAP:URI="fileSequence0.mp4"
#EXTINF:10.000,
fileSequence-0.s
#EXTINF:10.000,
fileSequence-10.s
#EXTINF:10.000,
fileSequence-20.s
#EXTINF:10.000,
fileSequence-30.s
#EXTINF:10.000,
fileSequence-40.s
#EXTINF:10.000,
fileSequence-50.s
# ends with 60s duration, live start @30 (-n3)
#EXT-X-DATERANGE:ID="mid-starts-at-40",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:40.000Z",X-TIMELINE-OCCUPIES="RANGE",DURATION=10,X-ASSET-LIST="https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-40"
`;
const playlist2 = `#EXTM3U
#EXT-X-TARGETDURATION:10
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PROGRAM-DATE-TIME:2024-02-23T15:00:00.000Z
#EXT-X-DATERANGE:ID="pre-resume-at-t15",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:00.000Z",CUE="PRE,ONCE",DURATION=15,X-RESUME-OFFSET=15,X-ASSET-URI="https://example.com/pre.m3u8?_HLS_interstitial_id=pre-resume-at-t15"
#EXT-X-MAP:URI="fileSequence0.mp4"
#EXTINF:10.000,
fileSequence-0.s
#EXTINF:10.000,
fileSequence-10.s
#EXTINF:10.000,
fileSequence-20.s
#EXTINF:10.000,
fileSequence-30.s
#EXTINF:10.000,
fileSequence-40.s
#EXTINF:10.000,
fileSequence-50.s
#EXT-X-DATERANGE:ID="mid-starts-at-40",CLASS="com.apple.hls.interstitial",START-DATE="2024-02-23T15:00:40.000Z",X-TIMELINE-OCCUPIES="RANGE",DURATION=10,X-ASSET-LIST="https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-40"
#EXTINF:10.000,
fileSequence-60.s
`;
it('should include _HLS_start_offset in midroll asset-list request, following preroll with explicit resumption offset', function () {
attachMediaToHls();
const details = setLoadedLevelDetails(playlist);
const interstitials = interstitialsController.interstitialsManager;
if (!interstitials) {
expect(interstitials, 'interstitialsManager').to.be.an('object');
return;
}
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
expectScheduleToInclude(
interstitials.schedule,
[
{
event: {
identifier: 'pre-resume-at-t15',
},
start: 0,
end: 45,
playout: { start: 0, end: 15 },
integrated: { start: 0, end: 0 },
},
{
event: {
identifier: 'mid-starts-at-40',
},
start: 40,
end: 50,
playout: { start: 15, end: 25 },
integrated: { start: 0, end: 10 },
},
{
previousEvent: {
identifier: 'mid-starts-at-40',
},
nextEvent: null,
start: 50,
end: Infinity,
playout: { start: 25, end: Infinity },
integrated: { start: 10, end: Infinity },
},
],
'before-preroll',
);
expect(interstitials.bufferingIndex).to.equal(0, 'bufferingIndex');
expect(interstitials.playingIndex).to.equal(0, 'playingIndex');
expect(
interstitials.interstitialPlayer,
`interstitialPlayer`,
).to.include({
playingIndex: 0,
currentTime: 0,
duration: 15,
});
expect(
interstitials.interstitialPlayer?.scheduleItem?.event,
`interstitialPlayer.scheduleItem`,
).to.include({ identifier: 'pre-resume-at-t15' });
expect(interstitials.events[1].assetListLoader).to.equal(undefined);
// Capture asset-list request
const loadSpy = sandbox.spy(hls.config.loader.prototype, 'load');
const primaryId = hls.sessionId;
// Advance playlists
const timeSinceLoadedStub = sinon.stub(details, 'age');
timeSinceLoadedStub.get(() => 15);
const details2 = setLoadedLevelDetails(playlist2);
const timeSinceLoadedStub2 = sinon.stub(details2, 'age');
timeSinceLoadedStub2.get(() => 5);
// skip preroll to advance schedule
interstitials.skip();
expect(loadSpy).calledOnce;
const assetListUrl = loadSpy.getCalls()[0].args[0].url;
expect(
assetListUrl,
'_HLS_primary_id and _HLS_start_offset match',
).to.equal(
`https://example.com/mid-list.json?_HLS_interstitial_id=mid-starts-at-40&_HLS_primary_id=${primaryId}&_HLS_start_offset=5`,
);
// Removing the CUE="ONCE" interstitial changes the `schedule` items, but does not remove it from `events`
expect(interstitials.events).is.an('array').which.has.lengthOf(2);
expectScheduleToInclude(
interstitials.schedule,
[
{
previousEvent: null,
nextEvent: {
identifier: 'mid-starts-at-40',
},
start: 0,
end: 40,
playout: { start: 0, end: 40 },
integrated: { start: 0, end: 40 },
},
{
event: {
identifier: 'mid-starts-at-40',
},
start: 40,
end: 50,
playout: { start: 40, end: 50 },
integrated: { start: 40, end: 50 },
},
{
previousEvent: {
identifier: 'mid-starts-at-40',
},
nextEvent: null,
start: 50,
end: Infinity,
playout: { start: 50, end: Infinity },
integrated: { start: 50, end: Infinity },
},
],
'after-preroll',
);
expect(interstitials.bufferingIndex).to.equal(1, 'bufferingIndex b');
expect(interstitials.playingIndex).to.equal(1, 'playingIndex b');
expect(interstitials.primary.currentTime).to.equal(40, 'timelinePos b');
});
});
});
});