Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -1891,6 +1891,12 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
/* keepAdManager= */ false, /* isSwitchingContent= */ true);
}

if (this.cmcdManager_) {
// The unload above (or a previous explicit unload()) stopped CMCD
// reporting; re-arm it before this load's first request goes out.
this.cmcdManager_.onLoad();
}

// Add a mechanism to detect if the load process has been interrupted by a
// call to another top-level operation (unload, load, etc).
const operationId = ++this.operationId_;
Expand Down
45 changes: 29 additions & 16 deletions lib/util/cmcd_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ goog.requireType('shaka.Player');
* shapes into CML's HttpRequest/HttpResponse shapes.
*
* Adapter responsibilities:
* 1. Lifecycle: construct/start/stop CmcdReporter from configure() / reset()
* 1. Lifecycle: construct/start/stop CmcdReporter from configure() /
* onLoad() / reset()
* 2. Player wiring: <video> + Player events → reporter.update.
* State-change events (PLAY_STATE/BITRATE_CHANGE/BACKGROUNDED_MODE)
* auto-fire from update() since v2.4.0; adapter only invokes
Expand Down Expand Up @@ -112,9 +113,6 @@ shaka.util.CmcdManager = class {
setMediaElement(mediaElement) {
this.video_ = mediaElement;
this.maybeStartReporter_();
if (this.reporter_) {
this.setupEventListeners_();
}
}

/**
Expand All @@ -136,25 +134,22 @@ shaka.util.CmcdManager = class {
oldConfig.useHeaders !== config.useHeaders ||
oldConfig.eventTargets !== config.eventTargets);
if (enabledOff || materialChange) {
this.reporter_.stop(true);
this.reporter_ = null;
this.lastPlayerState_ = null;
// A disable or material change ends the current CMCD session;
// reset() stops the reporter and clears listeners and
// session-scoped state so the rebuild below starts fresh.
this.reset();
}
}

if (!this.reporter_ && this.video_) {
this.maybeStartReporter_();
if (this.reporter_) {
this.setupEventListeners_();
}
}
this.maybeStartReporter_();
}

/**
* Reset the manager. Stops the reporter and clears session-scoped
* state. The video element reference is preserved — shaka's lifecycle
* keeps it attached across `unload()`/`load()` cycles, and only
* `detach()` releases it.
* `detach()` releases it — so `onLoad()` can re-arm the reporter for
* the next playback session without another `setMediaElement()` call.
*/
reset() {
if (this.reporter_) {
Expand All @@ -168,6 +163,22 @@ shaka.util.CmcdManager = class {
this.eventManager_.removeAll();
}

/**
* Re-arm the reporter for a new playback session. The Player calls
* this at the start of every `load()`: each `load()` after the first
* is preceded by an unload that stops the reporter via `reset()`, and
* neither `setMediaElement()` (the element stays attached) nor
* `configure()` runs again on that path, so without this hook CMCD
* would stay silent for every load after the first
* (https://github.com/shaka-project/shaka-player/issues/10414).
*
* No-ops when a reporter is already running, when CMCD is disabled,
* or when no media element is attached.
*/
onLoad() {
this.maybeStartReporter_();
}

/**
* Forwarded from Player buffering observer; translates to a
* REBUFFERING / PLAYING player-state transition.
Expand Down Expand Up @@ -407,8 +418,9 @@ shaka.util.CmcdManager = class {
}

/**
* Construct the reporter if `enabled` is set and a video element is
* available. No-ops if either precondition fails.
* Construct and start the reporter, and wire up its event listeners,
* if `enabled` is set and a video element is available. No-ops if the
* reporter is already running or either precondition fails.
*
* @private
*/
Expand All @@ -423,6 +435,7 @@ shaka.util.CmcdManager = class {
this.reporter_ = new cml.cmcd.CmcdReporter(
reporterConfig, this.makeRequester_());
this.reporter_.start();
this.setupEventListeners_();
}

/**
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions test/util/cmcd_integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,41 @@ describe('CmcdManager integration', () => {
expect(decoded['v']).toBe(2);
});

it('keeps emitting CMCD on the next load() on the same player',
async () => {
// Regression test for
// https://github.com/shaka-project/shaka-player/issues/10414:
// every load() after the first triggers an internal unload,
// which resets the CmcdManager; 5.2.0 never re-armed it, so
// CMCD silently stopped for the rest of the player's lifetime
// unless the app happened to call configure() again.
await player.load(TEST_STREAM);
await recorder.waitForManifest();

recorder.clear();
await player.load(TEST_STREAM);

// load() resolves only after the manifest fetch completes, and
// the recorder captures CMCD-bearing requests at request time —
// so no wait is needed: either the second manifest request
// carried CMCD and was recorded, or the data was never applied.
const manifests = recorder.getReports().filter(
(r) => r.type ===
cml.cmcd.CMCD_RECORDED_REQUEST_TYPE_MANIFEST);
expect(manifests.length)
.withContext(
'manifest request of a second load() should carry CMCD')
.toBeGreaterThan(0);
if (!manifests.length) {
return;
}
const decoded = validateRecordedReport(manifests[0]);
expect(decoded['ot']).toBe('m');
expect(decoded['sid']).toBe(SESSION_ID);
expect(decoded['cid']).toBe(CONTENT_ID);
expect(decoded['v']).toBe(2);
});

it('emits valid CMCD v2 on segment requests', async () => {
await player.load(TEST_STREAM);
await video.play();
Expand Down
106 changes: 106 additions & 0 deletions test/util/cmcd_manager_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,56 @@ describe('CmcdManager', () => {
expect(priv(manager)['reporter_']).toBe(firstReporter);
});

// configure()'s teardown branch must also clear event listeners —
// otherwise the rebuild path registers every video/player/document
// listener a second time, and recordEvent-based events (MUTE,
// UNMUTE, PLAYER_EXPAND, ...) get reported once per registration.
// State-change updates (sta) are deduped by lastPlayerState_, which
// masked the doubling.

it('does not duplicate listeners across disable/re-enable', () => {
const player = createMockPlayer();
const {manager, config} = createManager(player);
const video = priv(manager)['video_'];
manager.configure(Object.assign({}, config, {enabled: false}));
manager.configure(Object.assign({}, config, {enabled: true}));
spyOn(priv(manager)['reporter_'], 'recordEvent');
video.muted = true;
video.dispatchEvent(new shaka.util.FakeEvent('volumechange'));
expect(priv(manager)['reporter_'].recordEvent).toHaveBeenCalledTimes(1);
});

it('does not duplicate listeners on material config change', () => {
const player = createMockPlayer();
const {manager, config} = createManager(player);
const video = priv(manager)['video_'];
manager.configure(Object.assign({}, config, {contentId: 'changed'}));
spyOn(priv(manager)['reporter_'], 'recordEvent');
video.muted = true;
video.dispatchEvent(new shaka.util.FakeEvent('volumechange'));
expect(priv(manager)['reporter_'].recordEvent).toHaveBeenCalledTimes(1);
});

it('rebuilt reporter re-learns sf after a material config change', () => {
// The manifest path only pushes sf into the reporter when it
// differs from the cached sf_. The configure() teardown must clear
// session-scoped state (via reset()) so a rebuilt reporter is not
// starved of sf by the previous session's cache.
const player = createMockPlayer();
const {manager, config} = createManager(player);
const manifestContext = /** @type {shaka.extern.RequestContext} */ (
{type: AdvancedRequestType.MPD});
manager.applyRequestData(
RequestType.MANIFEST, createRequest(), manifestContext);
manager.configure(Object.assign({}, config, {contentId: 'changed'}));
const newReporter = priv(manager)['reporter_'];
spyOn(newReporter, 'update');
manager.applyRequestData(
RequestType.MANIFEST, createRequest(), manifestContext);
expect(newReporter.update).toHaveBeenCalledWith(
jasmine.objectContaining({sf: StreamingFormat.DASH}));
});

it('reset stops the reporter and clears state', () => {
const player = createMockPlayer();
const {manager} = createManager(player);
Expand All @@ -236,6 +286,62 @@ describe('CmcdManager', () => {
manager.configure(Object.assign({}, config, {useHeaders: true}));
expect(priv(manager)['reporter_']).not.toBeNull();
});

// Regression coverage for
// https://github.com/shaka-project/shaka-player/issues/10414: every
// load() after the first triggers an internal unload → reset(), and
// nothing in the plain load() path re-ran setMediaElement() or
// configure() — so the reporter stayed dead and CMCD silently stopped.
// Player.load() now calls onLoad() to re-arm the reporter for each
// new playback session.

it('onLoad() re-arms the reporter after reset()', () => {
const player = createMockPlayer();
const {manager} = createManager(player);
manager.reset();
expect(priv(manager)['reporter_']).toBeNull();
manager.onLoad();
expect(priv(manager)['reporter_']).not.toBeNull();
});

it('onLoad() keeps the running reporter when one exists', () => {
const player = createMockPlayer();
const {manager} = createManager(player);
const firstReporter = priv(manager)['reporter_'];
expect(firstReporter).not.toBeNull();
manager.onLoad();
expect(priv(manager)['reporter_']).toBe(firstReporter);
});

it('onLoad() does not start a reporter when disabled', () => {
const player = createMockPlayer();
const {manager} = createManager(player, {enabled: false});
manager.onLoad();
expect(priv(manager)['reporter_']).toBeNull();
});

it('applies request data after a reset()/onLoad() cycle', () => {
const player = createMockPlayer();
const {manager} = createManager(player);
manager.reset();
manager.onLoad();
const request = createRequest();
manager.applyRequestData(
RequestType.SEGMENT, request, createSegmentContext());
expect(request.uris[0]).toContain('CMCD=');
});

it('re-attaches video listeners after a reset()/onLoad() cycle', () => {
const player = createMockPlayer();
const {manager} = createManager(player);
const video = priv(manager)['video_'];
manager.reset();
manager.onLoad();
spyOn(priv(manager)['reporter_'], 'update');
video.dispatchEvent(new shaka.util.FakeEvent('pause'));
expect(priv(manager)['reporter_'].update).toHaveBeenCalledWith(
{sta: PlayerState.PAUSED});
});
});

// ── Configuration translation ──
Expand Down