diff --git a/build/webpack/modern/webpack.modern.dev.cjs b/build/webpack/modern/webpack.modern.dev.cjs index 8ca796a65a..165789e547 100644 --- a/build/webpack/modern/webpack.modern.dev.cjs +++ b/build/webpack/modern/webpack.modern.dev.cjs @@ -16,7 +16,7 @@ const umdDevConfig = merge(umdConfig, { open: ['samples/index.html'], hot: true, compress: true, - port: 3000 + port: 3001 } }); diff --git a/index.d.ts b/index.d.ts index 0132cd894c..1dcd7d1618 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3755,21 +3755,55 @@ export interface BaseURLTreeModel { } export interface CmcdModel { + setup(): void; + + reset(): void; + + setConfig(config: object): void; + getCmcdData(request: HTTPRequest): object; - getCmcdParametersFromManifest(): CMCDParameters; + onStateChange(state: any): void; - getHeaderParameters(request: HTTPRequest): object | null; + onPeriodSwitchComplete(): void; - getQueryParameter(request: HTTPRequest): { key: string, finalPayloadString: string } | null; + onPlaybackStarted(): void; - initialize(): void; + onPlaybackPlaying(): void; - isCmcdEnabled(): boolean; + onRebufferingStarted(mediaType: string): void; - reset(): void; + onRebufferingCompleted(mediaType: string): void; - setConfig(config: object): void; + onPlayerError(errorData: any): void; + + onPlaybackSeeking(): void; + + onPlaybackSeeked(): void; + + onPlaybackRateChanged(data: any): void; + + wasPlaying(): boolean; + + onManifestLoaded(data: any): void; + + onBufferLevelStateChanged(data: any): void; + + updateMsdData(mode: string): object; + + resetInitialSettings(): void; + + getCmcdParametersFromManifest(): CMCDParameters; + + triggerCmcdEventMode(event: string): object; + + getGenericCmcdData(mediaType?: string): object; + + isIncludedInRequestFilter(type: string, includeInRequests?: any): boolean; + + getLastMediaTypeRequest(): string; + + onEventChange(state: any): void; } export interface CmsdModel { @@ -6012,4 +6046,3 @@ export interface KeySystemInfo { export type RequestFilter = (request: LicenseRequest) => Promise; export type ResponseFilter = (response: LicenseResponse) => Promise; - diff --git a/package-lock.json b/package-lock.json index 61740e8764..e1f48208b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,12 @@ "license": "BSD-3-Clause", "dependencies": { "@svta/cml-608": "1.0.1", - "@svta/cml-cmcd": "1.0.1", - "@svta/cml-cmsd": "1.0.1", - "@svta/cml-dash": "1.0.1", - "@svta/cml-id3": "1.0.1", - "@svta/cml-request": "1.0.1", - "@svta/cml-xml": "1.0.1", + "@svta/cml-cmcd": "2.1.1", + "@svta/cml-cmsd": "1.0.4", + "@svta/cml-dash": "1.0.4", + "@svta/cml-id3": "1.0.4", + "@svta/cml-request": "1.0.6", + "@svta/cml-xml": "1.1.2", "bcp-47-match": "^2.0.3", "bcp-47-normalize": "^2.3.0", "codem-isoboxer": "0.3.10", @@ -2542,113 +2542,122 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@svta/cml-608/-/cml-608-1.0.1.tgz", "integrity": "sha512-Y/Ier9VPUSOBnf0bJqdDyTlPrt4dDB+jk5mYHa1bnD2kcRl8qn7KkW3PRuj4w1aVN+BS2eHmsLxodt7P2hylUg==", + "license": "Apache-2.0", "engines": { "node": ">=20" } }, "node_modules/@svta/cml-cmcd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-1.0.1.tgz", - "integrity": "sha512-eox305g+QUJgXqOLVrbgxeQHCgl90ewwQ9O2bIoo7m+hanR8Xswu5CknFnT5qqIbLOHfw80ug+raycoAFHTQ+w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@svta/cml-cmcd/-/cml-cmcd-2.1.1.tgz", + "integrity": "sha512-cEkULGNbZ2QhOGBby9KwqkjdznJRhPXDEUZM8pmwEg33lKGM0bCCK8kBTpVjs5+jP+6M5g4ua5nrgYDOFYCR3w==", + "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-cta": "1.0.1", - "@svta/cml-structured-field-values": "1.0.1", - "@svta/cml-utils": "1.0.1" + "@svta/cml-structured-field-values": "1.1.1", + "@svta/cml-utils": "1.3.0" } }, "node_modules/@svta/cml-cmsd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-cmsd/-/cml-cmsd-1.0.1.tgz", - "integrity": "sha512-+nIB8PuSfb/qw+xGaArPhNqPm84tBJUbe3H1DnPL5QUsjSUI7mUIUQwAtRV1ZdEu0+80g9i0op79woB0OIwr/g==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@svta/cml-cmsd/-/cml-cmsd-1.0.4.tgz", + "integrity": "sha512-4284bLITFLayFOE7aloXS5WkhlOJjIrw50pOwEdK5wWO02vGjcQmx0vrkc4MCOh2lmpgvuzrNfyaFlTl9JyH2A==", + "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-cta": "1.0.1", - "@svta/cml-structured-field-values": "1.0.1", - "@svta/cml-utils": "1.0.1" + "@svta/cml-cta": "1.0.4", + "@svta/cml-structured-field-values": "1.1.1", + "@svta/cml-utils": "1.3.0" } }, "node_modules/@svta/cml-cta": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-cta/-/cml-cta-1.0.1.tgz", - "integrity": "sha512-jcXqNIPv26bmFxIOFh8/c3+6WLH4qBjKpq9qTQcggDPoHuV1YBydMsJLOnYPDeK8rNMKcAkFLbnDRvyJthu5yw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@svta/cml-cta/-/cml-cta-1.0.4.tgz", + "integrity": "sha512-lNIrX9jnM/31q+O/p3QsR0IvRMa2OrHuNeaw86mDBrb3oyJcBOOWGQcCr2yLcf6EuBgbzQmswDEQecmlWRL9dg==", + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-structured-field-values": "1.0.1", - "@svta/cml-utils": "1.0.1" + "@svta/cml-structured-field-values": "1.1.1", + "@svta/cml-utils": "1.3.0" } }, "node_modules/@svta/cml-dash": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-dash/-/cml-dash-1.0.1.tgz", - "integrity": "sha512-lYnD1I7FUbbQND+xICI+kcRaRXuT+whKk27R8m8me5VMVu2sMsAMc7Yui6l9sxw2cBKt8pSETPYRm/1+n4LZkw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@svta/cml-dash/-/cml-dash-1.0.4.tgz", + "integrity": "sha512-1opr9SMb8fCN6FpApTF+T9DCu8gvMNwIi5oi2i2IBXLysszT+f++cDcfNq5E+1AJq4N0uoreKcW4PKwTQ2XDFw==", + "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.0.1" + "@svta/cml-utils": "1.3.0" } }, "node_modules/@svta/cml-id3": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.1.tgz", - "integrity": "sha512-90fGlL1qRI88CcaB89k6NG6cC3kky4Eu2jwqU4HefqK+S5k2OASUxf8JXkGz+DsdaiY7sh51vGPYdolfBZS7ug==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@svta/cml-id3/-/cml-id3-1.0.4.tgz", + "integrity": "sha512-v2IU+91SDrDXga6yZITqyBHa2Y1RVGT1ts6BHg3/YOHOCBAXy4o5gq9SQSRlHFvw6jzhJ3JdSpjSaXs3PPBwDw==", + "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.0.1" + "@svta/cml-utils": "1.3.0" } }, "node_modules/@svta/cml-request": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.1.tgz", - "integrity": "sha512-enL19BuXUjFkDDDF9jdNwUclMNPRsagnjGAetVC7xcmpDMpEx+ZLgsDip6BFNg5p6izSEk/OyujTWW1r8bDNiA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@svta/cml-request/-/cml-request-1.0.6.tgz", + "integrity": "sha512-+vCTcNN8Y8sFNSxIIFsIFckUcLKFd7v2H5Gg5ej2iKz2Z7MX5U/jxX4/McBDVA7g8AEqR6snHg7TbpUShD5hRA==", + "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.0.1", - "@svta/cml-xml": "1.0.1" + "@svta/cml-utils": "1.3.0", + "@svta/cml-xml": "1.1.2" } }, "node_modules/@svta/cml-structured-field-values": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.0.1.tgz", - "integrity": "sha512-Kibciki59Pon3Pn/sl5uyrbJcSpZQDKqdCfDrokBvOdLoqqcd0oFrkEPsZBiuuIODX1CB80612xe8hopeFDyBA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@svta/cml-structured-field-values/-/cml-structured-field-values-1.1.1.tgz", + "integrity": "sha512-04zNbiY2HrOMAQ2F0iZ4yDbKaAg3UybtTARNv930bhIY00vmmd8Elt0jHB+Q3y/hC3I35QU8SGrORKT5LE2sOQ==", + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.0.1" + "@svta/cml-utils": "1.3.0" } }, "node_modules/@svta/cml-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.0.1.tgz", - "integrity": "sha512-kso3curTJfp00I1mKFoBliBApjn4aPE+wF8cPucf7TrSDVWZDeLLuF14ASmUE9m7rnrqTTK4878VvmXaXcCCfQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@svta/cml-utils/-/cml-utils-1.3.0.tgz", + "integrity": "sha512-TaVyR899SZpjvw0RgA+/wRnij6pf4r0l+W1qHie9H/cPGNJBsQG1sKLx8inmKHwvZzcPt2SP/fv9MwMyqAbC+Q==", + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=20" } }, "node_modules/@svta/cml-xml": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz", - "integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.1.2.tgz", + "integrity": "sha512-4BCzRc1IpqfA94dsZXnfJ/8KsXaFYjJRbrMafgGT8tXwrKeR7ln6wO4L2FZ7b+D1e18zSHIyEqlU9MqKyMmnYg==", + "license": "Apache-2.0", "engines": { "node": ">=20" }, "peerDependencies": { - "@svta/cml-utils": "1.0.1" + "@svta/cml-utils": "1.3.0" } }, "node_modules/@types/body-parser": { diff --git a/package.json b/package.json index 23bce61e6d..19e1bd3392 100644 --- a/package.json +++ b/package.json @@ -90,12 +90,12 @@ }, "dependencies": { "@svta/cml-608": "1.0.1", - "@svta/cml-cmcd": "1.0.1", - "@svta/cml-cmsd": "1.0.1", - "@svta/cml-dash": "1.0.1", - "@svta/cml-id3": "1.0.1", - "@svta/cml-request": "1.0.1", - "@svta/cml-xml": "1.0.1", + "@svta/cml-cmcd": "2.1.1", + "@svta/cml-cmsd": "1.0.4", + "@svta/cml-dash": "1.0.4", + "@svta/cml-id3": "1.0.4", + "@svta/cml-request": "1.0.6", + "@svta/cml-xml": "1.1.2", "bcp-47-match": "^2.0.3", "bcp-47-normalize": "^2.3.0", "codem-isoboxer": "0.3.10", diff --git a/samples/advanced/cmcd-v2-network-interceptors.html b/samples/advanced/cmcd-v2-network-interceptors.html new file mode 100644 index 0000000000..d147b8ff37 --- /dev/null +++ b/samples/advanced/cmcd-v2-network-interceptors.html @@ -0,0 +1,211 @@ + + + + + CMCD v2 Callbacks with Network Interceptors + + + + + + + + + + + + + +
+
+
+ +
+
+
+
+

CMCD v2 Callbacks with Network Interceptors

+

This sample shows how to use Network Interceptors to execute callbacks after server response or pre-request callbacks for Response and Event Mode

+
+
+
+
+
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + + diff --git a/samples/advanced/cmcd-v2.html b/samples/advanced/cmcd-v2.html new file mode 100644 index 0000000000..66919f0fd7 --- /dev/null +++ b/samples/advanced/cmcd-v2.html @@ -0,0 +1,307 @@ + + + + + CMCD v2 Reporting + + + + + + + + + + + + + +
+
+
+ +
+
+
+
+

CMCD v2 Reporting

+

This sample shows how to use dash.js in order to enhance requests to the CDN with Common Media + Client Data (CMCD - CTA 5004).

+
+
+
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
+ + +
+ + +
+
+
+ + +
+
+
+
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + + diff --git a/samples/advanced/list-mpds.html b/samples/advanced/list-mpds.html new file mode 100644 index 0000000000..865038a439 --- /dev/null +++ b/samples/advanced/list-mpds.html @@ -0,0 +1,156 @@ + + + + List MPDs example + + + + + + +
+
+ +
+
+
+
+

List MPDs

+

This sample shows the following use cases of the List MPDs implementation in DASH

+
    +
  • Case 0: Single Linked Period - An MPD with a single Period that links to an Imported MPD.

  • +
  • Case 1: Multiple Linked Period - An MPD with multiple linked Periods, each pointing to a different Imported MPD.

  • +
  • Case 2: Linked Period + Regular Period - An MPD with one linked Period followed by a regular Period.

  • +
  • Case 3: Regular Period + Linked Period - An MPD with one regular Period followed by a linked Period.

  • +
  • Case 4: EarliestResolutionTime > minBufferTime - An MPD where the earliestResolutionTime of the linked Period exceeds the min buffer time.

  • +
  • Case 5: EarliestResolutionTime < minBufferTime - An MPD where the earliestResolutionTime of the linked Period is less than the buffer time.

  • +
  • Case 6: Different Linked Period and Imported Period durations - An MPD where a Linked Period references an Imported MPD with a duration that does not match the Linked Period's duration in the main manifest

  • +
  • Case 7: First List MPD Period with start time =/ 0 - The MPD is of type list, and the first Period in the manifest has a start time that is not equal to PT0S (0 seconds).

  • +
  • Case 8: Retry - The MPD is of type list, and the ImportedMPD should not be downloadable

  • +
+
+
+
+ +
+
+
+
+
+
+ © DASH-IF +
+ + + + + + + + diff --git a/samples/alternative/alternative-media-presentations.html b/samples/alternative/alternative-media-presentations.html new file mode 100644 index 0000000000..aafd1bdb56 --- /dev/null +++ b/samples/alternative/alternative-media-presentations.html @@ -0,0 +1,433 @@ + + + + + Alternative Media Presentations + + + + + + + + + + + +
+
+
+ +
+
+
+
+

Alternative Media Presentations

+

A sample showing alternative media presentations with a dedicated alternative video element. Configure replace or insert events with direct presentation times.

+
Additional Samples:
+ + +
+
VOD Configuration
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + Replace: Replaces main content with alternative content.
+ Insert: Inserts alternative content, then returns to the same point in main content. +
+
+ +
+
+ + + Time in main video to trigger event +
+
+ + +
+
+ +
+
+ + + Offset in main content after alternative ends +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + diff --git a/samples/alternative/listen-mode.html b/samples/alternative/listen-mode.html new file mode 100644 index 0000000000..9394489128 --- /dev/null +++ b/samples/alternative/listen-mode.html @@ -0,0 +1,372 @@ + + + + + Listen Mode - Alternative Media Presentations + + + + + + + + + + + +
+
+
+ +
+
+
+
+

Listen Mode - Alternative Media Presentations

+

A sample demonstrating listen mode where an alternative content can be replaced without maxDuration, and then a status update can add maxDuration to return to original content.

+ +
+
Stream Configuration
+
+ + +
+ +
+ + +
+ +
+ + + Time from now to trigger replace event +
+ +
+ + +
+ + +
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + diff --git a/samples/alternative/live-to-live.html b/samples/alternative/live-to-live.html new file mode 100644 index 0000000000..ee5026a1ec --- /dev/null +++ b/samples/alternative/live-to-live.html @@ -0,0 +1,354 @@ + + + + + Live-to-Live Alternative Media Presentations + + + + + + + + + + + +
+
+
+ +
+
+
+
+

Live-to-Live Alternative Media Presentations

+

A sample showing live-to-live alternative media presentations where both original and alternative content are live streams. Configure replace events with dynamic presentation times.

+ +
+
Live Stream Configuration
+
+ + +
+ +
+ + +
+ +
+
+ + + Time from now to trigger replace event +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + \ No newline at end of file diff --git a/samples/alternative/sgai-insert-test.html b/samples/alternative/sgai-insert-test.html new file mode 100644 index 0000000000..e74dc681f2 --- /dev/null +++ b/samples/alternative/sgai-insert-test.html @@ -0,0 +1,359 @@ + + + + + SGAI Insert Mode Test - Alternative MPD + + + + + + + + + + + +
+
+
+ +
+
+
+
+

SGAI Insert Mode Test

+

Testing SGAI with alternative MPD and list MPDs using insert mode only. The alternative content is inserted and then returns to the same point in the main content.

+ +
+
Configuration
+
+ + + Main content URL +
+ +
+ + + Alternative content to insert (List MPD) +
+ +
+ Insert Mode: Alternative content is inserted at the specified time, then playback returns to the same point in the main content. +
+ +
+
+ + + Time in main video to trigger insert +
+
+ + + Maximum duration for insert +
+
+ +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+
+ + + +
+
Generated Manifest Events:
+
No events configured yet...
+
+
+
+
+ © DASH-IF +
+
+
+ + + + + diff --git a/samples/highlighter.js b/samples/highlighter.js index a670dd2899..4f34ce4f9e 100644 --- a/samples/highlighter.js +++ b/samples/highlighter.js @@ -9,7 +9,7 @@ script.setAttribute('src', '//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0 head.append(style); head.append(script); -codeOutput.innerHTML += '

Source code

'; +codeOutput.innerHTML += '

Source code

'; /** * This helper functions checks how many whitespaces preceed the last tag, which should have 0 diff --git a/samples/samples.json b/samples/samples.json index 357d5e1552..047a0843b4 100644 --- a/samples/samples.json +++ b/samples/samples.json @@ -720,7 +720,30 @@ "Video", "Audio" ] + }, + { + "title": "CMCD v2 Reporting", + "description": "This sample shows how to use dash.js in order to enhance requests to the CDN with Common Media Client Data (CMCD - CTA 5005).", + "href": "advanced/cmcd-v2.html", + "image": "lib/img/bbb-1.jpg", + "labels": [ + "VoD", + "Video", + "Audio" + ] + }, + { + "title": "CMCD v2 Callbacks with Network Interceptors", + "description": "This sample shows how to use Network Interceptors to execute callbacks after server response or pre-request callbacks for Response and Event Mode", + "href": "advanced/cmcd-v2-network-interceptors.html", + "image": "lib/img/bbb-1.jpg", + "labels": [ + "VoD", + "Video", + "Audio" + ] }, + { "title": "Flexible Insertion of URL Parameters Sample", "description": "This sample demonstrates the Flexible Insertion of URL Parameters in dash.js.", @@ -821,6 +844,18 @@ "Video", "Audio" ] + }, + { + "title": "List MPD", + "description": "This sample demonstrates the List MPD implementation in dash.js", + "href": "advanced/list-mpds.html", + "image": "lib/img/bbb-1.jpg", + "labels": [ + "VoD", + "Video", + "Audio", + "List MPD" + ] } ] }, @@ -840,6 +875,64 @@ } ] }, + { + "section": "Alternative Media Presentations", + "samples": [ + { + "title": "Alternative Media Presentations", + "description": "A sample showing alternative media presentations with a dedicated alternative video element for content switching (Insert/Replace modes).", + "href": "alternative/alternative-media-presentations.html", + "image": "lib/img/bbb-1.jpg", + "labels": [ + "VoD", + "Alternative MPD", + "Video", + "Audio" + ] + }, + { + "title": "Live-to-Live Alternative Media Presentations", + "description": "A sample showing live-to-live alternative media presentations where both original and alternative content are live streams. Configure replace events with dynamic presentation times.", + "href": "alternative/live-to-live.html", + "image": "lib/img/livesim-1.jpg", + "labels": [ + "Live", + "Alternative MPD", + "Replace Events", + "Video", + "Audio" + ] + }, + { + "title": "Alternative MPD Listen Mode", + "description": "A sample demonstrating listen mode where alternative content can be inserted without maxDuration, and then a status update can add maxDuration to return to original content. Simplified interface with Insert and Return buttons.", + "href": "alternative/listen-mode.html", + "image": "lib/img/livesim-1.jpg", + "labels": [ + "Live", + "Alternative MPD", + "Insert Events", + "Status Update", + "Video", + "Audio" + ] + }, + { + "title": "SGAI Insert Mode Test", + "description": "Testing SGAI with alternative MPD and list MPDs using insert mode only. Uses List MPD as the main content with configurable insert events for alternative content.", + "href": "alternative/sgai-insert-test.html", + "image": "lib/img/bbb-2.jpg", + "labels": [ + "VoD", + "Alternative MPD", + "Insert Events", + "List MPD", + "Video", + "Audio" + ] + } + ] + }, { "section": "MPEG-5 Part 2 - LCEVC", "samples": [ diff --git a/src/core/Settings.js b/src/core/Settings.js index c40d426996..c77c6ec524 100644 --- a/src/core/Settings.js +++ b/src/core/Settings.js @@ -343,6 +343,9 @@ import SwitchRequest from '../streaming/rules/SwitchRequest.js'; * audioChannelConfiguration: 'urn:mpeg:mpegB:cicp:ChannelConfiguration', * role: 'urn:mpeg:dash:role:2011', * accessibility: 'urn:mpeg:dash:role:2011' + * }, + * listMpds: { + * minEarliestResolutionTimeOffset: 0, * } * }, * errors: { @@ -940,8 +943,32 @@ import SwitchRequest from '../streaming/rules/SwitchRequest.js'; * The version of the CMCD to use. * * If not specified this value defaults to 1. + * @property {Array.} [targets] + * List of CMCD reporting targets. */ +/** + * @typedef {Object} CmcdTarget + * @property {string} [mode] + * Mode of the CMCD reporting. + * @property {boolean} [enabled] + * Whether the CMCD reporting is enabled for this target. + * @property {string} [url] + * The reporting endpoint URL. + * @property {string} [events] + * The events that should trigger the CMCD reporting. + * @property {string} [timeInterval] + * The time interval for the CMCD reporting in event mode. The 't' event should be set in the events array to use this parameter. + * @property {Array.} [enabledKeys] + * CMCD keys to include in the report. + * @property {Array.} [includeOnRequests] + * Types of requests CMCD should be included on (e.g., 'mpd', 'segment'). + * @property {Array.} [batchRetryDelays] + * Array of retry delay values in milliseconds for batched CMCD reporting failures. + * Each value represents the delay before the next retry attempt. + * If not specified, defaults to Constants.CMCD_DEFAULT_BATCH_RETRY_DELAYS. +*/ + /** * @typedef {Object} module:Settings~CmsdSettings * @property {boolean} [enabled=false] @@ -974,6 +1001,16 @@ import SwitchRequest from '../streaming/rules/SwitchRequest.js'; * Maximum number of metrics that are persisted per type. */ +/** + * @typedef {Object} listMpdSettings + * @property {boolean} [minEarliestResolutionTimeOffset=0] + * Min earliest resolution time offset available for imported periods. + * + * If playback stalled during a period switch, setting this number can help fix a conflict with the GapController. + * It avoids a race condition between resolving linked periods and the GapController's gap jump logic. + * Set to 0 by default to be specification compliant. Adjust if you encounter issues with gap handling at period boundaries. + */ + /** * @typedef {Object} StreamingSettings * @property {number} [abandonLoadTimeout=10000] @@ -1099,6 +1136,8 @@ import SwitchRequest from '../streaming/rules/SwitchRequest.js'; * @property {module:Settings~defaultSchemeIdUri} defaultSchemeIdUri * Default schemeIdUri for descriptor type elements * These strings are used when not provided with setInitialMediaSettingsFor() + * @property {module:Settings~listMpdSettings} listMpd + * Settings related to List Mpd configuration */ @@ -1428,9 +1467,10 @@ function Settings() { rtp: null, rtpSafetyFactor: 5, mode: Constants.CMCD_MODE_QUERY, - enabledKeys: Constants.CMCD_AVAILABLE_KEYS, + enabledKeys: Constants.CMCD_KEYS, includeInRequests: ['segment', 'mpd'], - version: 1 + version: 1, + targets: [] }, cmsd: { enabled: false, @@ -1448,6 +1488,9 @@ function Settings() { audioChannelConfiguration: 'urn:mpeg:mpegB:cicp:ChannelConfiguration', role: 'urn:mpeg:dash:role:2011', accessibility: 'urn:mpeg:dash:role:2011' + }, + listMpd: { + minEarliestResolutionTimeOffset: 0 } }, errors: { diff --git a/src/core/events/CoreEvents.js b/src/core/events/CoreEvents.js index 60ee8f5305..2d8b2e032b 100644 --- a/src/core/events/CoreEvents.js +++ b/src/core/events/CoreEvents.js @@ -40,7 +40,9 @@ import EventsBase from './EventsBase.js'; class CoreEvents extends EventsBase { constructor () { super(); + this.ALTERNATIVE_EVENT_RECEIVED = 'alternativeEventReceived'; this.ATTEMPT_BACKGROUND_SYNC = 'attemptBackgroundSync'; + this.EVENT_READY_TO_RESOLVE = 'eventReadyToResolve'; this.BUFFERING_COMPLETED = 'bufferingCompleted'; this.BUFFER_CLEARED = 'bufferCleared'; this.BUFFER_REPLACEMENT_STARTED = 'bufferReplacementStarted'; @@ -54,6 +56,12 @@ class CoreEvents extends EventsBase { this.INIT_FRAGMENT_LOADED = 'initFragmentLoaded'; this.INIT_FRAGMENT_NEEDED = 'initFragmentNeeded'; this.INTERNAL_MANIFEST_LOADED = 'internalManifestLoaded'; + this.ORIGINAL_ALTERNATIVE_MANIFEST_LOADED = 'originalAlternativeManifestLoaded' + this.ORIGINAL_MANIFEST_LOADED = 'originalManifestLoaded'; + this.LIST_MPD_FOUND = 'listMpdFound'; + this.LOADING_COMPLETED = 'loadingCompleted'; + this.LOADING_PROGRESS = 'loadingProgress'; + this.LOADING_DATA_PROGRESS = 'loadingDataProgress'; this.LOADING_ABANDONED = 'loadingAborted'; this.LOADING_COMPLETED = 'loadingCompleted'; this.LOADING_DATA_PROGRESS = 'loadingDataProgress'; diff --git a/src/dash/DashAdapter.js b/src/dash/DashAdapter.js index 57c3e3fe75..4421fad385 100644 --- a/src/dash/DashAdapter.js +++ b/src/dash/DashAdapter.js @@ -325,6 +325,126 @@ function DashAdapter() { voPeriods = getRegularPeriods(newManifest); } + function mergeManifests(manifest, importedManifest, period, mpdHasDuration) { + const voPeriods = getRegularPeriods(manifest); + const periodIndex = voPeriods.findIndex(voperiod => voperiod.id === period.id); + + if (periodIndex === -1) { + // Period not found + return; + } + + let newPeriod = {}; + const linkedPeriod = voPeriods[periodIndex]; + + if (importedManifest) { + // availabilityEndTime must be greater than current time + if (importedManifest.availabilityEndTime?.getTime() < Date.now()) { + return; + } + + const importedPeriod = importedManifest.Period[0]; + + if (importedManifest.hasOwnProperty(DashConstants.PROFILES)) { + importedPeriod.profiles = importedManifest.profiles; + + const existingProfiles = manifest.profiles ? manifest.profiles.split(',') : []; + const importedProfiles = importedManifest.profiles ? importedManifest.profiles.split(',') : []; + const uniqueProfiles = new Set([...existingProfiles, ...importedProfiles]); + + manifest.profiles = Array.from(uniqueProfiles).join(','); + } + + if (importedManifest.EssentialProperty) { + importedPeriod.EssentialProperty = importedPeriod.EssentialProperty ? importedManifest.EssentialProperty.concat(importedPeriod.EssentialProperty) : importedManifest.EssentialProperty; + } + + if (importedManifest.SupplementalProperty) { + importedPeriod.SupplementalProperty = importedPeriod.SupplementalProperty ? importedManifest.SupplementalProperty.concat(importedPeriod.SupplementalProperty) : importedManifest.SupplementalProperty; + } + + newPeriod = { + baseUri: importedManifest.baseUri, + minBufferTime: importedManifest.minBufferTime, + start: linkedPeriod.start ?? importedPeriod.start, + id: linkedPeriod.id ?? importedPeriod.id, + duration: linkedPeriod.duration, + ServiceDescription: period.ServiceDescription || [], + SupplementalProperty: period.SupplementalProperty || [], + EssentialProperty: period.EssentialProperty || [], + EventStream: period.EventStream || [], + }; + + // Update duration + if (importedPeriod.duration && importedPeriod.duration < linkedPeriod.duration) { + newPeriod.duration = importedPeriod.duration; + if (!mpdHasDuration) { + manifest.mediaPresentationDuration += importedPeriod.duration - linkedPeriod.duration; + } + } + + // Merge custom namespace properties + Object.keys(linkedPeriod) + .filter(name => name.includes(':')) + .forEach(name => newPeriod[name] = linkedPeriod[name]); + + _mergeEquivalentProperties(newPeriod.ServiceDescription, importedPeriod.ServiceDescription, DashConstants.ID); + _mergeEquivalentProperties(newPeriod.SupplementalProperty, importedPeriod.SupplementalProperty, Constants.SCHEME_ID_URI, DashConstants.VALUE); + _mergeEquivalentProperties(newPeriod.EssentialProperty, importedPeriod.EssentialProperty, Constants.SCHEME_ID_URI, DashConstants.VALUE); + _mergeEquivalentProperties(newPeriod.EventStream, importedPeriod.EventStream, Constants.SCHEME_ID_URI, DashConstants.VALUE); + + removeEmptyProperties(newPeriod, [ + DashConstants.SERVICE_DESCRIPTION, + DashConstants.SUPPLEMENTAL_PROPERTY, + DashConstants.ESSENTIAL_PROPERTY, + DashConstants.EVENT_STREAM + ]); + + newPeriod.baseURL = importedManifest.baseUri; + if (importedManifest.BaseURL || importedPeriod.BaseURL) { + newPeriod.BaseURL = [...(importedManifest.BaseURL || []), ...(importedPeriod.BaseURL || [])].filter(Boolean); + } + + newPeriod.AdaptationSet = importedPeriod.AdaptationSet; + } else { + newPeriod = { ...linkedPeriod }; + delete newPeriod.ImportedMPD; + delete newPeriod.earliestResolutionTimeOffset; + } + + if (newPeriod.minBufferTime && (newPeriod.AdaptationSet || newPeriod.duration === 0)) { + manifest.Period[periodIndex] = newPeriod; + } else { + manifest.Period.splice(periodIndex, 1); + } + } + + function _mergeEquivalentProperties(targetArray, sourceArray, keyProp, valueProp) { + if (!sourceArray || !targetArray) { + return; + } + + for (const item of sourceArray) { + const index = targetArray.findIndex(existingItem => existingItem[keyProp] === item[keyProp] && (!valueProp || existingItem[valueProp] === item[valueProp])); + if (index !== -1) { + // Replace existing entry + targetArray[index] = item; + } else { + targetArray.push(item); + } + } + } + + // Helper function to remove empty properties + function removeEmptyProperties(obj, propertyNames) { + for (const prop of propertyNames) { + if (Array.isArray(obj[prop]) && obj[prop].length === 0) { + delete obj[prop]; + } + } + } + + /** * Returns an array of streamInfo objects * @param {object} externalManifest @@ -617,6 +737,18 @@ function DashAdapter() { return dashManifestModel.getDuration(manifest); } + /** + * Returns all linked periods of the MPD + * @param {object} externalManifest Omit this value if no external manifest should be used + * @returns {Array} linked periods + * @memberOf module:DashAdapter + * @instance + */ + function getLinkedPeriods(externalManifest) { + const mpd = getMpd(externalManifest); + return dashManifestModel.getLinkedPeriods(mpd); + } + /** * Returns all periods of the MPD * @param {object} externalManifest Omit this value if no external manifest should be used @@ -1330,6 +1462,7 @@ function DashAdapter() { getIsTextTrack, getIsTypeOf, getLocation, + getLinkedPeriods, getMainAdaptationForType, getMainAdaptationSetForPreselection, getCommonRepresentationForPreselection, @@ -1351,6 +1484,7 @@ function DashAdapter() { getUTCTimingSources, getVoRepresentations, isPatchValid, + mergeManifests, reset, setConfig, updatePeriods, diff --git a/src/dash/constants/DashConstants.js b/src/dash/constants/DashConstants.js index e2901ee84e..dd3ac0e112 100644 --- a/src/dash/constants/DashConstants.js +++ b/src/dash/constants/DashConstants.js @@ -39,6 +39,11 @@ export default { ADAPTATION_SETS: 'adaptationSets', ADAPTATION_SET_SWITCHING_SCHEME_ID_URI: 'urn:mpeg:dash:adaptation-set-switching:2016', ADD: 'add', + ALTERNATIVE_MPD: { + INSERT: 'InsertPresentation', + REPLACE: 'ReplacePresentation', + }, + ALTERNATIVE_MPD_SCHEME_ID: 'urn:mpeg:dash:event:alternativeMPD:2022', ASSET_IDENTIFIER: 'AssetIdentifier', AUDIO_CHANNEL_CONFIGURATION: 'AudioChannelConfiguration', AUDIO_SAMPLING_RATE: 'audioSamplingRate', @@ -87,6 +92,7 @@ export default { END_NUMBER: 'endNumber', ESSENTIAL_PROPERTY: 'EssentialProperty', EVENT: 'Event', + EVENT_TARGET: 'EventTarget', EVENT_STREAM: 'EventStream', FORCED_SUBTITLE: 'forced-subtitle', FRAMERATE: 'frameRate', @@ -105,6 +111,7 @@ export default { LA_URL_LOWER_CASE: 'laurl', LABEL: 'Label', LANG: 'lang', + LIST_PROFILE_SCHEME: 'urn:mpeg:dash:profile:list:2024', LOCATION: 'Location', MAIN: 'main', MAXIMUM_SAP_PERIOD: 'maximumSAPPeriod', @@ -122,6 +129,7 @@ export default { MIN_BUFFER_TIME: 'minBufferTime', MP4_PROTECTION_SCHEME: 'urn:mpeg:dash:mp4protection:2011', MPD: 'MPD', + MPD_LIST: 'list', MPD_TYPE: 'mpd', MPD_PATCH_TYPE: 'mpdpatch', ORDER: 'order', @@ -153,6 +161,7 @@ export default { REMOVE: 'remove', REPLACE: 'replace', REPORTING: 'Reporting', + REPORTING_TARGETS: 'ReportingTargets', REPRESENTATION: 'Representation', REPRESENTATION_INDEX: 'RepresentationIndex', ROBUSTNESS: 'robustness', @@ -182,6 +191,7 @@ export default { START_NUMBER: 'startNumber', START_WITH_SAP: 'startWithSAP', STATIC: 'static', + STATUS: 'status', STEERING_TYPE: 'steering', SUBSET: 'Subset', SUBTITLE: 'subtitle', diff --git a/src/dash/models/DashManifestModel.js b/src/dash/models/DashManifestModel.js index 75a4748ece..154afea1b0 100644 --- a/src/dash/models/DashManifestModel.js +++ b/src/dash/models/DashManifestModel.js @@ -51,6 +51,7 @@ import Period from '../vo/Period.js'; import Preselection from '../vo/Preselection.js'; import ProducerReferenceTime from '../vo/ProducerReferenceTime.js'; import Representation from '../vo/Representation.js'; +import AlternativeMpd from '../vo/AlternativeMpd.js'; import URLUtils from '../../streaming/utils/URLUtils.js'; import UTCTiming from '../vo/UTCTiming.js'; import Utils from '../../core/Utils.js'; @@ -389,10 +390,11 @@ function DashManifestModel() { let i, len; const adaptations = []; - - for (i = 0, len = realAdaptations.length; i < len; i++) { - if (getIsTypeOf(realAdaptations[i], type)) { - adaptations.push(processAdaptation(realAdaptations[i])); + if (realAdaptations) { + for (i = 0, len = realAdaptations.length; i < len; i++) { + if (getIsTypeOf(realAdaptations[i], type)) { + adaptations.push(processAdaptation(realAdaptations[i])); + } } } @@ -1133,7 +1135,7 @@ function DashManifestModel() { // If the attribute @start is present in the Period, then the // Period is a regular Period and the PeriodStart is equal // to the value of this attribute. - if (realPeriod.hasOwnProperty(DashConstants.START)) { + if (realPeriod.hasOwnProperty(DashConstants.START) && !isNaN(realPeriod.start)) { voPeriod = new Period(); voPeriod.start = realPeriod.start; } @@ -1196,6 +1198,30 @@ function DashManifestModel() { return voPeriods; } + function getLinkedPeriods(mpd) { + const linkedPeriods = [] + + if (!mpd || !mpd.manifest || !mpd.manifest.Period) { + return linkedPeriods; + } + + let currentPeriod = null; + for (let i = 0, len = mpd.manifest.Period.length; i < len; i++) { + currentPeriod = mpd.manifest.Period[i]; + if (currentPeriod.ImportedMPD) { + linkedPeriods.push(currentPeriod); + } + } + + if (linkedPeriods.length > 0) { + if (mpd.manifest.type !== DashConstants.MPD_LIST) { + throw new Error(`Linked periods are only allowed in an MPD with profile ${DashConstants.MPD_LIST}`); + } + } + + return linkedPeriods + } + function getPeriodId(realPeriod, i) { if (!realPeriod) { throw new Error('Period cannot be null or undefined'); @@ -1324,10 +1350,26 @@ function DashManifestModel() { event.duration = currentMpdEvent.duration / eventStream.timescale; } if (currentMpdEvent.hasOwnProperty(DashConstants.ID)) { - event.id = parseInt(currentMpdEvent.id); + const parsedId = parseInt(currentMpdEvent.id); + event.id = isNaN(parsedId) ? currentMpdEvent.id : parsedId; } else { event.id = null; } + if (currentMpdEvent.hasOwnProperty(DashConstants.STATUS)) { + event.status = currentMpdEvent.status; + } else { + event.status = null; + } + + const alternativeMpdKey = Object.keys(DashConstants.ALTERNATIVE_MPD).find(key => + currentMpdEvent.hasOwnProperty(DashConstants.ALTERNATIVE_MPD[key]) + ); + + if (alternativeMpdKey) { + event.alternativeMpd = getAlternativeMpd(currentMpdEvent[DashConstants.ALTERNATIVE_MPD[alternativeMpdKey]], DashConstants.ALTERNATIVE_MPD[alternativeMpdKey]); + } else { + event.alternativeMpd = null; + } if (currentMpdEvent.Signal && currentMpdEvent.Signal.Binary && currentMpdEvent.Signal.Binary.__text) { // toString is used to manage both regular and namespaced tags @@ -1351,6 +1393,41 @@ function DashManifestModel() { return events; } + function getAlternativeMpd(event, mode) { + if (!mode) { + return + } + const alternativeMpd = new AlternativeMpd(); + + getAlternativeMpdCommonData(alternativeMpd, event); + + // Keep to avoid errors with the old signaling + alternativeMpd.disableJumpTimeOffest = event.disableJumpTimeOffest ?? null; + alternativeMpd.playTimes = event.playTimes ?? null; + + if (mode === DashConstants.ALTERNATIVE_MPD.INSERT) { + alternativeMpd.mode = Constants.ALTERNATIVE_MPD.MODES.INSERT; + return alternativeMpd; + } + + if (mode === DashConstants.ALTERNATIVE_MPD.REPLACE) { + alternativeMpd.mode = Constants.ALTERNATIVE_MPD.MODES.REPLACE; + alternativeMpd.returnOffset = event.returnOffset ?? null; + alternativeMpd.clip = event.clip ? !(event.clip === 'false') : true; + alternativeMpd.startWithOffset = event.startWithOffset ? event.startWithOffset === 'true' : false; + return alternativeMpd; + } + } + + function getAlternativeMpdCommonData(alternativeMpd, event) { + alternativeMpd.url = event.url ?? null; + alternativeMpd.earliestResolutionTimeOffset = event.earliestResolutionTimeOffset / 1000 ?? null; + alternativeMpd.serviceDescriptionId = event.serviceDescriptionId; + alternativeMpd.maxDuration = event.maxDuration; + alternativeMpd.noJump = event.noJump; + alternativeMpd.executeOnce = event.executeOnce ? event.executeOnce === 'true' : false; + } + function getEventStreams(inbandStreams, representation, period) { const eventStreams = []; let i; @@ -1571,7 +1648,11 @@ function DashManifestModel() { function _createClientDataReportingInstance(element) { const entry = new ClientDataReporting(); - if (element.hasOwnProperty(DashConstants.CMCD_PARAMETERS) && element[DashConstants.CMCD_PARAMETERS].schemeIdUri === Constants.CTA_5004_2023_SCHEME) { + // Check if schemeIdUri is either in ClientDataReporting (v2) or CMCDParameters (v1) + const schemeIdUri = element.schemeIdUri || (element[DashConstants.CMCD_PARAMETERS] && element[DashConstants.CMCD_PARAMETERS].schemeIdUri); + const isCmcdSupported = schemeIdUri === Constants.CTA_5004_2023_SCHEME || schemeIdUri === Constants.CTA_5004_2025_SCHEME; + + if (element.hasOwnProperty(DashConstants.CMCD_PARAMETERS) && isCmcdSupported) { entry.cmcdParameters = new CMCDParameters(); entry.cmcdParameters.init(element[DashConstants.CMCD_PARAMETERS]); } @@ -1746,6 +1827,7 @@ function DashManifestModel() { getIsTypeOf, getLabelsForAdaptation, getLanguageForAdaptation, + getLinkedPeriods, getLocation, getMainAdaptationSetForPreselection, getCommonRepresentationForPreselection, diff --git a/src/dash/parser/DashParser.js b/src/dash/parser/DashParser.js index 1748bb64b3..439732614f 100644 --- a/src/dash/parser/DashParser.js +++ b/src/dash/parser/DashParser.js @@ -51,6 +51,7 @@ const arrayNodes = [ DashConstants.CONTENT_STEERING, DashConstants.ESSENTIAL_PROPERTY, DashConstants.EVENT, + DashConstants.EVENT_TARGET, DashConstants.EVENT_STREAM, DashConstants.INBAND_EVENT_STREAM, DashConstants.LABEL, diff --git a/src/dash/vo/AlternativeMpd.js b/src/dash/vo/AlternativeMpd.js new file mode 100644 index 0000000000..f36badd763 --- /dev/null +++ b/src/dash/vo/AlternativeMpd.js @@ -0,0 +1,57 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +/** + * @class + * @ignore + */ + +class AlternativeMpd { + constructor() { + this.url = ''; + this.earliestResolutionTimeOffset = NaN; + this.mode = ''; + this.maxDuration = ''; + this.serviceDescriptionId = NaN; + this.executeOnce = false; + + // Replace + this.returnOffset = NaN; + this.returnOffset = NaN; + this.clip = true; + this.startWithOffset = false; + + // Old attributes + this.disableJumpTimeOffest = NaN; + this.playTimes = ''; + } +} + +export default AlternativeMpd; diff --git a/src/dash/vo/CMCDParameters.js b/src/dash/vo/CMCDParameters.js index ce29456bf8..6422644cc4 100644 --- a/src/dash/vo/CMCDParameters.js +++ b/src/dash/vo/CMCDParameters.js @@ -30,6 +30,8 @@ */ import DescriptorType from './DescriptorType.js'; +import EventTarget from './EventTarget.js'; +import Constants from '../../streaming/constants/Constants.js'; /** * @class @@ -44,19 +46,36 @@ class CMCDParameters extends DescriptorType { this.mode = null; this.keys = null; this.includeInRequests = null; + this.reportingTargets = null; } init(data) { super.init(data); if (data) { - this.version = data.version; + this.version = data.version ? parseInt(data.version) : null; this.sessionID = data.sessionID; this.contentID = data.contentID; this.mode = data.mode ?? 'query'; this.keys = data.keys ? data.keys.split(' ') : null; - this.includeInRequests = data.includeInRequests ? data.includeInRequests.split(' ') : ['segment']; + this.includeInRequests = data.includeInRequests + ? data.includeInRequests.split(' ') + : [Constants.CMCD_DEFAULT_INCLUDE_IN_REQUESTS]; this.schemeIdUri = data.schemeIdUri; + + // Version 2: Parse ReportingTargets with EventTargets + if (data.ReportingTargets && data.ReportingTargets.EventTarget) { + this.reportingTargets = []; + const eventTargets = Array.isArray(data.ReportingTargets.EventTarget) + ? data.ReportingTargets.EventTarget + : [data.ReportingTargets.EventTarget]; + + eventTargets.forEach(targetData => { + const eventTarget = new EventTarget(); + eventTarget.init(targetData); + this.reportingTargets.push(eventTarget); + }); + } } } } diff --git a/src/dash/vo/Event.js b/src/dash/vo/Event.js index 28e40c7eba..234007091d 100644 --- a/src/dash/vo/Event.js +++ b/src/dash/vo/Event.js @@ -42,7 +42,8 @@ class Event { this.eventStream = null; this.presentationTimeDelta = NaN; // Specific EMSG Box parameter this.parsedMessageData = null; // Parsed value of the event message + this.alternativeMpd = null; } } -export default Event; \ No newline at end of file +export default Event; diff --git a/src/dash/vo/EventTarget.js b/src/dash/vo/EventTarget.js new file mode 100644 index 0000000000..a30254eca2 --- /dev/null +++ b/src/dash/vo/EventTarget.js @@ -0,0 +1,62 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2024, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * @class + * @ignore + */ +class EventTarget { + constructor() { + this.url = null; + this.timeInterval = 0; + this.mode = null; + this.keys = null; + this.events = null; + this.enabled = true; + this.batchSize = 0; + this.batchTimer = 0; + } + + init(data) { + if (data) { + this.url = data.url; + this.timeInterval = data.timeInterval ? parseInt(data.timeInterval, 10) : 0; + this.mode = data.mode ?? 'query'; + this.keys = data.keys ? data.keys.split(' ') : null; + this.events = data.events ? data.events.split(' ') : null; + this.enabled = data.enabled ?? true; + this.batchSize = data.batchSize ? parseInt(data.batchSize, 10) : 0; + this.batchTimer = data.batchTimer ? parseInt(data.batchTimer, 10) : 0; + } + } +} + +export default EventTarget; diff --git a/src/streaming/ManifestLoader.js b/src/streaming/ManifestLoader.js index 94b69b6876..89596df514 100644 --- a/src/streaming/ManifestLoader.js +++ b/src/streaming/ManifestLoader.js @@ -107,7 +107,7 @@ function ManifestLoader(config) { } } - function load(url, serviceLocation = null, queryParams = null) { + function load(url, serviceLocation = null, queryParams = null, linkedPeriod = null, alternative = false) { const requestStartDate = new Date(); const request = new TextRequest(url, HTTPRequest.MPD_TYPE); @@ -124,129 +124,141 @@ function ManifestLoader(config) { request.startDate = requestStartDate; } - eventBus.trigger( - Events.MANIFEST_LOADING_STARTED, { - request - } - ); - - urlLoader.load({ - request: request, - success: function (data, textStatus, responseURL) { - // Manage situations in which success is called after calling reset - if (!xlinkController) { - return; - } - - let actualUrl, - baseUri, - manifest; - - // Handle redirects for the MPD - as per RFC3986 Section 5.1.3 - // also handily resolves relative MPD URLs to absolute - if (responseURL && responseURL !== url) { - baseUri = urlUtils.parseBaseUrl(responseURL); - actualUrl = responseURL; - } else { - // usually this case will be caught and resolved by - // responseURL above but it is not available for IE11 and Edge/12 and Edge/13 - // baseUri must be absolute for BaseURL resolution later - if (urlUtils.isRelative(url)) { - url = urlUtils.resolve(url, window.location.href); + function createUrlLoaderObject(resolve, reject) { + return { + request: request, + success: function (data, textStatus, responseURL) { + // Manage situations in which success is called after calling reset + if (!xlinkController) { + return; } - baseUri = urlUtils.parseBaseUrl(url); - } - - // A response of no content implies in-memory is properly up to date - if (textStatus == 'No Content') { - eventBus.trigger( - Events.INTERNAL_MANIFEST_LOADED, { - manifest: null + let actualUrl, + baseUri, + manifest; + + // Handle redirects for the MPD - as per RFC3986 Section 5.1.3 + // also handily resolves relative MPD URLs to absolute + if (responseURL && responseURL !== url) { + baseUri = urlUtils.parseBaseUrl(responseURL); + actualUrl = responseURL; + } else { + // usually this case will be caught and resolved by + // responseURL above but it is not available for IE11 and Edge/12 and Edge/13 + // baseUri must be absolute for BaseURL resolution later + if (urlUtils.isRelative(url)) { + url = urlUtils.resolve(url, window.location.href); } - ); - return; - } - - // Create parser according to manifest type - if (parser === null) { - parser = createParser(data); - } - - if (parser === null) { - eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { - manifest: null, - error: new DashJSError( - Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE, - Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` - ) - }); - return; - } - // init xlinkcontroller with created parser - xlinkController.setParser(parser); - - try { - manifest = parser.parse(data); - } catch (e) { - eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { - manifest: null, - error: new DashJSError( - Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE, - Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` - ) - }); - return; - } + baseUri = urlUtils.parseBaseUrl(url); + } - if (manifest) { - manifest.url = actualUrl || url; + // Create parser according to manifest type + if (parser === null) { + parser = createParser(data); + } - // URL from which the MPD was originally retrieved (MPD updates will not change this value) - if (!manifest.originalUrl) { - manifest.originalUrl = manifest.url; + if (parser === null && !linkedPeriod) { + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { + manifest: null, + error: new DashJSError( + Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE, + Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` + ) + }); + return; } - // If there is a mismatch between the manifest's specified duration and the total duration of all periods, - // and the specified duration is greater than the total duration of all periods, - // overwrite the manifest's duration attribute. This is a patch for if a manifest is generated incorrectly. - if (settings && - settings.get().streaming.enableManifestDurationMismatchFix && - manifest.mediaPresentationDuration && - manifest.Period.length > 1) { - const sumPeriodDurations = manifest.Period.reduce((totalDuration, period) => totalDuration + period.duration, 0); - if (!isNaN(sumPeriodDurations) && manifest.mediaPresentationDuration > sumPeriodDurations) { - logger.warn('Media presentation duration greater than duration of all periods. Setting duration to total period duration'); - manifest.mediaPresentationDuration = sumPeriodDurations; + // init xlinkcontroller with created parser + xlinkController.setParser(parser); + + try { + manifest = parser.parse(data); + } catch (e) { + if (!linkedPeriod) { + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { + manifest: null, + error: new DashJSError( + Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE, + Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` + ) + }); } + return; } - manifest.baseUri = baseUri; - manifest.loadedTime = new Date(); - xlinkController.resolveManifestOnLoad(manifest); - - eventBus.trigger(Events.ORIGINAL_MANIFEST_LOADED, { originalManifest: data }); - } else { - eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { - manifest: null, - error: new DashJSError( - Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE, - Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` - ) - }); + if (manifest) { + manifest.url = actualUrl || url; + + // URL from which the MPD was originally retrieved (MPD updates will not change this value) + if (!manifest.originalUrl) { + manifest.originalUrl = manifest.url; + } + + // If there is a mismatch between the manifest's specified duration and the total duration of all periods, + // and the specified duration is greater than the total duration of all periods, + // overwrite the manifest's duration attribute. This is a patch for if a manifest is generated incorrectly. + if (settings && + settings.get().streaming.enableManifestDurationMismatchFix && + manifest.mediaPresentationDuration && + manifest.Period.length > 1) { + const sumPeriodDurations = manifest.Period.reduce((totalDuration, period) => totalDuration + period.duration, 0); + if (!isNaN(sumPeriodDurations) && manifest.mediaPresentationDuration > sumPeriodDurations) { + logger.warn('Media presentation duration greater than duration of all periods. Setting duration to total period duration'); + manifest.mediaPresentationDuration = sumPeriodDurations; + } + } + + manifest.baseUri = baseUri; + manifest.loadedTime = new Date(); + if (!linkedPeriod) { + xlinkController.resolveManifestOnLoad(manifest); + if (alternative) { + eventBus.trigger(Events.ORIGINAL_ALTERNATIVE_MANIFEST_LOADED, { manifest: data }); + } else { + eventBus.trigger(Events.ORIGINAL_MANIFEST_LOADED, { originalManifest: data }); + } + } else { + resolve(manifest); + } + } else if (!linkedPeriod) { + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { + manifest: null, + error: new DashJSError( + Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_CODE, + Errors.MANIFEST_LOADER_PARSING_FAILURE_ERROR_MESSAGE + `${url}` + ) + }); + } + }, + error: function (request, statusText, errorText) { + if (!linkedPeriod) { + eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { + manifest: null, + error: new DashJSError( + Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_CODE, + Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_MESSAGE + `${url}, ${errorText}` + ) + }); + } else { + reject(); + } } - }, - error: function (request, statusText, errorText) { - eventBus.trigger(Events.INTERNAL_MANIFEST_LOADED, { - manifest: null, - error: new DashJSError( - Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_CODE, - Errors.MANIFEST_LOADER_LOADING_FAILURE_ERROR_MESSAGE + `${url}, ${errorText}` - ) - }); } - }); + } + + if (linkedPeriod) { + return new Promise((resolve, reject) => { + urlLoader.load(createUrlLoaderObject(resolve, reject)); + }); + } else { + eventBus.trigger( + Events.MANIFEST_LOADING_STARTED, { + request + } + ); + urlLoader.load(createUrlLoaderObject()); + } } function reset() { @@ -269,7 +281,7 @@ function ManifestLoader(config) { instance = { load: load, - reset: reset + reset: reset, }; setup(); diff --git a/src/streaming/ManifestUpdater.js b/src/streaming/ManifestUpdater.js index 27a161d7fc..339c0f1c8f 100644 --- a/src/streaming/ManifestUpdater.js +++ b/src/streaming/ManifestUpdater.js @@ -262,8 +262,14 @@ function ManifestUpdater() { if (refreshDelay * 1000 > 0x7FFFFFFF) { refreshDelay = 0x7FFFFFFF / 1000; } - eventBus.trigger(Events.MANIFEST_UPDATED, { manifest: manifest }); - logger.info('Manifest has been refreshed at ' + date + '[' + date.getTime() / 1000 + '] '); + + const manifestProfiles = manifest.profiles ? manifest.profiles.split(',') : []; + if (manifestProfiles.includes(DashConstants.LIST_PROFILE_SCHEME)) { + eventBus.trigger(Events.LIST_MPD_FOUND, { manifest }); + } else { + eventBus.trigger(Events.MANIFEST_UPDATED, { manifest: manifest }); + logger.info('Manifest has been refreshed at ' + date + '[' + date.getTime() / 1000 + '] '); + } if (!isPaused) { startManifestRefreshTimer(); diff --git a/src/streaming/MediaManager.js b/src/streaming/MediaManager.js new file mode 100644 index 0000000000..5e9ef1b850 --- /dev/null +++ b/src/streaming/MediaManager.js @@ -0,0 +1,380 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +import Events from '../core/events/Events.js'; +import MediaPlayerEvents from './MediaPlayerEvents.js'; +import MediaPlayer from './MediaPlayer.js'; +import FactoryMaker from '../core/FactoryMaker.js'; +import Debug from '../core/Debug.js'; + +function MediaManager() { + let instance, + videoModel, + isSwitching = false, + hideAlternativePlayerControls = false, + altPlayer, + playbackController, + altVideoElement, + alternativeContext, + logger, + debug, + prebufferedPlayers = new Map(), + prebufferCleanupInterval = null, + cmcdSessionIdProvider = null, + cmcdContentIdProvider = null; + + const context = this.context; + + function setConfig(config) { + if (!config) { + return; + } + + if (!videoModel) { + videoModel = config.videoModel; + } + + if (config.debug) { + debug = config.debug; + } + + if (!!config.playbackController && !playbackController) { + playbackController = config.playbackController; + } + + if (!!config.hideAlternativePlayerControls && !hideAlternativePlayerControls) { + hideAlternativePlayerControls = config.hideAlternativePlayerControls; + } + + if (!!config.alternativeContext && !alternativeContext) { + alternativeContext = config.alternativeContext + } + if (config.cmcdSessionIdProvider) { + cmcdSessionIdProvider = config.cmcdSessionIdProvider; + } + if (config.cmcdContentIdProvider) { + cmcdContentIdProvider = config.cmcdContentIdProvider; + } + } + + function initialize() { + if (!debug) { + debug = Debug(context).getInstance(); + } + + logger = debug.getLogger(instance); + + document.addEventListener('fullscreenchange', () => { + if (document.fullscreenElement === videoModel.getElement()) { + // TODO: Implement fullscreen + } else { + // TODO: Handle error + } + }); + } + + function _applyMainPlayerCmcdData(player) { + const cmcdSettings = {}; + + const sid = typeof cmcdSessionIdProvider === 'function' ? cmcdSessionIdProvider() : null; + if (sid) { + cmcdSettings.sid = sid; + } + + player.updateSettings({ + streaming: { + cmcd: cmcdSettings + } + }); + + // Listen to MANIFEST_LOADED only once to load the initial period's id. + // Subsequent period changes are handled by the PERIOD_SWITCH events listeners + function onManifestLoaded(e) { + player.off(Events.MANIFEST_LOADED, onManifestLoaded); + + const mainCid = typeof cmcdContentIdProvider === 'function' ? cmcdContentIdProvider() : null; + const isMainCidValid = mainCid && mainCid !== 'null' && mainCid !== 'undefined'; + + // Extract CMCD parameters from manifest loaded event + const manifestCmcdParams = e.data?.ServiceDescription?.[0]?.ClientDataReporting?.CMCDParameters; + const altCid = manifestCmcdParams?.contentID ?? null; + const isAltCidValid = altCid && altCid !== 'null' && altCid !== 'undefined'; + + // Determine baseCid: prioritize main player's cid, fallback to alternative's configured cid + // Store baseCid on the player instance for successive period switches + const baseCid = isMainCidValid ? mainCid : (isAltCidValid ? altCid : null); + player.__cmcdBaseCid = baseCid; + + const periods = e.data && e.data.Period; + if (periods && periods.length > 0) { + const firstPeriodId = periods[0].id; + + if (baseCid) { + player.updateSettings({ + streaming: { + cmcd: { + cid: `${baseCid}-${firstPeriodId}` + } + } + }); + } + player.refreshCmcdReporter(); + } else { + logger.warn(`[MediaManager] MANIFEST_LOADED but periods array was empty`); + } + } + + player.on(Events.MANIFEST_LOADED, onManifestLoaded); + } + + function prebufferAlternativeContent(playerId, alternativeMpdUrl) { + try { + if (prebufferedPlayers.has(playerId)) { + return; + } + + logger.info(`Starting prebuffering for player ${playerId}`); + + // Create a prebuffered player + const prebufferedPlayer = MediaPlayer().create(); + _applyMainPlayerCmcdData(prebufferedPlayer); + prebufferedPlayer.initialize(null, alternativeMpdUrl, false, NaN); + prebufferedPlayer.updateSettings({ + streaming: { + cacheInitSegments: true + } + }); + prebufferedPlayer.preload(); + prebufferedPlayer.setAutoPlay(false); + + // Store the prebuffered player + prebufferedPlayers.set(playerId, { + player: prebufferedPlayer, + playerId: playerId + }); + + prebufferedPlayer.on(Events.STREAM_INITIALIZED, () => { + logger.info(`Prebuffering completed for player ${playerId}`); + }, this); + + prebufferedPlayer.on(Events.ERROR, (e) => { + logger.error(`Prebuffering error for player ${playerId}:`, e); + cleanupPrebufferedContent(playerId); + }, this); + } catch (err) { + logger.error('Error prebuffering alternative content:', err); + } + } + + function cleanupPrebufferedContent(playerId) { + try { + const prebufferedPlayer = prebufferedPlayers.get(playerId); + if (prebufferedPlayer) { + prebufferedPlayer.player.off(Events.STREAM_INITIALIZED); + prebufferedPlayer.player.off(Events.ERROR); + prebufferedPlayer.player.reset(); + + prebufferedPlayers.delete(playerId); + } + logger.debug(`Cleaned up prebuffered content for ${playerId}`); + } catch (err) { + logger.error('Error cleaning up prebuffered content:', err); + } + } + + function initializeAlternativePlayer(alternativeMpdUrl) { + if (altPlayer) { + altPlayer.off(Events.ERROR, onAlternativePlayerError, this); + } + altPlayer = MediaPlayer().create(); + _applyMainPlayerCmcdData(altPlayer); + altPlayer.updateSettings({ + streaming: { + cacheInitSegments: true + } + }); + altPlayer.initialize(null, alternativeMpdUrl, false, NaN); + altPlayer.preload(); + altPlayer.setAutoPlay(false); + altPlayer.on(Events.ERROR, onAlternativePlayerError, this); + } + + function onAlternativePlayerError(e) { + if (logger) { + logger.error('Alternative player error:', e); + } + } + + function switchToAlternativeContent(playerId, alternativeMpdUrl, time = 0) { + if (isSwitching) { + logger.debug('Switch already in progress - ignoring request'); + return + }; + + logger.info(`Switching to alternative content at time ${time}`); + isSwitching = true; + + const prebufferedContent = prebufferedPlayers.get(playerId); + + if (prebufferedContent) { + logger.info(`Using prebuffered content for player ${playerId}`); + altPlayer = prebufferedContent.player; + prebufferedPlayers.delete(playerId); + } else { + initializeAlternativePlayer(alternativeMpdUrl); + } + + if (!altVideoElement) { + altVideoElement = document.createElement('video'); + const videoElement = videoModel.getElement(); + const parentNode = videoElement && videoElement.parentNode; + if (parentNode) { + parentNode.insertBefore(altVideoElement, videoElement.nextSibling); + } + } + + if (altPlayer) { + altVideoElement.style.display = 'block'; + altPlayer.attachView(altVideoElement); + } + + videoModel.pause(); + videoModel.getElement().style.display = 'none'; + logger.debug('Main video paused'); + + if (time) { + logger.debug(`Seeking alternative content to time: ${time}`); + altPlayer.seek(time); + } + + altPlayer.play(); + logger.info(`Alternative content playback started for player ${playerId}`); + + isSwitching = false; + } + + + function switchBackToMainContent(seekTime) { + if (isSwitching) { + logger.debug('Switch already in progress - ignoring request'); + return + }; + + if (!altPlayer) { + logger.warn('No alternative player to switch back from'); + return; + } + + logger.info('Switching back to main content'); + isSwitching = true; + + altPlayer.pause(); + altVideoElement.style.display = 'none'; + videoModel.getElement().style.display = 'block'; + + if (playbackController.getIsDynamic()) { + logger.debug('Seeking to original live point for dynamic manifest'); + if (seekTime > playbackController.getDvrWindowStart()) { + playbackController.seek(seekTime, false, false); + } else { + logger.warn('Seek time is before DVR window start, seeking to start of DVR window'); + playbackController.seekToDvrWindowStart(); + } + } else { + logger.debug(`Seeking main content to time: ${seekTime}`); + playbackController.seek(seekTime, false, false); + } + + videoModel.play(); + logger.info('Main content playback resumed'); + + altPlayer.destroy(); + altPlayer = null; + + isSwitching = false; + + logger.debug('Alternative player resources cleaned up'); + } + + + function reset() { + if (altPlayer) { + altPlayer.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED); + altPlayer.off(Events.ERROR, onAlternativePlayerError, this); + altPlayer.reset(); + altPlayer = null; + } + + if (altVideoElement) { + altVideoElement.style.display = 'none'; + } + + // Clean up all prebuffered content + for (const [playerId] of prebufferedPlayers) { + cleanupPrebufferedContent(playerId); + } + prebufferedPlayers.clear(); + + // Clear cleanup interval + if (prebufferCleanupInterval) { + clearInterval(prebufferCleanupInterval); + prebufferCleanupInterval = null; + } + + isSwitching = false; + } + + function getAlternativePlayer() { + return altPlayer; + } + + function setAlternativeVideoElement(alternativeVideoElement) { + altVideoElement = alternativeVideoElement; + } + + instance = { + setConfig, + initialize, + prebufferAlternativeContent, + cleanupPrebufferedContent, + switchToAlternativeContent, + switchBackToMainContent, + getAlternativePlayer, + setAlternativeVideoElement, + reset + }; + + return instance; +} + +MediaManager.__dashjs_factory_name = 'MediaManager'; +const factory = FactoryMaker.getSingletonFactory(MediaManager); +FactoryMaker.updateSingletonFactory(MediaManager.__dashjs_factory_name, factory); +export default factory; diff --git a/src/streaming/MediaPlayer.js b/src/streaming/MediaPlayer.js index 2871c974c1..a7fa216d67 100644 --- a/src/streaming/MediaPlayer.js +++ b/src/streaming/MediaPlayer.js @@ -29,23 +29,24 @@ * POSSIBILITY OF SUCH DAMAGE. */ import AbrController from './controllers/AbrController.js'; +import AlternativeMediaController from './controllers/AlternativeMediaController.js'; import BASE64 from '../../externals/base64.js'; import BaseURLController from './controllers/BaseURLController.js'; import BoxParser from './utils/BoxParser.js'; import Capabilities from './utils/Capabilities.js'; import CapabilitiesFilter from './utils/CapabilitiesFilter.js'; +import CmcdController from './controllers/CmcdController.js'; import CatchupController from './controllers/CatchupController.js'; import ClientDataReportingController from './controllers/ClientDataReportingController.js'; -import CmcdModel from './models/CmcdModel.js'; import CmsdModel from './models/CmsdModel.js'; import Constants from './constants/Constants.js'; import ContentSteeringController from '../dash/controllers/ContentSteeringController.js'; import CustomParametersModel from './models/CustomParametersModel.js'; -import DOMStorage from './utils/DOMStorage.js'; import DashAdapter from '../dash/DashAdapter.js'; import DashConstants from '../dash/constants/DashConstants.js'; import DashJSError from './vo/DashJSError.js'; import DashMetrics from '../dash/DashMetrics.js'; +import DOMStorage from './utils/DOMStorage.js'; import Debug from './../core/Debug.js'; import ErrorHandler from './utils/ErrorHandler.js'; import Errors from './../core/errors/Errors.js'; @@ -55,6 +56,7 @@ import ExternalSubtitle from './vo/ExternalSubtitle.js'; import FactoryMaker from '../core/FactoryMaker.js'; import GapController from './controllers/GapController.js'; import ISOBoxer from 'codem-isoboxer'; +import ListMpdController from './controllers/ListMpdController.js'; import ManifestLoader from './ManifestLoader.js'; import ManifestModel from './models/ManifestModel.js'; import ManifestUpdater from './ManifestUpdater.js'; @@ -74,9 +76,9 @@ import TimelineConverter from '../dash/utils/TimelineConverter.js'; import URIFragmentModel from './models/URIFragmentModel.js'; import URLUtils from '../streaming/utils/URLUtils.js'; import VideoModel from './models/VideoModel.js'; -import {HTTPRequest} from './vo/metrics/HTTPRequest.js'; -import {checkParameterType} from './utils/SupervisorTools.js'; -import {getVersionString} from '../core/Version.js'; +import { HTTPRequest } from './vo/metrics/HTTPRequest.js'; +import { checkParameterType } from './utils/SupervisorTools.js'; +import { getVersionString } from '../core/Version.js'; import { Cta608Parser } from '@svta/cml-608'; /** @@ -140,6 +142,7 @@ function MediaPlayer() { throughputController, schemeLoaderFactory, timelineConverter, + alternativeMediaController, mediaController, protectionController, metricsReportingController, @@ -159,9 +162,10 @@ function MediaPlayer() { serviceDescriptionController, contentSteeringController, catchupController, + listMpdController, dashMetrics, manifestModel, - cmcdModel, + cmcdController, cmsdModel, videoModel, uriFragmentModel, @@ -221,6 +225,9 @@ function MediaPlayer() { if (config.gapController) { gapController = config.gapController; } + if (config.alternativeMediaController) { + alternativeMediaController = config.alternativeMediaController; + } if (config.throughputController) { throughputController = config.throughputController } @@ -317,6 +324,10 @@ function MediaPlayer() { schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); } + if (!alternativeMediaController) { + alternativeMediaController = AlternativeMediaController(context).getInstance(); + } + if (!playbackController) { playbackController = PlaybackController(context).getInstance(); } @@ -353,7 +364,7 @@ function MediaPlayer() { manifestModel = ManifestModel(context).getInstance(); - cmcdModel = CmcdModel(context).getInstance(); + cmcdController = CmcdController(context).getInstance(); cmsdModel = CmsdModel(context).getInstance(); @@ -387,6 +398,17 @@ function MediaPlayer() { adapter }); + alternativeMediaController.setConfig({ + videoModel, + DashConstants, + mediaPlayerFactory: FactoryMaker.getClassFactory(MediaPlayer)(), + playbackController, + alternativeContext: context, + logger, + cmcdSessionIdProvider: () => getCmcdSessionId(), + cmcdContentIdProvider: () => getCmcdContentId() + }); + if (!segmentBaseController) { segmentBaseController = SegmentBaseController(context).getInstance({ dashMetrics: dashMetrics, @@ -454,8 +476,12 @@ function MediaPlayer() { * @memberof module:MediaPlayer * @instance */ - function reset() { - attachSource(null); + function reset(onlyControllers) { + + if (!onlyControllers) { + attachSource(null); + } + attachView(null); protectionData = null; if (protectionController) { @@ -552,6 +578,49 @@ function MediaPlayer() { return getVersionString(); } + /** + * Returns the current CMCD session ID (sid) used by the main player's reporter. + * When no sessionID is configured explicitly, the reporter auto-generates a UUID; + * this method exposes that value so it can be shared with alternative media players. + * + * @returns {string|null} The active session ID, or null if CMCD is not initialized. + * @memberof module:MediaPlayer + * @instance + */ + function getCmcdSessionId() { + if (!cmcdController || !cmcdController.isCmcdEnabled()) { + return null; + } + return cmcdController.getCmcdSessionId(); + } + + /** + * Returns the current CMCD content ID (cid) used by the main player's reporter. + * + * @returns {string|null} The active content ID, or null if CMCD is not initialized. + * @memberof module:MediaPlayer + * @instance + */ + function getCmcdContentId() { + // debugger + if (!cmcdController || !cmcdController.isCmcdEnabled()) { + return null; + } + return cmcdController.getCmcdContentId(); + } + + /** + * Forces the rebuilding of the CMCD reporter. + * Useful when settings change and the reporter needs to be recreated immediately. + * @memberof module:MediaPlayer + * @instance + */ + function refreshCmcdReporter() { + if (cmcdController) { + cmcdController.rebuildReporter(); + } + } + /** * Use this method to access the dash.js logging class. * @@ -582,12 +651,13 @@ function MediaPlayer() { * @throws {@link module:MediaPlayer~SOURCE_NOT_ATTACHED_ERROR SOURCE_NOT_ATTACHED_ERROR} if called before attachSource function * @instance */ - function preload() { - if (videoModel.getElement() || streamingInitialized) { + function preload(time) { + if (videoModel.getElement() || (streamingInitialized && !time)) { return; } if (source) { - _initializePlayback(providedStartTime); + const playbackTime = time ? time : providedStartTime; + _initializePlayback(playbackTime); } else { throw SOURCE_NOT_ATTACHED_ERROR; } @@ -2126,6 +2196,16 @@ function MediaPlayer() { streamController.load(source); } + function setAlternativeVideoElement(element) { + if (!mediaPlayerInitialized) { + throw MEDIA_PLAYER_NOT_INITIALIZED_ERROR; + } + + if (alternativeMediaController) { + alternativeMediaController.setAlternativeVideoElement(element); + } + } + /** * Use this method to set a source URL to a valid MPD manifest file OR * a previously downloaded and parsed manifest object. Optionally, can @@ -2424,6 +2504,7 @@ function MediaPlayer() { throughputController.reset(); mediaController.reset(); segmentBaseController.reset(); + listMpdController.reset(); if (protectionController) { if (settings.get().streaming.protection.keepProtectionMediaKeys) { protectionController.stop(); @@ -2434,7 +2515,8 @@ function MediaPlayer() { } } textController.reset(); - cmcdModel.reset(); + alternativeMediaController.reset(); + cmcdController.reset(); cmsdModel.reset(); } @@ -2450,6 +2532,10 @@ function MediaPlayer() { streamController = StreamController(context).getInstance(); } + if (!listMpdController) { + listMpdController = ListMpdController(context).getInstance(); + } + if (!textController) { textController = TextController(context).create({ errHandler, @@ -2462,6 +2548,12 @@ function MediaPlayer() { }); } + listMpdController.setConfig({ + settings: settings, + dashAdapter: adapter, + manifestLoader: manifestLoader + }); + capabilitiesFilter.setConfig({ capabilities, customParametersModel, @@ -2544,12 +2636,14 @@ function MediaPlayer() { settings }); - cmcdModel.setConfig({ + cmcdController.setConfig({ abrController, dashMetrics, playbackController, serviceDescriptionController, throughputController, + mediaPlayerModel, + errHandler }); clientDataReportingController.setConfig({ @@ -2559,6 +2653,7 @@ function MediaPlayer() { cmsdModel.setConfig({}); // initializes controller + listMpdController.initialize(); mediaController.initialize(); throughputController.initialize(); abrController.initialize(); @@ -2566,7 +2661,8 @@ function MediaPlayer() { textController.initialize(); gapController.initialize(); catchupController.initialize(); - cmcdModel.initialize(autoPlay); + alternativeMediaController.initialize(); + cmcdController.initialize(autoPlay); cmsdModel.initialize(); contentSteeringController.initialize(); segmentBaseController.initialize(); @@ -2611,7 +2707,7 @@ function MediaPlayer() { events: Events, BASE64, constants: Constants, - cmcdModel, + cmcdController, settings }); @@ -2857,6 +2953,9 @@ function MediaPlayer() { getCurrentSteeringResponseData, getCurrentTextTrackIndex, getCurrentTrackFor, + getCmcdSessionId, + getCmcdContentId, + refreshCmcdReporter, getDashAdapter, getDashMetrics, getDebug, @@ -2925,6 +3024,7 @@ function MediaPlayer() { setProtectionData, setRepresentationForTypeById, setRepresentationForTypeByIndex, + setAlternativeVideoElement, setTextTrack, setVolume, setXHRWithCredentialsForType, diff --git a/src/streaming/MediaPlayerEvents.js b/src/streaming/MediaPlayerEvents.js index b098926916..b681ca7107 100644 --- a/src/streaming/MediaPlayerEvents.js +++ b/src/streaming/MediaPlayerEvents.js @@ -156,6 +156,11 @@ class MediaPlayerEvents extends EventsBase { * @event MediaPlayerEvents#MANIFEST_LOADED */ this.MANIFEST_LOADED = 'manifestLoaded'; + /** + * Triggered when TBD + * @event MediaPlayerEvents#MANIFEST_LOADED + */ + this.ALTERNATIVE_MANIFEST_LOADED = 'alternativeManifestLoaded' /** * Triggered anytime there is a change to the overall metrics. diff --git a/src/streaming/SourceBufferSink.js b/src/streaming/SourceBufferSink.js index 499bdef7d0..96748412ed 100644 --- a/src/streaming/SourceBufferSink.js +++ b/src/streaming/SourceBufferSink.js @@ -438,8 +438,8 @@ function SourceBufferSink(config) { } function _updateEndHandler() { - // if updating is still in progress do nothing and wait for the next check again. - if (buffer.updating) { + // if buffer is null or updating is still in progress do nothing and wait for the next check again. + if (!buffer || buffer.updating) { return; } diff --git a/src/streaming/cmcd/config/CmcdConfigAccessor.js b/src/streaming/cmcd/config/CmcdConfigAccessor.js new file mode 100644 index 0000000000..7c26c578ff --- /dev/null +++ b/src/streaming/cmcd/config/CmcdConfigAccessor.js @@ -0,0 +1,487 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2024, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +import FactoryMaker from '../../../core/FactoryMaker.js'; +import CmcdPropertyMap from './CmcdPropertyMap.js'; +import Settings from '../../../core/Settings.js'; +import Constants from '../../constants/Constants.js'; + +/** + * @module CmcdConfigAccessor + * @description Provides unified access to CMCD configuration properties. + * + * This accessor abstracts away the differences between: + * - Player settings (settings.get().streaming.cmcd) + * - Manifest parameters (CMCDParameters from MPD) + * - CMCD versions (v1 and v2) + * + * It uses CmcdPropertyMap for declarative configuration and provides + * a version-agnostic API for accessing CMCD properties with automatic + * fallback resolution. + * + * @example + * const cmcdConfig = CmcdConfigAccessor(context).getInstance(); + * cmcdConfig.setManifestParams(manifestParams); + * + * const version = cmcdConfig.getVersion(); // 1 or 2 + * const keys = cmcdConfig.get('keys'); // Resolves with fallback + * const isEnabled = cmcdConfig.isEnabled(); // Boolean + * + * @ignore + */ +function CmcdConfigAccessor() { + let instance; + let settings; + let manifestParams; + let manifestParamsProvider; + + const context = this.context; + + /** + * Initialize the accessor + * @private + */ + function setup() { + settings = Settings(context).getInstance(); + manifestParams = null; + manifestParamsProvider = null; + } + + /** + * Set a provider function for live access to manifest params + * This enables lazy loading of CMCDParameters, resolving timing issues + * where params are needed before they're available in the manifest + * + * @param {Function} providerFn - Function that returns CMCDParameters object or null + * @public + * + * @example + * cmcdConfig.setManifestParamsProvider(() => { + * return serviceDescriptionController.getServiceDescriptionSettings() + * ?.clientDataReporting?.cmcdParameters || null; + * }); + */ + function setManifestParamsProvider(providerFn) { + manifestParamsProvider = providerFn; + } + + /** + * Get CMCDParameters from the provider function + * @returns {Object|null} CMCDParameters or null if provider not set or returns nothing + * @private + */ + function _getManifestParamsFromProvider() { + if (typeof manifestParamsProvider !== 'function') { + return null; + } + try { + return manifestParamsProvider(); + } catch (e) { + // Provider not ready yet or threw an error + return null; + } + } + + /** + * Get the effective manifest params (live from provider, or cached) + * Prefers live access from provider when available + * @returns {Object|null} CMCDParameters + * @private + */ + function _getEffectiveManifestParams() { + // Try live access first (resolves timing issues) + const liveParams = _getManifestParamsFromProvider(); + if (liveParams && Object.keys(liveParams).length > 0) { + return liveParams; + } + // Fall back to cached params + return manifestParams; + } + + /** + * Set manifest parameters from MPD CMCDParameters + * @param {Object} params - CMCDParameters from manifest + * @public + */ + function setManifestParams(params) { + manifestParams = params; + } + + /** + * Detect the CMCD version from available sources + * Priority: manifest > settings > default (1) + * @returns {number} CMCD version (1 or 2) + * @private + */ + function _detectVersion() { + // Check manifest parameters first (use live access for timing safety) + const effectiveManifestParams = _getEffectiveManifestParams(); + if (effectiveManifestParams && effectiveManifestParams.version) { + return parseInt(effectiveManifestParams.version, 10); + } + + // Check settings (don't cache - settings can change dynamically) + const cmcdSettings = settings.get().streaming.cmcd; + if (cmcdSettings && cmcdSettings.version) { + return parseInt(cmcdSettings.version, 10); + } + + // Default + return Constants.CMCD_DEFAULT_VERSION; + } + + /** + * Resolve a dot-notation path in an object with support for array notation and context variables + * @param {Object} obj - Object to traverse + * @param {string} path - Dot-notation path (e.g., 'streaming.cmcd.version' or 'targets[0].mode') + * @param {Object} pathContext - Context variables for path interpolation (e.g., {targetIndex: 0}) + * @returns {*} Resolved value or undefined + * @private + * + * @example + * // Simple path + * _resolvePath(obj, 'settings.streaming.cmcd.version') + * + * // Array notation + * _resolvePath(obj, 'settings.streaming.cmcd.targets[0].url') + * + * // Context variables + * _resolvePath(obj, 'settings.streaming.cmcd.targets[{targetIndex}].url', {targetIndex: 0}) + */ + function _resolvePath(obj, path, pathContext = {}) { + if (!obj || !path) { + return undefined; + } + + // Replace contextual variables in the path + // Example: 'targets[{targetIndex}].mode' with {targetIndex: 0} -> 'targets[0].mode' + let resolvedPath = path; + for (const [key, value] of Object.entries(pathContext)) { + resolvedPath = resolvedPath.replace(`{${key}}`, value); + } + + // Split path by dots + const parts = resolvedPath.split('.'); + let current = obj; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + if (current === null || current === undefined) { + return undefined; + } + + // Handle array notation: targets[0] or reportingTargets[1] + const arrayMatch = part.match(/^([^\[]+)\[(\d+)\]$/); + + if (arrayMatch) { + const [, arrayName, arrayIndex] = arrayMatch; + + // First get the array + current = current[arrayName]; + + // Check if it's actually an array + if (!Array.isArray(current)) { + return undefined; + } + + // Then access the specific index + const index = parseInt(arrayIndex, 10); + current = current[index]; + } else { + // Normal property access + current = current[part]; + } + } + + return current; + } + + /** + * Get the available data context for property resolution + * @returns {Object} Context object with settings and manifestParams + * @private + */ + function _getContext() { + return { + settings: settings.get(), + manifestParams: _getEffectiveManifestParams() || {} + }; + } + + /** + * Get a CMCD property value with automatic fallback resolution + * @param {string} property - Property name from CmcdPropertyMap + * @param {Object} options - Optional configuration + * @param {*} options.defaultValue - Override default value + * @param {number} options.targetIndex - Target index for V2 contextual properties + * @returns {*} Property value or default + * @public + */ + function get(property, options = {}) { + const propertyMapping = CmcdPropertyMap[property]; + + if (!propertyMapping) { + return options.defaultValue !== undefined ? options.defaultValue : undefined; + } + + const version = _detectVersion(); + const context = _getContext(); + + // Check if this mapping applies to the current version + if (propertyMapping.version && !propertyMapping.version.includes(version)) { + return options.defaultValue !== undefined ? options.defaultValue : undefined; + } + + // Build path context for interpolation (e.g., {targetIndex: 0}) + const pathContext = {}; + if (options.targetIndex !== undefined) { + pathContext.targetIndex = options.targetIndex; + } + + // Check if we should skip manifest params (avoid circular dependency by checking settings directly) + const applyParametersFromMpd = property === 'applyParametersFromMpd' ? true : + (context.settings?.streaming?.cmcd?.applyParametersFromMpd ?? true); + + // Sort sources by priority (lower number = higher priority) + const sortedSources = [...propertyMapping.sources].sort((a, b) => a.priority - b.priority); + + // First pass: Try to find an actual value from any source + for (const source of sortedSources) { + // Skip manifestParams sources if applyParametersFromMpd is false + if (!applyParametersFromMpd && source.path.startsWith('manifestParams')) { + continue; + } + + const value = _resolvePath(context, source.path, pathContext); + + // Check if value exists and is not null/undefined + if (value !== null && value !== undefined) { + // Apply transformation if provided + if (typeof source.transform === 'function') { + return source.transform(value); + } + + return value; + } + } + + // Second pass: If no actual value found, look for the highest-priority default + for (const source of sortedSources) { + // Skip manifestParams sources if applyParametersFromMpd is false + if (!applyParametersFromMpd && source.path.startsWith('manifestParams')) { + continue; + } + + if (source.default !== undefined) { + return source.default; + } + } + + // No value or default found in any source, return override default or undefined + return options.defaultValue !== undefined ? options.defaultValue : undefined; + } + + /** + * Check if a property has a non-null/undefined value + * @param {string} property - Property name from CmcdPropertyMap + * @returns {boolean} True if property has a value + * @public + */ + function has(property) { + const value = get(property); + return value !== null && value !== undefined; + } + + /** + * Get the detected CMCD version + * @returns {number} CMCD version (1 or 2) + * @public + */ + function getVersion() { + return _detectVersion(); + } + + /** + * Check if manifest CMCDParameters are available + * @returns {boolean} True if manifest params exist with a version + * @public + */ + function hasManifestParams() { + const effectiveManifestParams = _getEffectiveManifestParams(); + return !!(effectiveManifestParams && effectiveManifestParams.version); + } + + /** + * Check if CMCD is enabled + * + * CMCD is considered enabled if: + * 1. Manifest has CMCDParameters configured (presence implies enabled), OR + * 2. Player settings explicitly set enabled: true + * + * Note: 'enabled' attribute does not exist in the manifest standard, + * only in player configuration. + * + * @returns {boolean} True if CMCD is enabled + * @public + */ + function isEnabled() { + const cmcdSettings = settings.get().streaming.cmcd; + + // Check if manifest has CMCDParameters (only if applyParametersFromMpd is not false) + // Manifest CMCDParameters override player settings including enabled: false + const applyFromMpd = cmcdSettings?.applyParametersFromMpd ?? true; + if (applyFromMpd) { + const effectiveManifestParams = _getEffectiveManifestParams(); + if (effectiveManifestParams && effectiveManifestParams.version) { + return true; + } + } + + // No manifest params (or applyParametersFromMpd is false) — use player settings + if (cmcdSettings && cmcdSettings.enabled === false) { + return false; + } + + // Fall back to player settings configuration + return get('enabled') === true; + } + + /** + * Reset the accessor state + * @public + */ + function reset() { + manifestParams = null; + manifestParamsProvider = null; + } + + /** + * Get the array of reporting targets (V2 only) + * @returns {Array} Array of target objects, empty array if V1 or no targets + * @public + * + * @example + * const targets = cmcdConfig.getTargets(); + * targets.forEach((target, index) => { + * const targetAccessor = cmcdConfig.getTarget(index); + * const keys = targetAccessor.get('targetKeys'); + * }); + */ + function getTargets() { + const version = _detectVersion(); + + // Only V2 supports targets + if (version !== 2) { + return []; + } + + const targets = get('targets'); + return Array.isArray(targets) ? targets : []; + } + + /** + * Get a target accessor for a specific target index (V2 only) + * @param {number} index - Target index + * @returns {Object|null} Target accessor object or null if invalid index + * @public + * + * @example + * const target = cmcdConfig.getTarget(0); + * if (target) { + * const url = target.get('targetUrl'); + * const keys = target.get('targetKeys'); + * const events = target.get('targetEvents'); + * } + */ + function getTarget(index) { + const targets = getTargets(); + + // Validate index + if (index < 0 || index >= targets.length) { + return null; + } + + // Return accessor object for this specific target + return { + /** + * Get a target-specific property value + * @param {string} property - Property name (should be target-specific like 'targetUrl', 'targetKeys', etc.) + * @returns {*} Property value + */ + get: (property) => { + return get(property, { targetIndex: index }); + }, + + /** + * Check if a target-specific property has a value + * @param {string} property - Property name + * @returns {boolean} True if property has a value + */ + has: (property) => { + const value = get(property, { targetIndex: index }); + return value !== null && value !== undefined; + }, + + /** + * Target index + * @type {number} + */ + index: index, + + /** + * Raw target object from settings/manifest + * @type {Object} + */ + raw: targets[index] + }; + } + + instance = { + setManifestParamsProvider, + setManifestParams, + get, + has, + getVersion, + hasManifestParams, + isEnabled, + getTargets, + getTarget, + reset + }; + + setup(); + + return instance; +} + +CmcdConfigAccessor.__dashjs_factory_name = 'CmcdConfigAccessor'; +export default FactoryMaker.getSingletonFactory(CmcdConfigAccessor); diff --git a/src/streaming/cmcd/config/CmcdPropertyMap.js b/src/streaming/cmcd/config/CmcdPropertyMap.js new file mode 100644 index 0000000000..bf398dac38 --- /dev/null +++ b/src/streaming/cmcd/config/CmcdPropertyMap.js @@ -0,0 +1,455 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2024, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +import Constants from '../../constants/Constants.js'; + +/** + * @module CmcdPropertyMap + * @description Declarative configuration for CMCD property mappings. + * + * Maps logical property names to physical paths in different sources (manifest, settings) + * with version awareness, priority-based fallback, and optional transformations. + * + * This centralized configuration allows handling structure changes in CMCD settings + * without modifying business logic across the codebase. + * + * @typedef {Object} PropertySource + * @property {string} path - Dot-notation path to property (e.g., 'settings.streaming.cmcd.version') + * @property {number} priority - Order of precedence (1 = highest) + * @property {string} [type] - Expected data type ('string', 'number', 'boolean', 'array', 'object') + * @property {Function} [transform] - Optional transformation function + * @property {*} [default] - Default value if not found + * @property {boolean} [deprecated] - Mark as deprecated for backward compatibility + * + * @typedef {Object} PropertyMapping + * @property {number[]} [version] - CMCD versions this mapping applies to (omit for all versions) + * @property {PropertySource[]} sources - Ordered list of sources to check + */ + +/** + * Property mappings for CMCD configuration + * @type {Object.} + */ +const CmcdPropertyMap = { + /** + * CMCD version number + * Priority: manifest > settings > default (1) + */ + version: { + version: [1, 2], + sources: [ + { + path: 'manifestParams.version', + priority: 1, + type: 'number' + }, + { + path: 'settings.streaming.cmcd.version', + priority: 2, + type: 'number', + default: Constants.CMCD_DEFAULT_VERSION + } + ] + }, + + /** + * CMCD enabled flag + * Note: This only exists in player settings, not in manifest. + * Manifest presence (CMCDParameters tag) implies enabled=true. + * Priority: settings > default (false) + */ + enabled: { + version: [1, 2], + sources: [ + { + path: 'settings.streaming.cmcd.enabled', + priority: 1, + type: 'boolean', + default: false + } + ] + }, + + /** + * Session ID (sid) + * Priority: manifest > settings > null + */ + sessionID: { + version: [1, 2], + sources: [ + { + path: 'manifestParams.sessionID', + priority: 1, + type: 'string', + }, + { + path: 'settings.streaming.cmcd.sid', + priority: 2, + type: 'string', + default: null + } + ] + }, + + /** + * Content ID (cid) + * Priority: settings > manifest > null + */ + contentID: { + version: [1, 2], + sources: [ + { + path: 'settings.streaming.cmcd.cid', + priority: 1, + type: 'string' + }, + { + path: 'manifestParams.contentID', + priority: 2, + type: 'string', + default: null + } + ] + }, + + /** + * Transmission mode (query, header, body) + * Priority: manifest > settings > default (query) + * + * V1: Single global mode + * V2: Can be per-target or global + */ + mode: { + version: [1, 2], + sources: [ + { + path: 'manifestParams.mode', + priority: 1, + type: 'string' + }, + { + path: 'settings.streaming.cmcd.mode', + priority: 2, + type: 'string', + default: Constants.CMCD_MODE_QUERY + } + ] + }, + + /** + * Requested throughput (rtp) + * Priority: settings > null (calculated dynamically if null) + */ + rtp: { + version: [1, 2], + sources: [ + { + path: 'settings.streaming.cmcd.rtp', + priority: 1, + type: 'number', + default: null + } + ] + }, + + /** + * RTP safety factor for throughput calculation + * Priority: settings > default (5) + */ + rtpSafetyFactor: { + version: [1, 2], + sources: [ + { + path: 'settings.streaming.cmcd.rtpSafetyFactor', + priority: 1, + type: 'number', + default: 5 + } + ] + }, + + /** + * Global enabled CMCD keys + * Priority: manifest > settings > default (all keys) + * + * Note: In V1, this is a global setting. + * In V2, keys can be per-target (see targetKeys), and this serves as a fallback. + */ + keys: { + version: [1, 2], + sources: [ + { + path: 'manifestParams.keys', + priority: 1, + type: 'array', + transform: (val) => { + // If string with spaces, split into array + if (typeof val === 'string') { + return val.split(' '); + } + return val; + } + }, + { + path: 'settings.streaming.cmcd.enabledKeys', + priority: 2, + type: 'array', + default: Constants.CMCD_KEYS + } + ] + }, + + /** + * Request types to include CMCD data in + * Priority: manifest > settings > default (['segment', 'mpd']) + */ + includeInRequests: { + version: [1, 2], + sources: [ + { + path: 'manifestParams.includeInRequests', + priority: 1, + type: 'array', + transform: (val) => { + // If string with spaces, split into array + if (typeof val === 'string') { + return val.split(' '); + } + return val; + } + }, + { + path: 'settings.streaming.cmcd.includeInRequests', + priority: 2, + type: 'array', + default: ['segment', 'mpd'] + } + ] + }, + + /** + * Apply CMCD parameters from MPD manifest + * Priority: settings > default (true) + */ + applyParametersFromMpd: { + version: [1, 2], + sources: [ + { + path: 'settings.streaming.cmcd.applyParametersFromMpd', + priority: 1, + type: 'boolean', + default: true + } + ] + }, + + /** + * V2: Reporting targets array + * Priority: manifest > settings > default ([]) + */ + targets: { + version: [2], + sources: [ + { + path: 'manifestParams.reportingTargets', + priority: 1, + type: 'array' + }, + { + path: 'settings.streaming.cmcd.targets', + priority: 2, + type: 'array', + default: [] + } + ] + }, + + /** + * V2: Target enabled flag + * Note: This is target-specific, requires context + */ + targetEnabled: { + version: [2], + sources: [ + { + path: 'manifestParams.reportingTargets[{targetIndex}].enabled', + priority: 1, + type: 'boolean' + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].enabled', + priority: 2, + type: 'boolean', + default: true + } + ] + }, + + /** + * V2: Target URL for event reporting + * Note: This is target-specific, requires context + */ + targetUrl: { + version: [2], + sources: [ + { + path: 'manifestParams.reportingTargets[{targetIndex}].url', + priority: 1, + type: 'string' + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].url', + priority: 2, + type: 'string', + default: null + } + ] + }, + + /** + * V2: Target enabled keys (can override global keys) + * Note: This is target-specific, requires context + */ + targetKeys: { + version: [2], + sources: [ + { + path: 'manifestParams.reportingTargets[{targetIndex}].keys', + priority: 1, + type: 'array', + transform: (val) => { + if (typeof val === 'string') { + return val.split(' '); + } + return val; + } + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].enabledKeys', + priority: 2, + type: 'array' + } + ] + }, + + /** + * V2: Target events to report + * Note: This is target-specific, requires context + */ + targetEvents: { + version: [2], + sources: [ + { + path: 'manifestParams.reportingTargets[{targetIndex}].events', + priority: 1, + type: 'array', + transform: (val) => { + if (typeof val === 'string') { + return val.split(' '); + } + return val; + } + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].events', + priority: 2, + type: 'array', + default: [] + } + ] + }, + + /** + * V2: Target time interval for periodic reporting + * Note: This is target-specific, requires context + */ + targetTimeInterval: { + version: [2], + sources: [ + { + path: 'manifestParams.reportingTargets[{targetIndex}].timeInterval', + priority: 1, + type: 'number' + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].timeInterval', + priority: 2, + type: 'number', + default: Constants.CMCD_DEFAULT_TIME_INTERVAL + } + ] + }, + + /** + * V2: Target batch size for batched reporting + * Note: This is target-specific, requires context + */ + targetBatchSize: { + version: [2], + sources: [ + { + path: 'manifestParams.reportingTargets[{targetIndex}].batchSize', + priority: 1, + type: 'number', + default: 0 + }, + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].batchSize', + priority: 2, + type: 'number', + default: 0 + } + ] + }, + + /** + * V2: Target includeInRequests filter + * Note: This is target-specific, requires context + */ + targetIncludeInRequests: { + version: [2], + sources: [ + { + path: 'settings.streaming.cmcd.targets[{targetIndex}].includeInRequests', + priority: 1, + type: 'array' + }, + { + path: 'settings.streaming.cmcd.includeInRequests', + priority: 2, + type: 'array', + default: ['segment', 'mpd'] + } + ] + } +}; + +export default CmcdPropertyMap; diff --git a/src/streaming/constants/Constants.js b/src/streaming/constants/Constants.js index 6922b7bb83..08629d48d0 100644 --- a/src/streaming/constants/Constants.js +++ b/src/streaming/constants/Constants.js @@ -28,6 +28,15 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ +import { + CmcdPlayerState, + CmcdReportingMode, + CmcdEventType, + CMCD_DEFAULT_TIME_INTERVAL, + CMCD_PARAM, + CMCD_QUERY, + CMCD_KEYS +} from '@svta/cml-cmcd'; /** * Constants declaration @@ -213,14 +222,14 @@ export default { * @memberof Constants# * @static */ - CMCD_QUERY_KEY: 'CMCD', + CMCD_QUERY_KEY: CMCD_PARAM, /** * @constant {string} CMCD_MODE_QUERY specifies to attach CMCD metrics as query parameters. * @memberof Constants# * @static */ - CMCD_MODE_QUERY: 'query', + CMCD_MODE_QUERY: CMCD_QUERY, /** * @constant {string} CMCD_MODE_HEADER specifies to attach CMCD metrics as HTTP headers. @@ -230,25 +239,79 @@ export default { CMCD_MODE_HEADER: 'header', /** - * @constant {string} CMCD_AVAILABLE_KEYS specifies all the available keys for CMCD metrics. + * @constant {string} CMCD_MODE_BODY specifies to attach CMCD metrics on request body. * @memberof Constants# * @static */ - CMCD_AVAILABLE_KEYS: ['br', 'd', 'ot', 'tb', 'bl', 'dl', 'mtp', 'nor', 'nrr', 'su', 'bs', 'rtp', 'cid', 'pr', 'sf', 'sid', 'st', 'v'], + CMCD_MODE_BODY: 'body', + + /** + * @constant {string} CMCD_AVAILABLE_REQUESTS specifies all the available requests type for CMCD metrics. + * @memberof Constants# + * @static + */ + CMCD_AVAILABLE_REQUESTS: ['segment', 'mpd', 'xlink', 'steering', 'other'], + /** + * @constant {integer} CMCD_DEFAULT_TIME_INTERVAL specifies the default value for time interval in seconds. + * @memberof Constants# + * @static + */ + CMCD_DEFAULT_TIME_INTERVAL: CMCD_DEFAULT_TIME_INTERVAL, /** - * @constant {string} CMCD_AVAILABLE_KEYS_V2 specifies all the available keys for CMCD version 2 metrics. + * @constant {string} CMCD_REPORTING_MODE specifies all the available modes for CMCD. * @memberof Constants# * @static */ - CMCD_V2_AVAILABLE_KEYS: ['msd', 'ltc'], + CMCD_REPORTING_MODE: CmcdReportingMode, /** - * @constant {string} CMCD_AVAILABLE_REQUESTS specifies all the available requests type for CMCD metrics. + * @constant {string} CMCD_KEYS specifies all the available keys for CMCD. * @memberof Constants# * @static */ - CMCD_AVAILABLE_REQUESTS: ['segment', 'mpd', 'xlink', 'steering', 'other'], + CMCD_KEYS: CMCD_KEYS, + + /** + * @constant {string} CMCD_REPORTING_EVENTS specifies all the available events for CMCD event mode. + * @memberof Constants# + * @static + */ + CMCD_REPORTING_EVENTS: CmcdEventType, + + /** + * @constant {string} CMCD_PLAYER_STATES specifies available player states for CMCD sta key. + * @memberof Constants# + * @static + */ + CMCD_PLAYER_STATES: CmcdPlayerState, + /** + * @constant {integer} CMCD_DEFAULT_VERSION specifies default CMCD version. + * @memberof Constants# + * @static + */ + CMCD_DEFAULT_VERSION: 1, + /** + * @constant {string} CMCD_DEFAULT_INCLUDE_IN_REQUESTS specifies default requests type to include CMCD data. + * @memberof Constants# + * @static + */ + CMCD_DEFAULT_INCLUDE_IN_REQUESTS: 'segment', + + /** + * @constant {string} CMCD_CONTENT_TYPE_HEADER specifies content type for cmcd batching + * @memberof Constants# + * @static + */ + CMCD_CONTENT_TYPE_HEADER: { + 'Content-Type': 'text/cmcd' + }, + /** + * @constant {Array.} CMCD_DEFAULT_BATCH_RETRY_DELAYS specifies default retry delays in milliseconds for batched CMCD reporting failures + * @memberof Constants# + * @static + */ + CMCD_DEFAULT_BATCH_RETRY_DELAYS: [100, 500, 1000, 3000, 5000], INITIALIZE: 'initialize', TEXT_SHOWING: 'showing', @@ -263,6 +326,7 @@ export default { SERVICE_DESCRIPTION_DVB_LL_SCHEME: 'urn:dvb:dash:lowlatency:scope:2019', SUPPLEMENTAL_PROPERTY_DVB_LL_SCHEME: 'urn:dvb:dash:lowlatency:critical:2019', CTA_5004_2023_SCHEME: 'urn:mpeg:dash:cta-5004:2023', + CTA_5004_2025_SCHEME: 'urn:dashif:cta-5004:2025', THUMBNAILS_SCHEME_ID_URIS: ['http://dashif.org/thumbnail_tile', 'http://dashif.org/guidelines/thumbnail_tile'], FONT_DOWNLOAD_DVB_SCHEME: 'urn:dvb:dash:fontdownload:2014', COLOUR_PRIMARIES_SCHEME_ID_URI: 'urn:mpeg:mpegB:cicp:ColourPrimaries', @@ -346,6 +410,30 @@ export default { ABANDON_FRAGMENT_RULES: { ABANDON_REQUEST_RULE: 'AbandonRequestsRule' }, + ALTERNATIVE_MPD: { + MODES: { + REPLACE: 'replace', + INSERT: 'insert' + }, + STATUS: { + UPDATE: 'update', + REPEAT: 'repeat' + }, + URIS: { + REPLACE: 'urn:mpeg:dash:event:alternativeMPD:replace:2025', + INSERT: 'urn:mpeg:dash:event:alternativeMPD:insert:2025' + }, + ATTRIBUTES: { + NO_JUMP_DEFAULT: 1, + NO_JUMP_PRIORITY: 2 + }, + CONTENT_START: 'alternativeContentStart', + CONTENT_END: 'alternativeContentEnd', + EVENT_UPDATED: 'alternativeEventUpdated', + AD_START: 'alternativeAdStart', + AD_END: 'alternativeAdEnd' + + }, /** * @constant {string} ID3_SCHEME_ID_URI specifies scheme ID URI for ID3 timed metadata diff --git a/src/streaming/controllers/AlternativeMediaController.js b/src/streaming/controllers/AlternativeMediaController.js new file mode 100644 index 0000000000..3e60f45716 --- /dev/null +++ b/src/streaming/controllers/AlternativeMediaController.js @@ -0,0 +1,540 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +import Events from '../../core/events/Events.js'; +import MediaPlayerEvents from '../MediaPlayerEvents.js'; +import EventBus from './../../core/EventBus.js'; +import FactoryMaker from '../../core/FactoryMaker.js'; +import Constants from '../constants/Constants.js'; +import DashConstants from '../../dash/constants/DashConstants.js'; +import MediaManager from '../MediaManager.js'; +import Debug from '../../core/Debug.js'; + +function AlternativeMediaController() { + const context = this.context; + const eventBus = EventBus(context).getInstance(); + + let instance, + debug, + logger, + manifestInfo = {}, + mediaManager, + playbackController, + currentEvent = null, + actualEventPresentationTime = 0, + alternativeSwitched = false, + timeToSwitch = 0, + switchTime = null, + calculatedMaxDuration = 0, + videoModel = null, + alternativeContext = null, + hideAlternativePlayerControls = false, + adPlaybackStarted = false, + adEndSent = false, + isSeekingInAltPlayer = false, + alternativeVideoElement = null, + cmcdSessionIdProvider = null, + cmcdContentIdProvider = null; + + function setConfig(config) { + if (!config) { + return; + } + + if (config.debug) { + debug = config.debug; + } + + if (config.playbackController && !playbackController) { + playbackController = config.playbackController; + } + + if (config.mediaManager && !mediaManager) { + mediaManager = config.mediaManager; + } + + if (config.videoModel) { + videoModel = config.videoModel; + } + + if (config.alternativeContext) { + alternativeContext = config.alternativeContext; + } + + if (config.hideAlternativePlayerControls) { + hideAlternativePlayerControls = config.hideAlternativePlayerControls; + } + if (config.cmcdSessionIdProvider) { + cmcdSessionIdProvider = config.cmcdSessionIdProvider; + } + if (config.cmcdContentIdProvider) { + cmcdContentIdProvider = config.cmcdContentIdProvider; + } + } + + function initialize() { + if (!debug) { + debug = Debug(context).getInstance(); + } + logger = debug.getLogger(instance); + + // Initialize the media manager if not already provided via config + if (!mediaManager) { + mediaManager = MediaManager(context).getInstance(); + } + + mediaManager.setConfig({ + videoModel, + logger, + playbackController, + alternativeContext, + hideAlternativePlayerControls, + cmcdSessionIdProvider, + cmcdContentIdProvider + }); + + mediaManager.initialize(); + + if (alternativeVideoElement) { + mediaManager.setAlternativeVideoElement(alternativeVideoElement); + } + + // Set up event listeners + eventBus.on(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, this); + + // Listen to alternative MPD events directly from EventController + eventBus.on(Constants.ALTERNATIVE_MPD.URIS.REPLACE, _onAlternativeEventTriggered, this); + eventBus.on(Constants.ALTERNATIVE_MPD.URIS.INSERT, _onAlternativeEventTriggered, this); + + // Listen to event ready to resolve for prebuffering + eventBus.on(Events.EVENT_READY_TO_RESOLVE, _onEventReadyToResolve, this); + + // Listen to alternative event updates + eventBus.on(Constants.ALTERNATIVE_MPD.EVENT_UPDATED, _onAlternativeEventUpdated, this); + } + + function _onManifestLoaded(e) { + const manifest = e.data + manifestInfo.type = manifest.type; + manifestInfo.originalUrl = manifest.originalUrl; + } + + function _getAnchor(url) { + const regexT = /#.*?t=(\d+)(?:&|$)/; + const t = url.match(regexT); + return t ? Number(t[1]) : 0; + } + + function _parseEvent(event) { + if (event.alternativeMpd) { + const timescale = event.eventStream.timescale || 1; + const alternativeMpdNode = event.alternativeMpd; + const mode = alternativeMpdNode.mode || Constants.ALTERNATIVE_MPD.MODES.INSERT; + return { + presentationTime: event.presentationTime / timescale, + duration: event.duration, + id: event.id, + schemeIdUri: event.eventStream.schemeIdUri, + maxDuration: alternativeMpdNode.maxDuration / timescale, + alternativeMPD: { + url: alternativeMpdNode.url, + }, + mode: mode, + type: DashConstants.STATIC, + ...(alternativeMpdNode.returnOffset && { returnOffset: parseInt(alternativeMpdNode.returnOffset || '0', 10) / 1000 }), + ...(alternativeMpdNode.maxDuration && { clip: alternativeMpdNode.clip }), + ...(alternativeMpdNode.clip && { startWithOffset: alternativeMpdNode.startWithOffset }), + }; + } + return event; + } + + function _onAlternativeEventTriggered(e) { + const event = e.event; + try { + if (!event || !event.alternativeMpd) { + return; + } + + // Only Alternative MPD replace events can be used for dynamic MPD + if (manifestInfo.type === DashConstants.DYNAMIC && event.alternativeMpd.mode === Constants.ALTERNATIVE_MPD.MODES.INSERT) { + logger.warn('Insert mode not supported for dynamic manifests - ignoring event'); + return; + } + + const parsedEvent = _parseEvent(event); + if (!parsedEvent) { + return; + } + + // maxDuration=0 means the event was cancelled before it started - skip entirely + if (parsedEvent.maxDuration === 0) { + mediaManager.cleanupPrebufferedContent(parsedEvent.id); + logger.info(`Alternative event ${parsedEvent.id} cancelled (maxDuration=0) - skipping switch`); + return; + } + + // Try to prebuffer if not already done + mediaManager.prebufferAlternativeContent( + parsedEvent.id, + parsedEvent.alternativeMPD.url + ); + + // Set current event and timing variables + currentEvent = parsedEvent; + + // Handle switching to alternative content (need to determine timing) + // This logic was previously in handleAlternativeEventTriggered + if (playbackController) { + actualEventPresentationTime = playbackController.getTime(); + timeToSwitch = parsedEvent.startWithOffset ? actualEventPresentationTime - parsedEvent.presentationTime : 0; + timeToSwitch = timeToSwitch + _getAnchor(parsedEvent.alternativeMPD.url); + mediaManager.switchToAlternativeContent( + parsedEvent.id, + parsedEvent.alternativeMPD.url, + timeToSwitch + ); + + // Trigger content start event + if (eventBus) { + eventBus.trigger(Constants.ALTERNATIVE_MPD.CONTENT_START, { + event: parsedEvent, + player: mediaManager.getAlternativePlayer() + }); + } + } else { + throw new Error('Playback controller is not initialized'); + } + + const altPlayer = mediaManager.getAlternativePlayer(); + if (altPlayer) { + altPlayer.on(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onAlternativePlaybackTimeUpdated, this); + altPlayer.on(MediaPlayerEvents.PLAYBACK_PLAYING, _onAlternativePlaybackPlaying, this); + altPlayer.on(MediaPlayerEvents.DYNAMIC_TO_STATIC, _onAlternativeDynamicToStatic, this); + altPlayer.on(MediaPlayerEvents.PLAYBACK_ENDED, _onAlternativePlaybackEnded, this); + altPlayer.on(MediaPlayerEvents.PERIOD_SWITCH_STARTED, _onAlternativePeriodSwitchStarted, this); + altPlayer.on(MediaPlayerEvents.PERIOD_SWITCH_COMPLETED, _onAlternativePeriodSwitchCompleted, this); + altPlayer.on(MediaPlayerEvents.PLAYBACK_SEEKING, _onAlternativePlaybackSeeking, this); + } + } catch (err) { + logger.error('Error handling alternative event:', err); + } + } + + + function _onAlternativePlaybackPlaying() { + if (!adPlaybackStarted) { + adPlaybackStarted = true; + adEndSent = false; + _triggerAltEvent(Constants.ALTERNATIVE_MPD.AD_START); + } + } + + function _onAlternativePeriodSwitchStarted(e) { + const altPlayer = mediaManager.getAlternativePlayer(); + const fromStreamInfo = e.fromStreamInfo; + + if (fromStreamInfo && adPlaybackStarted && !adEndSent) { + // If it's a seek/skip in the alternative player, it's not a "natural" completion + const isCompleted = !isSeekingInAltPlayer; + _triggerAdEnd(altPlayer, isCompleted); + } + + if (altPlayer && e.toStreamInfo) { + const baseCid = altPlayer.__cmcdBaseCid; + if (baseCid && baseCid !== 'null' && baseCid !== 'undefined') { + const newCid = `${baseCid}-${e.toStreamInfo.id}`; + altPlayer.updateSettings({ + streaming: { + cmcd: { + cid: newCid + } + } + }); + altPlayer.refreshCmcdReporter(); + } else { + logger.warn('[AlternativeMediaController] baseCid is falsy'); + } + } + } + + function _onAlternativePeriodSwitchCompleted(e) { + logger.debug('Alternative player period switch completed:', e.toStreamInfo?.id); + // Reset flags for the next ad in the timeline + adPlaybackStarted = false; + adEndSent = false; + isSeekingInAltPlayer = false; + } + + function _onAlternativePlaybackSeeking() { + isSeekingInAltPlayer = true; + } + + function _triggerAdEnd(altPlayer, completedOverride) { + if (adEndSent) { + return; + } + + const isCompleted = completedOverride !== undefined ? completedOverride : (altPlayer && (altPlayer.duration() - altPlayer.time() < 0.5)); + + _triggerAltEvent(Constants.ALTERNATIVE_MPD.AD_END, { + event: currentEvent, + completed: !!isCompleted + }); + adEndSent = true; + } + + function _triggerAltEvent(type, payload) { + // Trigger only on alternative player's internal bus for its own CmcdController + const altPlayer = mediaManager.getAlternativePlayer(); + if (altPlayer && typeof altPlayer.trigger === 'function') { + const altPayload = payload ? { ...payload } : {}; + altPlayer.trigger(type, altPayload); + } + } + + function _onEventReadyToResolve(e) { + const { schemeIdUri, eventId, event } = e; + + try { + // Check if this is an alternative MPD event + if (schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.REPLACE || + schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.INSERT) { + + logger.info(`Event ${eventId} is ready for prebuffering`); + + // Start prebuffering if we have the event data + if (event && event.alternativeMpd) { + const parsedEvent = _parseEvent(event); + if (parsedEvent) { + mediaManager.prebufferAlternativeContent( + parsedEvent.id, + parsedEvent.alternativeMPD.url + ); + } + } + } + } catch (err) { + logger.error('Error handling event ready to resolve:', err); + } + } + + function _onAlternativeEventUpdated(e) { + const { schemeIdUri, eventId, event } = e; + + try { + if (schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.REPLACE || + schemeIdUri === Constants.ALTERNATIVE_MPD.URIS.INSERT) { + if (currentEvent && currentEvent.id === eventId) { + const parsedEvent = _parseEvent(event); + if (parsedEvent) { + currentEvent = parsedEvent; + alternativeSwitched = false; + logger.info(`Alternative event ${eventId} has been updated`); + } + } + } + } catch (err) { + logger.error('Error on alternative event update:', err); + } + } + + function _onAlternativePlaybackTimeUpdated(e) { + try { + if (!currentEvent) { + return; + } + + if (!adPlaybackStarted && e.time > 0) { + adPlaybackStarted = true; + adEndSent = false; + _triggerAltEvent(Constants.ALTERNATIVE_MPD.AD_START); + } + + const altPlayer = mediaManager.getAlternativePlayer(); + + if (!adEndSent && altPlayer && (altPlayer.duration() - e.time < 0.5)) { + _triggerAdEnd(altPlayer); + } + + const event = { ...currentEvent }; + + if (event.type == DashConstants.DYNAMIC) { + return; + } + + const { presentationTime, maxDuration, clip } = event; + if (Math.round(e.time - actualEventPresentationTime) === 0) { + return; + } + + const adjustedTime = e.time - timeToSwitch; + if (!alternativeSwitched && adjustedTime > 0) { + alternativeSwitched = true; + switchTime = switchTime ? switchTime : adjustedTime; + calculatedMaxDuration = altPlayer.isDynamic() ? switchTime + maxDuration : maxDuration; + } + // maxDuration === 0 signals immediate termination per spec: + // presentationTime + 0 <= currentPlayhead is always true once alt content is playing + const shouldSwitchBack = + maxDuration === 0 || + (calculatedMaxDuration > 0 && ( + // Check if the alternative content has finished playing (only for non-dynamic content) + (!altPlayer.isDynamic() && Math.round(altPlayer.duration() - e.time) === 0) || + // Check if the alternative content reached the max duration + (clip && actualEventPresentationTime + adjustedTime >= presentationTime + calculatedMaxDuration) || + (calculatedMaxDuration && calculatedMaxDuration <= adjustedTime) + )); + if (shouldSwitchBack) { + _switchBackToMainContent(altPlayer, event); + } + } catch (err) { + logger.error(`Error at ${actualEventPresentationTime} in onAlternativePlaybackTimeUpdated:`, err); + } + } + + function _onAlternativePlaybackEnded(e) { + if (e.isLast) { + const event = { ...currentEvent }; + const altPlayer = mediaManager.getAlternativePlayer(); + if (altPlayer.isDynamic()) { + _switchBackToMainContent(altPlayer, event); + } + } + } + + function _onAlternativeDynamicToStatic() { + const event = { ...currentEvent }; + const altPlayer = mediaManager.getAlternativePlayer(); + if (altPlayer.isDynamic()) { + _switchBackToMainContent(altPlayer, event); + } + } + + function _switchBackToMainContent(altPlayer, event) { + if (!event) { + return; + } + + const seekTime = _calculateSeekTime(event, altPlayer); + + // Individual ad ends (if not already triggered by completion) + // Must be called BEFORE switchBackToMainContent to ensure the altPlayer is still alive to receive it + _triggerAdEnd(altPlayer); + + mediaManager.switchBackToMainContent(seekTime); + + // Trigger ad break end event + if (eventBus) { + eventBus.trigger(Constants.ALTERNATIVE_MPD.CONTENT_END, { event }); + } + + _resetAlternativeSwitchStates(); + } + + function _calculateSeekTime(currentEvent, altPlayer) { + let seekTime; + if (currentEvent.mode === Constants.ALTERNATIVE_MPD.MODES.REPLACE) { + if (currentEvent.returnOffset || currentEvent.returnOffset === 0) { + seekTime = currentEvent.presentationTime + currentEvent.returnOffset; + logger.debug(`Using return offset - seeking to: ${seekTime}`); + } else { + const alternativeDuration = altPlayer.duration() + const alternativeEffectiveDuration = !isNaN(currentEvent.maxDuration) ? Math.min(currentEvent.maxDuration, alternativeDuration) : alternativeDuration + seekTime = currentEvent.presentationTime + alternativeEffectiveDuration; + logger.debug(`Using alternative duration - seeking to: ${seekTime}`); + } + } else if (currentEvent.mode === Constants.ALTERNATIVE_MPD.MODES.INSERT) { + seekTime = currentEvent.presentationTime; + logger.debug(`Insert mode - seeking to original presentation time: ${seekTime}`); + } + return seekTime; + } + + function _resetAlternativeSwitchStates() { + currentEvent = null; + actualEventPresentationTime = 0; + timeToSwitch = 0; + switchTime = null; + alternativeSwitched = false; + calculatedMaxDuration = 0; + adPlaybackStarted = false; + adEndSent = false; + } + + function reset() { + + // Clean up alternative player event handlers before resetting media manager + const altPlayer = mediaManager && mediaManager.getAlternativePlayer(); + if (altPlayer) { + _switchBackToMainContent(altPlayer, currentEvent); + altPlayer.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _onAlternativePlaybackTimeUpdated, this); + altPlayer.off(MediaPlayerEvents.PLAYBACK_PLAYING, _onAlternativePlaybackPlaying, this); + altPlayer.off(MediaPlayerEvents.DYNAMIC_TO_STATIC, _onAlternativeDynamicToStatic, this); + altPlayer.off(MediaPlayerEvents.PLAYBACK_ENDED, _onAlternativePlaybackEnded, this); + altPlayer.off(MediaPlayerEvents.PERIOD_SWITCH_STARTED, _onAlternativePeriodSwitchStarted, this); + altPlayer.off(MediaPlayerEvents.PERIOD_SWITCH_COMPLETED, _onAlternativePeriodSwitchCompleted, this); + altPlayer.off(MediaPlayerEvents.PLAYBACK_SEEKING, _onAlternativePlaybackSeeking, this); + } + + if (mediaManager) { + mediaManager.reset(); + } + + _resetAlternativeSwitchStates(); + + eventBus.off(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, this); + eventBus.off(Constants.ALTERNATIVE_MPD.URIS.REPLACE, _onAlternativeEventTriggered, this); + eventBus.off(Constants.ALTERNATIVE_MPD.URIS.INSERT, _onAlternativeEventTriggered, this); + eventBus.off(Events.EVENT_READY_TO_RESOLVE, _onEventReadyToResolve, this); + eventBus.off(Constants.ALTERNATIVE_MPD.EVENT_UPDATED, _onAlternativeEventUpdated, this); + } + + function setAlternativeVideoElement(element) { + alternativeVideoElement = element; + if (mediaManager) { + mediaManager.setAlternativeVideoElement(element); + } + } + + instance = { + setConfig, + setAlternativeVideoElement, + initialize, + reset + }; + + return instance; +} + +AlternativeMediaController.__dashjs_factory_name = 'AlternativeMediaController'; +const factory = FactoryMaker.getSingletonFactory(AlternativeMediaController); +FactoryMaker.updateSingletonFactory(AlternativeMediaController.__dashjs_factory_name, factory); +export default factory; \ No newline at end of file diff --git a/src/streaming/controllers/CmcdController.js b/src/streaming/controllers/CmcdController.js new file mode 100644 index 0000000000..06a03655db --- /dev/null +++ b/src/streaming/controllers/CmcdController.js @@ -0,0 +1,655 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +import EventBus from '../../core/EventBus.js'; +import MetricsReportingEvents from '../metrics/MetricsReportingEvents.js'; +import FactoryMaker from '../../core/FactoryMaker.js'; +import MediaPlayerEvents from '../MediaPlayerEvents.js'; +import Constants from '../../streaming/constants/Constants.js'; +import { HTTPRequest } from '../vo/metrics/HTTPRequest.js'; +import { + CMCD_PARAM, + CmcdReporter, + CMCD_QUERY, + CMCD_HEADERS, +} from '@svta/cml-cmcd'; +import Debug from '../../core/Debug.js'; + +import CmcdReportRequest from '../../streaming/vo/CmcdReportRequest.js'; +import URLLoader from '../net/URLLoader.js'; +import CmcdModel from '../models/CmcdModel.js' +import Errors from '../../core/errors/Errors.js'; +import CmcdConfigAccessor from '../cmcd/config/CmcdConfigAccessor.js'; + +function CmcdController() { + let instance, + logger, + cmcdModel, + cmcdConfig, + cmcdReporter, + reporterNeedsRebuild, + urlLoader, + mediaPlayerModel, + dashMetrics, + errHandler; + + let context = this.context; + let eventBus = EventBus(context).getInstance(); + let debug = Debug(context).getInstance(); + + cmcdModel = CmcdModel(context).getInstance(); + cmcdConfig = CmcdConfigAccessor(context).getInstance(); + + function setup() { + logger = debug.getLogger(instance); + reset(); + } + + function setConfig(config) { + if (!config) { + return; + } + + if (config.dashMetrics) { + dashMetrics = config.dashMetrics; + } + + if (config.mediaPlayerModel) { + mediaPlayerModel = config.mediaPlayerModel; + } + + if (config.errHandler) { + errHandler = config.errHandler; + } + if (config.urlLoader) { + urlLoader = config.urlLoader; + } + + // Set up a provider function for CmcdConfigAccessor to get manifest params + // This resolves timing issues where CMCDParameters are needed before they're available + // Using a provider pattern keeps CmcdConfigAccessor decoupled from ServiceDescriptionController + if (config.serviceDescriptionController) { + const sdc = config.serviceDescriptionController; + cmcdConfig.setManifestParamsProvider(() => { + const serviceDescription = sdc.getServiceDescriptionSettings(); + return serviceDescription?.clientDataReporting?.cmcdParameters || null; + }); + } + + cmcdModel.setConfig(config); + } + + function initialize(autoPlay) { + reporterNeedsRebuild = false; + + eventBus.on(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, _onPlaybackRateChanged, instance); + eventBus.on(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, instance); + eventBus.on(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, _onBufferLevelStateChanged, instance); + eventBus.on(MediaPlayerEvents.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); + eventBus.on(MediaPlayerEvents.PERIOD_SWITCH_COMPLETED, _onPeriodSwitchComplete, instance); + + if (autoPlay) { + eventBus.on(MediaPlayerEvents.MANIFEST_LOADING_STARTED, _onPlaybackStarted, instance); + } + else { + eventBus.on(MediaPlayerEvents.PLAYBACK_STARTED, _onPlaybackStarted, instance); + } + + cmcdReporter = _createCmcdReporter(); + cmcdReporter.start(); + + _initializeEvenModeListeners(); + _initializePlaybackStateListeners(); + _initializeAlternativeContentListeners(); + } + + function _initializeAlternativeContentListeners() { + eventBus.on(Constants.ALTERNATIVE_MPD.CONTENT_START, () => triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.AD_BREAK_START)); + eventBus.on(Constants.ALTERNATIVE_MPD.CONTENT_END, () => triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.AD_BREAK_END)); + eventBus.on(Constants.ALTERNATIVE_MPD.AD_START, () => triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.AD_START)); + eventBus.on(Constants.ALTERNATIVE_MPD.AD_END, () => triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.AD_END)); + } + + function _initializePlaybackStateListeners() { + const stateMap = { + [MediaPlayerEvents.PLAYBACK_INITIALIZED]: Constants.CMCD_PLAYER_STATES.STARTING, + [MediaPlayerEvents.PLAYBACK_PAUSED]: Constants.CMCD_PLAYER_STATES.PAUSED, + [MediaPlayerEvents.PLAYBACK_ERROR]: Constants.CMCD_PLAYER_STATES.FATAL_ERROR, + [MediaPlayerEvents.PLAYBACK_ENDED]: Constants.CMCD_PLAYER_STATES.ENDED, + }; + + eventBus.on(MediaPlayerEvents.PLAYBACK_PLAYING, _onPlaybackPlaying, instance); + eventBus.on(MediaPlayerEvents.PLAYBACK_SEEKING, _onPlaybackSeeking, instance); + eventBus.on(MediaPlayerEvents.PLAYBACK_WAITING, _onPlaybackWaiting, instance); + + Object.entries(stateMap).forEach(([event, state]) => { + eventBus.on(event, () => _onStateChange(state), instance); + }); + } + + function _initializeEvenModeListeners() { + eventBus.on(MediaPlayerEvents.ERROR, _onPlayerError, instance); + } + + function _rebuildReporterIfNeeded() { + if (!reporterNeedsRebuild || !cmcdReporter) { + return; + } + + // Only rebuild if manifest params are available and enabled. + // Without manifest params, the reporter config hasn't changed + // and rebuilding would unnecessarily reset sid and sn. + // IMPORTANT: Don't reset reporterNeedsRebuild until we actually rebuild, + // otherwise a race condition can occur where params aren't available yet + // and we never get another chance to rebuild. + const applyFromMpd = cmcdConfig.get('applyParametersFromMpd') ?? true; + if (!applyFromMpd || !cmcdConfig.hasManifestParams()) { + return; + } + + // Reset flag only after confirming we will rebuild + reporterNeedsRebuild = false; + + cmcdReporter.stop(true); + cmcdReporter = _createCmcdReporter(); + cmcdReporter.start(); + } + + function _createCmcdReporter() { + const config = { + version: cmcdConfig.getVersion(), + transmissionMode: cmcdConfig.get('mode') === Constants.CMCD_MODE_HEADER + ? CMCD_HEADERS + : CMCD_QUERY, + enabledKeys: cmcdConfig.get('keys'), + eventTargets: _buildReporterTargets(), + }; + + // Only pass sid/cid if they have actual values, so CmcdReporter + // uses its own defaults (e.g., auto-generated uuid for sid) + const sid = cmcdConfig.get('sessionID'); + if (sid) { + config.sid = sid; + } + const cid = cmcdConfig.get('contentID'); + if (cid) { + config.cid = cid; + } + + return new CmcdReporter(config, _customRequester); + } + + function _buildReporterTargets() { + const targets = cmcdConfig.getTargets(); + return targets + .map((_target, index) => { + const accessor = cmcdConfig.getTarget(index); + if (!isCmcdEnabled(index)) { + return null; + } + return { + url: accessor.get('targetUrl'), + events: accessor.get('targetEvents'), + interval: accessor.get('targetTimeInterval') ?? Constants.CMCD_DEFAULT_TIME_INTERVAL, + batchSize: accessor.get('targetBatchSize') || 1, + enabledKeys: accessor.get('targetKeys'), + }; + }) + .filter(Boolean); + } + + function _customRequester(request) { + return new Promise((resolve) => { + if (!urlLoader) { + urlLoader = URLLoader(context).create({ + errHandler: errHandler, + mediaPlayerModel: mediaPlayerModel, + errors: Errors, + dashMetrics: dashMetrics, + }); + } + + const httpRequest = new CmcdReportRequest(); + httpRequest.url = request.url; + httpRequest.method = request.method; + httpRequest.headers = request.headers; + httpRequest.body = request.body; + httpRequest.type = HTTPRequest.CMCD_EVENT; + + urlLoader.load({ + request: httpRequest, + success: () => resolve({ status: 200 }), + error: (e) => resolve({ status: e?.status || 500 }), + }); + }); + } + + function _onStateChange(state) { + // Update CmcdReporter with the new player state + if (cmcdReporter) { + cmcdReporter.update({ sta: state }); + } + triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.PLAY_STATE); + } + + function _onPeriodSwitchComplete() { + cmcdModel.onPeriodSwitchComplete(); + } + + function _onPlaybackStarted() { + cmcdModel.onPlaybackStarted(); + } + + function _onPlaybackPlaying() { + cmcdModel.onPlaybackPlaying(); + _onStateChange(Constants.CMCD_PLAYER_STATES.PLAYING); + } + + function _onPlayerError(errorData) { + if (errorData.error && errorData.error.data.request && errorData.error.data.request.type === HTTPRequest.CMCD_EVENT) { + return; + } + // Update CmcdReporter with the error code + if (cmcdReporter) { + const errorCode = errorData.error?.code || errorData.error?.data?.code; + if (errorCode) { + cmcdReporter.update({ ec: errorCode }); + } + } + + triggerCmcdEventMode(Constants.CMCD_REPORTING_EVENTS.ERROR); + } + + function triggerCmcdEventMode(event) { + if (!cmcdReporter) { + return; + } + + _rebuildReporterIfNeeded(); + + const cmcdData = cmcdModel.getEventModeData(); + + // Route MSD through update() for the reporter's internal send-once tracking + const msdData = cmcdModel.calculateMsd(); + if (msdData.msd !== undefined) { + cmcdReporter.update(msdData); + } + + // Pass event-mode data as transient per-event data (not persisted) + cmcdReporter.recordEvent(event, cmcdData); + } + + /** + * Applies CMCD data to a request by decorating its URL and/or headers. + * Delegates to CmcdReporter.createRequestReport() which handles + * transmission mode (query vs header) internally. + * + * @param {object} request - The request object with at least { url, type }. + * Will be mutated with CMCD-decorated url/headers. + */ + function applyCmcdToRequest(request) { + if (!cmcdReporter || !isCmcdEnabled()) { + return; + } + + _rebuildReporterIfNeeded(); + + try { + const cmcdData = cmcdModel.calculateCmcdDataForRequest(request); + + // Route MSD through update() for the reporter's internal send-once tracking + const msdData = cmcdModel.calculateMsd(); + if (msdData.msd !== undefined) { + cmcdReporter.update(msdData); + } + + const decorated = cmcdReporter.createRequestReport(request, cmcdData); + request.url = decorated.url; + request.headers = decorated.headers; + request.cmcd = decorated.customData?.cmcd || {}; + + _triggerCMCDDataGeneratedEvent(request) + + } catch (e) { + //TODO: add warning + return null; + } + } + + function _triggerCMCDDataGeneratedEvent(request) { + const effectiveMode = cmcdConfig.get('mode'); + const eventData = { + url: request.url, + mediaType: request.mediaType, + requestType: request.type, + cmcdData: request.cmcd, + mode: effectiveMode, + }; + + if (effectiveMode === Constants.CMCD_MODE_HEADER) { + eventData.headers = request.headers; + } else { + try { + const url = new URL(request.url); + eventData.cmcdString = url.searchParams.get(CMCD_PARAM) || ''; + } catch (e) { + eventData.cmcdString = ''; + } + } + + eventBus.trigger(MetricsReportingEvents.CMCD_DATA_GENERATED, eventData); + } + + function isCmcdEnabled(targetIndex = null) { + if (targetIndex !== null) { + return _targetCanBeEnabled(targetIndex) && _checkTargetIncludeInRequests(targetIndex); + } + else { + return _canBeEnabled() && _checkIncludeInRequests(); + } + } + + function _canBeEnabled() { + const version = cmcdConfig.getVersion(); + + if (version !== 1 && version !== 2) { + logger.error(`version parameter must be 1 or 2, got ${version}.`); + return false; + } + + return cmcdConfig.isEnabled(); + } + + function _checkIncludeInRequests() { + const version = cmcdConfig.getVersion(); + if (version === 2) { + return true; // Skip this validation for version 2 + } + + // Version 1 validation + const enabledRequests = cmcdConfig.get('includeInRequests'); + + const defaultAvailableRequests = Constants.CMCD_AVAILABLE_REQUESTS; + const invalidRequests = enabledRequests.filter(k => !defaultAvailableRequests.includes(k)); + + if (invalidRequests.length === enabledRequests.length) { + logger.error(`None of the request types are supported.`); + return false; + } + + invalidRequests.map((k) => { + logger.warn(`request type ${k} is not supported.`); + }); + + return true; + } + + function _targetCanBeEnabled(targetIndex) { + const cmcdVersion = cmcdConfig.getVersion(); + + if (cmcdVersion !== 2) { + logger.warn('CMCD version 2 is required for target configuration'); + return false; + } + + const targetAccessor = cmcdConfig.getTarget(targetIndex); + const enabled = targetAccessor.get('targetEnabled'); + const url = targetAccessor.get('targetUrl'); + + if (!url) { + logger.warn('Target URL is not configured'); + return false; + } + + return (enabled && url); + } + + function _checkTargetIncludeInRequests(targetIndex) { + const targetAccessor = cmcdConfig.getTarget(targetIndex); + let enabledRequests = targetAccessor.get('targetIncludeInRequests'); + + if (!enabledRequests) { + return true; + } + + const defaultAvailableRequests = Constants.CMCD_AVAILABLE_REQUESTS; + const invalidRequests = enabledRequests.filter(k => !defaultAvailableRequests.includes(k)); + + if (invalidRequests.length === enabledRequests.length) { + logger.error(`None of the request types are supported.`); + return false; + } + + invalidRequests.map((k) => { + logger.warn(`request type ${k} is not supported.`); + }); + + return true; + } + + function _onPlaybackRateChanged(data) { + const prData = cmcdModel.onPlaybackRateChanged(data); + if (cmcdReporter && prData) { + cmcdReporter.update(prData); + } + } + + function _onManifestLoaded(data) { + getCmcdParametersFromManifest(); + + // Mark reporter for rebuild so it picks up sid, cid, and keys from manifest params. + // We can't rebuild here because ServiceDescriptionController may not have processed + // the manifest yet (MANIFEST_LOADED fires before service description is available). + // The reporter will be rebuilt lazily before the next request or event. + reporterNeedsRebuild = true; + + if (cmcdReporter) { + const streamInfo = cmcdModel.onManifestLoaded(data); + cmcdReporter.update(streamInfo); + } + } + + function _onBufferLevelStateChanged(data) { + cmcdModel.onBufferLevelStateChanged(data); + } + + function _onPlaybackSeeking() { + cmcdModel.onPlaybackSeeking(); + _onStateChange(Constants.CMCD_PLAYER_STATES.SEEKING); + } + + function _onPlaybackSeeked() { + cmcdModel.onPlaybackSeeked(); + } + + function _onPlaybackWaiting() { + if (cmcdModel.wasPlaying()) { + const mediaType = cmcdModel.getLastMediaTypeRequest(); + cmcdModel.onRebufferingStarted(mediaType); + _onStateChange(Constants.CMCD_PLAYER_STATES.REBUFFERING); + } else { + _onStateChange(Constants.CMCD_PLAYER_STATES.WAITING); + } + } + + function getCmcdRequestInterceptors() { + return [_cmcdRequestModeInterceptor]; + } + + function _cmcdRequestModeInterceptor(commonMediaRequest) { + const requestType = commonMediaRequest.customData.request.type; + + if (!cmcdModel.isIncludedInRequestFilter(requestType)) { + commonMediaRequest.cmcd = commonMediaRequest.customData.request.cmcd; + return commonMediaRequest; + } + + let request = commonMediaRequest.customData.request; + + applyCmcdToRequest(request) + + commonMediaRequest = { + ...commonMediaRequest, + url: request.url, + headers: request.headers, + cmcd: request.cmcd, + customData: { ...commonMediaRequest.customData, cmcd: request.cmcd }, + }; + + return commonMediaRequest; + } + + function getCmcdResponseInterceptors() { + return [_cmcdResponseReceivedInterceptor]; + } + + function _cmcdResponseReceivedInterceptor(response) { + const requestType = response.request?.customData?.request?.type; + if (requestType === HTTPRequest.CMCD_EVENT) { + return response; + } + _handleResponseReceived(response); + return response; + } + + function _handleResponseReceived(response) { + if (!cmcdReporter) { + return; + } + + _rebuildReporterIfNeeded(); + + // Collect event-mode data from the model + const eventData = cmcdModel.getEventModeData(); + + // Route MSD through update() for the reporter's internal send-once tracking + const msdData = cmcdModel.calculateMsd(); + if (msdData.msd !== undefined) { + cmcdReporter.update(msdData); + } + + // Collect dash.js-specific additional data + const additionalData = {}; + + if (response.headers) { + try { + const cmsdStaticHeader = response.headers['cmsd-static']; + if (cmsdStaticHeader) { + additionalData.cmsds = btoa(cmsdStaticHeader); + } + + const cmsdDynamicHeader = response.headers['cmsd-dynamic']; + if (cmsdDynamicHeader) { + additionalData.cmsdd = btoa(cmsdDynamicHeader); + } + } catch (e) { + logger.warn('Failed to base64 encode CMSD headers, ignoring.', e); + } + } + + cmcdReporter.recordResponseReceived(response, { ...eventData, ...additionalData }); + } + + function getCmcdParametersFromManifest() { + return cmcdModel.getCmcdParametersFromManifest(); + } + + /** + * Returns the current CMCD session ID used by the reporter. + * This may be an auto-generated UUID if no sessionID was explicitly configured. + * @returns {string|null} The current session ID, or null if the reporter is not active. + */ + function getCmcdSessionId() { + if (!cmcdReporter) { + return null; + } + return cmcdReporter.data?.sid || cmcdReporter.config?.sid || null; + } + + /** + * Returns the current CMCD content ID used by the reporter. + * @returns {string|null} The current content ID, or null if the reporter is not active. + */ + function getCmcdContentId() { + if (!cmcdReporter) { + return null; + } + return cmcdReporter.data?.cid || cmcdReporter.config?.cid || cmcdConfig.get('contentID') || null; + } + + /** + * Forces the rebuilding of the CMCD reporter. + * Useful when settings change and the reporter needs to be recreated immediately. + */ + function rebuildReporter() { + reporterNeedsRebuild = true; + _rebuildReporterIfNeeded(); + } + + function reset() { + eventBus.off(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, _onPlaybackRateChanged, this); + eventBus.off(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, this); + eventBus.off(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, _onBufferLevelStateChanged, instance); + eventBus.off(MediaPlayerEvents.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); + eventBus.off(MediaPlayerEvents.PLAYBACK_STARTED, _onPlaybackStarted, instance); + eventBus.off(MediaPlayerEvents.PLAYBACK_PLAYING, _onPlaybackPlaying, instance); + + eventBus.off(MediaPlayerEvents.PLAYBACK_SEEKING, _onPlaybackSeeking, instance); + eventBus.off(MediaPlayerEvents.PLAYBACK_WAITING, _onPlaybackWaiting, instance); + + if (cmcdReporter) { + cmcdReporter.stop(true); + cmcdReporter = null; + } + + cmcdModel.resetInitialSettings(); + } + + instance = { + applyCmcdToRequest, + getCmcdRequestInterceptors, + getCmcdResponseInterceptors, + getCmcdParametersFromManifest, + getCmcdSessionId, + getCmcdContentId, + rebuildReporter, + initialize, + isCmcdEnabled, + reset, + setConfig + }; + + setup(); + + return instance; +} + +CmcdController.__dashjs_factory_name = 'CmcdController'; +export default FactoryMaker.getSingletonFactory(CmcdController); diff --git a/src/streaming/controllers/EventController.js b/src/streaming/controllers/EventController.js index d5c39e5075..80059000bd 100644 --- a/src/streaming/controllers/EventController.js +++ b/src/streaming/controllers/EventController.js @@ -37,6 +37,8 @@ import XHRLoader from '../net/XHRLoader.js'; import Utils from '../../core/Utils.js'; import CommonMediaRequest from '../vo/CommonMediaRequest.js'; import CommonMediaResponse from '../vo/CommonMediaResponse.js'; +import Constants from '../constants/Constants.js'; +import Events from '../../core/events/Events.js' function EventController() { @@ -46,7 +48,16 @@ function EventController() { const MPD_CALLBACK_SCHEME = 'urn:mpeg:dash:event:callback:2015'; const MPD_CALLBACK_VALUE = 1; + const NO_JUMP_TRIGGER_ALL = 1; + const NO_JUMP_TRIGGER_LAST = 2; + const REMAINING_EVENTS_THRESHOLD = 300; + const MAX_PRESENTATION_TIME_THRESHOLD = 2.0; // Maximum threshold in seconds to prevent false positives during seeks + + const RETRIGGERABLES_SCHEMES = [ + Constants.ALTERNATIVE_MPD.URIS.REPLACE, + Constants.ALTERNATIVE_MPD.URIS.INSERT + ]; const EVENT_HANDLED_STATES = { DISCARDED: 'discarded', @@ -109,6 +120,8 @@ function EventController() { isStarted = false; _onStopEventController(); } + eventBus.off(Events.PLAYBACK_SEEKING, _onPlaybackSeeking, instance); + eventBus.off(Events.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); } catch (e) { throw e; } @@ -125,6 +138,9 @@ function EventController() { if (!isStarted && !isNaN(refreshDelay)) { isStarted = true; eventInterval = setInterval(_onEventTimer, refreshDelay); + // Set up event listeners for seek operations + eventBus.on(Events.PLAYBACK_SEEKING, _onPlaybackSeeking, instance); + eventBus.on(Events.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); } } catch (e) { throw e; @@ -143,6 +159,8 @@ function EventController() { // For dynamic streams lastEventTimeCall will be large in the first iteration. Avoid firing all events at once. presentationTimeThreshold = lastEventTimerCall > 0 ? Math.max(0, presentationTimeThreshold) : 0; + // If threshold is too big, it indicates a seek operation occurred, cap the threshold to prevent false positives during seeks + presentationTimeThreshold = presentationTimeThreshold > MAX_PRESENTATION_TIME_THRESHOLD ? 0 : presentationTimeThreshold _triggerEvents(inbandEvents, presentationTimeThreshold, currentVideoTime); _triggerEvents(inlineEvents, presentationTimeThreshold, currentVideoTime); @@ -167,15 +185,39 @@ function EventController() { */ function _triggerEvents(events, presentationTimeThreshold, currentVideoTime) { try { - const callback = function (event) { + const callback = function (event, currentPeriodEvents) { if (event !== undefined) { const duration = !isNaN(event.duration) ? event.duration : 0; - // The event is either about to start or has already been started and we are within its duration - if ((event.calculatedPresentationTime <= currentVideoTime && event.calculatedPresentationTime + presentationTimeThreshold + duration >= currentVideoTime)) { + const isRetriggerable = _isRetriggerable(event); + const hasNoJump = _hasNoJumpValue(event); + const hasExecuteOnce = _hasExecuteOnceValue(event); + + // Check if event is ready to resolve (earliestResolutionTimeOffset feature) + if (_checkEventReadyToResolve(event, currentVideoTime)) { + _triggerEventReadyToResolve(event); + } + + if (isRetriggerable && _canEventRetrigger(event, currentVideoTime, presentationTimeThreshold, hasExecuteOnce)) { + event.triggeredStartEvent = false; + } + + // Handle noJump events first - these ignore duration and trigger when skipping ahead + if (hasNoJump && _shouldTriggerNoJumpEvent(event, currentVideoTime, currentPeriodEvents)) { + event.triggeredNoJumpEvent = true; + _startEvent(event, MediaPlayerEvents.EVENT_MODE_ON_START); + } + // Handle regular events - these check duration and timing + else if (event.calculatedPresentationTime <= currentVideoTime && event.calculatedPresentationTime + presentationTimeThreshold + duration >= currentVideoTime) { _startEvent(event, MediaPlayerEvents.EVENT_MODE_ON_START); - } else if (_eventHasExpired(currentVideoTime, duration + presentationTimeThreshold, event.calculatedPresentationTime) || _eventIsInvalid(event)) { - logger.debug(`Removing event ${event.id} from period ${event.eventStream.period.id} as it is expired or invalid`); - _removeEvent(events, event); + if (hasNoJump) { + event.triggeredNoJumpEvent = true; + } + } else if (_eventHasExpired(currentVideoTime, duration + presentationTimeThreshold, event.calculatedPresentationTime, isRetriggerable) || _eventIsInvalid(event)) { + // Only remove non-retriggerables events or events with executeOnce that have been triggered + if (!isRetriggerable || (hasExecuteOnce && event.triggeredStartEvent)) { + logger.debug(`Removing event ${event.id} from period ${event.eventStream.period.id} as it is expired, invalid, or executeOnce`); + _removeEvent(events, event); + } } } }; @@ -303,11 +345,34 @@ function EventController() { return ((!value || (e.eventStream.value && e.eventStream.value === value)) && (e.id === id)); }); + if (event.status === Constants.ALTERNATIVE_MPD.STATUS.UPDATE) { + if (indexOfExistingEvent !== -1) { + const oldEvent = events[schemeIdUri][indexOfExistingEvent]; + event.triggeredReceivedEvent = oldEvent.triggeredReceivedEvent; + event.triggeredStartEvent = oldEvent.triggeredStartEvent; + event.triggeredReadyToResolve = oldEvent.triggeredReadyToResolve || false; + event.triggeredNoJumpEvent = oldEvent.triggeredNoJumpEvent || false; + events[schemeIdUri][indexOfExistingEvent] = event; + eventState = EVENT_HANDLED_STATES.UPDATED; + + eventBus.trigger(Constants.ALTERNATIVE_MPD.EVENT_UPDATED, { + schemeIdUri: event.eventStream.schemeIdUri, + eventId: event.id, + event: event + }) + } else { + logger.debug(`Ignoring update event with id ${id} - no existing event found`); + } + return eventState; + } + // New event, we add it to our list of events if (indexOfExistingEvent === -1) { events[schemeIdUri].push(event); event.triggeredReceivedEvent = false; event.triggeredStartEvent = false; + event.triggeredReadyToResolve = false; + event.triggeredNoJumpEvent = false; eventState = EVENT_HANDLED_STATES.ADDED; } @@ -316,6 +381,8 @@ function EventController() { const oldEvent = events[schemeIdUri][indexOfExistingEvent]; event.triggeredReceivedEvent = oldEvent.triggeredReceivedEvent; event.triggeredStartEvent = oldEvent.triggeredStartEvent; + event.triggeredReadyToResolve = oldEvent.triggeredReadyToResolve || false; + event.triggeredNoJumpEvent = oldEvent.triggeredNoJumpEvent || false; events[schemeIdUri][indexOfExistingEvent] = event; eventState = EVENT_HANDLED_STATES.UPDATED; } @@ -398,6 +465,38 @@ function EventController() { } } + /** + * Handles playback seeking events to prevent false event triggers + * @private + */ + function _onPlaybackSeeking() { + try { + // Reset the timer to current time to prevent large threshold calculations + const currentTime = playbackController.getTime(); + lastEventTimerCall = currentTime; + logger.debug(`Seek detected, resetting lastEventTimerCall to ${currentTime}`); + } catch (e) { + logger.error(e); + } + } + + /** + * Handles playback seeked events + * @private + */ + function _onPlaybackSeeked() { + try { + // Back-date by one interval so the next timer tick has enough threshold to fire + // events at or just before the seek target (e.g. period-boundary events). + const refreshDelay = settings.get().streaming.events.eventControllerRefreshDelay; + const currentTime = playbackController.getTime(); + lastEventTimerCall = currentTime - (refreshDelay / 1000); + logger.debug(`Seek completed, lastEventTimerCall reset to ${lastEventTimerCall}`); + } catch (e) { + logger.error(e); + } + } + /** * Iterates over the inline/inband event object and triggers a callback for each event * @param {object} events @@ -415,7 +514,7 @@ function EventController() { const schemeIdEvents = currentPeriod[schemeIdUris[j]]; schemeIdEvents.forEach((event) => { if (event !== undefined) { - callback(event); + callback(event, currentPeriod); } }); } @@ -426,16 +525,304 @@ function EventController() { } } + /** + * Auxiliary method to check for earliest resolution time events and return alternative MPD + * @param {object} event + * @return {object|null} - Returns the alternative MPD if it exists and has earliestResolutionTimeOffset, null otherwise + * @private + */ + function _checkForEarliestResolutionTimeEvents(event) { + try { + if (!event || !event.alternativeMpd) { + return null; + } + + if (event.alternativeMpd.earliestResolutionTimeOffset !== undefined) { + return event.alternativeMpd; + } + + return null; + } catch (e) { + logger.error(e); + return null; + } + } + + /** + * Checks if the event has an earliestResolutionTimeOffset and if it's ready to resolve + * @param {object} event + * @param {number} currentVideoTime + * @return {boolean} + * @private + */ + function _checkEventReadyToResolve(event, currentVideoTime) { + try { + const earlyToResolveEvent = _checkForEarliestResolutionTimeEvents(event); + + if (!earlyToResolveEvent || event.triggeredReadyToResolve) { + return false; + } + + const resolutionTime = event.calculatedPresentationTime - earlyToResolveEvent.earliestResolutionTimeOffset; + return currentVideoTime >= resolutionTime; + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Triggers the EVENT_READY_TO_RESOLVE internal event via EventBus + * @param {object} event + * @private + */ + function _triggerEventReadyToResolve(event) { + try { + eventBus.trigger(Events.EVENT_READY_TO_RESOLVE, { + schemeIdUri: event.eventStream.schemeIdUri, + eventId: event.id, + event: event + }); + event.triggeredReadyToResolve = true; + logger.debug(`Event ${event.id} is ready to resolve`); + } catch (e) { + logger.error(e); + } + } + + /** + * Checks if an event is retriggerables based on its schemeIdUri + * @param {object} event + * @return {boolean} + * @private + */ + function _isRetriggerable(event) { + try { + return RETRIGGERABLES_SCHEMES.includes(event.eventStream.schemeIdUri); + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Checks if a retriggerables event can retrigger based on presentation time and duration + * @param {object} event + * @param {number} currentVideoTime + * @return {boolean} + * @private + */ + function _canEventRetrigger(event, currentVideoTime, presentationTimeThreshold, executeOnce) { + try { + if (executeOnce) { + return false; + } + if (!event.triggeredStartEvent) { + return false; + } + // To avoid retrigger errors the presentationTimeThreshold must not be 0 + if (presentationTimeThreshold === 0) { + return false; + } + const duration = !isNaN(event.duration) ? event.duration : 0; + const presentationTime = event.calculatedPresentationTime; + // Event can retrigger if currentTime < presentationTime OR currentTime >= presentationTime + duration + return currentVideoTime < presentationTime || currentVideoTime > presentationTime + presentationTimeThreshold + duration; + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Checks if an event has a noJump value (1 or 2) + * @param {object} event + * @return {boolean} + * @private + */ + function _hasNoJumpValue(event) { + try { + return event && event.alternativeMpd && (event.alternativeMpd.noJump === NO_JUMP_TRIGGER_ALL || event.alternativeMpd.noJump === NO_JUMP_TRIGGER_LAST); + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Checks if an event has executeOnce value + * @param {object} event + * @return {boolean} + * @private + */ + function _hasExecuteOnceValue(event) { + try { + return event && event.alternativeMpd && event.alternativeMpd.executeOnce === true; + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Determines if a noJump event should be triggered + * @param {object} event + * @param {number} currentVideoTime + * @param {object} eventsInSamePeriod + * @return {boolean} + * @private + */ + function _shouldTriggerNoJumpEvent(event, currentVideoTime, eventsInSamePeriod) { + try { + if (!_hasNoJumpValue(event)) { + return false; + } + + // Check if the noJump attribute has already been used for this event + if (event.triggeredNoJumpEvent) { + return false; + } + + // Check if currentVideoTime has passed the presentation time (skip ahead condition) + if (currentVideoTime < event.calculatedPresentationTime) { + return false; + } + + if (event.alternativeMpd.noJump === NO_JUMP_TRIGGER_ALL) { + // noJump=1: only trigger the first event in the sequence + return _isFirstEventInSequence(event, eventsInSamePeriod, currentVideoTime); + } else if (event.alternativeMpd.noJump === NO_JUMP_TRIGGER_LAST) { + // noJump=2: only trigger the last event in the sequence + return _isLastEventInSequence(event, eventsInSamePeriod, currentVideoTime); + } + + return false; + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Determines if an event is the first one in a sequence for noJump=1 logic + * @param {object} event + * @param {object} eventsInSamePeriod + * @param {number} currentVideoTime + * @return {boolean} + * @private + */ + function _isFirstEventInSequence(event, eventsInSamePeriod, currentVideoTime) { + try { + if (!eventsInSamePeriod || !event.eventStream) { + return false; + } + + const schemeIdUri = event.eventStream.schemeIdUri; + const eventsWithSameScheme = eventsInSamePeriod[schemeIdUri] || []; + + // Get all events with noJump=1 from the same scheme that are not in the future + const noJump1Events = eventsWithSameScheme.filter(e => + e.alternativeMpd && + e.alternativeMpd.noJump === NO_JUMP_TRIGGER_ALL && + e.calculatedPresentationTime <= currentVideoTime + ); + + if (noJump1Events.length === 0) { + return false; + } + + // Find the event with the lowest presentation time (the first one) + // While doing so, flag all subsequent events as triggered + const firstEvent = noJump1Events.reduce((earliest, current) => { + if (current.calculatedPresentationTime < earliest.calculatedPresentationTime) { + // Current event is earlier, so flag the previous (earliest) as triggered + if (!earliest.triggeredNoJumpEvent) { + earliest.triggeredNoJumpEvent = true; + } + return current; + } else { + // Earliest event is still the first one, so flag current as triggered + if (!current.triggeredNoJumpEvent) { + current.triggeredNoJumpEvent = true; + } + return earliest; + } + }); + return event.id === firstEvent.id; + } catch (e) { + logger.error(e); + return false; + } + } + + /** + * Determines if an event is the last one in a sequence for noJump=2 logic + * @param {object} event + * @param {object} eventsInSamePeriod + * @param {number} currentVideoTime + * @return {boolean} + * @private + */ + function _isLastEventInSequence(event, eventsInSamePeriod, currentVideoTime) { + try { + if (!eventsInSamePeriod || !event.eventStream) { + return false; + } + + const schemeIdUri = event.eventStream.schemeIdUri; + const eventsWithSameScheme = eventsInSamePeriod[schemeIdUri] || []; + + // Get all events with noJump=2 from the same scheme that are not in the future + const noJump2Events = eventsWithSameScheme.filter(e => + e.alternativeMpd && + e.alternativeMpd.noJump === NO_JUMP_TRIGGER_LAST && + e.calculatedPresentationTime <= currentVideoTime + ); + + if (noJump2Events.length === 0) { + return false; + } + + // Find the event with the highest presentation time (the last one) + // While doing so, flag all previous events as triggered + const lastEvent = noJump2Events.reduce((latest, current) => { + if (current.calculatedPresentationTime > latest.calculatedPresentationTime) { + // Current event is later, so flag the previous (latest) as triggered + if (!latest.triggeredNoJumpEvent) { + latest.triggeredNoJumpEvent = true; + } + return current; + } else { + // Latest event is still the last one, so flag current as triggered + if (!current.triggeredNoJumpEvent) { + current.triggeredNoJumpEvent = true; + } + return latest; + } + }); + return event.id === lastEvent.id; + } catch (e) { + logger.error(e); + return false; + } + } + + /** * Checks if an event is expired. For instance if the presentationTime + the duration of an event are smaller than the current video time. * @param {number} currentVideoTime * @param {number} threshold * @param {number} calculatedPresentationTimeInSeconds + * @param {boolean} isRetriggerable * @return {boolean} * @private */ - function _eventHasExpired(currentVideoTime, threshold, calculatedPresentationTimeInSeconds) { + function _eventHasExpired(currentVideoTime, threshold, calculatedPresentationTimeInSeconds, isRetriggerable = false) { try { + // Retriggerables events don't expire in the traditional sense + if (isRetriggerable) { + return false; + } return currentVideoTime - threshold > calculatedPresentationTimeInSeconds; } catch (e) { logger.error(e); @@ -480,7 +867,6 @@ function EventController() { eventBus.trigger(event.eventStream.schemeIdUri, { event }, { mode }); return; } - if (!event.triggeredStartEvent) { if (event.eventStream.schemeIdUri === MPD_RELOAD_SCHEME && event.eventStream.value == MPD_RELOAD_VALUE) { //If both are set to zero, it indicates the media is over at this point. Don't reload the manifest. diff --git a/src/streaming/controllers/ExtUrlQueryInfoController.js b/src/streaming/controllers/ExtUrlQueryInfoController.js index a1c39b80ba..1a89fa859e 100644 --- a/src/streaming/controllers/ExtUrlQueryInfoController.js +++ b/src/streaming/controllers/ExtUrlQueryInfoController.js @@ -133,20 +133,22 @@ function ExtUrlQueryInfoController() { }; _generateQueryParams(periodObject, period, mpdUrlQuery, mpdQueryStringInformation, DashConstants.PERIOD); - period.AdaptationSet.forEach((adaptationSet) => { - const adaptationObject = { - representation: [] - }; - _generateQueryParams(adaptationObject, adaptationSet, mpdUrlQuery, periodObject, DashConstants.ADAPTATION_SET); - - adaptationSet.Representation.forEach((representation) => { - const representationObject = {}; - _generateQueryParams(representationObject, representation, mpdUrlQuery, adaptationObject, DashConstants.REPRESENTATION); - - adaptationObject.representation.push(representationObject); + if (!period.ImportedMPD) { + period.AdaptationSet.forEach((adaptationSet) => { + const adaptationObject = { + representation: [] + }; + _generateQueryParams(adaptationObject, adaptationSet, mpdUrlQuery, periodObject, DashConstants.ADAPTATION_SET); + + adaptationSet.Representation.forEach((representation) => { + const representationObject = {}; + _generateQueryParams(representationObject, representation, mpdUrlQuery, adaptationObject, DashConstants.REPRESENTATION); + + adaptationObject.representation.push(representationObject); + }); + periodObject.adaptation.push(adaptationObject); }); - periodObject.adaptation.push(adaptationObject); - }); + } mpdQueryStringInformation.period.push(periodObject); }); } diff --git a/src/streaming/controllers/ListMpdController.js b/src/streaming/controllers/ListMpdController.js new file mode 100644 index 0000000000..faefb46301 --- /dev/null +++ b/src/streaming/controllers/ListMpdController.js @@ -0,0 +1,186 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +import EventBus from '../../core/EventBus.js'; +import Events from '../../core/events/Events.js'; +import MediaPlayerEvents from '../MediaPlayerEvents.js'; +import FactoryMaker from '../../core/FactoryMaker.js'; +import DashConstants from '../../dash/constants/DashConstants.js'; + +const DEFAULT_EARLIEST_RESOLUTION_TIME_OFFSET = 60; + +function ListMpdController() { + + let context = this.context; + let eventBus = EventBus(context).getInstance(); + + let instance, + settings, + linkedPeriodList, + dashAdapter, + currentManifest, + manifestLoader, + mpdHasDuration + + function setConfig(config) { + if (!config) { + return; + } + + if (config.settings) { + settings = config.settings; + } + + if (config.dashAdapter) { + dashAdapter = config.dashAdapter; + } + + if (config.manifestLoader) { + manifestLoader = config.manifestLoader; + } + } + + function setup() { + resetInitialSettings(); + } + + function initialize() { + eventBus.on(Events.LIST_MPD_FOUND, _onListMpdFound, instance); + eventBus.on(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _triggerImportMpd, instance); + } + + function _importMpdInLinkedPeriod(time) { + linkedPeriodList.forEach(linkedPeriod => { + if (_shouldImportMpd(linkedPeriod, time)) { + loadImportedMpd(currentManifest, linkedPeriod); + } + }); + } + + function _onListMpdFound({ manifest }) { + currentManifest = manifest; + linkedPeriodList = dashAdapter.getLinkedPeriods(manifest); + + manifest.Period[0].start = manifest.Period[0].start ?? 0; + if (manifest.Period[0].start !== 0) { + throw new Error('The first period in a list MPD must have start time equal to 0'); + } + + mpdHasDuration = manifest.hasOwnProperty(DashConstants.MEDIA_PRESENTATION_DURATION); + if (!mpdHasDuration) { + manifest.mediaPresentationDuration = 0; + for (let i = manifest.Period.length - 1; i >= 0; i--) { + manifest.mediaPresentationDuration += manifest.Period[i].duration; + if (manifest.Period[i].start) { + manifest.mediaPresentationDuration += manifest.Period[i].start; + break; + } + } + } + + const startPeriod = linkedPeriodList.find(period => period.start === 0); + if (startPeriod) { + loadImportedMpd(manifest, startPeriod); + } else { + eventBus.trigger(Events.MANIFEST_UPDATED, { manifest: manifest }); + } + } + + function loadImportedMpd(manifest, period) { + const relativePath = period.ImportedMPD.uri; + const baseUrl = period.BaseURL ?? manifest.BaseURL; + const resolvedUri = baseUrl ? baseUrl[0].__text + relativePath : relativePath; + + const updatedManifest = new Promise(resolve => { + manifestLoader.load(resolvedUri, null, null, true) + .then((importedManifest) => { + dashAdapter.mergeManifests(manifest, importedManifest, period, mpdHasDuration); + }, () => { + dashAdapter.mergeManifests(manifest, null, period, mpdHasDuration); + }) + .then(() => { + eventBus.trigger(Events.MANIFEST_UPDATED, { manifest }); + linkedPeriodList = linkedPeriodList.filter((element) => element.id !== period.id); + resolve(manifest); + }); + }); + return updatedManifest; + } + + function _triggerImportMpd(e) { + if (!linkedPeriodList || !linkedPeriodList.length) { + return; + } + + _importMpdInLinkedPeriod(e.time); + } + + function _shouldImportMpd(linkedPeriod, time) { + if (!linkedPeriod.ImportedMPD) { + return false + } + + // This setting Resolves a conflict with GapController in low-bandwidth streams by preventing playback stalls during buffering gaps. + const { minEarliestResolutionTimeOffset } = settings.get().streaming.listMpd; + const earliestResolutionTimeOffset = linkedPeriod.ImportedMPD.earliestResolutionTimeOffset ?? DEFAULT_EARLIEST_RESOLUTION_TIME_OFFSET; + const resolutionTime = Math.max(earliestResolutionTimeOffset, minEarliestResolutionTimeOffset); + const { start } = dashAdapter.getPeriodById(linkedPeriod.id); + + return time >= start - resolutionTime; + } + + function resetInitialSettings() { + linkedPeriodList = []; + currentManifest = null; + mpdHasDuration = false; + } + + function reset() { + resetInitialSettings(); + eventBus.off(Events.LIST_MPD_FOUND, _onListMpdFound, instance); + eventBus.off(MediaPlayerEvents.PLAYBACK_TIME_UPDATED, _triggerImportMpd, instance); + } + + instance = { + initialize, + loadImportedMpd, + reset, + setConfig + }; + + setup(); + + return instance; +} + +ListMpdController.__dashjs_factory_name = 'ListMpdController'; +const factory = FactoryMaker.getSingletonFactory(ListMpdController); +FactoryMaker.updateSingletonFactory(ListMpdController.__dashjs_factory_name, factory); +export default factory; diff --git a/src/streaming/controllers/PlaybackController.js b/src/streaming/controllers/PlaybackController.js index a563e5170e..afc31cb204 100644 --- a/src/streaming/controllers/PlaybackController.js +++ b/src/streaming/controllers/PlaybackController.js @@ -266,6 +266,25 @@ function PlaybackController() { seek(seektime, stickToBuffered, internal, adjustLiveDelay); } + function seekToStartDvrWindow(stickToBuffered = false, internal = false, adjustLiveDelay = false) { + const dvrWindowStart = getDvrWindowStart(); + + if (dvrWindowStart === 0) { + return; + } + + seek(dvrWindowStart, stickToBuffered, internal, adjustLiveDelay); + } + + function getDvrWindowStart() { + if (!streamInfo || !videoModel || !isDynamic) { + return; + } + const type = streamController && streamController.hasVideoTrack() ? Constants.VIDEO : Constants.AUDIO; + const dvrInfo = dashMetrics.getCurrentDVRInfo(type); + return dvrInfo && dvrInfo.range ? dvrInfo.range.start : 0; + } + function _getDvrWindowEnd() { if (!streamInfo || !videoModel || !isDynamic) { return; @@ -704,7 +723,9 @@ function PlaybackController() { } function _onPlaybackProgress() { - eventBus.trigger(Events.PLAYBACK_PROGRESS, { streamId: streamInfo.id }); + if (streamInfo){ + eventBus.trigger(Events.PLAYBACK_PROGRESS, { streamId: streamInfo.id }); + } } function _onPlaybackRateChanged() { @@ -920,6 +941,7 @@ function PlaybackController() { getAvailabilityStartTime, getBufferLevel, getCurrentLiveLatency, + getDvrWindowStart, getEnded, getInitialCatchupModeActivated, getIsDynamic, @@ -945,6 +967,7 @@ function PlaybackController() { seek, seekToCurrentLive, seekToOriginalLive, + seekToStartDvrWindow, setConfig, updateCurrentTime, }; diff --git a/src/streaming/controllers/StreamController.js b/src/streaming/controllers/StreamController.js index fcfdf80342..a971587203 100644 --- a/src/streaming/controllers/StreamController.js +++ b/src/streaming/controllers/StreamController.js @@ -48,6 +48,7 @@ import ConformanceViolationConstants from '../constants/ConformanceViolationCons import ExtUrlQueryInfoController from './ExtUrlQueryInfoController.js'; import ProtectionEvents from '../protection/ProtectionEvents.js'; import ProtectionErrors from '../protection/errors/ProtectionErrors.js'; +import ListMpdController from './ListMpdController.js'; const PLAYBACK_ENDED_TIMER_INTERVAL = 200; const DVR_WAITING_OFFSET = 2; @@ -61,7 +62,7 @@ function StreamController() { dashMetrics, mediaSourceController, timeSyncController, contentSteeringController, baseURLController, segmentBaseController, uriFragmentModel, abrController, throughputController, mediaController, eventController, initCache, errHandler, timelineConverter, streams, activeStream, protectionController, textController, - protectionData, extUrlQueryInfoController, + protectionData, extUrlQueryInfoController, listMpdController, autoPlay, isStreamSwitchingInProgress, hasMediaError, hasInitialisationError, mediaSource, videoModel, playbackController, serviceDescriptionController, mediaPlayerModel, customParametersModel, isPaused, initialPlayback, initialSteeringRequest, playbackEndedTimerInterval, preloadingStreams, settings, @@ -101,6 +102,7 @@ function StreamController() { eventController.start(); extUrlQueryInfoController = ExtUrlQueryInfoController(context).getInstance(); + listMpdController = ListMpdController(context).getInstance(); timeSyncController.setConfig({ dashMetrics, baseURLController, errHandler, settings @@ -661,11 +663,25 @@ function StreamController() { }); Promise.all(promises) + .then(() => { + const periodId = seekToStream.getId(); + if (!periodId) { + throw new Error('Stream does not have a valid period id'); + } + const seekToPeriod = manifestModel.getValue().Period.find((periodInfo) => periodInfo.id === periodId); + if (seekToPeriod.ImportedMPD) { + return listMpdController + .loadImportedMpd(manifestModel.getValue(), seekToPeriod) + .then(updatedManifest => { + baseURLController.update(updatedManifest); + }); + } + }) .then(() => { _switchStream(seekToStream, activeStream, seekTime); }) - .catch((e) => { - errHandler.error(e); + .catch(error => { + errHandler.error(error); }); } @@ -936,7 +952,10 @@ function StreamController() { const previousStream = i === 0 ? activeStream : upcomingStreams[i - 1]; // If the preloading for the current stream is not scheduled, but its predecessor has finished buffering we can start prebuffering this stream - if (!stream.getPreloaded() && previousStream.getHasFinishedBuffering()) { + const periodId = stream.getId() + const linkedPeriod = manifestModel.getValue().Period.find((periodInfo => periodInfo.id === periodId)); + const isLinkedPeriod = linkedPeriod && linkedPeriod.ImportedMPD; + if (!stream.getPreloaded() && previousStream.getHasFinishedBuffering() && !isLinkedPeriod) { _onStreamCanLoadNext(stream, previousStream); } i += 1; @@ -1021,8 +1040,12 @@ function StreamController() { activeStream.setIsEndedEventSignaled(true); const nextStream = _getNextStream(); if (nextStream) { - logger.debug(`StreamController onEnded, found next stream with id ${nextStream.getStreamInfo().id}. Switching from ${activeStream.getStreamInfo().id} to ${nextStream.getStreamInfo().id}`); - _switchStream(nextStream, activeStream, NaN); + const streamId = nextStream.getStreamInfo().id; + logger.debug(`StreamController onEnded, found next stream with id ${streamId}. Switching from ${activeStream.getStreamInfo().id} to ${nextStream.getStreamInfo().id}`); + const nextPeriod = manifestModel.getValue().Period.find((periodInfo) => periodInfo.id == streamId); + if (!nextPeriod.ImportedMPD) { + _switchStream(nextStream, activeStream, NaN); + } } else { logger.debug('StreamController no next stream found'); activeStream.setIsEndedEventSignaled(false); diff --git a/src/streaming/models/CmcdModel.js b/src/streaming/models/CmcdModel.js index 422de3f0dd..79d1443b42 100644 --- a/src/streaming/models/CmcdModel.js +++ b/src/streaming/models/CmcdModel.js @@ -28,64 +28,48 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ -import EventBus from '../../core/EventBus.js'; + +import { + CmcdObjectType, + CmcdStreamType, + CmcdStreamingFormat, + toCmcdValue, +} from '@svta/cml-cmcd'; +import { HTTPRequest } from '../vo/metrics/HTTPRequest.js'; import MediaPlayerEvents from '../MediaPlayerEvents.js'; -import MetricsReportingEvents from '../metrics/MetricsReportingEvents.js'; -import FactoryMaker from '../../core/FactoryMaker.js'; -import Settings from '../../core/Settings.js'; +import Utils from '../../core/Utils.js'; import Constants from '../../streaming/constants/Constants.js'; -import {HTTPRequest} from '../vo/metrics/HTTPRequest.js'; +import FactoryMaker from '../../core/FactoryMaker.js'; import DashManifestModel from '../../dash/models/DashManifestModel.js'; -import Debug from '../../core/Debug.js'; -import Utils from '../../core/Utils.js'; -import { CMCD_PARAM, CmcdObjectType, CmcdStreamType, CmcdStreamingFormat, encodeCmcd, toCmcdHeaders, CmcdHeaderField } from '@svta/cml-cmcd'; -const DEFAULT_CMCD_VERSION = 1; -const DEFAULT_INCLUDE_IN_REQUESTS = 'segment'; +import CmcdConfigAccessor from '../cmcd/config/CmcdConfigAccessor.js'; + const RTP_SAFETY_FACTOR = 5; function CmcdModel() { - - let dashManifestModel, - instance, - logger, - internalData, - abrController, + let instance, dashMetrics, - playbackController, serviceDescriptionController, + playbackController, + abrController, throughputController, - streamProcessors, + cmcdConfig, _lastMediaTypeRequest, _isStartup, _bufferLevelStarved, _initialMediaRequestsDone, _playbackStartedTime, - _msdSent; + _isSeeking, + streamProcessors, + _rebufferingStartTime = {}, + _rebufferingDuration = {}, + _streamType, + _streamingFormat; let context = this.context; - let eventBus = EventBus(context).getInstance(); - let settings = Settings(context).getInstance(); - let debug = Debug(context).getInstance(); function setup() { - dashManifestModel = DashManifestModel(context).getInstance(); - logger = debug.getLogger(instance); - _resetInitialSettings(); - } - - function initialize(autoPlay) { - eventBus.on(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, _onPlaybackRateChanged, instance); - eventBus.on(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, instance); - eventBus.on(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, _onBufferLevelStateChanged, instance); - eventBus.on(MediaPlayerEvents.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); - eventBus.on(MediaPlayerEvents.PERIOD_SWITCH_COMPLETED, _onPeriodSwitchComplete, instance); - if (autoPlay) { - eventBus.on(MediaPlayerEvents.MANIFEST_LOADING_STARTED, _onPlaybackStarted, instance); - } - else { - eventBus.on(MediaPlayerEvents.PLAYBACK_STARTED, _onPlaybackStarted, instance); - } - eventBus.on(MediaPlayerEvents.PLAYBACK_PLAYING, _onPlaybackPlaying, instance); + cmcdConfig = CmcdConfigAccessor(context).getInstance(); + resetInitialSettings(); } function setConfig(config) { @@ -101,305 +85,45 @@ function CmcdModel() { dashMetrics = config.dashMetrics; } - if (config.throughputController) { - throughputController = config.throughputController; - } - if (config.playbackController) { playbackController = config.playbackController; } - if (config.serviceDescriptionController) { - serviceDescriptionController = config.serviceDescriptionController; - } - } - - function _resetInitialSettings() { - internalData = { - pr: 1, - nor: null, - st: null, - sf: null, - sid: `${Utils.generateUuid()}`, - cid: null - }; - _bufferLevelStarved = {}; - _isStartup = {}; - _initialMediaRequestsDone = {}; - _lastMediaTypeRequest = undefined; - _playbackStartedTime = undefined; - _msdSent = false; - _updateStreamProcessors(); - } - - function _onPeriodSwitchComplete() { - _updateStreamProcessors(); - } - - function _onPlaybackStarted() { - if (!_playbackStartedTime) { - _playbackStartedTime = Date.now(); - } - } - - function _onPlaybackPlaying() { - if (!_playbackStartedTime || internalData.msd) { - return; - } - - internalData.msd = Date.now() - _playbackStartedTime; - } - - function _updateStreamProcessors() { - if (!playbackController) { - return; - } - const streamController = playbackController.getStreamController(); - if (!streamController) { - return; - } - if (typeof streamController.getActiveStream !== 'function') { - return; - } - const activeStream = streamController.getActiveStream(); - if (!activeStream) { - return; - } - streamProcessors = activeStream.getStreamProcessors(); - } - - function getQueryParameter(request) { - try { - if (isCmcdEnabled()) { - const cmcdData = getCmcdData(request); - const filteredCmcdData = _applyWhitelist(cmcdData); - const finalPayloadString = encodeCmcd(filteredCmcdData); - - eventBus.trigger(MetricsReportingEvents.CMCD_DATA_GENERATED, { - url: request.url, - mediaType: request.mediaType, - cmcdData, - cmcdString: finalPayloadString - }); - return { - key: CMCD_PARAM, - value: finalPayloadString - }; - } - - return null; - } catch (e) { - return null; - } - } - - function _applyWhitelist(cmcdData) { - try { - const cmcdParametersFromManifest = getCmcdParametersFromManifest(); - const enabledCMCDKeys = cmcdParametersFromManifest.version ? cmcdParametersFromManifest.keys : settings.get().streaming.cmcd.enabledKeys; - - return Object.keys(cmcdData) - .filter(key => enabledCMCDKeys.includes(key)) - .reduce((obj, key) => { - obj[key] = cmcdData[key]; - return obj; - }, {}); - } catch (e) { - return cmcdData; - } - } - - function getHeaderParameters(request) { - try { - if (isCmcdEnabled()) { - const cmcdData = getCmcdData(request); - const filteredCmcdData = _applyWhitelist(cmcdData); - const options = _createCmcdV2HeadersCustomMap(); - const headers = toCmcdHeaders(filteredCmcdData, options); - - eventBus.trigger(MetricsReportingEvents.CMCD_DATA_GENERATED, { - url: request.url, - mediaType: request.mediaType, - cmcdData, - headers - }); - return headers; - } - - return null; - } catch (e) { - return null; - } - } - - function isCmcdEnabled() { - const cmcdParametersFromManifest = getCmcdParametersFromManifest(); - return _canBeEnabled(cmcdParametersFromManifest) && _checkIncludeInRequests(cmcdParametersFromManifest) && _checkAvailableKeys(cmcdParametersFromManifest); - } - - function _canBeEnabled(cmcdParametersFromManifest) { - if (Object.keys(cmcdParametersFromManifest).length) { - if (parseInt(cmcdParametersFromManifest.version) !== 1) { - logger.error(`version parameter must be defined in 1.`); - return false; - } - if (!cmcdParametersFromManifest.keys) { - logger.error(`keys parameter must be defined.`); - return false; - } - } - const isEnabledFromManifest = cmcdParametersFromManifest.version; - const isEnabledFromSettings = settings.get().streaming.cmcd && settings.get().streaming.cmcd.enabled; - return isEnabledFromManifest || isEnabledFromSettings; - } - - function _checkIncludeInRequests(cmcdParametersFromManifest) { - let enabledRequests = settings.get().streaming.cmcd.includeInRequests; - - if (cmcdParametersFromManifest.version) { - enabledRequests = cmcdParametersFromManifest.includeInRequests ?? [DEFAULT_INCLUDE_IN_REQUESTS]; - } - - const defaultAvailableRequests = Constants.CMCD_AVAILABLE_REQUESTS; - const invalidRequests = enabledRequests.filter(k => !defaultAvailableRequests.includes(k)); - - if (invalidRequests.length === enabledRequests.length) { - logger.error(`None of the request types are supported.`); - return false; - } - - invalidRequests.map((k) => { - logger.warn(`request type ${k} is not supported.`); - }); - - return true; - } - - function _checkAvailableKeys(cmcdParametersFromManifest) { - const defaultAvailableKeys = Constants.CMCD_AVAILABLE_KEYS; - const defaultV2AvailableKeys = Constants.CMCD_V2_AVAILABLE_KEYS; - const enabledCMCDKeys = cmcdParametersFromManifest.version ? cmcdParametersFromManifest.keys : settings.get().streaming.cmcd.enabledKeys; - const cmcdVersion = settings.get().streaming.cmcd.version; - const invalidKeys = enabledCMCDKeys.filter(k => !defaultAvailableKeys.includes(k) && !(cmcdVersion === 2 && defaultV2AvailableKeys.includes(k))); - - if (invalidKeys.length === enabledCMCDKeys.length && enabledCMCDKeys.length > 0) { - logger.error(`None of the keys are implemented for CMCD version ${cmcdVersion}.`); - return false; + if (config.throughputController) { + throughputController = config.throughputController; } - invalidKeys.map((k) => { - logger.warn(`key parameter ${k} is not implemented for CMCD version ${cmcdVersion}.`); - }); - - return true; - } - function getCmcdParametersFromManifest() { - let cmcdParametersFromManifest = {}; - if (serviceDescriptionController) { - const serviceDescription = serviceDescriptionController.getServiceDescriptionSettings(); - if ( - settings.get().streaming.cmcd.applyParametersFromMpd && - serviceDescription.clientDataReporting && - serviceDescription.clientDataReporting.cmcdParameters - ) { - cmcdParametersFromManifest = serviceDescription.clientDataReporting.cmcdParameters; - } + if (config.serviceDescriptionController) { + serviceDescriptionController = config.serviceDescriptionController; } - return cmcdParametersFromManifest; } - function _isIncludedInRequestFilter(type) { - const cmcdParametersFromManifest = getCmcdParametersFromManifest(); - let includeInRequestsArray = settings.get().streaming.cmcd.includeInRequests; - - if (cmcdParametersFromManifest.version) { - includeInRequestsArray = cmcdParametersFromManifest.includeInRequests ? cmcdParametersFromManifest.includeInRequests : [DEFAULT_INCLUDE_IN_REQUESTS]; - } - - const filtersTypes = { - [HTTPRequest.INIT_SEGMENT_TYPE]: 'segment', - [HTTPRequest.MEDIA_SEGMENT_TYPE]: 'segment', - [HTTPRequest.XLINK_EXPANSION_TYPE]: 'xlink', - [HTTPRequest.MPD_TYPE]: 'mpd', - [HTTPRequest.CONTENT_STEERING_TYPE]: 'steering', - [HTTPRequest.OTHER_TYPE]: 'other', - }; - - return includeInRequestsArray.some(t => filtersTypes[type] === t); + function _isValidValue(value) { + return value !== null && value !== undefined && !isNaN(value) && isFinite(value); } - function getCmcdData(request) { - try { - let cmcdData = null; - - _updateLastMediaTypeRequest(request.type, request.mediaType); - - if (_isIncludedInRequestFilter(request.type)) { - if (request.type === HTTPRequest.MPD_TYPE) { - return _getCmcdDataForMpd(request); - } else if (request.type === HTTPRequest.MEDIA_SEGMENT_TYPE) { - _initForMediaType(request.mediaType); - return _getCmcdDataForMediaSegment(request, request.mediaType); - } else if (request.type === HTTPRequest.INIT_SEGMENT_TYPE) { - return _getCmcdDataForInitSegment(request); - } else if (request.type === HTTPRequest.OTHER_TYPE || request.type === HTTPRequest.XLINK_EXPANSION_TYPE) { - return _getCmcdDataForOther(request); - } else if (request.type === HTTPRequest.LICENSE) { - return _getCmcdDataForLicense(request); - } else if (request.type === HTTPRequest.CONTENT_STEERING_TYPE) { - return _getCmcdDataForSteering(request); - } - } - return cmcdData; - } catch (e) { - return null; + function _toInnerList(videoValue, audioValue) { + const values = []; + if (_isValidValue(videoValue)) { + values.push(toCmcdValue(videoValue, { v: true })); } - } - - function _updateLastMediaTypeRequest(type, mediatype) { - // Video > Audio > None - if (mediatype === Constants.VIDEO || mediatype === Constants.AUDIO) { - if (!_lastMediaTypeRequest || _lastMediaTypeRequest == Constants.AUDIO) { - _lastMediaTypeRequest = mediatype; - } + if (_isValidValue(audioValue)) { + values.push(toCmcdValue(audioValue, { a: true })); } + return values.length > 0 ? values : null; } - function _getCmcdDataForSteering(request) { - const data = !_lastMediaTypeRequest ? _getGenericCmcdData(request) : _getCmcdDataForMediaSegment(request, _lastMediaTypeRequest); - - data.ot = CmcdObjectType.OTHER; - - return data; - } - - function _getCmcdDataForLicense(request) { - const data = _getGenericCmcdData(request); - - data.ot = CmcdObjectType.KEY; - - return data; - } - - function _getCmcdDataForMpd() { - const data = _getGenericCmcdData(); - - data.ot = CmcdObjectType.MANIFEST; - - return data; - } - - function _getCmcdDataForMediaSegment(request, mediaType) { + function _calculateCmcdDataForRequestForMediaSegment(request, mediaType) { _initForMediaType(mediaType); - const data = _getGenericCmcdData(); + const data = getGenericCmcdData(mediaType); const encodedBitrate = _getBitrateByRequest(request); const d = _getObjectDurationByRequest(request); const mtp = _getMeasuredThroughputByType(mediaType); const dl = _getDeadlineByType(mediaType); const bl = _getBufferLevelByType(mediaType); const tb = _getTopBitrateByType(request.representation?.mediaInfo); - const pr = internalData.pr; - + const tpb = _getTopPlayableBitrate(mediaType); + const pb = _getPlayheadBitrate(mediaType); const nextRequest = _probeNextRequest(mediaType); let ot; @@ -420,24 +144,25 @@ function CmcdModel() { } } - let rtp = settings.get().streaming.cmcd.rtp; - if (!rtp) { - rtp = _calculateRtp(request); - } + const rtp = cmcdConfig.has('rtp') + ? cmcdConfig.get('rtp') + : _calculateRtp(request); if (!isNaN(rtp)) { data.rtp = rtp; } if (nextRequest) { if (request.url !== nextRequest.url) { - data.nor = encodeURIComponent(Utils.getRelativeUrl(request.url, nextRequest.url)); - } else if (nextRequest.range) { - data.nrr = nextRequest.range; + const relativeUrl = Utils.getRelativeUrl(request.url, nextRequest.url); + const params = nextRequest.range ? { r: nextRequest.range } : undefined; + data.nor = [toCmcdValue(relativeUrl, params)]; } } if (encodedBitrate) { - data.br = encodedBitrate; + const videoBr = mediaType === Constants.VIDEO ? encodedBitrate : null; + const audioBr = mediaType === Constants.AUDIO ? encodedBitrate : null; + data.br = _toInnerList(videoBr, audioBr) || [toCmcdValue(encodedBitrate, {})]; } if (ot) { @@ -449,7 +174,9 @@ function CmcdModel() { } if (!isNaN(mtp)) { - data.mtp = mtp; + const videoMtp = mediaType === Constants.VIDEO ? mtp : null; + const audioMtp = mediaType === Constants.AUDIO ? mtp : null; + data.mtp = _toInnerList(videoMtp, audioMtp) || [toCmcdValue(mtp, {})]; } if (!isNaN(dl)) { @@ -457,15 +184,27 @@ function CmcdModel() { } if (!isNaN(bl)) { - data.bl = bl; + const videoBl = mediaType === Constants.VIDEO ? bl : null; + const audioBl = mediaType === Constants.AUDIO ? bl : null; + data.bl = _toInnerList(videoBl, audioBl) || [toCmcdValue(bl, {})]; } - if (!isNaN(tb)) { - data.tb = tb; + if (!isNaN(tb) && isFinite(tb)) { + const videoTb = mediaType === Constants.VIDEO ? tb : null; + const audioTb = mediaType === Constants.AUDIO ? tb : null; + data.tb = _toInnerList(videoTb, audioTb) || [toCmcdValue(tb, {})]; } - if (!isNaN(pr) && pr !== 1) { - data.pr = pr; + if (tpb !== null && !isNaN(tpb)) { + const videoTpb = mediaType === Constants.VIDEO ? tpb : null; + const audioTpb = mediaType === Constants.AUDIO ? tpb : null; + data.tpb = _toInnerList(videoTpb, audioTpb) || [toCmcdValue(tpb, {})]; + } + + if (pb !== null && !isNaN(pb)) { + const videoPb = mediaType === Constants.VIDEO ? pb : null; + const audioPb = mediaType === Constants.AUDIO ? pb : null; + data.pb = _toInnerList(videoPb, audioPb) || [toCmcdValue(pb, {})]; } if (_bufferLevelStarved[mediaType]) { @@ -473,12 +212,21 @@ function CmcdModel() { _bufferLevelStarved[mediaType] = false; } + if (_rebufferingDuration[mediaType]) { + const videoBsd = mediaType === Constants.VIDEO ? _rebufferingDuration[mediaType] : null; + const audioBsd = mediaType === Constants.AUDIO ? _rebufferingDuration[mediaType] : null; + data.bsd = _toInnerList(videoBsd, audioBsd) || [toCmcdValue(_rebufferingDuration[mediaType], {})]; + delete _rebufferingDuration[mediaType]; + } + if (_isStartup[mediaType] || !_initialMediaRequestsDone[mediaType]) { data.su = true; _isStartup[mediaType] = false; _initialMediaRequestsDone[mediaType] = true; } + Object.assign(data, _getAggregatedBitrateData()); + return data; } @@ -497,8 +245,8 @@ function CmcdModel() { } } - function _getCmcdDataForInitSegment() { - const data = _getGenericCmcdData(); + function _calculateCmcdDataForRequestForInitSegment() { + const data = getGenericCmcdData(); data.ot = CmcdObjectType.INIT; data.su = true; @@ -506,86 +254,132 @@ function CmcdModel() { return data; } - function _getCmcdDataForOther() { - const data = _getGenericCmcdData(); + function _calculateCmcdDataForRequestForOther() { + const data = getGenericCmcdData(); data.ot = CmcdObjectType.OTHER; return data; } - - function _getGenericCmcdData() { - const cmcdParametersFromManifest = getCmcdParametersFromManifest(); + function _getEncodedBitrateData() { const data = {}; - - let cid = settings.get().streaming.cmcd.cid ? settings.get().streaming.cmcd.cid : internalData.cid; - cid = cmcdParametersFromManifest.contentID ? cmcdParametersFromManifest.contentID : cid; - - data.v = settings.get().streaming.cmcd.version ?? DEFAULT_CMCD_VERSION; - - data.sid = settings.get().streaming.cmcd.sid ? settings.get().streaming.cmcd.sid : internalData.sid; - data.sid = cmcdParametersFromManifest.sessionID ? cmcdParametersFromManifest.sessionID : data.sid; - - data.sid = `${data.sid}`; - - if (cid) { - data.cid = `${cid}`; + const activeStream = playbackController.getStreamController()?.getActiveStream(); + if (!activeStream) { + return data; } - if (!isNaN(internalData.pr) && internalData.pr !== 1 && internalData.pr !== null) { - data.pr = internalData.pr; + const videoRep = activeStream.getCurrentRepresentationForType(Constants.VIDEO); + const audioRep = activeStream.getCurrentRepresentationForType(Constants.AUDIO); + const videoBr = videoRep ? Math.round(videoRep.bitrateInKbit) : null; + const audioBr = audioRep ? Math.round(audioRep.bitrateInKbit) : null; + const brValues = _toInnerList(videoBr, audioBr); + if (brValues) { + data.br = brValues; } - if (internalData.st) { - data.st = internalData.st; + return data; + } + + function _getBitrateByRequest(request) { + try { + return parseInt(request.bandwidth / 1000); + } catch (e) { + return null; } + } - if (internalData.sf) { - data.sf = internalData.sf; + function _getTopBitrateByType(mediaInfo) { + try { + const bitrates = abrController.getPossibleVoRepresentationsFilteredBySettings(mediaInfo).map((rep) => { + return rep.bitrateInKbit + }); + return Math.max(...bitrates) + } catch (e) { + return null; } + } - if (data.v === 2) { - let ltc = playbackController.getCurrentLiveLatency() * 1000; - if (!isNaN(ltc)) { - data.ltc = ltc; + function _getPlayheadBitrate(mediaType) { + try { + if (!streamProcessors || streamProcessors.length === 0) { + return null; } - const msd = internalData.msd; - if (!_msdSent && !isNaN(msd)) { - data.msd = msd; - _msdSent = true; + + const streamProcessor = streamProcessors.find(sp => sp.getType() === mediaType); + const bitrate = streamProcessor?.getRepresentationController()?.getCurrentRepresentation()?.bitrateInKbit; + + if (bitrate !== undefined && !isNaN(bitrate)) { + return Math.round(bitrate); } + + return null; + } catch (e) { + return null; } + } - + function _getPlayheadBitrateData() { + const data = {}; + const videoPb = _getPlayheadBitrate(Constants.VIDEO); + const audioPb = _getPlayheadBitrate(Constants.AUDIO); + const pbValues = _toInnerList(videoPb, audioPb); + if (pbValues) { + data.pb = pbValues; + } return data; } - function _createCmcdV2HeadersCustomMap() { - const cmcdVersion = settings.get().streaming.cmcd.version; - return cmcdVersion === 1 ? {} : { - customHeaderMap: { - [CmcdHeaderField.REQUEST]: ['ltc'], - [CmcdHeaderField.SESSION]: ['msd'] - } - }; + function _getTopBitrateDataForType(mediaType) { + if (!streamProcessors || streamProcessors.length === 0) { + return null; + } + const sp = streamProcessors.find(p => p.getType() === mediaType); + if (!sp) { + return null; + } + const mediaInfo = sp.getMediaInfo(); + const tb = _getTopBitrateByType(mediaInfo); + return isFinite(tb) && tb > 0 ? tb : null; } - function _getBitrateByRequest(request) { - try { - return parseInt(request.bandwidth / 1000); - } catch (e) { - return null; + function _getTopBitrateData() { + const data = {}; + const videoTb = _getTopBitrateDataForType(Constants.VIDEO); + const audioTb = _getTopBitrateDataForType(Constants.AUDIO); + const tbValues = _toInnerList(videoTb, audioTb); + if (tbValues) { + data.tb = tbValues; } + + const videoTpb = _getTopPlayableBitrate(Constants.VIDEO); + const audioTpb = _getTopPlayableBitrate(Constants.AUDIO); + const tpbValues = _toInnerList(videoTpb, audioTpb); + if (tpbValues) { + data.tpb = tpbValues; + } + + return data; } - function _getTopBitrateByType(mediaInfo) { + function _getTopPlayableBitrate(mediaType) { try { - const bitrates = abrController.getPossibleVoRepresentationsFilteredBySettings(mediaInfo).map((rep) => { - return rep.bitrateInKbit - }); - return Math.max(...bitrates) + if (!streamProcessors || streamProcessors.length === 0) { + return null; + } + + const streamProcessor = streamProcessors.find(p => p.getType() === mediaType); + + if (streamProcessor) { + const mediaInfo = streamProcessor.getMediaInfo(); + const topBitrate = _getTopBitrateByType(mediaInfo); + + // _getTopBitrateByType can return -Infinity for empty arrays, which is not a valid bitrate. + return isFinite(topBitrate) && topBitrate > 0 ? topBitrate : null; + } + + return null; } catch (e) { return null; } @@ -607,9 +401,21 @@ function CmcdModel() { } } + function _getMeasuredThroughputData() { + const data = {}; + const videoMtp = _getMeasuredThroughputByType(Constants.VIDEO); + const audioMtp = _getMeasuredThroughputByType(Constants.AUDIO); + const mtpValues = _toInnerList(videoMtp, audioMtp); + if (mtpValues) { + data.mtp = mtpValues; + } + + return data; + } + function _getDeadlineByType(mediaType) { try { - const playbackRate = internalData.pr; + const playbackRate = playbackController ? playbackController.getPlaybackRate() : 1; const bufferLevel = dashMetrics.getCurrentBufferLevel(mediaType); if (!isNaN(playbackRate) && !isNaN(bufferLevel)) { @@ -636,27 +442,19 @@ function CmcdModel() { } } - function _onPlaybackRateChanged(data) { - try { - internalData.pr = data.playbackRate; - } catch (e) { - + function _getBufferLevelData() { + const data = {}; + const videoBl = _getBufferLevelByType(Constants.VIDEO); + const audioBl = _getBufferLevelByType(Constants.AUDIO); + const blValues = _toInnerList(videoBl, audioBl); + if (blValues) { + data.bl = blValues; } - } - - function _onManifestLoaded(data) { - try { - const isDynamic = dashManifestModel.getIsDynamic(data.data); - const st = isDynamic ? CmcdStreamType.LIVE : CmcdStreamType.VOD; - const sf = data.protocol && data.protocol === 'MSS' ? CmcdStreamingFormat.SMOOTH : CmcdStreamingFormat.DASH; - internalData.st = `${st}`; - internalData.sf = `${sf}`; - } catch (e) { - } + return data; } - function _onBufferLevelStateChanged(data) { + function onBufferLevelStateChanged(data) { try { if (data.state && data.mediaType) { if (data.state === MediaPlayerEvents.BUFFER_EMPTY) { @@ -674,7 +472,13 @@ function CmcdModel() { } } - function _onPlaybackSeeked() { + function onPlaybackSeeking() { + _isSeeking = true; + } + + function onPlaybackSeeked() { + _isSeeking = false; + for (let key in _bufferLevelStarved) { if (_bufferLevelStarved.hasOwnProperty(key)) { _bufferLevelStarved[key] = true; @@ -688,6 +492,10 @@ function CmcdModel() { } } + function wasPlaying() { + return !_isSeeking && _playbackStartedTime; + } + function _probeNextRequest(mediaType) { if (!streamProcessors || streamProcessors.length === 0) { return; @@ -699,9 +507,129 @@ function CmcdModel() { } } + function onPeriodSwitchComplete() { + _updateStreamProcessors(); + } + + function onPlaybackStarted() { + if (!_playbackStartedTime) { + _playbackStartedTime = Date.now(); + } + } + + function onPlaybackPlaying() { + for (const mediaType in _rebufferingStartTime) { + if (_rebufferingStartTime.hasOwnProperty(mediaType)) { + onRebufferingCompleted(mediaType); + } + } + } + + function onRebufferingStarted(mediaType) { + if (mediaType && !_rebufferingStartTime[mediaType]) { + _rebufferingStartTime[mediaType] = Date.now(); + } + } + + function onRebufferingCompleted(mediaType) { + if (_rebufferingStartTime[mediaType] != null) { + _rebufferingDuration[mediaType] = Date.now() - _rebufferingStartTime[mediaType]; + delete _rebufferingStartTime[mediaType]; + } + } + + function _calculateMsd() { + if (!_playbackStartedTime) { + return null; + } + return Date.now() - _playbackStartedTime; + } + + function getGenericCmcdData(mediaType) { + const data = {}; + + // Note: ts, st, sf, pr are handled by CmcdReporter: + // - ts: auto-generated by recordEvent() / recordResponseReceived() + // - st, sf: persisted via cmcdReporter.update() in _onManifestLoaded + // - pr: persisted via cmcdReporter.update() in _onPlaybackRateChanged + + let ltc = playbackController.getCurrentLiveLatency() * 1000; + if (!isNaN(ltc)) { + data.ltc = ltc; + } + + if (typeof document !== 'undefined' && document.hidden) { + data.bg = true; + } + + if (mediaType && _shouldIncludeDroppedFrames(mediaType)) { + const droppedFrames = dashMetrics.getCurrentDroppedFrames()?.droppedFrames; + if (droppedFrames > 0) { + data.df = droppedFrames; + } + } + + return data; + } + + function _shouldIncludeDroppedFrames(mediaType) { + return mediaType === Constants.VIDEO || + mediaType === Constants.AUDIO || + mediaType === Constants.OTHER; + } + + function getEventModeData() { + const cmcdData = { + ...getGenericCmcdData(), + ..._getAggregatedBitrateData(), + ..._getEncodedBitrateData(), + ..._getBufferLevelData(), + ..._getMeasuredThroughputData(), + ..._getPlayheadBitrateData(), + ..._getTopBitrateData(), + }; + + return cmcdData; + } + + + function resetInitialSettings() { + _bufferLevelStarved = {}; + _isStartup = {}; + _initialMediaRequestsDone = {}; + _lastMediaTypeRequest = undefined; + _playbackStartedTime = undefined; + _rebufferingStartTime = {}; + _rebufferingDuration = {}; + _streamType = undefined; + _streamingFormat = undefined; + + if (cmcdConfig) { + cmcdConfig.reset(); + } + _updateStreamProcessors(); + } + + function _updateStreamProcessors() { + if (!playbackController) { + return; + } + const streamController = playbackController.getStreamController(); + if (!streamController) { + return; + } + if (typeof streamController.getActiveStream !== 'function') { + return; + } + const activeStream = streamController.getActiveStream(); + if (!activeStream) { + return; + } + streamProcessors = activeStream.getStreamProcessors(); + } + function _calculateRtp(request) { try { - // Get the values we need let playbackRate = playbackController.getPlaybackRate(); if (!playbackRate) { playbackRate = 1; @@ -721,7 +649,7 @@ function CmcdModel() { let segmentSize = (bandwidth * duration) / 1000; // Calculate file size in kilobits let timeToLoad = (currentBufferLevel / playbackRate) / 1000; // Calculate time available to load file in seconds let minBandwidth = segmentSize / timeToLoad; // Calculate the exact bandwidth required - let rtpSafetyFactor = settings.get().streaming.cmcd.rtpSafetyFactor && !isNaN(settings.get().streaming.cmcd.rtpSafetyFactor) ? settings.get().streaming.cmcd.rtpSafetyFactor : RTP_SAFETY_FACTOR; + const rtpSafetyFactor = cmcdConfig.get('rtpSafetyFactor', { defaultValue: RTP_SAFETY_FACTOR }); let maxBandwidth = minBandwidth * rtpSafetyFactor; // Include a safety buffer @@ -732,26 +660,219 @@ function CmcdModel() { } } + function calculateMsd() { + const data = {}; + const msd = _calculateMsd(); + + if (msd !== null && !isNaN(msd)) { + data.msd = msd; + } + + return data; + } + + function onPlaybackRateChanged(data) { + if (data.playbackRate !== undefined) { + return { pr: data.playbackRate }; + } + return null; + } + + function onManifestLoaded(data) { + try { + const dashManifestModel = DashManifestModel(context).getInstance(); + const isDynamic = dashManifestModel.getIsDynamic(data.data); + _streamType = isDynamic ? `${CmcdStreamType.LIVE}` : `${CmcdStreamType.VOD}`; + _streamingFormat = data.protocol && data.protocol === 'MSS' ? `${CmcdStreamingFormat.SMOOTH}` : `${CmcdStreamingFormat.DASH}`; + return { st: _streamType, sf: _streamingFormat }; + } catch (e) { + return {}; + } + } + + function getCmcdParametersFromManifest() { + let cmcdParametersFromManifest = {}; + if (serviceDescriptionController) { + const serviceDescription = serviceDescriptionController.getServiceDescriptionSettings(); + if ( + serviceDescription.clientDataReporting && + serviceDescription.clientDataReporting.cmcdParameters + ) { + cmcdParametersFromManifest = serviceDescription.clientDataReporting.cmcdParameters; + } + } + + // Update CmcdConfigAccessor with manifest parameters if available + // Note: Always update accessor when params exist, regardless of applyParametersFromMpd + // The accessor uses priority-based resolution, so manifest params will only be used + // when they have higher priority in the PropertyMap configuration + if (cmcdConfig && Object.keys(cmcdParametersFromManifest).length > 0) { + cmcdConfig.setManifestParams(cmcdParametersFromManifest); + } else if (cmcdConfig) { + // Clear manifest params if none are available + cmcdConfig.setManifestParams(null); + } + + return cmcdParametersFromManifest; + } + + function calculateCmcdDataForRequest(request) { + try { + _updateLastMediaTypeRequest(request.type, request.mediaType); + let cmcdData = {}; + + if (isIncludedInRequestFilter(request.type)) { + if (request.type === HTTPRequest.MPD_TYPE) { + return _calculateCmcdDataForRequestForMpd(request); + } else if (request.type === HTTPRequest.MEDIA_SEGMENT_TYPE) { + _initForMediaType(request.mediaType); + return _calculateCmcdDataForRequestForMediaSegment(request, request.mediaType); + } else if (request.type === HTTPRequest.INIT_SEGMENT_TYPE) { + return _calculateCmcdDataForRequestForInitSegment(request); + } else if (request.type === HTTPRequest.OTHER_TYPE || request.type === HTTPRequest.XLINK_EXPANSION_TYPE) { + return _calculateCmcdDataForRequestForOther(request); + } else if (request.type === HTTPRequest.LICENSE) { + return _calculateCmcdDataForRequestForLicense(request); + } else if (request.type === HTTPRequest.CONTENT_STEERING_TYPE) { + return _calculateCmcdDataForRequestForSteering(request); + } + } + return cmcdData; + } catch (e) { + return null; + } + } + + function isIncludedInRequestFilter(type, includeInRequests) { + const includeInRequestsArray = includeInRequests || cmcdConfig.get('includeInRequests'); + + const filtersTypes = { + [HTTPRequest.INIT_SEGMENT_TYPE]: 'segment', + [HTTPRequest.MEDIA_SEGMENT_TYPE]: 'segment', + [HTTPRequest.XLINK_EXPANSION_TYPE]: 'xlink', + [HTTPRequest.MPD_TYPE]: 'mpd', + [HTTPRequest.CONTENT_STEERING_TYPE]: 'steering', + [HTTPRequest.OTHER_TYPE]: 'other', + }; + + return includeInRequestsArray.some(t => filtersTypes[type] === t); + } + function reset() { - eventBus.off(MediaPlayerEvents.PLAYBACK_RATE_CHANGED, _onPlaybackRateChanged, this); - eventBus.off(MediaPlayerEvents.MANIFEST_LOADED, _onManifestLoaded, this); - eventBus.off(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, _onBufferLevelStateChanged, instance); - eventBus.off(MediaPlayerEvents.PLAYBACK_SEEKED, _onPlaybackSeeked, instance); - eventBus.off(MediaPlayerEvents.PLAYBACK_STARTED, _onPlaybackStarted, instance); - eventBus.off(MediaPlayerEvents.PLAYBACK_PLAYING, _onPlaybackPlaying, instance); + resetInitialSettings(); + } + + function _updateLastMediaTypeRequest(type, mediatype) { + // Video > Audio > None + if (mediatype === Constants.VIDEO || mediatype === Constants.AUDIO) { + if (!_lastMediaTypeRequest || _lastMediaTypeRequest == Constants.AUDIO) { + _lastMediaTypeRequest = mediatype; + } + } + } + + function _calculateCmcdDataForRequestForSteering(request) { + const data = !_lastMediaTypeRequest ? getGenericCmcdData(request) : _calculateCmcdDataForRequestForMediaSegment(request, _lastMediaTypeRequest); + + data.ot = CmcdObjectType.OTHER; + + return data; + } + + function _calculateCmcdDataForRequestForLicense(request) { + const data = getGenericCmcdData(request); + + data.ot = CmcdObjectType.KEY; - _resetInitialSettings(); + return data; + } + + function _calculateCmcdDataForRequestForMpd() { + const data = getGenericCmcdData(); + + data.ot = CmcdObjectType.MANIFEST; + + return data; + } + + function _getAggregatedBitrateData() { + // defining data to return + const data = {}; + // accessing active stream + const activeStream = playbackController.getStreamController()?.getActiveStream(); + if (!activeStream) { + return data; + } + + // Get current representations + const videoRep = activeStream.getCurrentRepresentationForType(Constants.VIDEO); + const audioRep = activeStream.getCurrentRepresentationForType(Constants.AUDIO); + + const currentVideoBitrate = videoRep ? videoRep.bitrateInKbit : 0; + const currentAudioBitrate = audioRep ? audioRep.bitrateInKbit : 0; + + // Calculate aggregated bitrate + const abValues = _toInnerList( + currentVideoBitrate > 0 ? Math.round(currentVideoBitrate) : null, + currentAudioBitrate > 0 ? Math.round(currentAudioBitrate) : null + ); + if (abValues) { + data.ab = abValues; + } + + // Calculate top aggregated bitrate + const allVideoReps = activeStream.getRepresentationsByType(Constants.VIDEO) || []; + const allAudioReps = activeStream.getRepresentationsByType(Constants.AUDIO) || []; + const topVideoBitrate = allVideoReps.reduce((max, rep) => Math.max(max, rep.bitrateInKbit), 0); + const topAudioBitrate = allAudioReps.reduce((max, rep) => Math.max(max, rep.bitrateInKbit), 0); + const tabValues = _toInnerList( + topVideoBitrate > 0 ? Math.round(topVideoBitrate) : null, + topAudioBitrate > 0 ? Math.round(topAudioBitrate) : null + ); + if (tabValues) { + data.tab = tabValues; + } + + // Calculate lowest aggregated bitrate + const lowestVideoBitrate = allVideoReps.length > 0 ? Math.min(...allVideoReps.map(rep => rep.bitrateInKbit)) : 0; + const lowestAudioBitrate = allAudioReps.length > 0 ? Math.min(...allAudioReps.map(rep => rep.bitrateInKbit)) : 0; + const labValues = _toInnerList( + lowestVideoBitrate > 0 ? Math.round(lowestVideoBitrate) : null, + lowestAudioBitrate > 0 ? Math.round(lowestAudioBitrate) : null + ); + if (labValues) { + data.lab = labValues; + } + + return data; + } + + function getLastMediaTypeRequest() { + return _lastMediaTypeRequest; } instance = { - getCmcdData, - getQueryParameter, - getHeaderParameters, - getCmcdParametersFromManifest, - setConfig, + setup, reset, - initialize, - isCmcdEnabled, + setConfig, + calculateCmcdDataForRequest, + onPeriodSwitchComplete, + onPlaybackStarted, + onPlaybackPlaying, + onRebufferingStarted, + onRebufferingCompleted, + onPlaybackSeeking, + onPlaybackSeeked, + wasPlaying, + onBufferLevelStateChanged, + calculateMsd, + resetInitialSettings, + getCmcdParametersFromManifest, + onPlaybackRateChanged, + onManifestLoaded, + getEventModeData, + isIncludedInRequestFilter, + getLastMediaTypeRequest }; setup(); diff --git a/src/streaming/models/CustomParametersModel.js b/src/streaming/models/CustomParametersModel.js index 992d401482..1ff46cc6ae 100644 --- a/src/streaming/models/CustomParametersModel.js +++ b/src/streaming/models/CustomParametersModel.js @@ -33,6 +33,7 @@ import FactoryMaker from '../../core/FactoryMaker.js'; import Settings from '../../core/Settings.js'; import {checkParameterType} from '../utils/SupervisorTools.js'; import Constants from '../constants/Constants.js'; +import CmcdController from '../controllers/CmcdController.js'; import ExternalSubtitle from '../vo/ExternalSubtitle.js'; const DEFAULT_XHR_WITH_CREDENTIALS = false; @@ -49,7 +50,8 @@ function CustomParametersModel() { customCapabilitiesFilters, customInitialTrackSelectionFunction, externalSubtitles, - customAbrRules; + customAbrRules, + cmcdController; const context = this.context; const settings = Settings(context).getInstance(); @@ -62,14 +64,18 @@ function CustomParametersModel() { } function _resetInitialSettings() { - requestInterceptors = []; - responseInterceptors = []; licenseRequestFilters = []; licenseResponseFilters = []; customCapabilitiesFilters = []; customAbrRules = []; customInitialTrackSelectionFunction = null; utcTimingSources = []; + + // Initialize request interceptors with default CMCD interceptors + cmcdController = CmcdController(context).getInstance(); + requestInterceptors = cmcdController.getCmcdRequestInterceptors(); + responseInterceptors = cmcdController.getCmcdResponseInterceptors(); + externalSubtitles = new Set(); } diff --git a/src/streaming/net/HTTPLoader.js b/src/streaming/net/HTTPLoader.js index a13e7e3ad1..5641d1b70a 100644 --- a/src/streaming/net/HTTPLoader.js +++ b/src/streaming/net/HTTPLoader.js @@ -33,7 +33,6 @@ import FetchLoader from './FetchLoader.js'; import {HTTPRequest} from '../vo/metrics/HTTPRequest.js'; import FactoryMaker from '../../core/FactoryMaker.js'; import DashJSError from '../vo/DashJSError.js'; -import CmcdModel from '../models/CmcdModel.js'; import CmsdModel from '../models/CmsdModel.js'; import Utils from '../../core/Utils.js'; import Debug from '../../core/Debug.js'; @@ -43,7 +42,6 @@ import Settings from '../../core/Settings.js'; import Constants from '../constants/Constants.js'; import CustomParametersModel from '../models/CustomParametersModel.js'; import CommonAccessTokenController from '../controllers/CommonAccessTokenController.js'; -import ClientDataReportingController from '../controllers/ClientDataReportingController.js'; import ExtUrlQueryInfoController from '../controllers/ExtUrlQueryInfoController.js'; import CommonMediaRequest from '../vo/CommonMediaRequest.js'; import CommonMediaResponse from '../vo/CommonMediaResponse.js'; @@ -73,13 +71,11 @@ function HTTPLoader(cfg) { delayedRequests, retryRequests, downloadErrorToRequestTypeMap, - cmcdModel, cmsdModel, xhrLoader, fetchLoader, customParametersModel, commonAccessTokenController, - clientDataReportingController, extUrlQueryInfoController, logger; @@ -88,8 +84,6 @@ function HTTPLoader(cfg) { httpRequests = []; delayedRequests = []; retryRequests = []; - cmcdModel = CmcdModel(context).getInstance(); - clientDataReportingController = ClientDataReportingController(context).getInstance(); cmsdModel = CmsdModel(context).getInstance(); customParametersModel = CustomParametersModel(context).getInstance(); commonAccessTokenController = CommonAccessTokenController(context).getInstance(); @@ -303,7 +297,8 @@ function HTTPLoader(cfg) { } const _updateResourceTimingInfo = function () { - commonMediaResponse.resourceTiming.responseEnd = Date.now(); + commonMediaResponse.resourceTiming.responseEnd = performance.now(); + commonMediaResponse.resourceTiming.duration = commonMediaResponse.resourceTiming.responseEnd - commonMediaResponse.resourceTiming.startTime; // If enabled the ResourceTimingApi we add the corresponding information to the request object. // These values are more accurate and can be used by the ThroughputController later @@ -314,13 +309,13 @@ function HTTPLoader(cfg) { return new Promise((resolve) => { _applyRequestInterceptors(httpRequest).then((_httpRequest) => { httpRequest = _httpRequest; - + httpResponse.request = httpRequest; httpRequest.customData.onloadend = _onloadend; httpRequest.customData.onprogress = _onprogress; httpRequest.customData.onabort = _onabort; httpRequest.customData.ontimeout = _ontimeout; - httpResponse.resourceTiming.startTime = Date.now(); + httpResponse.resourceTiming.startTime = performance.now(); loader.load(httpRequest, httpResponse); resolve(); }); @@ -393,7 +388,7 @@ function HTTPLoader(cfg) { const loader = loaderInformation.loader; requestObject.fileLoaderType = loaderInformation.fileLoaderType; - requestObject.headers = {}; + requestObject.headers = requestObject.headers || {}; _updateRequestUrlAndHeaders(requestObject); if (requestObject.range) { requestObject.headers['Range'] = 'bytes=' + requestObject.range; @@ -403,19 +398,19 @@ function HTTPLoader(cfg) { commonMediaRequest = new CommonMediaRequest({ url: requestObject.url, - method: HTTPRequest.GET, + method: requestObject.method || HTTPRequest.GET, responseType: requestObject.responseType, headers: requestObject.headers, credentials: withCredentials ? 'include' : 'omit', timeout: requestTimeout, - cmcd: cmcdModel.getCmcdData(requestObject), - customData: { request: requestObject } + customData: { request: requestObject }, + body: requestObject.body }); commonMediaResponse = new CommonMediaResponse({ request: commonMediaRequest, resourceTiming: { - startTime: Date.now(), + startTime: performance.now(), encodedBodySize: 0 }, status: 0 @@ -584,7 +579,6 @@ function HTTPLoader(cfg) { * @private */ function _updateRequestUrlAndHeaders(request) { - _updateRequestUrlAndHeadersWithCmcd(request); if (request.retryAttempts === 0) { _addExtUrlQueryParameters(request); } @@ -620,51 +614,6 @@ function HTTPLoader(cfg) { } } - /** - * Updates the request url and headers with CMCD data - * @param request - * @private - */ - function _updateRequestUrlAndHeadersWithCmcd(request) { - const currentServiceLocation = request?.serviceLocation; - const currentAdaptationSetId = request?.mediaInfo?.id?.toString(); - const isIncludedFilters = clientDataReportingController.isServiceLocationIncluded(request.type, currentServiceLocation) && - clientDataReportingController.isAdaptationsIncluded(currentAdaptationSetId); - - if (isIncludedFilters && cmcdModel.isCmcdEnabled()) { - const cmcdParameters = cmcdModel.getCmcdParametersFromManifest(); - const cmcdMode = cmcdParameters.mode ? cmcdParameters.mode : settings.get().streaming.cmcd.mode; - if (cmcdMode === Constants.CMCD_MODE_QUERY) { - request.url = Utils.removeQueryParameterFromUrl(request.url, Constants.CMCD_QUERY_KEY); - const additionalQueryParameter = _getAdditionalQueryParameter(request); - request.url = Utils.addAdditionalQueryParameterToUrl(request.url, additionalQueryParameter); - } else if (cmcdMode === Constants.CMCD_MODE_HEADER) { - request.headers = Object.assign(request.headers, cmcdModel.getHeaderParameters(request)); - } - } - } - - /** - * Generates the additional query parameters to be appended to the request url - * @param {object} request - * @return {array} - * @private - */ - function _getAdditionalQueryParameter(request) { - try { - const additionalQueryParameter = []; - const cmcdQueryParameter = cmcdModel.getQueryParameter(request); - - if (cmcdQueryParameter) { - additionalQueryParameter.push(cmcdQueryParameter); - } - - return additionalQueryParameter; - } catch (e) { - return []; - } - } - /** * Aborts any inflight downloads * @memberof module:HTTPLoader diff --git a/src/streaming/net/XHRLoader.js b/src/streaming/net/XHRLoader.js index e3606031d0..2efe71db1c 100644 --- a/src/streaming/net/XHRLoader.js +++ b/src/streaming/net/XHRLoader.js @@ -80,9 +80,8 @@ function XHRLoader() { xhr.onabort = commonMediaRequest.customData.onabort; xhr.ontimeout = commonMediaRequest.customData.ontimeout; } - - xhr.send(); - + let body = commonMediaRequest.body || null; + xhr.send(body); commonMediaRequest.customData.abort = abort.bind(this); return true; } diff --git a/src/streaming/protection/Protection.js b/src/streaming/protection/Protection.js index 57750623d5..ffc81a46d8 100644 --- a/src/streaming/protection/Protection.js +++ b/src/streaming/protection/Protection.js @@ -125,7 +125,7 @@ function Protection() { if (protectionModel) { controller = ProtectionController(context).create({ BASE64: config.BASE64, - cmcdModel: config.cmcdModel, + cmcdController: config.cmcdController, constants: config.constants, customParametersModel: config.customParametersModel, debug: config.debug, diff --git a/src/streaming/protection/controllers/ProtectionController.js b/src/streaming/protection/controllers/ProtectionController.js index aba78ec103..7fe6bd62b3 100644 --- a/src/streaming/protection/controllers/ProtectionController.js +++ b/src/streaming/protection/controllers/ProtectionController.js @@ -38,7 +38,6 @@ import LicenseRequest from '../vo/LicenseRequest.js'; import LicenseResponse from '../vo/LicenseResponse.js'; import {HTTPRequest} from '../../vo/metrics/HTTPRequest.js'; import Utils from '../../../core/Utils.js'; -import Constants from '../../constants/Constants.js'; import FactoryMaker from '../../../core/FactoryMaker.js'; import ProtectionConstants from '../../constants/ProtectionConstants.js'; @@ -68,7 +67,7 @@ function ProtectionController(config) { config = config || {}; const BASE64 = config.BASE64; - const cmcdModel = config.cmcdModel; + const cmcdController = config.cmcdController; const constants = config.constants; const customParametersModel = config.customParametersModel; const debug = config.debug; @@ -870,21 +869,17 @@ function ProtectionController(config) { */ function _doLicenseRequest(request, retriesCount, timeout, onLoad, onAbort, onError) { const xhr = new XMLHttpRequest(); - const cmcdParameters = cmcdModel.getCmcdParametersFromManifest(); - - if (cmcdModel.isCmcdEnabled()) { - const cmcdMode = cmcdParameters.mode ? cmcdParameters.mode : settings.get().streaming.cmcd.mode; - if (cmcdMode === Constants.CMCD_MODE_QUERY) { - const cmcdParams = cmcdModel.getQueryParameter({ - url: request.url, - type: HTTPRequest.LICENSE - }); - if (cmcdParams) { - request.url = Utils.addAdditionalQueryParameterToUrl(request.url, [cmcdParams]); - } - } - } + // Apply CMCD data to the license request (handles both query and header modes) + const cmcdRequest = { + url: request.url, + type: HTTPRequest.LICENSE, + method: request.method, + headers: request.headers || {}, + }; + cmcdController.applyCmcdToRequest(cmcdRequest) + request.url = cmcdRequest.url; + request.headers = cmcdRequest.headers; xhr.open(request.method, request.url, true); xhr.responseType = request.responseType; @@ -896,25 +891,6 @@ function ProtectionController(config) { xhr.setRequestHeader(key, request.headers[key]); } - if (cmcdModel.isCmcdEnabled()) { - const cmcdMode = cmcdParameters.mode ? cmcdParameters.mode : settings.get().streaming.cmcd.mode; - if (cmcdMode === Constants.CMCD_MODE_HEADER) { - const cmcdHeaders = cmcdModel.getHeaderParameters({ - url: request.url, - type: HTTPRequest.LICENSE - }); - - if (cmcdHeaders) { - for (const header in cmcdHeaders) { - let value = cmcdHeaders[header]; - if (value) { - xhr.setRequestHeader(header, value); - } - } - } - } - } - const _retryRequest = function () { // fail silently and retry retriesCount--; diff --git a/src/streaming/utils/CapabilitiesFilter.js b/src/streaming/utils/CapabilitiesFilter.js index 65711522c4..26eea689cc 100644 --- a/src/streaming/utils/CapabilitiesFilter.js +++ b/src/streaming/utils/CapabilitiesFilter.js @@ -79,7 +79,7 @@ function CapabilitiesFilter() { _removeMultiRepresentationPreselections(manifest); _removePreselectionWithNoAdaptationSet(manifest); - + return _applyCustomFilters(manifest); }) .then(() => { @@ -205,19 +205,21 @@ function CapabilitiesFilter() { const configurations = []; manifest.Period.forEach((period) => { - period.AdaptationSet.forEach((as) => { - if (adapter.getIsTypeOf(as, type)) { - as.Representation.forEach((rep, i) => { - const codec = adapter.getCodec(as, i, false); - _processCodecToCheck(type, rep, codec, configurationsSet, configurations); - - const supplementalCodecs = adapter.getSupplementalCodecs(rep) - if (supplementalCodecs.length > 0) { - _processCodecToCheck(type, rep, supplementalCodecs[0], configurationsSet, configurations); - } - }); - } - }); + if (!period.ImportedMPD) { + period.AdaptationSet.forEach((as) => { + if (adapter.getIsTypeOf(as, type)) { + as.Representation.forEach((rep, i) => { + const codec = adapter.getCodec(as, i, false); + _processCodecToCheck(type, rep, codec, configurationsSet, configurations); + + const supplementalCodecs = adapter.getSupplementalCodecs(rep) + if (supplementalCodecs.length > 0) { + _processCodecToCheck(type, rep, supplementalCodecs[0], configurationsSet, configurations); + } + }); + } + }); + } if (period.Preselection && period.Preselection.length) { period.Preselection.forEach((prsl) => { if (adapter.getPreselectionIsTypeOf(prsl, period.AdaptationSet, type)) { @@ -305,12 +307,12 @@ function CapabilitiesFilter() { if (primaryElement.tagName === DashConstants.PRESELECTION && prslCommonRep) { let prslCommonRepresentationHDRColorimetryConfig = _convertHDRColorimetryToConfig(prslCommonRep); - + // if either the properties of the Preselection or the CommonRepresentation is not supported, we can't mark the config as supported. let isCommonRepCfgSupported = prslCommonRepresentationHDRColorimetryConfig.isSupported; delete prslCommonRepresentationHDRColorimetryConfig.isSupported; config.isSupported = config.isSupported && isCommonRepCfgSupported; - + // asign only those attributes that are not present in config _assignMissing(config, prslCommonRepresentationHDRColorimetryConfig); } @@ -322,7 +324,7 @@ function CapabilitiesFilter() { if (primaryElement.tagName === DashConstants.PRESELECTION && prslCommonRep) { let prslCommonRepresentationHDRMetadataFormatConfig = _convertHDRMetadataFormatToConfig(prslCommonRep); - + // if either the properties of the Preselection or the CommonRepresentation is not supported, we can't mark the config as supported. let isCommonRepCfgSupported = prslCommonRepresentationHDRMetadataFormatConfig.isSupported; delete prslCommonRepresentationHDRMetadataFormatConfig.isSupported; @@ -463,26 +465,28 @@ function CapabilitiesFilter() { } manifest.Period.forEach((period) => { - period.AdaptationSet = period.AdaptationSet.filter((as) => { + if (!period.ImportedMPD) { + period.AdaptationSet = period.AdaptationSet.filter((as) => { - if (!as.Representation || as.Representation.length === 0) { - return true; - } + if (!as.Representation || as.Representation.length === 0) { + return true; + } - const adaptationSetEssentialProperties = adapter.getEssentialProperties(as); - const doesSupportEssentialProperties = _doesSupportEssentialProperties(adaptationSetEssentialProperties); + const adaptationSetEssentialProperties = adapter.getEssentialProperties(as); + const doesSupportEssentialProperties = _doesSupportEssentialProperties(adaptationSetEssentialProperties); - if (!doesSupportEssentialProperties) { - return false; - } + if (!doesSupportEssentialProperties) { + return false; + } - as.Representation = as.Representation.filter((rep) => { - const essentialProperties = adapter.getEssentialProperties(rep); - return _doesSupportEssentialProperties(essentialProperties); - }); + as.Representation = as.Representation.filter((rep) => { + const essentialProperties = adapter.getEssentialProperties(rep); + return _doesSupportEssentialProperties(essentialProperties); + }); - return as.Representation && as.Representation.length > 0; - }); + return as.Representation && as.Representation.length > 0; + }); + } if (period.Preselection && period.Preselection.length) { period.Preselection = period.Preselection.filter(prsl => { diff --git a/src/streaming/vo/CmcdReportRequest.js b/src/streaming/vo/CmcdReportRequest.js new file mode 100644 index 0000000000..6e7f9df74e --- /dev/null +++ b/src/streaming/vo/CmcdReportRequest.js @@ -0,0 +1,44 @@ +/** + * The copyright in this software is being made available under the BSD License, + * included below. This software may be subject to other third party and contributor + * rights, including patent rights, and no such rights are granted under this license. + * + * Copyright (c) 2013, Dash Industry Forum. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * * Neither the name of Dash Industry Forum nor the names of its + * contributors may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +/** + * @class + * @ignore + */ +import FragmentRequest from './FragmentRequest.js'; + +class CmcdReportRequest extends FragmentRequest { + constructor(method) { + super(); + this.method = method || null; + } +} + +export default CmcdReportRequest; diff --git a/src/streaming/vo/CommonMediaRequest.js b/src/streaming/vo/CommonMediaRequest.js index 819d0e8884..02c837cec3 100644 --- a/src/streaming/vo/CommonMediaRequest.js +++ b/src/streaming/vo/CommonMediaRequest.js @@ -3,24 +3,24 @@ class CommonMediaRequest { * @param {Object} params * @param {string} params.url * @param {string} params.method + * @param {BodyInit} [params.body] * @param {string} [params.responseType] * @param {Object} [params.headers] * @param {RequestCredentials} [params.credentials] * @param {RequestMode} [params.mode] * @param {number} [params.timeout] - * @param {Cmcd} [params.cmcd] - * @param {any} [params.customData] + * @param {Object} [params.customData] */ constructor(params) { this.url = params.url; this.method = params.method; + this.body = params.body !== undefined ? params.body : null; this.responseType = params.responseType !== undefined ? params.responseType : null; this.headers = params.headers !== undefined ? params.headers : {}; this.credentials = params.credentials !== undefined ? params.credentials : null; this.mode = params.mode !== undefined ? params.mode : null; this.timeout = params.timeout !== undefined ? params.timeout : 0; - this.cmcd = params.cmcd !== undefined ? params.cmcd : null; - this.customData = params.customData !== undefined ? params.customData : null; + this.customData = params.customData !== undefined ? params.customData : {}; } } diff --git a/src/streaming/vo/metrics/HTTPRequest.js b/src/streaming/vo/metrics/HTTPRequest.js index 55e2455e1d..17bb754f95 100644 --- a/src/streaming/vo/metrics/HTTPRequest.js +++ b/src/streaming/vo/metrics/HTTPRequest.js @@ -53,6 +53,7 @@ class HTTPRequest { * - Index Fragment * - Media Fragment * - Bitstream Switching Fragment + * - CMCD Response * - other * @public */ @@ -167,6 +168,7 @@ class HTTPRequestTrace { } HTTPRequest.GET = 'GET'; +HTTPRequest.POST = 'POST'; HTTPRequest.HEAD = 'HEAD'; HTTPRequest.MPD_TYPE = 'MPD'; HTTPRequest.XLINK_EXPANSION_TYPE = 'XLinkExpansion'; @@ -178,6 +180,7 @@ HTTPRequest.MSS_FRAGMENT_INFO_SEGMENT_TYPE = 'FragmentInfoSegment'; HTTPRequest.DVB_REPORTING_TYPE = 'DVBReporting'; HTTPRequest.LICENSE = 'license'; HTTPRequest.CONTENT_STEERING_TYPE = 'ContentSteering'; +HTTPRequest.CMCD_EVENT = 'CmcdEvent'; HTTPRequest.OTHER_TYPE = 'other'; export {HTTPRequest, HTTPRequestTrace}; diff --git a/test/functional/adapter/DashJsAdapter.js b/test/functional/adapter/DashJsAdapter.js index c1cc2c6949..e47327c02e 100644 --- a/test/functional/adapter/DashJsAdapter.js +++ b/test/functional/adapter/DashJsAdapter.js @@ -8,6 +8,7 @@ class DashJsAdapter { constructor() { this.player = null; this.videoElement = document.getElementById('video-element'); + this.alternativeVideoElement = document.getElementById('alternative-video-element'); this.ttmlRenderingDiv = document.getElementById('ttml-rendering-div'); this.startedFragmentDownloads = []; this.logEvents = {}; @@ -44,6 +45,15 @@ class DashJsAdapter { }) } + initForAlternativeMedia(mpd) { + this._initLogEvents(); + this._createPlayerInstance(); + + this.player.initialize(this.videoElement, mpd, true); + this.player.setAlternativeVideoElement(this.alternativeVideoElement); + this._registerInternalEvents(); + } + _initLogEvents() { this.logEvents[Debug.LOG_LEVEL_NONE] = []; this.logEvents[Debug.LOG_LEVEL_FATAL] = []; diff --git a/test/functional/config/test-configurations/streams/alternative-mpd.json b/test/functional/config/test-configurations/streams/alternative-mpd.json new file mode 100644 index 0000000000..4ba8a08279 --- /dev/null +++ b/test/functional/config/test-configurations/streams/alternative-mpd.json @@ -0,0 +1,123 @@ +{ + "customLaunchers": { + "chrome_custom": { + "base": "Chrome", + "flags": [ + "--disable-web-security", + "--autoplay-policy=no-user-gesture-required", + "--disable-popup-blocking", + "--disable-search-engine-choice-screen", + "--allow-running-insecure-content", + "--disable-features=VizDisplayCompositor" + ] + } + }, + "testfiles": { + "included": [ + "feature-support/alternative/alternative-mpd-replace-vod", + "feature-support/alternative/alternative-mpd-insert-vod", + "feature-support/alternative/alternative-mpd-replace-live", + "feature-support/alternative/alternative-mpd-executeOnce", + "feature-support/alternative/alternative-mpd-clip-vod", + "feature-support/alternative/alternative-mpd-clip-live", + "feature-support/alternative/alternative-mpd-status-update-live", + "feature-support/alternative/alternative-mpd-returnOffset" + ], + "excluded": [] + }, + "testvectors": [ + { + "name": "Alternative MPD Replace - VOD to VOD Test", + "type": "vod", + "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-replace-vod" + ] + }, + { + "name": "Alternative MPD Replace - VOD to LIVE Test", + "type": "vod", + "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-replace-vod" + ] + }, + { + "name": "Alternative MPD Insert - VOD to VOD Test", + "type": "vod", + "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-insert-vod" + ] + }, + { + "name": "Alternative MPD Insert - VOD to LIVE Test", + "type": "vod", + "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-insert-vod" + ] + }, + { + "name": "Alternative MPD Replace - Live to VOD Test", + "type": "live", + "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "alternativeUrl": "https://dash.akamaized.net/dashif/ad-insertion-testcase1/batch2/real/b/ad-insertion-testcase1.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-replace-live" + ] + }, + { + "name": "Alternative MPD Replace - Live to Live Test", + "type": "live", + "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-replace-live" + ] + }, + { + "name": "Alternative MPD Execute Once - VOD to VOD Test", + "type": "vod", + "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-executeOnce" + ] + }, + { + "name": "Alternative MPD Clip - VOD to VOD Test", + "type": "vod", + "url": "/base/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-clip-vod" + ] + }, + { + "name": "Alternative MPD Clip - Live to Live Test", + "type": "live", + "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-clip-live" + ] + }, + { + "name": "Alternative MPD Status Update - Live to Live Test", + "type": "live", + "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-status-update-live" + ] + }, + { + "name": "Alternative MPD returnOffset - Live to Live Test", + "type": "live", + "originalUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "alternativeUrl": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "includedTestfiles": [ + "feature-support/alternative/alternative-mpd-returnOffset" + ] + } + ] +} \ No newline at end of file diff --git a/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd b/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd new file mode 100644 index 0000000000..de2069352c --- /dev/null +++ b/test/functional/content/alternative-mpd/alternative-mpd-clip.mpd @@ -0,0 +1,22 @@ + +https://dash.akamaized.net/akamai/bbb_30fps/ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd b/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd new file mode 100644 index 0000000000..6626a35239 --- /dev/null +++ b/test/functional/content/alternative-mpd/alternative-mpd-executeOnce.mpd @@ -0,0 +1,22 @@ + +https://dash.akamaized.net/akamai/bbb_30fps/ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd new file mode 100644 index 0000000000..ec0c697a4c --- /dev/null +++ b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd @@ -0,0 +1,22 @@ + +https://dash.akamaized.net/akamai/bbb_30fps/ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd new file mode 100644 index 0000000000..aff17eda2c --- /dev/null +++ b/test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd @@ -0,0 +1,22 @@ + +https://dash.akamaized.net/akamai/bbb_30fps/ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd new file mode 100644 index 0000000000..b958a82be9 --- /dev/null +++ b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd @@ -0,0 +1,22 @@ + +https://dash.akamaized.net/akamai/bbb_30fps/ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd new file mode 100644 index 0000000000..a684c18ef8 --- /dev/null +++ b/test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd @@ -0,0 +1,22 @@ + +https://dash.akamaized.net/akamai/bbb_30fps/ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/functional/test/common/common.js b/test/functional/test/common/common.js index ba1e60e08c..68fa46b29d 100644 --- a/test/functional/test/common/common.js +++ b/test/functional/test/common/common.js @@ -106,6 +106,18 @@ export function initializeDashJsAdapterForPreload(item, mpd, settings) { return playerAdapter } +export function initializeDashJsAdapterForAlternativMedia(item, mpd, settings) { + let playerAdapter = new DashJsAdapter(); + playerAdapter.initForAlternativeMedia(mpd); + playerAdapter.setDrmData(item.drm); + if (settings) { + playerAdapter.updateSettings(settings); + } + + playerAdapter.attachSource(mpd); + return playerAdapter +} + export function playForDuration(durationInMilliseconds) { return new Promise(resolve => setTimeout(resolve, durationInMilliseconds)); } diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-clip-live.js b/test/functional/test/feature-support/alternative/alternative-mpd-clip-live.js new file mode 100644 index 0000000000..dab4d07750 --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-clip-live.js @@ -0,0 +1,140 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +/** + * Utility function to modify a live manifest by injecting Alternative MPD events with clip functionality + * This tests the clip feature scenarios where only a portion of the alternative live content is played + */ +function injectAlternativeMpdClipEvents(player, originalManifestUrl, alternativeManifestUrl, presentationTime, maxDuration, callback) { + const mediaPlayer = player.player; + + mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => { + manifest.Period[0].EventStream = []; + + const duration = 8000; + const earliestResolutionTimeOffset = 3000; + + const replaceClipEvent = { + schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025', + timescale: 1000, + Event: [{ + id: 1, + presentationTime: presentationTime, + duration: duration, + ReplacePresentation: { + url: alternativeManifestUrl, + earliestResolutionTimeOffset: earliestResolutionTimeOffset, + maxDuration: maxDuration, + clip: 'true', + } + }] + }; + + manifest.Period[0].EventStream.push(replaceClipEvent); + mediaPlayer.attachSource(manifest); + + if (callback) { + callback(); + } + }); +} + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-clip-live').forEach((item) => { + const name = item.name; + const originalUrl = item.originalUrl; + const alternativeUrl = item.alternativeUrl; + + describe(`Alternative MPD Replace with Clip functionality tests for Live-to-Live: ${name}`, () => { + + let player; + let presentationTime; + let maxDuration; + let presentationTimeOffset; + + before((done) => { + const currentPresentationTime = Date.now(); + presentationTimeOffset = 10000 //includes potential latency + presentationTime = currentPresentationTime - presentationTimeOffset; //alternative content already started + maxDuration = 10000; + + // Initialize the player without attaching source immediately + player = initializeDashJsAdapterForAlternativMedia(item, null); + + // Use the utility function to inject Alternative MPD events with clip for live-to-live + injectAlternativeMpdClipEvents(player, originalUrl, alternativeUrl, presentationTime, maxDuration, () => { + done(); + }); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should play live content, switch to clipped alternative live content, then back to original live content', (done) => { + let alternativeContentDetected = false; + let backToOriginalDetected = false; + let eventTriggered = false; + let alternativeEndTime = 0; + let alternativeStartTime = 0; + let expectedMaxDuration = 0; + let expectedPresentationTime = 0; + + const timeout = setTimeout(() => { + done(new Error('Test timed out - alternative MPD replace clip event not completed within 35 seconds')); + }, 35000); + + player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => { + eventTriggered = true; + }); + + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'replace') { + alternativeContentDetected = true; + alternativeStartTime = Date.now() / 1000; + expectedMaxDuration = data.event.maxDuration; + expectedPresentationTime = data.event.presentationTime; + expect(data.event.clip).to.be.true; + } + }); + + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'replace') { + backToOriginalDetected = true; + alternativeEndTime = Date.now() / 1000; + const actualTerminationTime = player.player.timeAsUTC(); + const expectedTerminationTime = (expectedPresentationTime + expectedMaxDuration); + clearTimeout(timeout); + + // Wait to ensure stability + setTimeout(() => { + expect(eventTriggered).to.be.true; + expect(alternativeContentDetected).to.be.true; + expect(backToOriginalDetected).to.be.true; + + // Verify that the actual duration of alternative content is less than maxDuration + const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime); + expect(actualAlternativeDuration).to.be.lessThan(expectedMaxDuration); + + // The alternative content should terminate at approximately PRT + APDmax + // Allow tolerance for live content timing variations + expect(actualTerminationTime).to.be.at.closeTo(expectedTerminationTime, 2); + + done(); + }, 2000); // Longer wait for live content stability + } + }); + + // Handle errors + player.registerEvent('error', (e) => { + clearTimeout(timeout); + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }, 45000); + + }); +}); \ No newline at end of file diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-clip-vod.js b/test/functional/test/feature-support/alternative/alternative-mpd-clip-vod.js new file mode 100644 index 0000000000..27b9146bcb --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-clip-vod.js @@ -0,0 +1,93 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-clip-vod').forEach((item) => { + const name = item.name; + const url = item.url; + + describe(`Alternative MPD Clip functionality tests for VOD-to-VOD: ${name}`, () => { + + let player; + + before(() => { + player = initializeDashJsAdapterForAlternativMedia(item, url); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should play VOD content, seek forward to simulate delay, then test clip behavior with alternative VOD content', (done) => { + let alternativeContentDetected = false; + let backToOriginalDetected = false; + let eventTriggered = false; + let alternativeEndTime = 0; + let expectedMaxDuration = 0; + let expectedPresentationTime = 0; + let seekPerformed = false; + + const timeout = setTimeout(() => { + done(new Error('Test timed out - alternative MPD replace clip event not completed within 30 seconds')); + }, 30000); + + player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => { + eventTriggered = true; + }); + + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'replace') { + alternativeContentDetected = true; + expectedMaxDuration = data.event.maxDuration; + expectedPresentationTime = data.event.presentationTime; + + expect(data.event.clip).to.be.true; + } + }); + + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'replace') { + alternativeEndTime = player.getCurrentTime(); + backToOriginalDetected = true; + clearTimeout(timeout); + + // Wait to ensure stability + setTimeout(() => { + expect(eventTriggered).to.be.true; + expect(alternativeContentDetected).to.be.true; + expect(backToOriginalDetected).to.be.true; + + // With clip="true", alternative should terminate at PRT + maxDuration + const expectedTerminationTime = expectedPresentationTime + expectedMaxDuration; + expect(alternativeEndTime).to.be.closeTo(expectedTerminationTime, 0.5); + done(); + }, 1000); // Wait for VOD content stability + } + }); + + // Perform seek forward to simulate delay after player starts + player.registerEvent('playbackStarted', () => { + if (!seekPerformed) { + seekPerformed = true; + + // Wait a moment for stable playback, then seek forward + setTimeout(() => { + const seekTime = 7; // Seek to 7 seconds - event should have started at 5s + player.seek(seekTime); + }, 2000); + } + }); + + // Handle errors + player.registerEvent('error', (e) => { + clearTimeout(timeout); + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }, 35000); + + }); +}); \ No newline at end of file diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-executeOnce.js b/test/functional/test/feature-support/alternative/alternative-mpd-executeOnce.js new file mode 100644 index 0000000000..0b66fed64a --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-executeOnce.js @@ -0,0 +1,252 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +/** + * Utility function to modify a manifest by setting executeOnce to a specific value + * Also stores the original value for cleanup purposes + */ +function injectExecuteOnce(player, manifestUrl, executeOnceValue, callback) { + const mediaPlayer = player.player; + let originalValue = null; + + mediaPlayer.retrieveManifest(manifestUrl, (manifest) => { + // Find the EventStream with the insert event + if (manifest.Period && manifest.Period[0] && manifest.Period[0].EventStream) { + manifest.Period[0].EventStream.forEach((eventStream) => { + if (eventStream.schemeIdUri === 'urn:mpeg:dash:event:alternativeMPD:insert:2025' && eventStream.Event) { + eventStream.Event.forEach((event) => { + if (event.InsertPresentation) { + // Store original value for cleanup + originalValue = event.InsertPresentation.executeOnce; + // Set executeOnce to the desired value + event.InsertPresentation.executeOnce = executeOnceValue; + } + }); + } + }); + } + + // Attach the modified manifest + mediaPlayer.attachSource(manifest); + + if (callback) { + callback(originalValue); + } + }); +} + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-executeOnce').forEach((item) => { + const name = item.name; + const url = item.url; + + describe(`Alternative MPD executeOnce functionality tests for: ${name}`, () => { + + let player; + + before(() => { + player = initializeDashJsAdapterForAlternativMedia(item, url); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should execute insert event only once, even after seek backwards', (done) => { + const videoElement = player.getVideoElement(); + let firstEventTriggered = false; + let firstAlternativeContentDetected = false; + let firstBackToOriginalDetected = false; + let secondEventTriggered = false; + let secondAlternativeContentDetected = false; + let timeBeforeFirstSwitch = 0; + let eventTriggerCount = 0; + let alternativeContentStartCount = 0; + + const timeout = setTimeout(() => { + done(new Error('Test timed out - executeOnce validation not completed within 35 seconds')); + }, 35000); // Extended timeout for seek operations + + // Listen for alternative MPD INSERT events + player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.INSERT, () => { + eventTriggerCount++; + if (!firstEventTriggered) { + firstEventTriggered = true; + timeBeforeFirstSwitch = videoElement.currentTime; + } else { + secondEventTriggered = true; + } + }); + + // Listen for alternative content start events + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'insert') { + alternativeContentStartCount++; + if (!firstAlternativeContentDetected) { + firstAlternativeContentDetected = true; + } else { + secondAlternativeContentDetected = true; + } + } + }); + + // Listen for alternative content end events + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'insert' && !firstBackToOriginalDetected) { + firstBackToOriginalDetected = true; + + // Wait a moment for stability, then seek backwards to before the event presentation time + setTimeout(() => { + const seekTime = timeBeforeFirstSwitch - 2; // Seek 2 seconds before the original event + player.seek(seekTime); + + // Wait for seek to complete and playback to progress past the event time again + setTimeout(() => { + // Wait additional time to see if event retriggers + setTimeout(() => { + clearTimeout(timeout); + + // Basic test validations + expect(firstEventTriggered).to.be.true; + expect(firstAlternativeContentDetected).to.be.true; + expect(firstBackToOriginalDetected).to.be.true; + + // ExecuteOnce validations - event should NOT retrigger + expect(eventTriggerCount).to.equal(1); + expect(alternativeContentStartCount).to.equal(1); + expect(secondEventTriggered).to.be.false; + expect(secondAlternativeContentDetected).to.be.false; + done(); + }, 8000); // Wait 8 seconds to verify no retriggering + }, 3000); // Wait 3 seconds for seek to complete + }, 2000); // Wait 2 seconds after alternative content ends + } + }); + + // Handle errors + player.registerEvent('error', (e) => { + console.error('Player error:', e); + }); + + + }, 40000); // 40 second timeout for this complex test + + it('should execute insert event multiple times when executeOnce=false, even after seek backwards', (done) => { + // Create a new player instance for this test with modified manifest + let testPlayer; + + const cleanup = () => { + if (testPlayer) { + // Restore original executeOnce value to true + injectExecuteOnce(testPlayer, url, 'true', () => { + testPlayer.destroy(); + }); + } + }; + + const initPlayerWithModifiedManifest = () => { + testPlayer = initializeDashJsAdapterForAlternativMedia(item, null); + + injectExecuteOnce(testPlayer, url, 'false', () => { + runExecuteOnceFalseTest(); + }); + }; + + const runExecuteOnceFalseTest = () => { + const videoElement = testPlayer.getVideoElement(); + let firstEventTriggered = false; + let firstAlternativeContentDetected = false; + let firstBackToOriginalDetected = false; + let secondEventTriggered = false; + let secondAlternativeContentDetected = false; + let timeBeforeFirstSwitch = 0; + let eventTriggerCount = 0; + let alternativeContentStartCount = 0; + + const timeout = setTimeout(() => { + cleanup(); + done(new Error('Test timed out - executeOnce=false validation not completed within 45 seconds')); + }, 45000); + + // Listen for alternative MPD INSERT events + testPlayer.registerEvent(Constants.ALTERNATIVE_MPD.URIS.INSERT, () => { + eventTriggerCount++; + if (!firstEventTriggered) { + firstEventTriggered = true; + timeBeforeFirstSwitch = videoElement.currentTime; + } else if (!secondEventTriggered) { + secondEventTriggered = true; + } + }); + + // Listen for alternative content start events + testPlayer.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'insert') { + alternativeContentStartCount++; + if (!firstAlternativeContentDetected) { + firstAlternativeContentDetected = true; + } else if (!secondAlternativeContentDetected) { + secondAlternativeContentDetected = true; + } + } + }); + + // Listen for alternative content end events + testPlayer.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'insert') { + if (!firstBackToOriginalDetected) { + firstBackToOriginalDetected = true; + + // Wait a moment for stability, then seek backwards to before the event presentation time + setTimeout(() => { + const seekTime = timeBeforeFirstSwitch - 2; + testPlayer.seek(seekTime); + + // Wait for seek to complete and playback to progress past the event time again + setTimeout(() => { + // Wait additional time to allow second event to trigger + setTimeout(() => { + clearTimeout(timeout); + + // Basic test validations + expect(firstEventTriggered).to.be.true; + expect(firstAlternativeContentDetected).to.be.true; + expect(firstBackToOriginalDetected).to.be.true; + + // ExecuteOnce=false validations - event SHOULD retrigger + expect(eventTriggerCount).to.be.at.least(2); + expect(alternativeContentStartCount).to.be.at.least(2); + expect(secondEventTriggered).to.be.true; + expect(secondAlternativeContentDetected).to.be.true; + + cleanup(); + done(); + }, 10000); // Wait 10 seconds to allow second event to complete + }, 3000); + }, 2000); + } + } + }); + + // Handle errors + testPlayer.registerEvent('error', (e) => { + console.error('Player error:', e); + clearTimeout(timeout); + if (testPlayer) { + testPlayer.destroy(); + } + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }; + + // Initialize the test + initPlayerWithModifiedManifest(); + + }, 50000); // 50 second timeout for this complex test + + }); +}); \ No newline at end of file diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-insert-vod.js b/test/functional/test/feature-support/alternative/alternative-mpd-insert-vod.js new file mode 100644 index 0000000000..cced906e26 --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-insert-vod.js @@ -0,0 +1,96 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +// Executes with: +// test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-vod.mpd +// test/functional/content/alternative-mpd/alternative-mpd-insert-vod-to-live.mpd + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-insert-vod').forEach((item) => { + const name = item.name; + const url = item.url; + + describe(`Alternative MPD Insert functionality tests for: ${name}`, () => { + + let player; + + before(() => { + player = initializeDashJsAdapterForAlternativMedia(item, url); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should play original content, insert alternative content, then resume original at same position', (done) => { + const videoElement = player.getVideoElement(); + let alternativeContentDetected = false; + let backToOriginalDetected = false; + let eventTriggered = false; + let timeBeforeSwitch = 0; + let timeAfterSwitch = 0; + let alternativeStartTime = 0; + let alternativeEndTime = 0; + let expectedAlternativeDuration = 0; + + const timeout = setTimeout(() => { + done(new Error('Test timed out - alternative MPD insert event not completed within 25 seconds')); + }, 25000); + + // Listen for alternative MPD INSERT events + player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.INSERT, () => { + eventTriggered = true; + timeBeforeSwitch = videoElement.currentTime; + // Validate that timeBeforeSwitch is close to presentation time + expect(timeBeforeSwitch).to.be.closeTo(5, 1); + }); + + // Listen for alternative content start event + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'insert') { + alternativeContentDetected = true; + alternativeStartTime = Date.now(); + expectedAlternativeDuration = data.event.duration; + } + }); + + // Listen for alternative content end event + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'insert') { + timeAfterSwitch = videoElement.currentTime; + alternativeEndTime = Date.now(); + backToOriginalDetected = true; + clearTimeout(timeout); + + // Wait to ensure stability + setTimeout(() => { + expect(eventTriggered).to.be.true; + expect(alternativeContentDetected).to.be.true; + expect(backToOriginalDetected).to.be.true; + + // Verify that alternative content played for its full duration + const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime) / 1000; // Convert to seconds + expect(actualAlternativeDuration).to.be.at.least(expectedAlternativeDuration - 1); // Allow 1 second tolerance + expect(actualAlternativeDuration).to.be.at.most(expectedAlternativeDuration + 1.5); // Allow 1 second tolerance + + // For INSERT mode, it expects to return close to the original presentation time + expect(Math.abs(timeAfterSwitch - timeBeforeSwitch)).to.be.below(1); + done(); + }, 2000); + } + }); + + + // Handle errors + player.registerEvent('error', (e) => { + clearTimeout(timeout); + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }, 35000); + + }); +}); \ No newline at end of file diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-replace-live.js b/test/functional/test/feature-support/alternative/alternative-mpd-replace-live.js new file mode 100644 index 0000000000..fb4fb76c83 --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-replace-live.js @@ -0,0 +1,153 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +/** + * Utility function to modify a live manifest by injecting Alternative MPD events + * This simulates the functionality from the demo.html tool for live to VOD and live to live scenarios + */ +function injectAlternativeMpdEvents(player, originalManifestUrl, alternativeManifestUrl, presentationTime, callback) { + // Access the underlying MediaPlayer instance + const mediaPlayer = player.player; + + mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => { + manifest.Period[0].EventStream = []; + + // Use the provided presentation time + const duration = 9000; + const earliestResolutionTimeOffset = 5000; + const maxDuration = 9000; + + // Create the replace event + const replaceEvent = { + schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025', + timescale: 1000, + Event: [{ + id: 1, + presentationTime: presentationTime, + duration: duration, + ReplacePresentation: { + url: alternativeManifestUrl, + earliestResolutionTimeOffset: earliestResolutionTimeOffset, + maxDuration: maxDuration, + clip: false, + } + }] + }; + + // Add the event to the manifest + manifest.Period[0].EventStream.push(replaceEvent); + + // Attach the modified manifest using the MediaPlayer directly + mediaPlayer.attachSource(manifest); + + if (callback) { + callback(); + } + }); +} + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-replace-live').forEach((item) => { + const name = item.name; + const originalUrl = item.originalUrl; + const alternativeUrl = item.alternativeUrl; + + describe(`Alternative MPD Replace Live functionality tests for: ${name}`, () => { + + let player; + let presentationTime; + + before((done) => { + // Initialize the player without attaching source immediately + player = initializeDashJsAdapterForAlternativMedia(item, null); + + // Calculate presentation time for live content + // For live streams, use current time + offset to ensure the event is in the future + const currentPresentationTime = Date.now(); + presentationTime = currentPresentationTime + 4000; // 4 seconds from now + + // Use the utility function to inject Alternative MPD events + injectAlternativeMpdEvents(player, originalUrl, alternativeUrl, presentationTime, () => { + done(); + }); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should play live content, switch to alternative content, then back to live content', (done) => { + const videoElement = player.getVideoElement(); + let alternativeContentDetected = false; + let backToOriginalDetected = false; + let eventTriggered = false; + let timeAfterSwitch = 0; + let alternativeStartTime = 0; + let alternativeEndTime = 0; + let expectedAlternativeDuration = 0; + + + const timeout = setTimeout(() => { + done(new Error('Test timed out - alternative MPD replace event not completed within 30 seconds')); + }, 30000); + + // Listen for alternative MPD REPLACE events + player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => { + eventTriggered = true; + }); + + // Listen for alternative content start event + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'replace') { + alternativeContentDetected = true; + alternativeStartTime = Date.now(); + expectedAlternativeDuration = data.event.duration; + const latency = player.getCurrentLiveLatency(); + if (!isNaN(latency)){ //prevents execution if player is not loaded yet + let expectedStartTimeWithLatency = presentationTime + (latency * 1000); + expect(alternativeStartTime).to.be.closeTo(expectedStartTimeWithLatency, 1000); // Allow tolerance + } + } + }); + + // Listen for alternative content end event + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'replace') { + timeAfterSwitch = videoElement.currentTime; + alternativeEndTime = Date.now(); + backToOriginalDetected = true; + clearTimeout(timeout); + + // Wait to ensure stability + setTimeout(() => { + expect(eventTriggered).to.be.true; + expect(alternativeContentDetected).to.be.true; + expect(backToOriginalDetected).to.be.true; + + // Verify that alternative content played for its full duration + const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime) / 1000; // Convert to seconds + expect(actualAlternativeDuration).to.be.closeTo(expectedAlternativeDuration, 1.5); // Allow 1 second tolerance + + // For REPLACE mode from live to VOD, verify timing behavior + // The expected behavior is to return at presentationTime + duration or returnOffset + const expectedMinTime = data.event.presentationTime + data.event.duration; + expect(timeAfterSwitch).to.be.closeTo(expectedMinTime, 2); // Allow 2 seconds tolerance for live content + done(); + }, 2000); + } + }); + + + // Handle errors + player.registerEvent('error', (e) => { + clearTimeout(timeout); + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }, 40000); // Extended timeout for live content + + }); +}); \ No newline at end of file diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-replace-vod.js b/test/functional/test/feature-support/alternative/alternative-mpd-replace-vod.js new file mode 100644 index 0000000000..2a58e1037f --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-replace-vod.js @@ -0,0 +1,94 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +// Executes with: +// test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-vod.mpd +// test/functional/content/alternative-mpd/alternative-mpd-replace-vod-to-live.mpd + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-replace-vod').forEach((item) => { + const name = item.name; + const url = item.url; + + describe(`Alternative MPD Replace functionality tests for: ${name}`, () => { + + let player; + + before(() => { + player = initializeDashJsAdapterForAlternativMedia(item, url); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should play original content, switch to alternative content, then back to original', (done) => { + const videoElement = player.getVideoElement(); + let alternativeContentDetected = false; + let backToOriginalDetected = false; + let eventTriggered = false; + let timeAfterSwitch = 0; + let alternativeStartTime = 0; + let alternativeEndTime = 0; + let expectedAlternativeDuration = 0; + + const timeout = setTimeout(() => { + done(new Error('Test timed out - alternative MPD replace event not completed within 25 seconds')); + }, 25000); // 25 seconds should be enough for full test + + // Listen for alternative MPD REPLACE events + player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => { + eventTriggered = true; + }); + + // Listen for alternative content start event + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'replace') { + alternativeContentDetected = true; + alternativeStartTime = player.getCurrentTime(); + expectedAlternativeDuration = data.event.duration; + // Validate that alternativeStartTime is close to presentation time + expect(alternativeStartTime).to.be.closeTo(5, 1); + } + }); + + // Listen for alternative content end event + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'replace') { + timeAfterSwitch = videoElement.currentTime; + alternativeEndTime = player.getCurrentTime(); + backToOriginalDetected = true; + clearTimeout(timeout); + + // Wait to ensure stability + setTimeout(() => { + expect(eventTriggered).to.be.true; + expect(alternativeContentDetected).to.be.true; + expect(backToOriginalDetected).to.be.true; + + // Verify that alternative content played for its full duration + const actualAlternativeDuration = alternativeEndTime - alternativeStartTime; // Both are in seconds now + expect(actualAlternativeDuration).to.be.at.least(expectedAlternativeDuration - 1); // Allow 1 second tolerance + expect(actualAlternativeDuration).to.be.at.most(expectedAlternativeDuration + 1.5); // Allow 1.5 second tolerance + + // For REPLACE mode, it expectes to return on presentationTime + duration + const expectedMinTime = data.event.presentationTime + data.event.duration; + expect(timeAfterSwitch).equals(expectedMinTime); + done(); + }, 2000); + } + }); + + // Handle errors + player.registerEvent('error', (e) => { + clearTimeout(timeout); + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }, 30000); + + }); +}); \ No newline at end of file diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-returnOffset.js b/test/functional/test/feature-support/alternative/alternative-mpd-returnOffset.js new file mode 100644 index 0000000000..a8a9728530 --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-returnOffset.js @@ -0,0 +1,119 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +/** + * Utility function to modify a live manifest by injecting Alternative MPD events with returnOffset + */ +function injectAlternativeMpdEventsWithReturnOffset(player, originalManifestUrl, alternativeManifestUrl, presentationTime, callback) { + // Access the underlying MediaPlayer instance + const mediaPlayer = player.player; + + mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => { + manifest.Period[0].EventStream = []; + + const duration = 8000; + const earliestResolutionTimeOffset = 5000; + const maxDuration = 8000; + const returnOffset = 3000; + + const replaceEvent = { + schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025', + timescale: 1000, + Event: [{ + id: 1, + presentationTime: presentationTime, + duration: duration, + ReplacePresentation: { + url: alternativeManifestUrl, + earliestResolutionTimeOffset: earliestResolutionTimeOffset, + maxDuration: maxDuration, + returnOffset: returnOffset, + clip: false, + } + }] + }; + + manifest.Period[0].EventStream.push(replaceEvent); + + mediaPlayer.attachSource(manifest); + + if (callback) { + callback(); + } + }); +} + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-returnOffset').forEach((item) => { + const name = item.name; + const originalUrl = item.originalUrl; + const alternativeUrl = item.alternativeUrl; + + describe(`Alternative MPD returnOffset functionality tests for: ${name}`, () => { + + let player; + let presentationTime; + + before((done) => { + player = initializeDashJsAdapterForAlternativMedia(item, null); + + const currentPresentationTime = Date.now(); + presentationTime = currentPresentationTime + 4000; // 4 seconds from now + + // Use the utility function to inject Alternative MPD events with returnOffset + injectAlternativeMpdEventsWithReturnOffset(player, originalUrl, alternativeUrl, presentationTime, () => { + done(); + }); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should return to main content at correct time based on returnOffset', (done) => { + const videoElement = player.getVideoElement(); + let backToOriginalDetected = false; + let timeAfterSwitch = 0; + let eventPresentationTime = 0; + let eventReturnOffset = 0; + + const timeout = setTimeout(() => { + done(new Error('Test timed out - returnOffset test not completed within 35 seconds')); + }, 35000); + + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'replace' && data.event.returnOffset !== undefined) { + eventPresentationTime = data.event.presentationTime; + eventReturnOffset = data.event.returnOffset; + backToOriginalDetected = true; + clearTimeout(timeout); + + // Wait for playback to stabilize + setTimeout(() => { + timeAfterSwitch = videoElement.currentTime; + expect(backToOriginalDetected).to.be.true; + + // RT = PRT + returnOffset (where PRT is presentationTime) + const expectedReturnTime = eventPresentationTime + eventReturnOffset; + + // Allow 2 seconds tolerance for live content timing variations + expect(timeAfterSwitch).to.be.closeTo(expectedReturnTime, 2); + + done(); + }, 2000); + } + }); + + // Handle errors + player.registerEvent('error', (e) => { + clearTimeout(timeout); + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }, 40000); // Extended timeout for live content + + }); +}); \ No newline at end of file diff --git a/test/functional/test/feature-support/alternative/alternative-mpd-status-update-live.js b/test/functional/test/feature-support/alternative/alternative-mpd-status-update-live.js new file mode 100644 index 0000000000..c437cc1282 --- /dev/null +++ b/test/functional/test/feature-support/alternative/alternative-mpd-status-update-live.js @@ -0,0 +1,205 @@ +import Constants from '../../../../../src/streaming/constants/Constants.js'; +import Utils from '../../../src/Utils.js'; +import { initializeDashJsAdapterForAlternativMedia } from '../../common/common.js'; +import { expect } from 'chai'; + +/** + * Utility function to modify a live manifest by injecting Alternative MPD events without maxDuration + * This simulates the initial event that starts alternative content playback without a preset duration limit + */ +function injectInitialAlternativeMpdEvent(player, originalManifestUrl, alternativeManifestUrl, presentationTime, callback) { + const mediaPlayer = player.player; + + mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => { + manifest.Period[0].EventStream = []; + + const duration = 15000; // 15 seconds default duration + const earliestResolutionTimeOffset = 3000; + const uniqueEventId = Math.floor(presentationTime / 1000); // Use timestamp-based unique ID + + // Create the replace event WITHOUT maxDuration initially + const replaceEvent = { + schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025', + timescale: 1000, + Event: [{ + id: uniqueEventId, + presentationTime: presentationTime, + duration: duration, + ReplacePresentation: { + url: alternativeManifestUrl, + earliestResolutionTimeOffset: earliestResolutionTimeOffset, + // NOTE: No maxDuration set initially + clip: false, + } + }] + }; + + manifest.Period[0].EventStream.push(replaceEvent); + mediaPlayer.attachSource(manifest); + + if (callback) { + callback(); + } + }); +} + +/** + * Utility function to inject a status update event with maxDuration during active playback + * This simulates the status="update" scenario where maxDuration is added mid-execution + * Instead of modifying the manifest, we inject the event via manifest update simulation + */ +function injectStatusUpdateEvent(player, originalManifestUrl, alternativeManifestUrl, presentationTime, newMaxDuration, callback) { + const mediaPlayer = player.player; + + // Status updates should be processed like MPD updates + // So we simulate an MPD update that contains the status="update" event + mediaPlayer.retrieveManifest(originalManifestUrl, (manifest) => { + // Keep existing EventStreams and add the status update + if (!manifest.Period[0].EventStream) { + manifest.Period[0].EventStream = []; + } + + const duration = 15000; // Keep same duration + const earliestResolutionTimeOffset = 3000; + const uniqueEventId = Math.floor(presentationTime / 1000); // Same ID as original event + + // Create the status update event that will update the existing event + // This event should have the same ID as the original event but with status="update" + const statusUpdateEvent = { + schemeIdUri: 'urn:mpeg:dash:event:alternativeMPD:replace:2025', + timescale: 1000, + Event: [{ + id: uniqueEventId, // Same ID as the original event to update it + presentationTime: presentationTime, + duration: duration, + status: 'update', // This marks it as an update event + ReplacePresentation: { + url: alternativeManifestUrl, + earliestResolutionTimeOffset: earliestResolutionTimeOffset, + maxDuration: newMaxDuration, // NEW: Add maxDuration via status update + clip: false, + } + }] + }; + + let existingEventStream = manifest.Period[0].EventStream.find( + stream => stream.schemeIdUri === 'urn:mpeg:dash:event:alternativeMPD:replace:2025' + ); + + if (existingEventStream) { + // Add the status update event to the existing EventStream + existingEventStream.Event.push(statusUpdateEvent.Event[0]); + } else { + // Add as a new EventStream (this creates the update scenario) + manifest.Period[0].EventStream.push(statusUpdateEvent); + } + + // Re-attach the modified manifest to trigger processing of the status update + mediaPlayer.attachSource(manifest); + + if (callback) { + callback(); + } + }); +} + +Utils.getTestvectorsForTestcase('feature-support/alternative/alternative-mpd-status-update-live').forEach((item) => { + const name = item.name; + const originalUrl = item.originalUrl; + const alternativeUrl = item.alternativeUrl; + + describe(`Alternative MPD Status Update Live functionality tests for: ${name}`, () => { + + let player; + let presentationTime; + let newMaxDuration; + + before((done) => { + player = initializeDashJsAdapterForAlternativMedia(item, null); + + // For live streams, use current time + offset to ensure the event is in the future + const currentPresentationTime = Date.now(); + presentationTime = currentPresentationTime + 6000; // 6 seconds from now (longer to avoid timing issues) + newMaxDuration = 8000; // 8 seconds - shorter than original duration + + injectInitialAlternativeMpdEvent(player, originalUrl, alternativeUrl, presentationTime, () => { + done(); + }); + }); + + after(() => { + if (player) { + player.destroy(); + } + }); + + it('should start alternative content without maxDuration, then update with maxDuration via status update and terminate early', (done) => { + let alternativeContentDetected = false; + let statusUpdateApplied = false; + let backToOriginalDetected = false; + let eventTriggered = false; + let alternativeStartTime = 0; + let alternativeEndTime = 0; + let updatedMaxDuration = null; + + const timeout = setTimeout(() => { + done(new Error('Test timed out - status update live event not completed within 35 seconds')); + }, 35000); + + player.registerEvent(Constants.ALTERNATIVE_MPD.URIS.REPLACE, () => { + eventTriggered = true; + }); + + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_START, (data) => { + if (data.event.mode === 'replace') { + alternativeContentDetected = true; + alternativeStartTime = Date.now(); + + setTimeout(() => { + injectStatusUpdateEvent(player, originalUrl, alternativeUrl, presentationTime, newMaxDuration, () => { + statusUpdateApplied = true; + }); + }, 3000); + } + }); + + player.registerEvent(Constants.ALTERNATIVE_MPD.CONTENT_END, (data) => { + if (data.event.mode === 'replace') { + alternativeEndTime = Date.now(); + backToOriginalDetected = true; + updatedMaxDuration = data.event.maxDuration; + + clearTimeout(timeout); + + const actualAlternativeDuration = (alternativeEndTime - alternativeStartTime) / 1000; + + // Wait to ensure stability + setTimeout(() => { + expect(eventTriggered).to.be.true; + expect(alternativeContentDetected).to.be.true; + expect(statusUpdateApplied).to.be.true; + expect(backToOriginalDetected).to.be.true; + + // Verify that the status update was applied and maxDuration was set + const expectedMaxDurationInSeconds = newMaxDuration / 1000; + expect(updatedMaxDuration).to.equal(expectedMaxDurationInSeconds); + + // Verify that alternative content terminated early due to maxDuration from status update + expect(actualAlternativeDuration).to.be.lessThan(10); // Much less than original 15s + expect(actualAlternativeDuration).to.be.lessThan(12); + + done(); + }, 2000); + } + }); + + // Handle errors + player.registerEvent('error', (e) => { + clearTimeout(timeout); + done(new Error(`Player error: ${JSON.stringify(e)}`)); + }); + + }, 45000); // Extended timeout for live content with status updates + + }); +}); \ No newline at end of file diff --git a/test/functional/view/index.html b/test/functional/view/index.html index 585786db6e..22faabecdb 100644 --- a/test/functional/view/index.html +++ b/test/functional/view/index.html @@ -14,6 +14,7 @@
+