diff --git a/deno/package.json b/deno/package.json index 09cbba11..88286d9e 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "5.2.1", + "version": "5.3.0", "description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).", "type": "module", "types": "./dist/src/platform/lib.d.ts", diff --git a/deno/src/core/endpoints/PlayerEndpoint.ts b/deno/src/core/endpoints/PlayerEndpoint.ts index 5a28edcb..069d391c 100644 --- a/deno/src/core/endpoints/PlayerEndpoint.ts +++ b/deno/src/core/endpoints/PlayerEndpoint.ts @@ -8,6 +8,11 @@ export const PATH = '/player'; * @returns The payload. */ export function build(opts: PlayerEndpointOptions): IPlayerRequest { + const is_android = + opts.client === 'ANDROID' || + opts.client === 'YTMUSIC_ANDROID' || + opts.client === 'YTSTUDIO_ANDROID'; + return { playbackContext: { contentPlaybackContext: { @@ -37,8 +42,8 @@ export function build(opts: PlayerEndpointOptions): IPlayerRequest { ...{ client: opts.client, playlistId: opts.playlist_id, - // Workaround streaming URLs returning 403 when using Android clients and throttling in web clients. - params: '2AMBCgIQBg' + // Workaround streaming URLs returning 403 or getting throttled when using Android based clients. + params: is_android ? '2AMBCgIQBg' : opts.params } }; } \ No newline at end of file diff --git a/deno/src/parser/classes/misc/Format.ts b/deno/src/parser/classes/misc/Format.ts index 277f533a..93a36ccb 100644 --- a/deno/src/parser/classes/misc/Format.ts +++ b/deno/src/parser/classes/misc/Format.ts @@ -46,6 +46,12 @@ export default class Format { is_descriptive?: boolean; is_original?: boolean; + color_info?: { + primaries?: string; + transfer_characteristics?: string; + matrix_coefficients?: string; + }; + constructor(data: RawNode) { this.itag = data.itag; this.mime_type = data.mimeType; @@ -81,14 +87,24 @@ export default class Format { this.has_audio = !!data.audioBitrate || !!data.audioQuality; this.has_video = !!data.qualityLabel; + this.color_info = data.colorInfo ? { + primaries: data.colorInfo.primaries?.replace('COLOR_PRIMARIES_', ''), + transfer_characteristics: data.colorInfo.transferCharacteristics?.replace('COLOR_TRANSFER_CHARACTERISTICS_', ''), + matrix_coefficients: data.colorInfo.matrixCoefficients?.replace('COLOR_MATRIX_COEFFICIENTS_', '') + } : undefined; + if (this.has_audio) { const args = new URLSearchParams(this.cipher || this.signature_cipher); const url_components = new URLSearchParams(args.get('url') || this.url); - this.language = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('lang='))?.split('=').at(1) || null; - this.is_dubbed = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'dubbed'; - this.is_descriptive = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'descriptive'; - this.is_original = url_components.get('xtags')?.split(':').find((x: string) => x.startsWith('acont='))?.split('=').at(1) === 'original' || !this.is_dubbed; + const xtags = url_components.get('xtags')?.split(':'); + + const audio_content = xtags?.find((x) => x.startsWith('acont='))?.split('=')[1]; + + this.language = xtags?.find((x: string) => x.startsWith('lang='))?.split('=')[1] || null; + this.is_dubbed = audio_content === 'dubbed'; + this.is_descriptive = audio_content === 'descriptive'; + this.is_original = audio_content === 'original' || !this.is_dubbed || !this.is_descriptive; if (Reflect.has(data, 'audioTrack')) { this.audio_track = { diff --git a/deno/src/utils/FormatUtils.ts b/deno/src/utils/FormatUtils.ts index d4d9bf67..64371398 100644 --- a/deno/src/utils/FormatUtils.ts +++ b/deno/src/utils/FormatUtils.ts @@ -373,10 +373,17 @@ class FormatUtils { label: first_format.audio_track?.display_name as string }, children); + const hoisted: string[] = []; + + this.#hoistCodecsIfPossible(set, track_objects[j], hoisted); + this.#hoistNumberAttributeIfPossible(set, track_objects[j], 'audioSamplingRate', 'audio_sample_rate', hoisted); + + this.#hoistAudioChannelsIfPossible(document, set, track_objects[j], hoisted); + period.appendChild(set); for (const format of track_objects[j]) { - await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions); + await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions); } } } else { @@ -387,43 +394,174 @@ class FormatUtils { subsegmentAlignment: 'true' }); + const color_info = mime_objects[i][0].color_info; + if (typeof color_info !== 'undefined') { + // Section 5.5 Video source metadata signalling https://dashif.org/docs/IOP-Guidelines/DASH-IF-IOP-Part7-v5.0.0.pdf + // Section 8 Video code points https://www.itu.int/rec/T-REC-H.273-202107-I/en + // The player.js file was also helpful + + if (color_info.primaries) { + let primaries = ''; + + switch (color_info.primaries) { + case 'BT709': + primaries = '1'; + break; + case 'BT2020': + primaries = '9'; + break; + } + + if (primaries !== '') { + set.appendChild(this.#el(document, 'EssentialProperty', { + schemeIdUri: 'urn:mpeg:mpegB:cicp:ColourPrimaries', + value: primaries + })); + } + } + + if (color_info.transfer_characteristics) { + let transfer_characteristics = ''; + + switch (color_info.transfer_characteristics) { + case 'BT709': + transfer_characteristics = '1'; + break; + case 'BT2020_10': + transfer_characteristics = '14'; + break; + case 'SMPTEST2084': + transfer_characteristics = '16'; + break; + case 'ARIB_STD_B67': + transfer_characteristics = '18'; + break; + } + + if (transfer_characteristics !== '') { + set.appendChild(this.#el(document, 'EssentialProperty', { + schemeIdUri: 'urn:mpeg:mpegB:cicp:TransferCharacteristics', + value: transfer_characteristics + })); + } + } + + + if (color_info.matrix_coefficients) { + let matrix_coefficients = ''; + + // This list is incomplete, as the player.js doesn't currently have any code for matrix coefficients, + // So it doesn't have a list like with the other two, so this is just based on what we've seen in responses + switch (color_info.matrix_coefficients) { + case 'BT709': + matrix_coefficients = '1'; + break; + case 'BT2020_NCL': + matrix_coefficients = '14'; + break; + default: { + const format = mime_objects[i][0]; + const url = new URL(format.url as string); + + const anonymisedFormat = JSON.parse(JSON.stringify(format)); + anonymisedFormat.url = 'REDACTED'; + anonymisedFormat.signature_cipher = 'REDACTED'; + anonymisedFormat.cipher = 'REDACTED'; + + console.warn(`YouTube.js toDash(): Unknown matrix coefficients "${color_info.matrix_coefficients}", the DASH manifest is still usuable without this.\n` + + `Please report it at ${Platform.shim.info.bugs_url} so we can add support for it.\n` + + `Innertube client: ${url.searchParams.get('c')}\nformat:`, anonymisedFormat); + } + } + + if (matrix_coefficients !== '') { + set.appendChild(this.#el(document, 'EssentialProperty', { + schemeIdUri: 'urn:mpeg:mpegB:cicp:MatrixCoefficients', + value: matrix_coefficients + })); + } + } + } + + const hoisted: string[] = []; + + this.#hoistCodecsIfPossible(set, mime_objects[i], hoisted); + + if (mime_objects[i][0].has_audio) { + this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'audioSamplingRate', 'audio_sample_rate', hoisted); + + this.#hoistAudioChannelsIfPossible(document, set, mime_objects[i], hoisted); + } else { + set.setAttribute('maxPlayoutRate', '1'); + + this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'frameRate', 'fps', hoisted); + } + period.appendChild(set); for (const format of mime_objects[i]) { if (format.has_video) { - await this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player, actions); + await this.#generateRepresentationVideo(document, set, format, url_transformer, hoisted, cpn, player, actions); } else { - await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions); + await this.#generateRepresentationAudio(document, set, format, url_transformer, hoisted, cpn, player, actions); } } } } } - static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) { - const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"'); + static #hoistCodecsIfPossible(set: Element, formats: Format[], hoisted: string[]) { + if (formats.length > 1 && new Set(formats.map((format) => getStringBetweenStrings(format.mime_type, 'codecs="', '"'))).size === 1) { + set.setAttribute('codecs', getStringBetweenStrings(formats[0].mime_type, 'codecs="', '"') as string); + hoisted.push('codecs'); + } + } + static #hoistNumberAttributeIfPossible(set: Element, formats: Format[], attribute: 'audioSamplingRate' | 'frameRate', property: 'audio_sample_rate' | 'fps', hoisted: string[]) { + if (formats.length > 1 && new Set(formats.map((format) => format.fps)).size === 1) { + set.setAttribute(attribute, formats[0][property]?.toString() as string); + hoisted.push(attribute); + } + } + + static #hoistAudioChannelsIfPossible(document: XMLDocument, set: Element, formats: Format[], hoisted: string[]) { + if (formats.length > 1 && new Set(formats.map((format) => format.audio_channels?.toString() || '2')).size === 1) { + set.appendChild( + this.#el(document, 'AudioChannelConfiguration', { + schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011', + value: formats[0].audio_channels?.toString() || '2' + }) + ); + hoisted.push('AudioChannelConfiguration'); + } + } + + static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) { const url = new URL(format.decipher(player)); url.searchParams.set('cpn', cpn || ''); const representation = this.#el(document, 'Representation', { id: format.itag?.toString(), - codecs, bandwidth: format.bitrate?.toString(), width: format.width?.toString(), - height: format.height?.toString(), - maxPlayoutRate: '1', - frameRate: format.fps?.toString() + height: format.height?.toString() }); + if (!hoisted.includes('codecs')) { + const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"'); + representation.setAttribute('codecs', codecs as string); + } + + if (!hoisted.includes('frameRate')) { + representation.setAttribute('frameRate', format.fps?.toString() as string); + } + set.appendChild(representation); await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions); } - static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) { - const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"'); - + static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, hoisted: string[], cpn?: string, player?: Player, actions?: Actions) { const url = new URL(format.decipher(player)); url.searchParams.set('cpn', cpn || ''); @@ -436,15 +574,26 @@ class FormatUtils { const representation = this.#el(document, 'Representation', { id, - codecs, - bandwidth: format.bitrate?.toString(), - audioSamplingRate: format.audio_sample_rate?.toString() - }, [ - this.#el(document, 'AudioChannelConfiguration', { - schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011', - value: format.audio_channels?.toString() || '2' - }) - ]); + bandwidth: format.bitrate?.toString() + }); + + if (!hoisted.includes('codecs')) { + const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"'); + representation.setAttribute('codecs', codecs as string); + } + + if (!hoisted.includes('audioSamplingRate')) { + representation.setAttribute('audioSamplingRate', format.audio_sample_rate?.toString() as string); + } + + if (!hoisted.includes('AudioChannelConfiguration')) { + representation.appendChild( + this.#el(document, 'AudioChannelConfiguration', { + schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011', + value: format.audio_channels?.toString() || '2' + }) + ); + } set.appendChild(representation);