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));