From 031ffb696e3b7e160779e8b55a49b0cfa9f95620 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Tue, 28 May 2024 07:43:10 +0200 Subject: [PATCH] feat(toDash): Add support for stable volume/DRC (#662) --- src/core/mixins/MediaInfo.ts | 12 ++++- src/types/DashOptions.ts | 4 +- src/types/StreamingInfoOptions.ts | 21 ++++++++ src/utils/DashManifest.tsx | 22 ++++++--- src/utils/StreamingInfo.ts | 82 ++++++++++++++++++++++++------- 5 files changed, 114 insertions(+), 27 deletions(-) create mode 100644 src/types/StreamingInfoOptions.ts diff --git a/src/core/mixins/MediaInfo.ts b/src/core/mixins/MediaInfo.ts index 36fb3c15..e685dd5f 100644 --- a/src/core/mixins/MediaInfo.ts +++ b/src/core/mixins/MediaInfo.ts @@ -59,7 +59,17 @@ export default class MediaInfo { storyboards = player_response.storyboards; } - return FormatUtils.toDash(this.streaming_data, this.page[0].video_details?.is_post_live_dvr, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards); + return FormatUtils.toDash( + this.streaming_data, + this.page[0].video_details?.is_post_live_dvr, + url_transformer, + format_filter, + this.#cpn, + this.#actions.session.player, + this.#actions, + storyboards, + options + ); } /** diff --git a/src/types/DashOptions.ts b/src/types/DashOptions.ts index 9ee78015..593e4a00 100644 --- a/src/types/DashOptions.ts +++ b/src/types/DashOptions.ts @@ -1,4 +1,6 @@ -export interface DashOptions { +import type { StreamingInfoOptions } from './StreamingInfoOptions.js'; + +export interface DashOptions extends StreamingInfoOptions { /** * Include the storyboards in the DASH manifest when YouTube provides them. * Not all players support parsing and displaying storyboards. diff --git a/src/types/StreamingInfoOptions.ts b/src/types/StreamingInfoOptions.ts new file mode 100644 index 00000000..5d0982a6 --- /dev/null +++ b/src/types/StreamingInfoOptions.ts @@ -0,0 +1,21 @@ +export interface StreamingInfoOptions { + /** + * The label to use for the non-DRC streams when a video has DRC and streams. + * + * Defaults to `"Original"` + */ + label_original?: string; + /** + * The label to use for the DRC streams when a video has DRC streams. + * + * Defaults to `"Stable Volume"` + */ + label_drc?: string; + /** + * A function that generates the label to use for the DRC streams when a video has multiple audio tracks and DRC streams. + * The non-DRC streams use the unmodified audio track label provided by YouTube. + * + * Defaults to `(audio_track_display_name) => audio_track_display_name + " (Stable Volume)"` + */ + label_drc_mutiple?: (audio_track_display_name: string) => string; +} \ No newline at end of file diff --git a/src/utils/DashManifest.tsx b/src/utils/DashManifest.tsx index 51af5187..d8997b57 100644 --- a/src/utils/DashManifest.tsx +++ b/src/utils/DashManifest.tsx @@ -12,12 +12,14 @@ import type { PlayerStoryboardSpec } from '../parser/nodes.js'; import type { SegmentInfo as FSegmentInfo } from './StreamingInfo.js'; import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js'; import type PlayerLiveStoryboardSpec from '../parser/classes/PlayerLiveStoryboardSpec.js'; +import type { StreamingInfoOptions } from '../types/StreamingInfoOptions.js'; interface DashManifestProps { streamingData: IStreamingData; isPostLiveDvr: boolean; transformURL?: URLTransformer; rejectFormat?: FormatFilter; + options?: StreamingInfoOptions, cpn?: string; player?: Player; actions?: Actions; @@ -70,14 +72,15 @@ async function DashManifest({ cpn, player, actions, - storyboards + storyboards, + options }: DashManifestProps) { const { getDuration, audio_sets, video_sets, image_sets - } = getStreamingInfo(streamingData, isPostLiveDvr, transformURL, rejectFormat, cpn, player, actions, storyboards); + } = getStreamingInfo(streamingData, isPostLiveDvr, transformURL, rejectFormat, cpn, player, actions, storyboards, options); // XXX: DASH spec: https://standards.iso.org/ittf/PubliclyAvailableStandards/c083314_ISO_IEC%2023009-1_2022(en).zip @@ -104,11 +107,12 @@ async function DashManifest({ contentType="audio" > { - set.track_role && - + set.track_roles && set.track_roles.map((role) => ( + + )) } { set.track_name && @@ -237,7 +241,8 @@ export function toDash( cpn?: string, player?: Player, actions?: Actions, - storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec + storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec, + options?: StreamingInfoOptions ) { if (!streaming_data) throw new InnertubeError('Streaming data not available'); @@ -247,6 +252,7 @@ export function toDash( streamingData={streaming_data} isPostLiveDvr={is_post_live_dvr} transformURL={url_transformer} + options={options} rejectFormat={format_filter} cpn={cpn} player={player} diff --git a/src/utils/StreamingInfo.ts b/src/utils/StreamingInfo.ts index 696f6107..b2bb7c4a 100644 --- a/src/utils/StreamingInfo.ts +++ b/src/utils/StreamingInfo.ts @@ -11,6 +11,7 @@ import type { IStreamingData } from '../parser/index.js'; import type { Format } from '../parser/misc.js'; import type { PlayerLiveStoryboardSpec } from '../parser/nodes.js'; import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js'; +import type { StreamingInfoOptions } from '../types/StreamingInfoOptions.js'; const TAG_ = 'StreamingInfo'; @@ -27,7 +28,7 @@ export interface AudioSet { codecs?: string; audio_sample_rate?: number; track_name?: string; - track_role?: 'main' | 'dub' | 'description' | 'alternate'; + track_roles?: ('main' | 'dub' | 'description' | 'enhanced-audio-intelligibility' | 'alternate')[]; channels?: number; representations: AudioRepresentation[]; } @@ -130,6 +131,12 @@ interface SharedPostLiveDvrInfo { item?: PostLiveDvrInfo } +interface DrcLabels { + label_original: string; + label_drc: string; + label_drc_mutiple: (audio_track_display_name: string) => string; +} + function getFormatGroupings(formats: Format[], is_post_live_dvr: boolean) { const group_info = new Map(); @@ -149,7 +156,9 @@ function getFormatGroupings(formats: Format[], is_post_live_dvr: boolean) { const audio_track_id = format.audio_track?.id || ''; - const group_id = `${mime_type}-${just_codec}-${color_info}-${audio_track_id}`; + const drc = format.is_drc ? 'drc' : ''; + + const group_id = `${mime_type}-${just_codec}-${color_info}-${audio_track_id}-${drc}`; if (!group_info.has(group_id)) { group_info.set(group_id, []); @@ -373,8 +382,18 @@ function getAudioRepresentation( const url = new URL(format.decipher(player)); url.searchParams.set('cpn', cpn || ''); + const uid_parts = [ format.itag.toString() ]; + + if (format.audio_track) { + uid_parts.push(format.audio_track.id); + } + + if (format.is_drc) { + uid_parts.push('drc'); + } + const rep: AudioRepresentation = { - uid: format.audio_track ? `${format.itag}-${format.audio_track.id}` : format.itag.toString(), + uid: uid_parts.join('-'), bitrate: format.bitrate, codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined, audio_sample_rate: !hoisted.includes('audio_sample_rate') ? format.audio_sample_rate : undefined, @@ -385,22 +404,25 @@ function getAudioRepresentation( return rep; } -function getTrackRole(format: Format) { - const { audio_track } = format; - - if (!audio_track) +function getTrackRoles(format: Format, has_drc_streams: boolean) { + if (!format.audio_track && !has_drc_streams) { return; + } - if (audio_track.audio_is_default) - return 'main'; + const roles: ('main' | 'dub' | 'description' | 'enhanced-audio-intelligibility' | 'alternate')[] = [ + format.is_original ? 'main' : 'alternate' + ]; if (format.is_dubbed) - return 'dub'; + roles.push('dub'); if (format.is_descriptive) - return 'description'; + roles.push('description'); - return 'alternate'; + if (format.is_drc) + roles.push('enhanced-audio-intelligibility'); + + return roles; } function getAudioSet( @@ -409,19 +431,34 @@ function getAudioSet( actions?: Actions, player?: Player, cpn?: string, - shared_post_live_dvr_info?: SharedPostLiveDvrInfo + shared_post_live_dvr_info?: SharedPostLiveDvrInfo, + drc_labels?: DrcLabels ) { const first_format = formats[0]; const { audio_track } = first_format; const hoisted: string[] = []; + const has_drc_streams = !!drc_labels; + + let track_name; + + if (audio_track) { + if (has_drc_streams && first_format.is_drc) { + track_name = drc_labels.label_drc_mutiple(audio_track.display_name); + } else { + track_name = audio_track.display_name; + } + } else if (has_drc_streams) { + track_name = first_format.is_drc ? drc_labels.label_drc : drc_labels.label_original; + } + const set: AudioSet = { mime_type: first_format.mime_type.split(';')[0], language: first_format.language ?? undefined, codecs: hoistCodecsIfPossible(formats, hoisted), audio_sample_rate: hoistNumberAttributeIfPossible(formats, 'audio_sample_rate', hoisted), - track_name: audio_track?.display_name, - track_role: getTrackRole(first_format), + track_name, + track_roles: getTrackRoles(first_format, has_drc_streams), channels: hoistAudioChannelsIfPossible(formats, hoisted), representations: formats.map((format) => getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn, shared_post_live_dvr_info)) }; @@ -706,7 +743,8 @@ export function getStreamingInfo( cpn?: string, player?: Player, actions?: Actions, - storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec + storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec, + options?: StreamingInfoOptions ) { if (!streaming_data) throw new InnertubeError('Streaming data not available'); @@ -768,7 +806,17 @@ export function getStreamingInfo( audio_groups: [] as Format[][] }); - const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn, shared_post_live_dvr_info)); + let drc_labels: DrcLabels | undefined; + + if (audio_groups.flat().some((format) => format.is_drc)) { + drc_labels = { + label_original: options?.label_original || 'Original', + label_drc: options?.label_drc || 'Stable Volume', + label_drc_mutiple: options?.label_drc_mutiple || ((display_name) => `${display_name} (Stable Volume)`) + }; + } + + const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn, shared_post_live_dvr_info, drc_labels)); const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn, shared_post_live_dvr_info));