diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1e00f4a70f..79261a5bafd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -279,6 +279,9 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release_npm: + # npm oidc is configured for this environment + # https://docs.npmjs.com/trusted-publishers#for-github-actions + environment: release needs: [config, test_unit] if: needs.config.outputs.tag || needs.config.outputs.isMainBranch == 'true' runs-on: ubuntu-latest @@ -318,12 +321,15 @@ jobs: env: CI: true + # Publishing with OICD needs >= 11.5.1 + - name: Update npm to v11 + run: npm install -g npm@11 + - name: publish to npm run: | ./scripts/publish-npm.sh env: CI: true - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} TAG: ${{ needs.config.outputs.tag }} test_functional_required: diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index c88337b5f27..4e3f96ef31e 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -3355,11 +3355,15 @@ export class LevelDetails { export class LevelKey implements DecryptData { constructor(method: string, uri: string, format: string, formatversions?: number[], iv?: Uint8Array | null, keyId?: string); // (undocumented) + static addKeyIdForUri(uri: string): Uint8Array; + // (undocumented) static clearKeyUriToKeyIdMap(): void; // (undocumented) readonly encrypted: boolean; // (undocumented) - getDecryptData(sn: number | 'initSegment'): LevelKey | null; + getDecryptData(sn: number | 'initSegment', levelKeys?: { + [key: string]: LevelKey | undefined; + }): LevelKey | null; // (undocumented) readonly isCommonEncryption: boolean; // (undocumented) diff --git a/package-lock.json b/package-lock.json index 1ea07ed9f98..254e34253fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hls.js", - "version": "1.6.14", + "version": "1.6.16", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hls.js", - "version": "1.6.14", + "version": "1.6.16", "license": "Apache-2.0", "devDependencies": { "@babel/core": "7.28.0", diff --git a/package.json b/package.json index 32e458148c8..554f25eab48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hls.js", - "version": "1.6.14", + "version": "1.6.16", "license": "Apache-2.0", "description": "JavaScript HLS client using MediaSourceExtension", "homepage": "https://github.com/video-dev/hls.js", diff --git a/scripts/publish-npm.sh b/scripts/publish-npm.sh index fd95fb16c46..6f1e3cb06cd 100755 --- a/scripts/publish-npm.sh +++ b/scripts/publish-npm.sh @@ -2,9 +2,6 @@ set -e if [[ $(node ./scripts/check-already-published.js) = "not published" ]]; then - # write the token to config - # see https://docs.npmjs.com/private-modules/ci-server-config - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc if [[ -z "$TAG" ]]; then npm publish --provenance --tag canary echo "Published canary." diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index f00b94a1a2a..52896f65b50 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -974,7 +974,8 @@ export default class BaseStreamController this.state = State.FRAG_LOADING; // Load key before streaming fragment data - const dataOnProgress = this.config.progressive; + const dataOnProgress = + this.config.progressive && frag.type !== PlaylistLevelType.SUBTITLE; let result: Promise; if (dataOnProgress && keyLoadingPromise) { result = keyLoadingPromise @@ -2149,11 +2150,15 @@ export default class BaseStreamController false, ); if (!parsed) { - if (level.fragmentError === 0) { - // Mark and track the odd empty segment as a gap to avoid reloading + const mediaNotFound = this.transmuxer?.error === null; + if ( + level.fragmentError === 0 || + (mediaNotFound && (level.fragmentError < 2 || frag.endList)) + ) { + // Mark and track the odd (or last) empty segment as a gap to avoid reloading this.treatAsGap(frag, level); } - if (this.transmuxer?.error === null) { + if (mediaNotFound) { const error = new Error( `Found no media in fragment ${frag.sn} of ${this.playlistLabel()} ${frag.level} resetting transmuxer to fallback to playlist timing`, ); diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index 388e0dc33ed..1836e7bb010 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -664,6 +664,18 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe if (this.sourceBufferCount) { return; } + + if ( + this.bufferCodecEventsTotal > 1 && + !this.tracks.video && + !data.video && + data.audio?.id === 'main' + ) { + // MVP is missing CODECS and only audio was found in main segment (#7524) + this.log(`Main audio-only`); + this.bufferCodecEventsTotal = 1; + } + if (this.mediaSourceOpenOrEnded) { this.checkPendingTracks(); } diff --git a/src/controller/eme-controller.ts b/src/controller/eme-controller.ts index a4ea86f8854..54902c85cd2 100644 --- a/src/controller/eme-controller.ts +++ b/src/controller/eme-controller.ts @@ -83,6 +83,7 @@ class EMEController extends Logger implements ComponentAPI { keyLoadPolicy: LoadPolicy; }; private media: HTMLMediaElement | null = null; + private mediaResolved?: () => void; private keyFormatPromise: Promise | null = null; private keySystemAccessPromises: { [keysystem: string]: KeySystemAccessPromises | undefined; @@ -704,6 +705,7 @@ class EMEController extends Logger implements ComponentAPI { keySystem: KeySystems, mediaKeys: MediaKeys, ): Promise { + this.mediaResolved = undefined; if (this.mediaKeys === mediaKeys) { return Promise.resolve(); } @@ -714,10 +716,20 @@ class EMEController extends Logger implements ComponentAPI { // can be queued for execution for multiple key sessions. const setMediaKeysPromise = Promise.all(queue).then(() => { if (!this.media) { - this.mediaKeys = null; - throw new Error( - 'Attempted to set mediaKeys without media element attached', - ); + return new Promise((resolve: (value?: void) => void, reject) => { + this.mediaResolved = () => { + this.mediaResolved = undefined; + if (!this.media) { + return reject( + new Error( + 'Attempted to set mediaKeys without media element attached', + ), + ); + } + this.mediaKeys = mediaKeys; + this.media.setMediaKeys(mediaKeys).then(resolve).catch(reject); + }; + }); } return this.media.setMediaKeys(mediaKeys); }); @@ -877,7 +889,7 @@ class EMEController extends Logger implements ComponentAPI { handleKeyStatus(keyStatus, context); } else { // Timeout key-status - const timeout = 0; + const timeout = 1000; context.keyStatusTimeouts ||= {}; context.keyStatusTimeouts[keyId] ||= self.setTimeout(() => { if ((!context.mediaKeysSession as any) || !this.mediaKeys) { @@ -968,10 +980,17 @@ class EMEController extends Logger implements ComponentAPI { 'buffer' in keyId ? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength) : new Uint8Array(keyId); + if ( mediaKeySessionContext.keySystem === KeySystems.PLAYREADY && keyIdArray.length === 16 ) { + // On some devices, the key ID has already been converted for endianness. + // In such cases, this key ID is the one we need to cache. + const originKeyIdWithStatusChange = arrayToHex(keyIdArray); + // Cache the original key IDs to ensure compatibility across all cases. + keyStatuses[originKeyIdWithStatusChange] = status; + changeEndianness(keyIdArray); } const keyIdWithStatusChange = arrayToHex(keyIdArray); @@ -982,6 +1001,7 @@ class EMEController extends Logger implements ComponentAPI { this.log( `key status change "${status}" for keyStatuses keyId: ${keyIdWithStatusChange} key-session "${mediaKeySessionContext.mediaKeysSession.sessionId}"`, ); + keyStatuses[keyIdWithStatusChange] = status; }, ); @@ -1344,6 +1364,13 @@ class EMEController extends Logger implements ComponentAPI { addEventListener(media, 'encrypted', this.onMediaEncrypted); addEventListener(media, 'waitingforkey', this.onWaitingForKey); + + const mediaResolved = this.mediaResolved; + if (mediaResolved) { + mediaResolved(); + } else { + this.mediaKeys = media.mediaKeys; + } } private onMediaDetached() { @@ -1361,6 +1388,10 @@ class EMEController extends Logger implements ComponentAPI { this._requestLicenseFailureCount = 0; this.keyIdToKeySessionPromise = {}; this.bannedKeyIds = {}; + const mediaResolved = this.mediaResolved; + if (mediaResolved) { + mediaResolved(); + } if (!this.mediaKeys && !this.mediaKeySessions.length) { return; } @@ -1414,8 +1445,7 @@ class EMEController extends Logger implements ComponentAPI { } private onManifestLoading() { - this.keyFormatPromise = null; - this.bannedKeyIds = {}; + this._clear(); } private onManifestLoaded( diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index af3b0583751..3e5b5a2e43a 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -231,12 +231,7 @@ export class FragmentTracker implements ComponentAPI { }); fragmentEntity.loaded = null; if (Object.keys(fragmentEntity.range).length) { - fragmentEntity.buffered = true; - const endList = (fragmentEntity.body.endList = - frag.endList || fragmentEntity.body.endList); - if (endList) { - this.endListFragments[fragmentEntity.body.type] = fragmentEntity; - } + this.bufferedEnd(fragmentEntity, frag); if (!isPartial(fragmentEntity)) { // Remove older fragment parts from lookup after frag is tracked as buffered this.removeParts(frag.sn - 1, frag.type); @@ -247,6 +242,15 @@ export class FragmentTracker implements ComponentAPI { } } + private bufferedEnd(fragmentEntity: FragmentEntity, frag: MediaFragment) { + fragmentEntity.buffered = true; + const endList = (fragmentEntity.body.endList = + frag.endList || fragmentEntity.body.endList); + if (endList) { + this.endListFragments[fragmentEntity.body.type] = fragmentEntity; + } + } + private removeParts(snToKeep: number, levelType: PlaylistLevelType) { const activeParts = this.activePartLists[levelType]; if (!activeParts) { @@ -275,7 +279,7 @@ export class FragmentTracker implements ComponentAPI { } if (fragmentEntity) { fragmentEntity.loaded = null; - fragmentEntity.buffered = true; + this.bufferedEnd(fragmentEntity, frag); } } diff --git a/src/controller/interstitials-controller.ts b/src/controller/interstitials-controller.ts index abe29884b49..31daace8792 100644 --- a/src/controller/interstitials-controller.ts +++ b/src/controller/interstitials-controller.ts @@ -81,7 +81,7 @@ export type PlayheadTimes = { }; function playWithCatch(media: HTMLMediaElement | null) { - media?.play().catch(() => { + (media?.play() as Promise | undefined)?.catch(() => { /* no-op */ }); } @@ -968,7 +968,8 @@ export default class InterstitialsController currentTime >= start + duration ) { if (playingItem.event?.appendInPlace) { - this.clearInterstitial(playingItem.event, playingItem); + // Return SourceBuffer(s) to primary player and flush + this.clearAssetPlayers(playingItem.event, playingItem); this.flushFrontBuffer(currentTime); } this.setScheduleToAssetAtTime(currentTime, playingAsset); @@ -1533,6 +1534,9 @@ export default class InterstitialsController return; } const main = this.hls.levels[data.level]; + if (!main.details) { + return; + } const currentSelection = { ...(this.mediaSelection || this.altSelection), main, @@ -2475,12 +2479,19 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli private clearInterstitial( interstitial: InterstitialEvent, toSegment: InterstitialScheduleItem | null, + ) { + this.clearAssetPlayers(interstitial, toSegment); + // Remove asset list and resolved duration + interstitial.reset(); + } + + private clearAssetPlayers( + interstitial: InterstitialEvent, + toSegment: InterstitialScheduleItem | null, ) { interstitial.assetList.forEach((asset) => { this.clearAssetPlayer(asset.identifier, toSegment); }); - // Remove asset list and resolved duration - interstitial.reset(); } private resetAssetPlayer(assetId: InterstitialAssetId) { @@ -2738,16 +2749,16 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli // Fallback to Primary by on current or future events by updating schedule to skip errored interstitials/assets const flushStart = interstitial.timelineStart; const playingItem = this.effectivePlayingItem; + let timelinePos = this.timelinePos; // Update schedule now that interstitial/assets are flagged with `error` for fallback if (playingItem) { this.log( `Fallback to primary from event "${interstitial.identifier}" start: ${ flushStart - } pos: ${this.timelinePos} playing: ${segmentToString( + } pos: ${timelinePos} playing: ${segmentToString( playingItem, )} error: ${interstitial.error}`, ); - let timelinePos = this.timelinePos; if (timelinePos === -1) { timelinePos = this.hls.startPosition; } @@ -2759,14 +2770,15 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli this.attachPrimary(flushStart, null); this.flushFrontBuffer(flushStart); } - if (!this.schedule) { - return; - } - const scheduleIndex = this.schedule.findItemIndexAtTime(timelinePos); - this.setSchedulePosition(scheduleIndex); - } else { + } else if (timelinePos === -1) { this.checkStart(); + return; + } + if (!this.schedule) { + return; } + const scheduleIndex = this.schedule.findItemIndexAtTime(timelinePos); + this.setSchedulePosition(scheduleIndex); } // Asset List loading @@ -2819,8 +2831,9 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli const index = this.schedule.findItemIndexAtTime(this.timelinePos); if (index !== scheduleIndex) { interstitial.error = new Error( - `Interstitial no longer within playback range ${this.timelinePos} ${interstitial}`, + `Interstitial ${assets.length ? 'no longer within playback range' : 'asset-list is empty'} ${this.timelinePos} ${interstitial}`, ); + this.log(interstitial.error.message); this.updateSchedule(true); this.primaryFallback(interstitial); return; diff --git a/src/controller/level-controller.ts b/src/controller/level-controller.ts index c2999e7eaf4..fdd518c1b88 100644 --- a/src/controller/level-controller.ts +++ b/src/controller/level-controller.ts @@ -8,7 +8,6 @@ import { codecsSetSelectionPreferenceValue, convertAVC1ToAVCOTI, getCodecCompatibleName, - sampleEntryCodesISO, videoCodecPreferenceValue, } from '../utils/codecs'; import { reassignFragmentLevelIndexes } from '../utils/level-helper'; @@ -133,29 +132,8 @@ export default class LevelController extends BasePlaylistController { // only keep levels with supported audio/video codecs const { width, height, unknownCodecs } = levelParsed; - let unknownUnsupportedCodecCount = unknownCodecs - ? unknownCodecs.length - : 0; - if (unknownCodecs) { - // Treat unknown codec as audio or video codec based on passing `isTypeSupported` check - // (allows for playback of any supported codec even if not indexed in utils/codecs) - for (let i = unknownUnsupportedCodecCount; i--; ) { - const unknownCodec = unknownCodecs[i]; - if (this.isAudioSupported(unknownCodec)) { - levelParsed.audioCodec = audioCodec = audioCodec - ? `${audioCodec},${unknownCodec}` - : unknownCodec; - unknownUnsupportedCodecCount--; - sampleEntryCodesISO.audio[audioCodec.substring(0, 4)] = 2; - } else if (this.isVideoSupported(unknownCodec)) { - levelParsed.videoCodec = videoCodec = videoCodec - ? `${videoCodec},${unknownCodec}` - : unknownCodec; - unknownUnsupportedCodecCount--; - sampleEntryCodesISO.video[videoCodec.substring(0, 4)] = 2; - } - } - } + const unknownUnsupportedCodecCount = unknownCodecs?.length || 0; + resolutionFound ||= !!(width && height); videoCodecFound ||= !!videoCodec; audioCodecFound ||= !!audioCodec; @@ -252,6 +230,7 @@ export default class LevelController extends BasePlaylistController { let audioTracks: MediaPlaylist[] = []; let subtitleTracks: MediaPlaylist[] = []; let levels = filteredLevels; + const statsParsing = data.stats?.parsing || {}; // remove audio-only and invalid video-range levels if we also have levels with video codecs or RESOLUTION signalled if ((resolutionFound || videoCodecFound) && audioCodecFound) { @@ -290,6 +269,7 @@ export default class LevelController extends BasePlaylistController { }); } }); + statsParsing.end = performance.now(); return; } @@ -408,6 +388,7 @@ export default class LevelController extends BasePlaylistController { altAudio: altAudioEnabled && !audioOnly && audioTracks.some((t) => !!t.url), }; + statsParsing.end = performance.now(); this.hls.trigger(Events.MANIFEST_PARSED, edata); } diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 2e3402bc5cf..d4b1d18f332 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -867,7 +867,7 @@ export default class StreamController ) { const hls = this.hls; // if any URL found on new audio track, it is an alternate audio track - const fromAltAudio = this.altAudio === AlternateAudio.SWITCHED; + const fromAltAudio = this.altAudio !== AlternateAudio.DISABLED; const altAudio = useAlternateAudio(data.url, hls); // if we switch on main audio, ensure that main fragment scheduling is synced with media.buffered // don't do anything if we switch to alt audio: audio stream controller is handling it. @@ -895,6 +895,7 @@ export default class StreamController } // If switching from alt to main audio, flush all audio and trigger track switched if (fromAltAudio) { + this.altAudio = AlternateAudio.DISABLED; this.fragmentTracker.removeAllFragments(); hls.once(Events.BUFFER_FLUSHED, () => { if (!this.hls as any) { diff --git a/src/loader/fragment.ts b/src/loader/fragment.ts index 97e5e08cd95..621647eb563 100644 --- a/src/loader/fragment.ts +++ b/src/loader/fragment.ts @@ -259,25 +259,24 @@ export class Fragment extends BaseSegment { get decryptdata(): LevelKey | null { const { levelkeys } = this; - if (!levelkeys && !this._decryptdata) { + + if (!levelkeys || levelkeys.NONE) { return null; } - if (!this._decryptdata && this.levelkeys && !this.levelkeys.NONE) { - const key = this.levelkeys.identity; - if (key) { - this._decryptdata = key.getDecryptData(this.sn); - } else { - const keyFormats = Object.keys(this.levelkeys); - if (keyFormats.length === 1) { - const levelKey = (this._decryptdata = - this.levelkeys[keyFormats[0]] || null); - if (levelKey) { - return levelKey.getDecryptData(this.sn); - } - } else { - // Multiple keys. key-loader to call Fragment.setKeyFormat based on selected key-system. + if (levelkeys.identity) { + if (!this._decryptdata) { + this._decryptdata = levelkeys.identity.getDecryptData(this.sn); + } + } else if (!this._decryptdata?.keyId) { + const keyFormats = Object.keys(levelkeys); + if (keyFormats.length === 1) { + const levelKey = (this._decryptdata = levelkeys[keyFormats[0]] || null); + if (levelKey) { + this._decryptdata = levelKey.getDecryptData(this.sn, levelkeys); } + } else { + // Multiple keys. key-loader to call Fragment.setKeyFormat based on selected key-system. } } @@ -364,10 +363,11 @@ export class Fragment extends BaseSegment { } setKeyFormat(keyFormat: KeySystemFormats) { - if (this.levelkeys) { - const key = this.levelkeys[keyFormat]; - if (key && !this._decryptdata) { - this._decryptdata = key.getDecryptData(this.sn); + const levelkeys = this.levelkeys; + if (levelkeys) { + const key = levelkeys[keyFormat]; + if (key && !this._decryptdata?.keyId) { + this._decryptdata = key.getDecryptData(this.sn, levelkeys); } } } diff --git a/src/loader/key-loader.ts b/src/loader/key-loader.ts index 27c85dc5daa..0470dc17fb2 100644 --- a/src/loader/key-loader.ts +++ b/src/loader/key-loader.ts @@ -1,4 +1,5 @@ import { LoadError } from './fragment-loader'; +import { LevelKey } from './level-key'; import { ErrorDetails, ErrorTypes } from '../errors'; import { type Fragment, isMediaFragment } from '../loader/fragment'; import { arrayToHex } from '../utils/hex'; @@ -8,7 +9,7 @@ import { keySystemFormatToKeySystemDomain, } from '../utils/mediakeys-helper'; import { KeySystemFormats } from '../utils/mediakeys-helper'; -import type { LevelKey } from './level-key'; +import { parseKeyIdsFromTenc } from '../utils/mp4-tools'; import type { HlsConfig } from '../config'; import type EMEController from '../controller/eme-controller'; import type { @@ -258,6 +259,27 @@ export default class KeyLoader extends Logger implements ComponentAPI { loadKeyEME(keyInfo: KeyLoaderInfo, frag: Fragment): Promise { const keyLoadedData: KeyLoadedData = { frag, keyInfo }; if (this.emeController && this.config.emeEnabled) { + if (!keyInfo.decryptdata.keyId && frag.initSegment?.data) { + const keyIds = parseKeyIdsFromTenc( + frag.initSegment.data as Uint8Array, + ); + if (keyIds.length) { + let keyId = keyIds[0]; + if (keyId.some((b) => b !== 0)) { + this.log(`Using keyId found in init segment ${arrayToHex(keyId)}`); + LevelKey.setKeyIdForUri(keyInfo.decryptdata.uri, keyId); + } else { + keyId = LevelKey.addKeyIdForUri(keyInfo.decryptdata.uri); + this.log(`Generating keyId to patch media ${arrayToHex(keyId)}`); + } + keyInfo.decryptdata.keyId = keyId; + } + } + if (!keyInfo.decryptdata.keyId && !isMediaFragment(frag)) { + // Resolve so that unencrypted init segment is loaded + // key id is extracted from tenc box when processing key for next segment above + return Promise.resolve(keyLoadedData); + } const keySessionContextPromise = this.emeController.loadKey(keyLoadedData); return (keyInfo.keyLoadPromise = keySessionContextPromise.then( diff --git a/src/loader/level-key.ts b/src/loader/level-key.ts index 2c6a8d076cb..4fdb316d4f2 100644 --- a/src/loader/level-key.ts +++ b/src/loader/level-key.ts @@ -41,6 +41,15 @@ export class LevelKey implements DecryptData { keyUriToKeyIdMap[uri] = keyId; } + static addKeyIdForUri(uri: string): Uint8Array { + const val = Object.keys(keyUriToKeyIdMap).length % Number.MAX_SAFE_INTEGER; + const keyId = new Uint8Array(16); + const dv = new DataView(keyId.buffer, 12, 4); // Just set the last 4 bytes + dv.setUint32(0, val); + keyUriToKeyIdMap[uri] = keyId; + return keyId; + } + constructor( method: string, uri: string, @@ -100,7 +109,10 @@ export class LevelKey implements DecryptData { return false; } - public getDecryptData(sn: number | 'initSegment'): LevelKey | null { + public getDecryptData( + sn: number | 'initSegment', + levelKeys?: { [key: string]: LevelKey | undefined }, + ): LevelKey | null { if (!this.encrypted || !this.uri) { return null; } @@ -135,10 +147,19 @@ export class LevelKey implements DecryptData { return this; } - if (this.pssh && this.keyId) { - return this; + if (this.keyId) { + // Handle case where key id is changed in KEY_LOADING event handler #7542#issuecomment-3305203929 + const assignedKeyId = keyUriToKeyIdMap[this.uri]; + if (assignedKeyId && !arrayValuesMatch(this.keyId, assignedKeyId)) { + LevelKey.setKeyIdForUri(this.uri, this.keyId); + } + + if (this.pssh) { + return this; + } } + // Key bytes are signalled the KEYID attribute, typically only found on WideVine KEY tags // Initialize keyId if possible const keyBytes = convertDataUriToArrayBytes(this.uri); if (keyBytes) { @@ -156,8 +177,7 @@ export class LevelKey implements DecryptData { } } if (!this.keyId) { - const offset = keyBytes.length - 22; - this.keyId = keyBytes.subarray(offset, offset + 16); + this.keyId = getKeyIdFromPlayReadyKey(levelKeys); } break; case KeySystemFormats.PLAYREADY: { @@ -187,24 +207,49 @@ export class LevelKey implements DecryptData { } } - // Default behavior: assign a new keyId for each uri + // Default behavior: get keyId from other KEY tag or URI lookup if (!this.keyId || this.keyId.byteLength !== 16) { - let keyId = keyUriToKeyIdMap[this.uri]; + let keyId: Uint8Array | null | undefined; + keyId = getKeyIdFromWidevineKey(levelKeys); if (!keyId) { - const val = - Object.keys(keyUriToKeyIdMap).length % Number.MAX_SAFE_INTEGER; - keyId = new Uint8Array(16); - const dv = new DataView(keyId.buffer, 12, 4); // Just set the last 4 bytes - dv.setUint32(0, val); + keyId = getKeyIdFromPlayReadyKey(levelKeys); + if (!keyId) { + keyId = keyUriToKeyIdMap[this.uri]; + } + } + if (keyId) { + this.keyId = keyId; LevelKey.setKeyIdForUri(this.uri, keyId); } - this.keyId = keyId; } return this; } } +function getKeyIdFromWidevineKey( + levelKeys: { [key: string]: LevelKey | undefined } | undefined, +) { + const widevineKey = levelKeys?.[KeySystemFormats.WIDEVINE]; + if (widevineKey) { + return widevineKey.keyId; + } + return null; +} + +function getKeyIdFromPlayReadyKey( + levelKeys: { [key: string]: LevelKey | undefined } | undefined, +) { + const playReadyKey = levelKeys?.[KeySystemFormats.PLAYREADY]; + if (playReadyKey) { + const playReadyKeyBytes = convertDataUriToArrayBytes(playReadyKey.uri); + if (playReadyKeyBytes) { + return parsePlayReadyWRM(playReadyKeyBytes); + } + } + return null; +} + function createInitializationVector(segmentNumber: number) { const uint8View = new Uint8Array(16); for (let i = 12; i < 16; i++) { diff --git a/src/loader/m3u8-parser.ts b/src/loader/m3u8-parser.ts index 66c73d2744d..1b24c734a60 100644 --- a/src/loader/m3u8-parser.ts +++ b/src/loader/m3u8-parser.ts @@ -119,7 +119,10 @@ export default class M3U8Parser { const levelsWithKnownCodecs: LevelParsed[] = []; MASTER_PLAYLIST_REGEX.lastIndex = 0; - + if (!string.startsWith('#EXTM3U')) { + parsed.playlistParsingError = new Error('no EXTM3U delimiter'); + return parsed; + } let result: RegExpExecArray | null; while ((result = MASTER_PLAYLIST_REGEX.exec(string)) != null) { if (result[1]) { @@ -710,9 +713,7 @@ export default class M3U8Parser { } } if (!level.targetduration) { - level.playlistParsingError = new Error( - `#EXT-X-TARGETDURATION is required`, - ); + level.playlistParsingError = new Error(`Missing Target Duration`); } const fragmentLength = fragments.length; const firstFragment = fragments[0]; diff --git a/src/loader/playlist-loader.ts b/src/loader/playlist-loader.ts index b589ad1f8fc..70c05fcca33 100644 --- a/src/loader/playlist-loader.ts +++ b/src/loader/playlist-loader.ts @@ -11,6 +11,10 @@ import { ErrorDetails, ErrorTypes } from '../errors'; import { Events } from '../events'; import { PlaylistContextType, PlaylistLevelType } from '../types/loader'; import { AttrList } from '../utils/attr-list'; +import { + areCodecsMediaSourceSupported, + sampleEntryCodesISO, +} from '../utils/codecs'; import { computeReloadInterval } from '../utils/level-helper'; import type { LevelDetails } from './level-details'; import type { LoaderConfig, RetryConfig } from '../config'; @@ -340,18 +344,6 @@ class PlaylistLoader implements NetworkComponentAPI { const string = response.data as string; - // Validate if it is an M3U8 at all - if (string.indexOf('#EXTM3U') !== 0) { - this.handleManifestParsingError( - response, - context, - new Error('no EXTM3U delimiter'), - networkDetails || null, - stats, - ); - return; - } - stats.parsing.start = performance.now(); if ( M3U8Parser.isMediaPlaylist(string) || @@ -423,6 +415,7 @@ class PlaylistLoader implements NetworkComponentAPI { const parsedResult = M3U8Parser.parseMasterPlaylist(string, url); if (parsedResult.playlistParsingError) { + stats.parsing.end = performance.now(); this.handleManifestParsingError( response, context, @@ -444,6 +437,44 @@ class PlaylistLoader implements NetworkComponentAPI { this.variableList = variableList; + // Treat unknown codec as audio or video codec based on passing `isTypeSupported` check + // (allows for playback of any supported codec even if not indexed in utils/codecs) + levels.forEach((levelParsed: LevelParsed) => { + const { unknownCodecs } = levelParsed; + if (unknownCodecs) { + const { preferManagedMediaSource } = this.hls.config; + let { audioCodec, videoCodec } = levelParsed; + for (let i = unknownCodecs.length; i--; ) { + const unknownCodec = unknownCodecs[i]; + if ( + areCodecsMediaSourceSupported( + unknownCodec, + 'audio', + preferManagedMediaSource, + ) + ) { + levelParsed.audioCodec = audioCodec = audioCodec + ? `${audioCodec},${unknownCodec}` + : unknownCodec; + sampleEntryCodesISO.audio[audioCodec.substring(0, 4)] = 2; + unknownCodecs.splice(i, 1); + } else if ( + areCodecsMediaSourceSupported( + unknownCodec, + 'video', + preferManagedMediaSource, + ) + ) { + levelParsed.videoCodec = videoCodec = videoCodec + ? `${videoCodec},${unknownCodec}` + : unknownCodec; + sampleEntryCodesISO.video[videoCodec.substring(0, 4)] = 2; + unknownCodecs.splice(i, 1); + } + } + } + }); + const { AUDIO: audioTracks = [], SUBTITLES: subtitles, @@ -679,36 +710,14 @@ class PlaylistLoader implements NetworkComponentAPI { loader: Loader | undefined, ): void { const hls = this.hls; - const { type, level, id, groupId, deliveryDirectives } = context; + const { type, level, levelOrTrack, id, groupId, deliveryDirectives } = + context; const url = getResponseUrl(response, context); const parent = mapContextToLevelType(context); - const levelIndex = + let levelIndex = typeof context.level === 'number' && parent === PlaylistLevelType.MAIN ? (level as number) : undefined; - if (!levelDetails.fragments.length) { - const error = (levelDetails.playlistParsingError = new Error( - 'No Segments found in Playlist', - )); - hls.trigger(Events.ERROR, { - type: ErrorTypes.NETWORK_ERROR, - details: ErrorDetails.LEVEL_EMPTY_ERROR, - fatal: false, - url, - error, - reason: error.message, - response, - context, - level: levelIndex, - parent, - networkDetails, - stats, - }); - return; - } - if (!levelDetails.targetduration) { - levelDetails.playlistParsingError = new Error('Missing Target Duration'); - } const error = levelDetails.playlistParsingError; if (error) { this.hls.logger.warn(`${error} ${levelDetails.url}`); @@ -731,6 +740,26 @@ class PlaylistLoader implements NetworkComponentAPI { } levelDetails.playlistParsingError = null; } + if (!levelDetails.fragments.length) { + const error = (levelDetails.playlistParsingError = new Error( + 'No Segments found in Playlist', + )); + hls.trigger(Events.ERROR, { + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.LEVEL_EMPTY_ERROR, + fatal: false, + url, + error, + reason: error.message, + response, + context, + level: levelIndex, + parent, + networkDetails, + stats, + }); + return; + } if (levelDetails.live && loader) { if (loader.getCacheAge) { @@ -744,9 +773,23 @@ class PlaylistLoader implements NetworkComponentAPI { switch (type) { case PlaylistContextType.MANIFEST: case PlaylistContextType.LEVEL: + if (levelIndex) { + if (!levelOrTrack) { + // fall-through to hls.levels[0] + levelIndex = 0; + } else { + if (levelOrTrack !== hls.levels[levelIndex]) { + // correct levelIndex when lower levels were removed from hls.levels + const updatedIndex = hls.levels.indexOf(levelOrTrack as Level); + if (updatedIndex > -1) { + levelIndex = updatedIndex; + } + } + } + } hls.trigger(Events.LEVEL_LOADED, { details: levelDetails, - levelInfo: (context.levelOrTrack as Level) || hls.levels[0], + levelInfo: (levelOrTrack as Level | null) || hls.levels[0], level: levelIndex || 0, id: id || 0, stats, @@ -758,7 +801,7 @@ class PlaylistLoader implements NetworkComponentAPI { case PlaylistContextType.AUDIO_TRACK: hls.trigger(Events.AUDIO_TRACK_LOADED, { details: levelDetails, - track: context.levelOrTrack as MediaPlaylist, + track: levelOrTrack as MediaPlaylist, id: id || 0, groupId: groupId || '', stats, @@ -769,7 +812,7 @@ class PlaylistLoader implements NetworkComponentAPI { case PlaylistContextType.SUBTITLE_TRACK: hls.trigger(Events.SUBTITLE_TRACK_LOADED, { details: levelDetails, - track: context.levelOrTrack as MediaPlaylist, + track: levelOrTrack as MediaPlaylist, id: id || 0, groupId: groupId || '', stats, diff --git a/src/remux/mp4-remuxer.ts b/src/remux/mp4-remuxer.ts index 9bd59545f98..b26f8aab8d3 100644 --- a/src/remux/mp4-remuxer.ts +++ b/src/remux/mp4-remuxer.ts @@ -4,7 +4,10 @@ import { ErrorDetails, ErrorTypes } from '../errors'; import { Events } from '../events'; import { PlaylistLevelType } from '../types/loader'; import { type ILogger, Logger } from '../utils/logger'; -import { toMsFromMpegTsClock } from '../utils/timescale-conversion'; +import { + timestampToString, + toMsFromMpegTsClock, +} from '../utils/timescale-conversion'; import type { HlsConfig } from '../config'; import type { HlsEventEmitter } from '../events'; import type { SourceBufferName } from '../types/buffer'; @@ -107,7 +110,19 @@ export default class MP4Remuxer extends Logger implements Remuxer { } resetTimeStamp(defaultTimeStamp: TimestampOffset | null) { - this.log('initPTS & initDTS reset'); + const initPTS = this._initPTS; + if ( + !initPTS || + !defaultTimeStamp || + defaultTimeStamp.trackId !== initPTS.trackId || + defaultTimeStamp.baseTime !== initPTS.baseTime || + defaultTimeStamp.timescale !== initPTS.timescale + ) { + this.log( + `Reset initPTS: ${initPTS ? timestampToString(initPTS) : initPTS} > ${defaultTimeStamp ? timestampToString(defaultTimeStamp) : defaultTimeStamp}`, + ); + } + this._initPTS = this._initDTS = defaultTimeStamp; } @@ -334,6 +349,25 @@ export default class MP4Remuxer extends Logger implements Remuxer { }; } + computeInitPts( + basetime: number, + timescale: number, + presentationTime: number, + type: 'audio' | 'video', + ): number { + const offset = Math.round(presentationTime * timescale); + let timestamp = normalizePts(basetime, offset); + if (timestamp < offset + timescale) { + this.log( + `Adjusting PTS for rollover in timeline near ${(offset - timestamp) / timescale} ${type}`, + ); + while (timestamp < offset + timescale) { + timestamp += 8589934592; + } + } + return timestamp - offset; + } + generateIS( audioTrack: DemuxedAudioTrack, videoTrack: DemuxedVideoTrack, @@ -395,8 +429,12 @@ export default class MP4Remuxer extends Logger implements Remuxer { timescale = audioTrack.inputTimeScale; if (!_initPTS || timescale !== _initPTS.timescale) { // remember first PTS of this demuxing context. for audio, PTS = DTS - initPTS = initDTS = - audioSamples[0].pts - Math.round(timescale * timeOffset); + initPTS = initDTS = this.computeInitPts( + audioSamples[0].pts, + timescale, + timeOffset, + 'audio', + ); } else { computePTSDTS = false; } @@ -421,13 +459,22 @@ export default class MP4Remuxer extends Logger implements Remuxer { trackId = videoTrack.id; timescale = videoTrack.inputTimeScale; if (!_initPTS || timescale !== _initPTS.timescale) { - const startPTS = this.getVideoStartPts(videoSamples); - const startOffset = Math.round(timescale * timeOffset); - initDTS = Math.min( - initDTS as number, - normalizePts(videoSamples[0].dts, startPTS) - startOffset, + const basePTS = this.getVideoStartPts(videoSamples); + const baseDTS = normalizePts(videoSamples[0].dts, basePTS); + const videoInitDTS = this.computeInitPts( + baseDTS, + timescale, + timeOffset, + 'video', + ); + const videoInitPTS = this.computeInitPts( + basePTS, + timescale, + timeOffset, + 'video', ); - initPTS = Math.min(initPTS as number, startPTS - startOffset); + initDTS = Math.min(initDTS as number, videoInitDTS); + initPTS = Math.min(initPTS as number, videoInitPTS); } else { computePTSDTS = false; } @@ -897,11 +944,18 @@ export default class MP4Remuxer extends Logger implements Remuxer { }); if (!contiguous || nextAudioTs < 0) { + const sampleCount = inputSamples.length; // filter out sample with negative PTS that are not playable anyway // if we don't remove these negative samples, they will shift all audio samples forward. // leading to audio overlap between current / next fragment inputSamples = inputSamples.filter((sample) => sample.pts >= 0); + if (sampleCount !== inputSamples.length) { + this.warn( + `Removed ${inputSamples.length - sampleCount} of ${sampleCount} samples (initPTS ${initTime} / ${inputTimeScale})`, + ); + } + // in case all samples have negative PTS, and have been filtered out, return now if (!inputSamples.length) { return; @@ -1092,7 +1146,7 @@ export default class MP4Remuxer extends Logger implements Remuxer { // Clear the track samples. This also clears the samples array in the demuxer, since the reference is shared track.samples = []; const start = (firstPTS! - initTime) / inputTimeScale; - const end = nextAudioTs / inputTimeScale; + const end = this.nextAudioTs / inputTimeScale; const type: SourceBufferName = 'audio'; const audioData = { data1: moof, diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index be093141986..4d6d836db2f 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -7,6 +7,8 @@ import type { DecryptData } from '../loader/level-key'; import type { PassthroughTrack, UserdataSample } from '../types/demuxer'; import type { ILogger } from '../utils/logger'; +type BoxDataOrUndefined = Uint8Array | undefined; + const UINT32_MAX = Math.pow(2, 32) - 1; const push = [].push; @@ -573,51 +575,74 @@ export function patchEncyptionData( } const keyId = decryptdata.keyId; if (keyId && decryptdata.isCommonEncryption) { - const traks = findBox(initSegment, ['moov', 'trak']); - traks.forEach((trak) => { - const stsd = findBox(trak, ['mdia', 'minf', 'stbl', 'stsd'])[0]; - - // skip the sample entry count - const sampleEntries = stsd.subarray(8); - let encBoxes = findBox(sampleEntries, ['enca']); - const isAudio = encBoxes.length > 0; - if (!isAudio) { - encBoxes = findBox(sampleEntries, ['encv']); + applyToTencBoxes(initSegment, (tenc, isAudio) => { + // Look for default key id (keyID offset is always 8 within the tenc box): + const tencKeyId = tenc.subarray(8, 24); + if (!tencKeyId.some((b) => b !== 0)) { + logger.log( + `[eme] Patching keyId in 'enc${ + isAudio ? 'a' : 'v' + }>sinf>>tenc' box: ${arrayToHex(tencKeyId)} -> ${arrayToHex(keyId)}`, + ); + tenc.set(keyId, 8); } - encBoxes.forEach((enc) => { - const encBoxChildren = isAudio ? enc.subarray(28) : enc.subarray(78); - const sinfBoxes = findBox(encBoxChildren, ['sinf']); - sinfBoxes.forEach((sinf) => { - const tenc = parseSinf(sinf); - if (tenc) { - // Look for default key id (keyID offset is always 8 within the tenc box): - const tencKeyId = tenc.subarray(8, 24) as Uint8Array; - if (!tencKeyId.some((b) => b !== 0)) { - logger.log( - `[eme] Patching keyId in 'enc${ - isAudio ? 'a' : 'v' - }>sinf>>tenc' box: ${arrayToHex(tencKeyId)} -> ${arrayToHex( - keyId, - )}`, - ); - tenc.set(keyId, 8); - } - } - }); - }); }); } } -export function parseSinf(sinf: Uint8Array): Uint8Array | null { - const schm = findBox(sinf, ['schm'])[0]; - if (schm as any) { +export function parseKeyIdsFromTenc( + initSegment: Uint8Array, +): Uint8Array[] { + const keyIds: Uint8Array[] = []; + applyToTencBoxes(initSegment, (tenc) => keyIds.push(tenc.subarray(8, 24))); + return keyIds; +} + +function applyToTencBoxes( + initSegment: Uint8Array, + predicate: (tenc: Uint8Array, isAudio: boolean) => void, +) { + const traks = findBox(initSegment, ['moov', 'trak']); + traks.forEach((trak) => { + const stsd = findBox(trak, [ + 'mdia', + 'minf', + 'stbl', + 'stsd', + ])[0] as BoxDataOrUndefined; + if (!stsd) return; + const sampleEntries = stsd.subarray(8); + let encBoxes = findBox(sampleEntries, ['enca']); + const isAudio = encBoxes.length > 0; + if (!isAudio) { + encBoxes = findBox(sampleEntries, ['encv']); + } + encBoxes.forEach((enc) => { + const encBoxChildren = isAudio ? enc.subarray(28) : enc.subarray(78); + const sinfBoxes = findBox(encBoxChildren, ['sinf']); + sinfBoxes.forEach((sinf) => { + const tenc = parseSinf(sinf); + if (tenc) { + predicate(tenc, isAudio); + } + }); + }); + }); +} + +export function parseSinf(sinf: Uint8Array): BoxDataOrUndefined { + const schm = findBox(sinf, ['schm'])[0] as BoxDataOrUndefined; + if (schm) { const scheme = bin2str(schm.subarray(4, 8)); if (scheme === 'cbcs' || scheme === 'cenc') { - return findBox(sinf, ['schi', 'tenc'])[0]; + const tenc = findBox(sinf, ['schi', 'tenc'])[0] as BoxDataOrUndefined; + if (tenc) { + return tenc; + } + } else if (scheme === 'cbc2') { + /* no-op */ } } - return null; } /* diff --git a/src/utils/timescale-conversion.ts b/src/utils/timescale-conversion.ts index c2762a6ba32..90c5c5b459d 100644 --- a/src/utils/timescale-conversion.ts +++ b/src/utils/timescale-conversion.ts @@ -39,3 +39,8 @@ export function toMpegTsClockFromTimescale( ): number { return toTimescaleFromBase(baseTime, MPEG_TS_CLOCK_FREQ_HZ, 1 / srcScale); } + +export function timestampToString(timestamp: TimestampOffset): string { + const { baseTime, timescale, trackId } = timestamp; + return `${baseTime / timescale} (${baseTime}/${timescale}) trackId: ${trackId}`; +} diff --git a/tests/index.js b/tests/index.js index 6490a348464..105d2aa9aa7 100644 --- a/tests/index.js +++ b/tests/index.js @@ -35,6 +35,7 @@ import './unit/loader/fragment-loader'; import './unit/loader/fragment'; import './unit/loader/level'; import './unit/loader/m3u8-parser'; +import './unit/loader/playlist-loader'; import './unit/remux/mp4-remuxer'; import './unit/utils/attr-list'; import './unit/utils/binary-search'; diff --git a/tests/unit/controller/content-steering-controller.ts b/tests/unit/controller/content-steering-controller.ts index 79b5c3d3c7e..104da9b15fa 100644 --- a/tests/unit/controller/content-steering-controller.ts +++ b/tests/unit/controller/content-steering-controller.ts @@ -54,7 +54,7 @@ type ConentSteeringControllerTestable = Omit< audioTracks: MediaPlaylist[] | null; subtitleTracks: MediaPlaylist[] | null; onManifestLoading: () => void; - onManifestLoaded: (event: string, data: Partial) => void; + onManifestLoaded: (event: string, data: ManifestLoadedData) => void; }; describe('ContentSteeringController', function () { @@ -100,6 +100,16 @@ describe('ContentSteeringController', function () { uri: 'http://example.com/manifest.json', pathwayId: 'pathway-2', }, + levels: [], + audioTracks: [], + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, }); expect(contentSteeringController.uri).to.equal( 'http://example.com/manifest.json', @@ -114,6 +124,16 @@ describe('ContentSteeringController', function () { uri: 'http://example.com/manifest.json', pathwayId: 'pathway-2', }, + levels: [], + audioTracks: [], + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, }); contentSteeringController.stopLoad(); expect(contentSteeringController).to.have.property('loader').that.is.null; @@ -128,6 +148,16 @@ describe('ContentSteeringController', function () { uri: 'http://example.com/manifest.json', pathwayId: 'pathway-2', }, + levels: [], + audioTracks: [], + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, }); expect(contentSteeringController.stopLoad).to.be.a('function'); contentSteeringController.stopLoad(); @@ -149,6 +179,16 @@ describe('ContentSteeringController', function () { uri: 'http://example.com/manifest.json', pathwayId: 'pathway-2', }, + levels: [], + audioTracks: [], + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, }); expect(contentSteeringController.loader) .to.have.property('context') @@ -166,6 +206,16 @@ describe('ContentSteeringController', function () { uri: 'http://example.com/manifest.json', pathwayId: 'pathway-2', }, + levels: [], + audioTracks: [], + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, }); expect(contentSteeringController.uri).to.equal( 'http://example.com/manifest.json', @@ -209,11 +259,18 @@ http://a.example.com/md/prog_index.m3u8`; 'http://example.com/main.m3u8', parsedMultivariant, ); - const manifestLoadedData = { + const manifestLoadedData: ManifestLoadedData = { contentSteering: parsedMultivariant.contentSteering, levels: parsedMultivariant.levels, - audioTracks: parsedMediaOptions.AUDIO, + audioTracks: parsedMediaOptions.AUDIO!, subtitles: parsedMediaOptions.SUBTITLES, + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, }; const levelController: any = (hls.levelController = new LevelController( hls as any, @@ -310,11 +367,18 @@ http://a.example.com/md/prog_index.m3u8`; 'http://example.com/main.m3u8', parsedMultivariant, ); - const manifestLoadedData = { + const manifestLoadedData: ManifestLoadedData = { contentSteering: parsedMultivariant.contentSteering, levels: parsedMultivariant.levels, - audioTracks: parsedMediaOptions.AUDIO, + audioTracks: parsedMediaOptions.AUDIO!, subtitles: parsedMediaOptions.SUBTITLES, + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, }; levelController = hls.levelController = new LevelController( hls as any, diff --git a/tests/unit/controller/level-controller.ts b/tests/unit/controller/level-controller.ts index 0a2b1e660b2..7b95ffbd48d 100755 --- a/tests/unit/controller/level-controller.ts +++ b/tests/unit/controller/level-controller.ts @@ -4,6 +4,7 @@ import sinonChai from 'sinon-chai'; import LevelController from '../../../src/controller/level-controller'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { Events } from '../../../src/events'; +import { LoadStats } from '../../../src/loader/load-stats'; import M3U8Parser from '../../../src/loader/m3u8-parser'; import { Level } from '../../../src/types/level'; import { PlaylistLevelType } from '../../../src/types/loader'; @@ -29,7 +30,7 @@ chai.use(sinonChai); const expect = chai.expect; type LevelControllerTestable = Omit & { - onManifestLoaded: (event: string, data: Partial) => void; + onManifestLoaded: (event: string, data: ManifestLoadedData) => void; onAudioTrackSwitched: (event: string, data: { id: number }) => void; onError: ( event: string, @@ -123,7 +124,7 @@ describe('LevelController', function () { contentSteering: null, startTimeOffset: null, variableList: null, - stats: {} as any, + stats: new LoadStats(), subtitles: [], url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -189,6 +190,12 @@ describe('LevelController', function () { networkDetails: '', subtitles: [], url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); return Promise.resolve().then(() => { @@ -204,6 +211,7 @@ describe('LevelController', function () { }); it('emits MANIFEST_PARSED when levels are found in the manifest', function () { + const stats = new LoadStats(); const data: ManifestLoadedData = { audioTracks: [], levels: [ @@ -239,7 +247,7 @@ describe('LevelController', function () { contentSteering: null, startTimeOffset: null, variableList: null, - stats: {} as any, + stats, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -252,7 +260,7 @@ describe('LevelController', function () { sessionData: null, sessionKeys: null, firstLevel: 0, - stats: {}, + stats, audio: false, video: false, altAudio: false, @@ -280,6 +288,15 @@ http://bar.example.com/audio-only/prog_index.m3u8`, levelController.onManifestLoaded(Events.MANIFEST_LOADED, { levels: parsedLevels, audioTracks: [], + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); const { name, @@ -304,6 +321,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, describe('Manifest Parsed Alt-Audio', function () { it('emits MANIFEST_PARSED with `altAudio = true` when there are no codec attributes in MANIFEST_LOADED', function () { + const stats = new LoadStats(); const data: ManifestLoadedData = { audioTracks: [ mediaPlaylist({ @@ -324,7 +342,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, contentSteering: null, startTimeOffset: null, variableList: null, - stats: {} as any, + stats, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -335,7 +353,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, sessionData: null, sessionKeys: null, firstLevel: 0, - stats: {} as any, + stats, audio: false, video: false, altAudio: true, @@ -349,6 +367,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, }); it('emits MANIFEST_PARSED with `altAudio = true` when there are codec attributes in MANIFEST_LOADED', function () { + const stats = new LoadStats(); const data: ManifestLoadedData = { audioTracks: [ mediaPlaylist({ audioCodec: 'mp4a.40.5', url: 'audio-track.m3u8' }), @@ -368,7 +387,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, contentSteering: null, startTimeOffset: null, variableList: null, - stats: {} as any, + stats, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -380,7 +399,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, sessionData: null, sessionKeys: null, firstLevel: 0, - stats: {}, + stats, audio: true, video: true, altAudio: true, @@ -388,6 +407,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, }); it('emits MANIFEST_PARSED with `altAudio = false` when Variant(s) are audio-only with audio Media Playlists in MANIFEST_LOADED', function () { + const stats = new LoadStats(); const data: ManifestLoadedData = { audioTracks: [ mediaPlaylist({ audioCodec: 'mp4a.40.5', name: 'main' }), @@ -407,7 +427,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, contentSteering: null, startTimeOffset: null, variableList: null, - stats: {} as any, + stats, url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', }; @@ -419,7 +439,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, sessionData: null, sessionKeys: null, firstLevel: 0, - stats: {}, + stats, audio: true, video: false, altAudio: false, @@ -445,7 +465,7 @@ http://bar.example.com/audio-only/prog_index.m3u8`, contentSteering: null, startTimeOffset: null, variableList: null, - stats: {} as any, + stats: new LoadStats(), url: 'foo', }; }); @@ -664,6 +684,15 @@ http://bar.example.com/md/prog_index.m3u8`, levelController.onManifestLoaded(Events.MANIFEST_LOADED, { levels: parsedLevels, audioTracks: [], + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); const { name, @@ -718,7 +747,16 @@ http://bar.example.com/md/prog_index.m3u8`; expect(parsedSubs).to.be.undefined; levelController.onManifestLoaded(Events.MANIFEST_LOADED, { levels: parsedLevels, - audioTracks: parsedAudio, + audioTracks: parsedAudio!, + subtitles: [], + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); const { name, @@ -774,6 +812,14 @@ http://bar.example.com/md/prog_index.m3u8`; levels: parsedLevels, audioTracks: parsedAudio, subtitles: parsedSubs, + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); const { name, payload } = hls.getEventData(0) as { name: string; @@ -909,6 +955,14 @@ http://bar.example.com/md/prog_index.m3u8`; levels: parsedLevels, audioTracks: parsedAudio, subtitles: parsedSubs, + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); const { name, payload } = hls.getEventData(0) as { name: string; @@ -995,6 +1049,14 @@ http://bar.example.com/md/prog_index.m3u8`; levels: parsedLevels, audioTracks: parsedAudio, subtitles: parsedSubs, + networkDetails: new Response('ok'), + url: 'https://example.com/prog.m3u8', + stats: new LoadStats(), + sessionData: null, + sessionKeys: null, + contentSteering: null, + startTimeOffset: null, + variableList: null, }); const { name, payload } = hls.getEventData(0) as { name: string; diff --git a/tests/unit/loader/m3u8-parser.ts b/tests/unit/loader/m3u8-parser.ts index 1549f308ce7..6570cf593fb 100644 --- a/tests/unit/loader/m3u8-parser.ts +++ b/tests/unit/loader/m3u8-parser.ts @@ -4,6 +4,8 @@ import { LoadStats } from '../../../src/loader/load-stats'; import M3U8Parser from '../../../src/loader/m3u8-parser'; import { PlaylistLevelType } from '../../../src/types/loader'; import { AttrList } from '../../../src/utils/attr-list'; +import { arrayToHex } from '../../../src/utils/hex'; +import { KeySystemFormats } from '../../../src/utils/mediakeys-helper'; import type { Fragment, Part } from '../../../src/loader/fragment'; import type { LevelKey } from '../../../src/loader/level-key'; @@ -19,11 +21,11 @@ describe('M3U8Parser', function () { ); expect(result.levels).to.deep.equal([]); expect(result.sessionData).to.equal(null); - expectPlaylistParsingError(result, 'no levels found in manifest'); + expectPlaylistParsingError(result, 'no EXTM3U delimiter'); }); it('manifest with broken syntax returns empty array', function () { - const manifest = `#EXTXSTREAMINF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" + const manifest = `#EXTM3U#EXTXSTREAMINF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x360,NAME="480" http://proxy-62.dailymotion.com/sec(3ae40f708f79ca9471f52b86da76a3a8)/video/107/282/158282701_mp4_h264_aac_hq.m3u8#cell=core`; const result = M3U8Parser.parseMasterPlaylist( manifest, @@ -2414,6 +2416,59 @@ media_1638278.m4s`; .which.has.members([result.fragments[2], result.fragments[6]]); }); + it('parse KEYID other keys when available', function () { + const level = `#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:6 +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://any",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1" +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="data:text/plain;base64,AAAA",KEYID=0x50906c6be9179460c3d1a59f16ee9804,KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1" +#EXT-X-KEY:METHOD=SAMPLE-AES,URI="data:text/plain;charset=UTF-16;base64,UAIA",KEYFORMATVERSIONS="1",KEYFORMAT="com.microsoft.playready" +#EXT-X-MAP:URI="init.mp4" +#EXTINF:5.0, +1.mp4`; + const result = M3U8Parser.parseLevelPlaylist( + level, + 'http://foo.com/adaptive/test.m3u8', + 0, + PlaylistLevelType.MAIN, + 0, + null, + ); + expect(result.playlistParsingError).to.be.null; + expect(result.fragments.length).to.equal(1); + expect(result.fragments[0].levelkeys, 'first segment has three keys') + .to.be.an('object') + .with.keys([ + 'com.apple.streamingkeydelivery', + 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + 'com.microsoft.playready', + ]); + const fragment = result.fragments[0]; + expect(result) + .to.have.property('encryptedFragments') + .which.is.an('array') + .which.has.members([fragment]); + expect( + fragment.decryptdata, + 'decryptdata should by null until format is selected', + ).to.be.null; + fragment.setKeyFormat(KeySystemFormats.FAIRPLAY); + expect(fragment.decryptdata) + .to.include({ + uri: 'skd://any', + method: 'SAMPLE-AES', + keyFormat: 'com.apple.streamingkeydelivery', + encrypted: true, + isCommonEncryption: true, + pssh: null, + }) + .and.have.property('keyId') + .which.is.a('Uint8Array'); + expect(arrayToHex(fragment.decryptdata!.keyId!)).to.equal( + '50906c6be9179460c3d1a59f16ee9804', + ); + }); + it('parses manifest with EXT-X-SESSION-KEYs', function () { const manifest = `#EXTM3U #EXT-X-SESSION-DATA:DATA-ID="key",VALUE="value" diff --git a/tests/unit/loader/playlist-loader.ts b/tests/unit/loader/playlist-loader.ts new file mode 100644 index 00000000000..8dc8c76705a --- /dev/null +++ b/tests/unit/loader/playlist-loader.ts @@ -0,0 +1,91 @@ +import chai from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import LevelController from '../../../src/controller/level-controller'; +import { Events } from '../../../src/events'; +import { LoadStats } from '../../../src/loader/load-stats'; +import PlaylistLoader from '../../../src/loader/playlist-loader'; +import HlsMock from '../../mocks/hls.mock'; +import { multivariantPlaylistWithPathways } from '../controller/level-controller'; +import type Hls from '../../../src/hls'; +import type { + LoaderCallbacks, + LoaderConfiguration, + PlaylistLoaderContext, +} from '../../../src/types/loader'; + +chai.use(sinonChai); +const expect = chai.expect; + +describe('PlaylistLoader tests', function () { + const sandbox = sinon.createSandbox(); + let hls: Hls; // HlsMock + let playlistLoader: PlaylistLoader; + let levelController: LevelController; // level-controller finishes manifest parsing + let response; + + beforeEach(function () { + hls = new HlsMock({}) as unknown as Hls; + playlistLoader = new PlaylistLoader(hls); + levelController = new LevelController(hls, null); + }); + + afterEach(function () { + sandbox.restore(); + playlistLoader.destroy(); + levelController.destroy(); + }); + + it('handles multivariant playlist loading and parsing (with level-controller) on MANIFEST_LOADING', function () { + return new Promise((resolve, reject) => { + const stats = new LoadStats(); + const networkDetails = new Response('ok'); + sinon.stub(playlistLoader as any, 'createInternalLoader').returns({ + load: ( + context: PlaylistLoaderContext, + config: LoaderConfiguration, + callback: LoaderCallbacks, + ) => { + expect(context.type).eq('manifest'); + Promise.resolve() + .then(() => { + response = { data: multivariantPlaylistWithPathways }; + callback.onSuccess(response, stats, context, networkDetails); + }) + .catch(reject); + }, + abort: () => {}, + }); + + hls.on(Events.MANIFEST_LOADED, (type, data) => { + expect(data).includes({ + url: 'http://example.cpm/program.m3u8', + stats, + networkDetails, + captions: undefined, + sessionData: null, + sessionKeys: null, + startTimeOffset: null, + variableList: null, + }); + expect(data.levels, 'levels.length').to.have.lengthOf(30); + expect(data.audioTracks, 'audioTracks.length').to.have.lengthOf(6); + expect(data.subtitles, 'subtitles.length').to.have.lengthOf(6); + expect(data.contentSteering, 'contentSteering').to.includes({ + uri: 'http://example.com/manifest.json', + pathwayId: 'Bar', + }); + expect(stats.parsing.start, 'parsing.start').to.be.greaterThan(0); + expect(stats.parsing.end, 'parsing.end').to.be.greaterThanOrEqual( + stats.parsing.start, + ); + + resolve(); + }); + + hls.trigger(Events.MANIFEST_LOADING, { + url: 'http://example.cpm/program.m3u8', + }); + }); + }); +});