diff --git a/src/core/fetchers/segment/segment_fetcher.ts b/src/core/fetchers/segment/segment_fetcher.ts index 936887d407..2cad01e69f 100644 --- a/src/core/fetchers/segment/segment_fetcher.ts +++ b/src/core/fetchers/segment/segment_fetcher.ts @@ -94,6 +94,7 @@ export default function createSegmentFetcher({ requestOptions.requestTimeout < 0 ? undefined : requestOptions.requestTimeout, connectionTimeout, cmcdPayload: undefined, + requestData: undefined, }; /** @@ -286,6 +287,7 @@ export default function createSegmentFetcher({ ): ReturnType> { pipelineRequestOptions.cmcdPayload = cmcdDataBuilder?.getCmcdDataForSegmentRequest(content); + pipelineRequestOptions.requestData = content.representation.requestData; return loadSegment( cdnMetadata, context, diff --git a/src/core/fetchers/thumbnails/thumbnail_fetcher.ts b/src/core/fetchers/thumbnails/thumbnail_fetcher.ts index 7e115d36c9..e43ea0ef00 100644 --- a/src/core/fetchers/thumbnails/thumbnail_fetcher.ts +++ b/src/core/fetchers/thumbnails/thumbnail_fetcher.ts @@ -141,6 +141,7 @@ export default function createThumbnailFetcher( requestOptions.requestTimeout < 0 ? undefined : requestOptions.requestTimeout, connectionTimeout, cmcdPayload: undefined, + requestData: thumbnailTrack.requestData, }; /** diff --git a/src/manifest/classes/period.ts b/src/manifest/classes/period.ts index 4f6a68827d..6ce1d7d29e 100644 --- a/src/manifest/classes/period.ts +++ b/src/manifest/classes/period.ts @@ -19,6 +19,7 @@ import type { IManifestStreamEvent, IParsedAdaptations, IParsedPeriod, + IParsedThumbnailTrack, } from "../../parsers/manifest/index.ts"; import type { ITrackType, IRepresentationFilter } from "../../public_types.ts"; import arrayFind from "../../utils/array_find.ts"; @@ -97,6 +98,7 @@ export default class Period implements IPeriodMetadata { mimeType: thumbnailTrack.mimeType, index: thumbnailTrack.index, cdnMetadata: thumbnailTrack.cdnMetadata, + requestData: thumbnailTrack.requestData, height: thumbnailTrack.height, width: thumbnailTrack.width, horizontalTiles: thumbnailTrack.horizontalTiles, @@ -264,6 +266,8 @@ export interface IThumbnailTrack { mimeType: string; /** CDN(s) on which the thumbnails may be loaded. */ cdnMetadata: ICdnMetadata[] | null; + /** Transport-specific data used when requesting this track's resources. */ + requestData?: IParsedThumbnailTrack["requestData"]; /** * A loaded thumbnail's height in pixels. Note that there can be multiple actual * thumbnails per loaded thumbnail resource (see `horizontalTiles` and diff --git a/src/manifest/classes/representation.ts b/src/manifest/classes/representation.ts index 4cd1ef8b4f..b17829fc89 100644 --- a/src/manifest/classes/representation.ts +++ b/src/manifest/classes/representation.ts @@ -46,6 +46,8 @@ class Representation implements IRepresentationMetadata { * @see IRepresentationMetadata.bitrate */ public bitrate: number; + /** Transport-specific data used when requesting this Representation's resources. */ + public requestData?: IParsedRepresentation["requestData"]; /** * @see IRepresentationMetadata.frameRate */ @@ -143,6 +145,7 @@ class Representation implements IRepresentationMetadata { this.uniqueId = generateRepresentationUniqueId(); this.shouldBeAvoided = false; this.bitrate = args.bitrate; + this.requestData = args.requestData; this.baseCodecs = []; this.trackType = trackType; diff --git a/src/manifest/classes/update_period_in_place.ts b/src/manifest/classes/update_period_in_place.ts index 4131cbaaf7..55ac1da6c4 100644 --- a/src/manifest/classes/update_period_in_place.ts +++ b/src/manifest/classes/update_period_in_place.ts @@ -81,6 +81,7 @@ export default function updatePeriodInPlace( oldThumbnailTrack.end = newThumbnailTrack.end; oldThumbnailTrack.tileDuration = newThumbnailTrack.tileDuration; oldThumbnailTrack.cdnMetadata = newThumbnailTrack.cdnMetadata; + oldThumbnailTrack.requestData = newThumbnailTrack.requestData; if (updateType === MANIFEST_UPDATE_TYPE.Full) { oldThumbnailTrack.index._replace(newThumbnailTrack.index); } else { @@ -177,6 +178,7 @@ export default function updatePeriodInPlace( const [newRepresentation] = newRepresentations.splice(newRepresentationIdx, 1); updatedRepresentations.push(oldRepresentation.getMetadataSnapshot()); oldRepresentation.cdnMetadata = newRepresentation.cdnMetadata; + oldRepresentation.requestData = newRepresentation.requestData; if (updateType === MANIFEST_UPDATE_TYPE.Full) { oldRepresentation.index._replace(newRepresentation.index); } else { diff --git a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts index 0e9eab4a61..140d23029e 100644 --- a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts +++ b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts @@ -41,6 +41,7 @@ import inferAdaptationType, { } from "./infer_adaptation_type.ts"; import type { IRepresentationContext } from "./parse_representations.ts"; import parseRepresentations from "./parse_representations.ts"; +import { parseUrlQueryInfo } from "./parse_url_query_info.ts"; import resolveBaseURLs from "./resolve_base_urls.ts"; /** @@ -326,6 +327,11 @@ export default function parseAdaptationSets( ); } + const adaptationUrlQueryInfo = parseUrlQueryInfo( + adaptationChildren.EssentialProperty, + adaptationChildren.SupplementalProperty, + context.mpdUrl, + ); const reprCtxt: IRepresentationContext = { availabilityTimeComplete, availabilityTimeOffset, @@ -336,10 +342,15 @@ export default function parseAdaptationSets( isDynamic: context.isDynamic, isLastPeriod: context.isLastPeriod, manifestProfiles: context.manifestProfiles, + mpdUrl: context.mpdUrl, parentSegmentTemplates, receivedTime: context.receivedTime, start: context.start, unsafelyBaseOnPreviousAdaptation: null, + urlQueryInfo: + adaptationUrlQueryInfo === undefined + ? context.urlQueryInfo + : context.urlQueryInfo.concat(adaptationUrlQueryInfo), }; const trickModeProperty = Array.isArray(essentialProperties) @@ -595,6 +606,7 @@ function createThumbnailTracks( tracks.push({ id: representation.id, cdnMetadata: representation.cdnMetadata, + requestData: representation.requestData, index: representation.index, mimeType: representation.mimeType, height: representation.height, diff --git a/src/parsers/manifest/dash/common/parse_mpd.ts b/src/parsers/manifest/dash/common/parse_mpd.ts index 19d717cea6..63469b9359 100644 --- a/src/parsers/manifest/dash/common/parse_mpd.ts +++ b/src/parsers/manifest/dash/common/parse_mpd.ts @@ -39,6 +39,7 @@ import ManifestBoundsCalculator from "./manifest_bounds_calculator.ts"; import parseAvailabilityStartTime from "./parse_availability_start_time.ts"; import type { IXLinkInfos } from "./parse_periods.ts"; import parsePeriods from "./parse_periods.ts"; +import { parseUrlQueryInfo } from "./parse_url_query_info.ts"; import type { IResolvedBaseUrl } from "./resolve_base_urls.ts"; import resolveBaseURLs from "./resolve_base_urls.ts"; @@ -275,6 +276,11 @@ function parseCompleteIntermediateRepresentation( }); const contentProtectionParser = new ContentProtectionParser(); contentProtectionParser.addReferences(rootChildren.ContentProtection); + const mpdUrlQueryInfo = parseUrlQueryInfo( + rootChildren.EssentialProperty, + rootChildren.SupplementalProperty, + args.url, + ); const manifestInfos = { availabilityStartTime, baseURLs: mpdBaseUrls, @@ -284,7 +290,9 @@ function parseCompleteIntermediateRepresentation( isDynamic, manifestBoundsCalculator, manifestProfiles: mpdIR.attributes.profiles, + mpdUrl: args.url, receivedTime: args.manifestReceivedTime, + urlQueryInfo: mpdUrlQueryInfo === undefined ? [] : [mpdUrlQueryInfo], unsafelyBaseOnPreviousManifest, xlinkInfos, xmlNamespaces: mpdIR.attributes.namespaces, diff --git a/src/parsers/manifest/dash/common/parse_periods.ts b/src/parsers/manifest/dash/common/parse_periods.ts index 259a7b3037..4df324d427 100644 --- a/src/parsers/manifest/dash/common/parse_periods.ts +++ b/src/parsers/manifest/dash/common/parse_periods.ts @@ -37,6 +37,8 @@ import flattenOverlappingPeriods from "./flatten_overlapping_periods.ts"; import getPeriodsTimeInformation from "./get_periods_time_infos.ts"; import type { IAdaptationSetContext } from "./parse_adaptation_sets.ts"; import parseAdaptationSets from "./parse_adaptation_sets.ts"; +import type { IDashUrlQueryInfo } from "./parse_url_query_info.ts"; +import { parseUrlQueryInfo } from "./parse_url_query_info.ts"; import resolveBaseURLs from "./resolve_base_urls.ts"; const generatePeriodID = idGenerator(); @@ -113,6 +115,11 @@ export default function parsePeriods( const segmentTemplate = periodIR.children.SegmentTemplate[periodIR.children.SegmentTemplate.length - 1]; contentProtectionParser.addReferences(periodIR.children.ContentProtection); + const periodUrlQueryInfo = parseUrlQueryInfo( + [], + periodIR.children.SupplementalProperty, + context.mpdUrl, + ); const adapCtxt: IAdaptationSetContext = { availabilityTimeComplete, availabilityTimeOffset, @@ -123,10 +130,15 @@ export default function parsePeriods( isDynamic, isLastPeriod, manifestProfiles, + mpdUrl: context.mpdUrl, receivedTime, segmentTemplate, start: periodStart, unsafelyBaseOnPreviousPeriod, + urlQueryInfo: + periodUrlQueryInfo === undefined + ? context.urlQueryInfo + : context.urlQueryInfo.concat(periodUrlQueryInfo), }; const { adaptations, thumbnailTracks } = parseAdaptationSets( periodIR.children.AdaptationSet, @@ -359,6 +371,10 @@ export interface IPeriodContext extends IInheritedAdaptationContext { clockOffset?: number | undefined; /** Duration (mediaPresentationDuration) of the whole MPD, in seconds. */ duration?: number | undefined; + /** URL of the MPD from which Annex I parameters may be inherited. */ + mpdUrl?: string | undefined; + /** Annex I URL query instructions inherited from the MPD level. */ + urlQueryInfo: IDashUrlQueryInfo[]; /** * The parser should take this Manifest - which is a previously parsed * Manifest for the same dynamic content - as a base to speed-up the parsing diff --git a/src/parsers/manifest/dash/common/parse_representations.ts b/src/parsers/manifest/dash/common/parse_representations.ts index 0b5277e5b2..716a50c5db 100644 --- a/src/parsers/manifest/dash/common/parse_representations.ts +++ b/src/parsers/manifest/dash/common/parse_representations.ts @@ -30,6 +30,8 @@ import { convertSupplementalCodecsToRFC6381 } from "./convert_supplemental_codec import { getWEBMHDRInformation } from "./get_hdr_information.ts"; import type { IRepresentationIndexContext } from "./parse_representation_index.ts"; import parseRepresentationIndex from "./parse_representation_index.ts"; +import type { IDashUrlQueryInfo } from "./parse_url_query_info.ts"; +import { combineUrlQueryInfo, parseUrlQueryInfo } from "./parse_url_query_info.ts"; import resolveBaseURLs from "./resolve_base_urls.ts"; /** @@ -209,12 +211,48 @@ export default function parseRepresentations( })); // Construct Representation Base + const representationUrlQueryInfo = parseUrlQueryInfo( + representation.children.EssentialProperty, + representation.children.SupplementalProperty, + context.mpdUrl, + ); + const urlQueryInfo = + representationUrlQueryInfo === undefined + ? context.urlQueryInfo + : context.urlQueryInfo.concat(representationUrlQueryInfo); + const segmentUrlQuery = combineUrlQueryInfo( + urlQueryInfo.filter((info) => info.appliesTo.segment), + ); + const initUrlQuery = combineUrlQueryInfo( + urlQueryInfo.filter((info) => info.appliesTo.init), + ); const parsedRepresentation: IParsedRepresentation = { bitrate: representationBitrate, cdnMetadata, index: representationIndex, id: representationID, }; + if (segmentUrlQuery.length > 0 || initUrlQuery.length > 0) { + parsedRepresentation.requestData = {}; + if (segmentUrlQuery.length > 0) { + parsedRepresentation.requestData.segment = { + urlQuery: segmentUrlQuery.map((info) => ({ + value: info.queryString, + sameOriginOnly: info.sameOriginOnly, + sourceUrl: info.sourceUrl, + })), + }; + } + if (initUrlQuery.length > 0) { + parsedRepresentation.requestData.init = { + urlQuery: initUrlQuery.map((info) => ({ + value: info.queryString, + sameOriginOnly: info.sameOriginOnly, + sourceUrl: info.sourceUrl, + })), + }; + } + } if ( representation.children.SupplementalProperty.length > 0 && @@ -300,6 +338,10 @@ export default function parseRepresentations( export interface IRepresentationContext extends IInheritedRepresentationIndexContext { /** Manifest DASH profiles used for signalling some features */ manifestProfiles?: string | undefined; + /** URL of the MPD from which Annex I parameters may be inherited. */ + mpdUrl?: string | undefined; + /** Annex I URL query instructions inherited from upper MPD levels. */ + urlQueryInfo: IDashUrlQueryInfo[]; /** * The parser should take this Adaptation - which is from a previously parsed * Manifest for the same dynamic content - as a base to speed-up the parsing diff --git a/src/parsers/manifest/dash/common/parse_url_query_info.ts b/src/parsers/manifest/dash/common/parse_url_query_info.ts new file mode 100644 index 0000000000..cd316dd991 --- /dev/null +++ b/src/parsers/manifest/dash/common/parse_url_query_info.ts @@ -0,0 +1,167 @@ +import { getQueryString } from "../../../../utils/url-utils.ts"; +import type { IDescriptorIntermediateRepresentation } from "../node_parser_types.ts"; + +/** A query string generated from a DASH Annex I descriptor. */ +export interface IDashUrlQueryInfo { + /** Annex I signalling scheme from which this query string was generated. */ + scheme: "2014" | "2016"; + /** Query string without its leading question mark. */ + queryString: string; + /** Whether this query string can only be sent back to its source origin. */ + sameOriginOnly: boolean; + /** URL from which this query string was obtained. */ + sourceUrl?: string | undefined; + /** Segment request categories on which this query string applies. */ + appliesTo: { + /** Media segment requests. */ + segment: boolean; + /** Initialization segment requests. */ + init: boolean; + }; +} + +/** + * Parse the Annex I descriptors declared at one level of the MPD hierarchy. + * + * This currently handles URL output for segment requests from the 2014 and + * 2016 schemes. Remote elements, header sources and scheme-dependent client + * parameters are deliberately left out of this first implementation. + */ +export function parseUrlQueryInfo( + essentialProperties: IDescriptorIntermediateRepresentation[], + supplementalProperties: IDescriptorIntermediateRepresentation[], + mpdUrl: string | undefined, +): IDashUrlQueryInfo | undefined { + const mpdQuery = mpdUrl === undefined ? "" : getQueryString(mpdUrl); + // Both Shaka Player and dash.js select at most one descriptor at a given + // level, prioritizing EssentialProperty over SupplementalProperty and then + // taking the first matching descriptor. Keep that behavior here instead of + // trying to merge ambiguous same-level signalling. + const properties = essentialProperties.concat(supplementalProperties); + for (const property of properties) { + const schemeIdUri = property.attributes.schemeIdUri; + let scheme: IDashUrlQueryInfo["scheme"] | undefined; + if (schemeIdUri === "urn:mpeg:dash:urlparam:2014") { + scheme = "2014"; + } else if (schemeIdUri === "urn:mpeg:dash:urlparam:2016") { + scheme = "2016"; + } + if (scheme === undefined) { + continue; + } + + const element = + scheme === "2014" + ? property.children.UrlQueryInfo[0] + : property.children.ExtUrlQueryInfo[0]; + if (element === undefined) { + return undefined; + } + const attributes = element.attributes; + const appliesTo = getSegmentRequestApplicability( + scheme, + attributes.includeInRequests, + ); + if (!appliesTo.segment && !appliesTo.init) { + return undefined; + } + if (attributes.queryTemplate === undefined) { + return undefined; + } + + const initialParts: string[] = []; + if (attributes.useMpdUrlQuery === true && mpdQuery.length > 0) { + initialParts.push(mpdQuery); + } + if (attributes.queryString !== undefined && attributes.queryString.length > 0) { + initialParts.push(attributes.queryString); + } + const initialQueryString = initialParts.join("&"); + const queryString = applyQueryTemplate(attributes.queryTemplate, initialQueryString); + return queryString.length === 0 + ? undefined + : { + scheme, + queryString, + sameOriginOnly: scheme === "2016" && attributes.sameOriginOnly === true, + sourceUrl: mpdUrl, + appliesTo, + }; + } + return undefined; +} + +/** Concatenate query strings inherited by a Representation. */ +export function combineUrlQueryInfo(infos: IDashUrlQueryInfo[]): IDashUrlQueryInfo[] { + // The 2016 scheme follows the MPD hierarchy from the root down to the + // Representation. The baseline 2014 text specifies the inverse order. + const extended = infos.filter((info) => info.scheme === "2016"); + const baseline = infos.filter((info) => info.scheme === "2014").reverse(); + return extended.concat(baseline); +} + +function getSegmentRequestApplicability( + scheme: IDashUrlQueryInfo["scheme"], + includeInRequests: string | undefined, +): IDashUrlQueryInfo["appliesTo"] { + // The 2026 specification distinguishes "segment" (media segments) from + // "init" (initialization segments). However, Shaka Player and dash.js apply + // Annex I parameters selected through "segment" to both kinds of segment. + // RxPlayer currently follows that interoperable behavior. This may be + // revisited independently from the parsing logic if stricter conformance is + // preferred in the future. + if (scheme === "2014" || includeInRequests === undefined) { + return { segment: true, init: true }; + } + const requestTypes = includeInRequests.trim().split(/\s+/); + const appliesToAll = requestTypes.indexOf("*") >= 0; + const appliesToSegments = appliesToAll || requestTypes.indexOf("segment") >= 0; + return { + segment: appliesToSegments, + init: appliesToSegments || requestTypes.indexOf("init") >= 0, + }; +} + +function applyQueryTemplate(template: string, initialQueryString: string): string { + const queryData = parseQueryString(initialQueryString); + return template.replace( + /\$\$|\$querypart\$|\$query:([^$]+)\$|\$[^$]+\$/g, + (identifier, queryParameterName: string | undefined) => { + if (identifier === "$$") { + return "$"; + } + if (identifier === "$querypart$") { + return initialQueryString; + } + if (queryParameterName !== undefined) { + return queryData.get(queryParameterName) ?? ""; + } + return ""; + }, + ); +} + +function parseQueryString(queryString: string): Map { + const result = new Map(); + for (const part of queryString.split("&")) { + if (part.length === 0) { + continue; + } + const separatorIndex = part.indexOf("="); + const rawName = separatorIndex < 0 ? part : part.substring(0, separatorIndex); + const rawValue = separatorIndex < 0 ? "" : part.substring(separatorIndex + 1); + // Keep the value encoded: it is substituted into an already-encoded query + // template. Decoding it here could turn an encoded ampersand into a query + // separator when the final URL is built. + result.set(decodeFormComponent(rawName), rawValue); + } + return result; +} + +function decodeFormComponent(value: string): string { + try { + return decodeURIComponent(value.replace(/\+/g, " ")); + } catch (_) { + return value; + } +} diff --git a/src/parsers/manifest/dash/js-parser/node_parsers/UrlQueryInfo.ts b/src/parsers/manifest/dash/js-parser/node_parsers/UrlQueryInfo.ts index e2298293e1..f307c16dcb 100644 --- a/src/parsers/manifest/dash/js-parser/node_parsers/UrlQueryInfo.ts +++ b/src/parsers/manifest/dash/js-parser/node_parsers/UrlQueryInfo.ts @@ -31,6 +31,12 @@ export default function parseUrlQueryInfo( name: "useMpdUrlQuery", }); break; + case "sameOriginOnly": + parseValue(attributeValue, { + parser: parseBoolean, + name: "sameOriginOnly", + }); + break; } } return [{ attributes }, warnings]; diff --git a/src/parsers/manifest/dash/node_parser_types.ts b/src/parsers/manifest/dash/node_parser_types.ts index 6c31036f14..5b7830ff3b 100644 --- a/src/parsers/manifest/dash/node_parser_types.ts +++ b/src/parsers/manifest/dash/node_parser_types.ts @@ -460,6 +460,7 @@ export interface IUrlQueryInfoIntermediateRepresentation { queryTemplate?: string | undefined; includeInRequests?: string | undefined; useMpdUrlQuery?: boolean | undefined; + sameOriginOnly?: boolean | undefined; }; } diff --git a/src/parsers/manifest/dash/wasm-parser/rs/events.rs b/src/parsers/manifest/dash/wasm-parser/rs/events.rs index caca94b252..57f34d9226 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/events.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/events.rs @@ -300,6 +300,7 @@ pub enum AttributeName { IncludeInRequests = 82, // String UseMpdUrlQuery = 83, // Boolean QueryString = 84, // String + SameOriginOnly = 85, // Boolean } impl TagName { diff --git a/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs b/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs index 766b73dde6..fe2dfb6af6 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs @@ -291,6 +291,7 @@ pub fn report_url_query_info_attrs(tag_bs: &quick_xml::events::BytesStart) { b"queryString" => QueryString.try_report_as_string(&attr), b"includeInRequests" => IncludeInRequests.try_report_as_string(&attr), b"useMPDUrlQuery" => UseMpdUrlQuery.try_report_as_bool(&attr), + b"sameOriginOnly" => SameOriginOnly.try_report_as_bool(&attr), _ => {} }, Err(err) => ParsingError::from(err).report_err(), diff --git a/src/parsers/manifest/dash/wasm-parser/ts/generators/Descriptor.ts b/src/parsers/manifest/dash/wasm-parser/ts/generators/Descriptor.ts index 58811c345a..462f566667 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/generators/Descriptor.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/Descriptor.ts @@ -83,6 +83,9 @@ function generateUrlQueryInfoAttrParser( case AttributeName.UseMpdUrlQuery: attributes.useMpdUrlQuery = new DataView(linearMemory.buffer).getUint8(0) === 0; break; + case AttributeName.SameOriginOnly: + attributes.sameOriginOnly = new DataView(linearMemory.buffer).getUint8(0) === 0; + break; } }; } diff --git a/src/parsers/manifest/dash/wasm-parser/ts/types.ts b/src/parsers/manifest/dash/wasm-parser/ts/types.ts index 795e302036..d4b223374d 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/types.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/types.ts @@ -314,4 +314,5 @@ export const enum AttributeName { IncludeInRequests = 82, // String UseMpdUrlQuery = 83, // Boolean QueryString = 84, // String + SameOriginOnly = 85, // Boolean } diff --git a/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts b/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts index e121a74148..f0498f4857 100644 --- a/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts +++ b/src/parsers/manifest/metaplaylist/metaplaylist_parser.ts @@ -242,6 +242,7 @@ function createManifest( bitrate: currentRepresentation.bitrate, index: newIndex, cdnMetadata: currentRepresentation.cdnMetadata, + requestData: currentRepresentation.requestData, id: currentRepresentation.id, height: currentRepresentation.height, width: currentRepresentation.width, diff --git a/src/parsers/manifest/types.ts b/src/parsers/manifest/types.ts index cf8e0d25bb..8c9dfb0850 100644 --- a/src/parsers/manifest/types.ts +++ b/src/parsers/manifest/types.ts @@ -118,6 +118,8 @@ export interface IParsedThumbnailTrack { * no resource can be loaded in that situation. */ cdnMetadata: ICdnMetadata[] | null; + /** Transport-specific data used when requesting this track's resources. */ + requestData?: IRequestData | undefined; /** Interface allowing to get timed thumbnail metadata to then be able to fetch them. */ index: IRepresentationIndex; /** @@ -168,6 +170,8 @@ export interface IParsedThumbnailTrack { export interface IParsedRepresentation { /** Maximum bitrate the Representation is available in, in bits per seconds. */ bitrate: number; + /** Transport-specific data used when requesting this Representation's resources. */ + requestData?: IRequestData | undefined; /** * Information on the CDN(s) on which requests should be done to request this * Representation's initialization and media segments. @@ -229,6 +233,29 @@ export interface IParsedRepresentation { supplementalCodecs?: string | undefined; } +/** Parameters to add to an HTTP request. */ +export interface IRequestParameters { + /** Query strings to append to the request URL. */ + urlQuery?: Array<{ + /** Query string without its leading question mark. */ + value: string; + /** Only add the query string when requesting its source origin. */ + sameOriginOnly: boolean; + /** URL from which the query string was obtained. */ + sourceUrl?: string | undefined; + }>; + /** HTTP headers to add to the request. Reserved for future use. */ + headers?: Record | undefined; +} + +/** Transport-specific request parameters, indexed by request category. */ +export interface IRequestData { + /** Parameters for media segment requests. */ + segment?: IRequestParameters | undefined; + /** Parameters for initialization segment requests. */ + init?: IRequestParameters | undefined; +} + /** Every possible types an Adaptation can have. */ export type IParsedAdaptationType = "audio" | "video" | "text"; diff --git a/src/transports/dash/construct_segment_url.ts b/src/transports/dash/construct_segment_url.ts index 3fd9065e33..92325c27cc 100644 --- a/src/transports/dash/construct_segment_url.ts +++ b/src/transports/dash/construct_segment_url.ts @@ -15,18 +15,42 @@ */ import type { ISegment } from "../../manifest/index.ts"; -import type { ICdnMetadata } from "../../parsers/manifest/index.ts"; -import { resolveURL } from "../../utils/url-utils.ts"; +import type { ICdnMetadata, IRequestParameters } from "../../parsers/manifest/index.ts"; +import { + appendURLQueryString, + areSameOrigin, + resolveURL, +} from "../../utils/url-utils.ts"; export default function constructSegmentUrl( wantedCdn: ICdnMetadata | null, segment: ISegment, + requestParameters?: IRequestParameters | undefined, ): string | null { if (wantedCdn === null) { return null; } if (segment.url === null) { - return wantedCdn.baseUrl; + return appendQueryString(wantedCdn.baseUrl, requestParameters); } - return resolveURL(wantedCdn.baseUrl, segment.url); + return appendQueryString(resolveURL(wantedCdn.baseUrl, segment.url), requestParameters); +} + +/** Append a raw query string while preserving a possible URL fragment. */ +function appendQueryString( + url: string, + requestParameters: IRequestParameters | undefined, +): string { + const queryStrings = requestParameters?.urlQuery + ?.filter( + (query) => + !query.sameOriginOnly || + (query.sourceUrl !== undefined && areSameOrigin(query.sourceUrl, url)), + ) + .map((query) => query.value) + .filter((query) => query.length > 0); + if (queryStrings === undefined || queryStrings.length === 0) { + return url; + } + return appendURLQueryString(url, queryStrings.join("&")); } diff --git a/src/transports/dash/segment_loader.ts b/src/transports/dash/segment_loader.ts index c8277874ac..e948b6a03d 100644 --- a/src/transports/dash/segment_loader.ts +++ b/src/transports/dash/segment_loader.ts @@ -161,7 +161,11 @@ export default function generateSegmentLoader({ | ISegmentLoaderResultSegmentCreated | ISegmentLoaderResultChunkedComplete > { - const url = constructSegmentUrl(wantedCdn, context.segment); + const url = constructSegmentUrl( + wantedCdn, + context.segment, + context.segment.isInit ? options.requestData?.init : options.requestData?.segment, + ); if (url === null) { return Promise.resolve({ resultType: "segment-created", diff --git a/src/transports/dash/text_loader.ts b/src/transports/dash/text_loader.ts index 7bace88a58..8f8608dd13 100644 --- a/src/transports/dash/text_loader.ts +++ b/src/transports/dash/text_loader.ts @@ -73,7 +73,11 @@ export default function generateTextTrackLoader({ > { const { segment } = context; - const initialUrl = constructSegmentUrl(wantedCdn, segment); + const initialUrl = constructSegmentUrl( + wantedCdn, + segment, + segment.isInit ? options.requestData?.init : options.requestData?.segment, + ); if (initialUrl === null) { return Promise.resolve({ resultType: "segment-created", diff --git a/src/transports/dash/thumbnails.ts b/src/transports/dash/thumbnails.ts index 02c8a319ce..c82fdbe271 100644 --- a/src/transports/dash/thumbnails.ts +++ b/src/transports/dash/thumbnails.ts @@ -26,7 +26,11 @@ export async function loadThumbnail( options: IThumbnailLoaderOptions, cancelSignal: CancellationSignal, ): Promise> { - const initialUrl = constructSegmentUrl(wantedCdn, thumbnail); + const initialUrl = constructSegmentUrl( + wantedCdn, + thumbnail, + options.requestData?.segment, + ); if (initialUrl === null) { return Promise.reject(new Error("Cannot load thumbnail: no URL")); } diff --git a/src/transports/types.ts b/src/transports/types.ts index f87a5951c0..e5e6c2e806 100644 --- a/src/transports/types.ts +++ b/src/transports/types.ts @@ -17,7 +17,7 @@ import type { IInbandEvent } from "../core/types.ts"; import type { IManifest, ISegment } from "../manifest/index.ts"; import type { IThumbnailTrackMetadata } from "../manifest/types.ts"; -import type { ICdnMetadata } from "../parsers/manifest/index.ts"; +import type { ICdnMetadata, IRequestData } from "../parsers/manifest/index.ts"; import type { ITrackType, ILoadedManifestFormat, @@ -245,6 +245,8 @@ export interface ISegmentLoaderOptions { * the request. */ cmcdPayload: ICmcdPayload | undefined; + /** Transport-specific parameters associated with the resource's requests. */ + requestData: IRequestData | undefined; } /** diff --git a/src/utils/url-utils.ts b/src/utils/url-utils.ts index 83a0b7b2df..6147ed6100 100644 --- a/src/utils/url-utils.ts +++ b/src/utils/url-utils.ts @@ -280,6 +280,73 @@ function isAbsoluteURL(url: string): boolean { return parseURL(url).scheme.length > 0; } +/** Return the query string of a URL, without its leading question mark. */ +function getQueryString(url: string): string { + return parseURL(url).query; +} + +/** + * Append an already-encoded query string to a URL without parsing or + * normalizing either the existing query or the appended one. + */ +function appendURLQueryString(url: string, queryString: string): string { + if (queryString.length === 0) { + return url; + } + const urlParts = parseURL(url); + const query = + urlParts.query.length === 0 ? queryString : `${urlParts.query}&${queryString}`; + return formatURL({ ...urlParts, query }); +} + +/** Compare the RFC 6454 origins of two absolute URLs. */ +function areSameOrigin(firstUrl: string, secondUrl: string): boolean { + const first = parseURL(firstUrl); + const second = parseURL(secondUrl); + if ( + first.scheme.length === 0 || + first.authority.length === 0 || + second.scheme.length === 0 || + second.authority.length === 0 + ) { + return false; + } + const firstScheme = first.scheme.toLowerCase(); + const secondScheme = second.scheme.toLowerCase(); + return ( + firstScheme === secondScheme && + normalizeAuthority(firstScheme, first.authority) === + normalizeAuthority(secondScheme, second.authority) + ); +} + +function normalizeAuthority(scheme: string, authority: string): string { + const authorityWithoutUserInfo = authority.substring(authority.lastIndexOf("@") + 1); + let host: string; + let port: string; + if (authorityWithoutUserInfo[0] === "[") { + const closingBracket = authorityWithoutUserInfo.indexOf("]"); + if (closingBracket < 0) { + return authorityWithoutUserInfo.toLowerCase(); + } + host = authorityWithoutUserInfo.substring(0, closingBracket + 1); + port = authorityWithoutUserInfo.substring(closingBracket + 1); + } else { + const colonIndex = authorityWithoutUserInfo.lastIndexOf(":"); + if (colonIndex < 0) { + host = authorityWithoutUserInfo; + port = ""; + } else { + host = authorityWithoutUserInfo.substring(0, colonIndex); + port = authorityWithoutUserInfo.substring(colonIndex); + } + } + if ((scheme === "http" && port === ":80") || (scheme === "https" && port === ":443")) { + port = ""; + } + return host.toLowerCase() + port; +} + /** * Removes "." and ".." from the URL path, as described by the algorithm * in RFC 3986 Section 5.2.4. Remove Dot Segments @@ -361,4 +428,12 @@ function resolveURL(...args: Array): string { } } -export { getFilenameIndexInUrl, getRelativeUrl, isAbsoluteURL, resolveURL }; +export { + appendURLQueryString, + areSameOrigin, + getFilenameIndexInUrl, + getQueryString, + getRelativeUrl, + isAbsoluteURL, + resolveURL, +}; diff --git a/tests/contents/static/DASH_static_AnnexI/index.js b/tests/contents/static/DASH_static_AnnexI/index.js new file mode 100644 index 0000000000..f2acfc52c2 --- /dev/null +++ b/tests/contents/static/DASH_static_AnnexI/index.js @@ -0,0 +1,15 @@ +const BASE_URL = + "http://" + + __TEST_CONTENT_SERVER__.URL + + ":" + + __TEST_CONTENT_SERVER__.PORT + + "/DASH_static_AnnexI/media/"; + +export default { + url: BASE_URL + "manifest.mpd", + transport: "dash", + expectedRequests: { + videoInit: BASE_URL + "init.mp4?from=network&kind=video", + videoMedia: BASE_URL + "segment-1.m4s?from=network&kind=video", + }, +}; diff --git a/tests/contents/static/DASH_static_AnnexI/media/manifest.mpd b/tests/contents/static/DASH_static_AnnexI/media/manifest.mpd new file mode 100644 index 0000000000..b326f655f3 --- /dev/null +++ b/tests/contents/static/DASH_static_AnnexI/media/manifest.mpd @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/contents/static/DASH_static_AnnexI/media/text-1.m4s b/tests/contents/static/DASH_static_AnnexI/media/text-1.m4s new file mode 100644 index 0000000000..c9a07e48f6 Binary files /dev/null and b/tests/contents/static/DASH_static_AnnexI/media/text-1.m4s differ diff --git a/tests/contents/static/DASH_static_AnnexI/media/text-init.mp4 b/tests/contents/static/DASH_static_AnnexI/media/text-init.mp4 new file mode 100644 index 0000000000..1fcca05246 Binary files /dev/null and b/tests/contents/static/DASH_static_AnnexI/media/text-init.mp4 differ diff --git a/tests/contents/static/DASH_static_AnnexI/urls.mjs b/tests/contents/static/DASH_static_AnnexI/urls.mjs new file mode 100644 index 0000000000..1f8587f167 --- /dev/null +++ b/tests/contents/static/DASH_static_AnnexI/urls.mjs @@ -0,0 +1,47 @@ +/* eslint-env node */ + +import * as path from "path"; +import { fileURLToPath } from "url"; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const BASE_URL = "/DASH_static_AnnexI/media/"; +const SEGMENT_TIMELINE_MEDIA = path.join( + currentDirectory, + "..", + "DASH_static_SegmentTimeline", + "media", + "dash", +); + +export default [ + { + url: BASE_URL + "manifest.mpd", + path: path.join(currentDirectory, "media", "manifest.mpd"), + contentType: "application/dash+xml", + }, + { + url: BASE_URL + "init.mp4?from=network&kind=video", + path: path.join(SEGMENT_TIMELINE_MEDIA, "ateam-video=400000.dash"), + contentType: "video/mp4", + }, + { + url: BASE_URL + "segment-1.m4s?from=network&kind=video", + path: path.join(SEGMENT_TIMELINE_MEDIA, "ateam-video=400000-0.dash"), + contentType: "video/mp4", + }, + { + url: BASE_URL + "text-init.mp4?from=network&kind=text", + path: path.join(currentDirectory, "media", "text-init.mp4"), + contentType: "application/mp4", + }, + { + url: BASE_URL + "text-1.m4s?from=network&kind=text", + path: path.join(currentDirectory, "media", "text-1.m4s"), + contentType: "application/mp4", + }, + { + url: BASE_URL + "thumbnail-1.jpg?from=network&kind=thumbnail", + path: path.join(SEGMENT_TIMELINE_MEDIA, "thumbnails_320x180-tile_1.jpg"), + contentType: "image/jpeg", + }, +]; diff --git a/tests/contents/static/urls.mjs b/tests/contents/static/urls.mjs index 7527fbefdf..7a43ec9c65 100644 --- a/tests/contents/static/urls.mjs +++ b/tests/contents/static/urls.mjs @@ -16,6 +16,7 @@ import urls13 from "./DASH_dynamic_SegmentTemplate_UnsupportedAudio/urls.mjs"; import urls14 from "./imagetracks/urls.mjs"; import urls15 from "./DASH_static_audio_tag/urls.mjs"; import urls16 from "./DASH_static_Large_MultiPeriod/urls.mjs"; +import urls17 from "./DASH_static_AnnexI/urls.mjs"; export default [ ...urls1, @@ -34,4 +35,5 @@ export default [ ...urls14, ...urls15, ...urls16, + ...urls17, ]; diff --git a/tests/integration/scenarios/dash_annex_i.test.js b/tests/integration/scenarios/dash_annex_i.test.js new file mode 100644 index 0000000000..7d2b13f7a4 --- /dev/null +++ b/tests/integration/scenarios/dash_annex_i.test.js @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import RxPlayer from "../../../dist/es2017"; +import { MULTI_THREAD } from "../../../dist/es2017/experimental/features/index.js"; +import { EMBEDDED_DASH_WASM } from "../../../dist/es2017/__GENERATED_CODE/index.js"; +import annexINetworkInfos from "../../contents/static/DASH_static_AnnexI"; +import TestWorkerEmbed from "../../embedded_worker_bundle"; +import { checkAfterSleepWithBackoff } from "../../utils/checkAfterSleepWithBackoff.js"; +import { waitForLoadedStateAfterLoadVideo } from "../../utils/waitForPlayerState"; + +const VIRTUAL_BASE_URL = + "http://" + + __TEST_CONTENT_SERVER__.URL + + ":" + + __TEST_CONTENT_SERVER__.PORT + + "/DASH_static_AnnexI/virtual/"; + +const EXTENDED_MPD = ` + + + + + + + + + + + + + + + + + + + + +`; + +const BASELINE_MPD = ` + + + + + + + + + + + + + + +`; + +runDashAnnexIIntegrationTests(); +runDashAnnexIIntegrationTests({ multithread: true }); + +function runDashAnnexIIntegrationTests({ multithread } = {}) { + let title = "DASH Annex I segment requests"; + if (multithread === true) { + RxPlayer.addFeatures([MULTI_THREAD]); + title += " with worker"; + } + + describe(title, () => { + let player; + let textTrackElement; + + beforeEach(() => { + player = new RxPlayer(); + textTrackElement = null; + if (multithread === true) { + player.attachWorker({ + workerUrl: TestWorkerEmbed, + dashWasmUrl: EMBEDDED_DASH_WASM, + }); + } + }); + + afterEach(() => { + player.dispose(); + textTrackElement?.remove(); + }); + + it("should expose inherited 2016 parameters on initialization requests", async () => { + await expectSegmentRequest({ + player, + multithread, + mpd: EXTENDED_MPD, + manifestUrl: VIRTUAL_BASE_URL + "manifest.mpd?mpd=source%26value", + expectedRequest: { + url: + VIRTUAL_BASE_URL + + "init.mp4?mpd=source%26value&root=one&period=two&adaptation=three" + + "&selected=a%26b&cash=$&unknown=", + isInit: true, + trackType: "video", + }, + }); + }); + + it("should expose inherited 2014 parameters on media requests", async () => { + await expectSegmentRequest({ + player, + multithread, + mpd: BASELINE_MPD, + manifestUrl: VIRTUAL_BASE_URL + "manifest.mpd?mpd=source%26value", + expectedRequest: { + url: + VIRTUAL_BASE_URL + + "segment-1.m4s?representation=value%2Fencoded&mpd=source%26value&root=one", + isInit: false, + trackType: "video", + }, + }); + }); + + it("should load video, text, and thumbnail resources with Annex I queries", async () => { + const requestedSegments = []; + textTrackElement = document.createElement("div"); + document.body.appendChild(textTrackElement); + if (multithread === true) { + const workerInterface = player.getWorkerInterface(); + expect(workerInterface).not.toBeNull(); + workerInterface.addMessageListener("segment-loader", (info) => { + requestedSegments.push(info); + }); + } + + player.loadVideo({ + url: annexINetworkInfos.url, + transport: annexINetworkInfos.transport, + mode: multithread === true ? "multithread" : "main", + textTrackMode: "html", + textTrackElement, + segmentLoader: { + fn: (info, callbacks) => { + requestedSegments.push(info); + callbacks.fallback(); + }, + workerId: "default-segment-loader", + }, + }); + await waitForLoadedStateAfterLoadVideo(player); + + expect(player.getError()).toBeNull(); + expect(requestedSegments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + url: annexINetworkInfos.expectedRequests.videoInit, + isInit: true, + trackType: "video", + }), + expect.objectContaining({ + url: annexINetworkInfos.expectedRequests.videoMedia, + isInit: false, + trackType: "video", + }), + ]), + ); + + const textTracks = player.getAvailableTextTracks(); + expect(textTracks).toHaveLength(1); + player.setTextTrack(textTracks[0].id); + player.seekTo(0.5); + await checkAfterSleepWithBackoff({ maxTimeMs: 5000, stepMs: 100 }, () => { + expect(textTrackElement.textContent).toContain("Subtitle in the first segment."); + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + try { + await player.renderThumbnail({ container, time: 0.5 }); + expect(container.childElementCount).toBe(1); + } finally { + container.remove(); + } + }, 20000); + }); +} + +function expectSegmentRequest({ + player, + multithread, + mpd, + manifestUrl, + expectedRequest, +}) { + return new Promise((resolve, reject) => { + let hasFinished = false; + const onSegmentRequest = (request) => { + if ( + hasFinished || + request.isInit !== expectedRequest.isInit || + request.trackType !== expectedRequest.trackType + ) { + return; + } + hasFinished = true; + try { + expect(request).toMatchObject(expectedRequest); + player.stop(); + resolve(); + } catch (error) { + reject(error); + } + }; + + if (multithread === true) { + const workerInterface = player.getWorkerInterface(); + expect(workerInterface).not.toBeNull(); + workerInterface.sendMessage("fake-manifest", mpd); + workerInterface.addMessageListener("segment-loader", onSegmentRequest); + } + + player.addEventListener("error", (error) => { + if (!hasFinished) { + hasFinished = true; + reject(error); + } + }); + player.loadVideo({ + url: manifestUrl, + transport: "dash", + mode: multithread === true ? "multithread" : "main", + manifestLoader: { + fn: (_info, callbacks) => { + callbacks.resolve({ data: mpd }); + }, + workerId: "fake-manifest-manifest-loader", + }, + segmentLoader: { + fn: onSegmentRequest, + workerId: "hanging-segment-loader", + }, + }); + }); +} diff --git a/tests/unit/mocks/manifest.ts b/tests/unit/mocks/manifest.ts index b283d83618..2e815cfbb6 100644 --- a/tests/unit/mocks/manifest.ts +++ b/tests/unit/mocks/manifest.ts @@ -177,6 +177,7 @@ export const DummyRepresentation = makeMockedClass( frameRate: undefined, hdrInfo: undefined, contentProtections: undefined, + requestData: undefined, }, ); diff --git a/tests/unit/src/parsers/manifest/dash/common/parse_url_query_info.test.ts b/tests/unit/src/parsers/manifest/dash/common/parse_url_query_info.test.ts new file mode 100644 index 0000000000..6ef56c5398 --- /dev/null +++ b/tests/unit/src/parsers/manifest/dash/common/parse_url_query_info.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; +import { + combineUrlQueryInfo, + parseUrlQueryInfo, +} from "../../../../../../../src/parsers/manifest/dash/common/parse_url_query_info.ts"; +import type { + IDescriptorIntermediateRepresentation, + IUrlQueryInfoIntermediateRepresentation, +} from "../../../../../../../src/parsers/manifest/dash/node_parser_types.ts"; + +function createDescriptor( + scheme: "2014" | "2016", + attributes: IUrlQueryInfoIntermediateRepresentation["attributes"], +): IDescriptorIntermediateRepresentation { + const queryInfo = { attributes }; + return { + attributes: { schemeIdUri: `urn:mpeg:dash:urlparam:${scheme}` }, + children: { + UrlQueryInfo: scheme === "2014" ? [queryInfo] : [], + ExtUrlQueryInfo: scheme === "2016" ? [queryInfo] : [], + }, + }; +} + +describe("DASH Annex I URL query information", () => { + it("ignores unrelated descriptors", () => { + const descriptor: IDescriptorIntermediateRepresentation = { + attributes: { schemeIdUri: "urn:example" }, + children: { UrlQueryInfo: [], ExtUrlQueryInfo: [] }, + }; + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd?a=1"), + ).toBeUndefined(); + }); + + it("uses MPD and explicit query strings as template sources", () => { + const descriptor = createDescriptor("2014", { + queryString: "b=2", + queryTemplate: "$querypart$", + useMpdUrlQuery: true, + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd?a=1#fragment"), + ).toEqual({ + scheme: "2014", + queryString: "a=1&b=2", + sameOriginOnly: false, + sourceUrl: "https://example.com/a.mpd?a=1#fragment", + appliesTo: { segment: true, init: true }, + }); + }); + + it("substitutes selected parameters, keeps the last value and escapes dollars", () => { + const descriptor = createDescriptor("2016", { + queryString: "token=first&token=last&unused=x", + queryTemplate: "renamed=$query:token$&missing=$query:missing$&price=$$1", + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toMatchObject({ + queryString: "renamed=last&missing=&price=$1", + }); + }); + + it("preserves encoded source values during substitution", () => { + const descriptor = createDescriptor("2016", { + queryString: "token=a%26b%3Dc", + queryTemplate: "token=$query:token$", + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toMatchObject({ queryString: "token=a%26b%3Dc" }); + }); + + it("replaces unknown template identifiers with an empty string", () => { + const descriptor = createDescriptor("2014", { + queryString: "a=1", + queryTemplate: "a=$unknown$", + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toMatchObject({ queryString: "a=" }); + }); + + it("requires a query template", () => { + const descriptor = createDescriptor("2014", { queryString: "a=1" }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toBeUndefined(); + }); + + it("applies 2016 instructions to segment and wildcard request types", () => { + const segment = createDescriptor("2016", { + includeInRequests: "mpd segment", + queryString: "a=1", + queryTemplate: "$querypart$", + }); + const wildcard = createDescriptor("2016", { + includeInRequests: "*", + queryString: "a=1", + queryTemplate: "$querypart$", + }); + expect(parseUrlQueryInfo([segment], [], "https://example.com/a.mpd")).toMatchObject({ + appliesTo: { segment: true, init: true }, + }); + expect(parseUrlQueryInfo([wildcard], [], "https://example.com/a.mpd")).toMatchObject({ + appliesTo: { segment: true, init: true }, + }); + }); + + it("applies init-only 2016 instructions only to initialization segments", () => { + const descriptor = createDescriptor("2016", { + includeInRequests: "init", + queryString: "a=1", + queryTemplate: "$querypart$", + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toMatchObject({ appliesTo: { segment: false, init: true } }); + }); + + it("defaults 2016 instructions to segment requests", () => { + const descriptor = createDescriptor("2016", { + queryString: "a=1", + queryTemplate: "$querypart$", + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toMatchObject({ appliesTo: { segment: true, init: true } }); + }); + + it("ignores 2016 instructions for other request types", () => { + const descriptor = createDescriptor("2016", { + includeInRequests: "mpd steering", + queryString: "a=1", + queryTemplate: "$querypart$", + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toBeUndefined(); + }); + + it("prioritizes the first EssentialProperty over SupplementalProperty", () => { + const firstEssential = createDescriptor("2014", { + queryString: "essential=first", + queryTemplate: "$querypart$", + }); + const secondEssential = createDescriptor("2016", { + queryString: "essential=second", + queryTemplate: "$querypart$", + }); + const supplemental = createDescriptor("2016", { + queryString: "supplemental=true", + queryTemplate: "$querypart$", + }); + expect( + parseUrlQueryInfo( + [firstEssential, secondEssential], + [supplemental], + "https://example.com/a.mpd", + ), + ).toMatchObject({ scheme: "2014", queryString: "essential=first" }); + }); + + it("uses the first SupplementalProperty when no EssentialProperty matches", () => { + const first = createDescriptor("2016", { + queryString: "first=true", + queryTemplate: "$querypart$", + }); + const second = createDescriptor("2014", { + queryString: "second=true", + queryTemplate: "$querypart$", + }); + expect( + parseUrlQueryInfo([], [first, second], "https://example.com/a.mpd"), + ).toMatchObject({ scheme: "2016", queryString: "first=true" }); + }); + + it("retains same-origin restrictions for the 2016 scheme", () => { + const descriptor = createDescriptor("2016", { + queryString: "token=1", + queryTemplate: "$querypart$", + sameOriginOnly: true, + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toMatchObject({ sameOriginOnly: true, sourceUrl: "https://example.com/a.mpd" }); + }); + + it("does not apply the extended same-origin attribute to the 2014 scheme", () => { + const descriptor = createDescriptor("2014", { + queryString: "token=1", + queryTemplate: "$querypart$", + sameOriginOnly: true, + }); + expect( + parseUrlQueryInfo([descriptor], [], "https://example.com/a.mpd"), + ).toMatchObject({ sameOriginOnly: false }); + }); + + it("orders 2016 from parent to child and 2014 from child to parent", () => { + expect( + combineUrlQueryInfo([ + { + scheme: "2014", + queryString: "mpd-2014", + sameOriginOnly: false, + appliesTo: { segment: true, init: true }, + }, + { + scheme: "2016", + queryString: "period-2016", + sameOriginOnly: false, + appliesTo: { segment: true, init: true }, + }, + { + scheme: "2014", + queryString: "representation-2014", + sameOriginOnly: false, + appliesTo: { segment: true, init: true }, + }, + { + scheme: "2016", + queryString: "representation-2016", + sameOriginOnly: false, + appliesTo: { segment: true, init: true }, + }, + ]).map((info) => info.queryString), + ).toEqual(["period-2016", "representation-2016", "representation-2014", "mpd-2014"]); + }); +}); diff --git a/tests/unit/src/parsers/manifest/dash/js-parser/node_parsers/Descriptor.test.ts b/tests/unit/src/parsers/manifest/dash/js-parser/node_parsers/Descriptor.test.ts index 8d6a0dec9a..099a9df34b 100644 --- a/tests/unit/src/parsers/manifest/dash/js-parser/node_parsers/Descriptor.test.ts +++ b/tests/unit/src/parsers/manifest/dash/js-parser/node_parsers/Descriptor.test.ts @@ -40,7 +40,8 @@ describe("DASH Node Parsers - Descriptor", () => { const element = parseXml( ` - + `, )[0] as ITNode; @@ -52,6 +53,7 @@ describe("DASH Node Parsers - Descriptor", () => { attributes: { queryTemplate: "$querypart$", includeInRequests: "mpd segment", + sameOriginOnly: true, }, }, ], @@ -67,4 +69,13 @@ describe("DASH Node Parsers - Descriptor", () => { const [, warnings] = parseDescriptor(element); expect(warnings).toHaveLength(1); }); + + it("forwards warnings from an invalid sameOriginOnly attribute", () => { + const element = parseXml( + '', + )[0] as ITNode; + + const [, warnings] = parseDescriptor(element); + expect(warnings).toHaveLength(1); + }); }); diff --git a/tests/unit/src/transports/dash/construct_segment_url.test.ts b/tests/unit/src/transports/dash/construct_segment_url.test.ts new file mode 100644 index 0000000000..6d8ef0357d --- /dev/null +++ b/tests/unit/src/transports/dash/construct_segment_url.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { ISegment } from "../../../../../src/manifest/index.ts"; +import type { IRequestParameters } from "../../../../../src/parsers/manifest/index.ts"; +import constructSegmentUrl from "../../../../../src/transports/dash/construct_segment_url.ts"; + +const segment: ISegment = { + id: "1", + isInit: false, + time: 0, + end: 1, + duration: 1, + timescale: 1, + complete: true, + privateInfos: {}, + url: "segment.m4s", +}; + +function parameters( + ...urlQuery: Array<{ + value: string; + sameOriginOnly?: boolean; + sourceUrl?: string; + }> +): IRequestParameters { + return { + urlQuery: urlQuery.map((query) => ({ + value: query.value, + sameOriginOnly: query.sameOriginOnly ?? false, + sourceUrl: query.sourceUrl, + })), + }; +} + +describe("DASH constructSegmentUrl", () => { + it("returns null without CDN metadata", () => { + expect(constructSegmentUrl(null, segment)).toBeNull(); + }); + + it("resolves segment URLs without request parameters", () => { + expect(constructSegmentUrl({ baseUrl: "https://example.com/path/" }, segment)).toBe( + "https://example.com/path/segment.m4s", + ); + }); + + it("uses the BaseURL for a null segment URL", () => { + expect( + constructSegmentUrl( + { baseUrl: "https://example.com/path/?base=1" }, + { ...segment, url: null }, + parameters({ value: "token=2" }), + ), + ).toBe("https://example.com/path/?base=1&token=2"); + }); + + it("appends query strings to the resolved segment URL", () => { + expect( + constructSegmentUrl( + { baseUrl: "https://example.com/path/" }, + segment, + parameters({ value: "a=1" }, { value: "b=2" }), + ), + ).toBe("https://example.com/path/segment.m4s?a=1&b=2"); + }); + + it("preserves existing queries and URL fragments", () => { + expect( + constructSegmentUrl( + { baseUrl: "https://example.com/path/" }, + { ...segment, url: "segment.m4s?existing=1#fragment" }, + parameters({ value: "added=2" }), + ), + ).toBe("https://example.com/path/segment.m4s?existing=1&added=2#fragment"); + }); + + it("adds same-origin-only query strings on the same origin", () => { + expect( + constructSegmentUrl( + { baseUrl: "https://EXAMPLE.com:443/path/" }, + segment, + parameters({ + value: "token=1", + sameOriginOnly: true, + sourceUrl: "https://example.COM/manifest.mpd", + }), + ), + ).toBe("https://EXAMPLE.com:443/path/segment.m4s?token=1"); + }); + + it("drops same-origin-only query strings on another origin", () => { + expect( + constructSegmentUrl( + { baseUrl: "https://cdn.example.com/path/" }, + segment, + parameters( + { + value: "private=1", + sameOriginOnly: true, + sourceUrl: "https://manifest.example.com/manifest.mpd", + }, + { value: "public=2" }, + ), + ), + ).toBe("https://cdn.example.com/path/segment.m4s?public=2"); + }); + + it("drops restricted parameters when their source URL is unknown", () => { + expect( + constructSegmentUrl( + { baseUrl: "https://example.com/path/" }, + segment, + parameters({ value: "token=1", sameOriginOnly: true }), + ), + ).toBe("https://example.com/path/segment.m4s"); + }); + + it("does not append empty query strings", () => { + expect( + constructSegmentUrl( + { baseUrl: "https://example.com/path/" }, + segment, + parameters({ value: "" }), + ), + ).toBe("https://example.com/path/segment.m4s"); + }); +}); diff --git a/tests/unit/src/utils/url-utils.test.ts b/tests/unit/src/utils/url-utils.test.ts index 0870ab35b7..79b95fa9ee 100644 --- a/tests/unit/src/utils/url-utils.test.ts +++ b/tests/unit/src/utils/url-utils.test.ts @@ -1,11 +1,50 @@ import { describe, it, expect } from "vitest"; import { + appendURLQueryString, + areSameOrigin, getFilenameIndexInUrl, + getQueryString, getRelativeUrl, isAbsoluteURL, resolveURL, } from "../../../../src/utils/url-utils.ts"; +describe("utils - DASH Annex I URL helpers", () => { + it("extracts an encoded query string without its fragment", () => { + expect(getQueryString("https://example.com/a.mpd?a=1%262&b=3#fragment")).toBe( + "a=1%262&b=3", + ); + expect(getQueryString("https://example.com/a.mpd#fragment")).toBe(""); + }); + + it("appends an encoded query string before a URL fragment", () => { + expect( + appendURLQueryString( + "https://example.com/segment.m4s?existing=1#fragment", + "added=a%26b", + ), + ).toBe("https://example.com/segment.m4s?existing=1&added=a%26b#fragment"); + }); + + it("compares HTTP origins with normalized hosts and default ports", () => { + expect( + areSameOrigin( + "https://USER@example.COM/manifest.mpd", + "https://example.com:443/segment.m4s", + ), + ).toBe(true); + expect(areSameOrigin("http://example.com/a", "http://EXAMPLE.com:80/b")).toBe(true); + }); + + it("rejects different and unknown origins", () => { + expect(areSameOrigin("https://example.com/a", "http://example.com/a")).toBe(false); + expect(areSameOrigin("https://example.com/a", "https://example.com:444/a")).toBe( + false, + ); + expect(areSameOrigin("/relative-a", "/relative-b")).toBe(false); + }); +}); + describe(`utils - isAbsoluteURL ${isAbsoluteURL.name}`, () => { it("should identify URLs containing an RFC 3986 scheme", () => { expect(isAbsoluteURL("https://example.com/manifest.mpd")).toBe(true);