From 561e60b934df29520eb0bd83a98e42fa4d7d35bb Mon Sep 17 00:00:00 2001 From: Luan Date: Sun, 8 Jun 2025 07:02:10 -0300 Subject: [PATCH] feat(StreamingInfoOptions)!: Add `is_sabr` option (#974) Returns a manifest suitable for use in SABR player implementations. --- src/core/mixins/MediaInfo.ts | 31 ++++++++++++--------- src/types/StreamingInfoOptions.ts | 5 ++++ src/utils/StreamingInfo.ts | 46 ++++++++++++++++++------------- 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/src/core/mixins/MediaInfo.ts b/src/core/mixins/MediaInfo.ts index 854287e4..6ce2ba53 100644 --- a/src/core/mixins/MediaInfo.ts +++ b/src/core/mixins/MediaInfo.ts @@ -31,7 +31,7 @@ import type PlayerLiveStoryboardSpec from '../../parser/classes/PlayerLiveStoryb import type PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js'; export default class MediaInfo { - readonly #page: [IPlayerResponse, INextResponse?]; + readonly #page: [ IPlayerResponse, INextResponse? ]; readonly #actions: Actions; readonly #cpn: string; readonly #playback_tracking?: IPlaybackTracking; @@ -46,7 +46,7 @@ export default class MediaInfo { public playability_status?: IPlayabilityStatus; public player_config?: IPlayerConfig; - constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) { + constructor(data: [ ApiResponse, ApiResponse? ], actions: Actions, cpn: string) { this.#actions = actions; const info = Parser.parseResponse(data[0].data.playerResponse ? data[0].data.playerResponse : data[0].data); @@ -98,13 +98,18 @@ export default class MediaInfo { /** * Generates a DASH manifest from the streaming data. - * @param url_transformer - Function to transform the URLs. - * @param format_filter - Function to filter the formats. - * @param options - Additional options to customise the manifest generation + * @param options * @returns DASH manifest */ - async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise { + async toDash(options: { + url_transformer?: URLTransformer; + format_filter?: FormatFilter; + include_thumbnails?: boolean; + captions_format?: string; + manifest_options?: DashOptions; + } = {}): Promise { const player_response = this.#page[0]; + const manifest_options = options.manifest_options || {}; if (player_response.video_details && (player_response.video_details.is_live)) { throw new InnertubeError('Generating DASH manifests for live videos is not supported. Please use the DASH and HLS manifests provided by YouTube in `streaming_data.dash_manifest_url` and `streaming_data.hls_manifest_url` instead.'); @@ -113,28 +118,28 @@ export default class MediaInfo { let storyboards; let captions; - if (options.include_thumbnails && player_response.storyboards) { + if (manifest_options.include_thumbnails && player_response.storyboards) { storyboards = player_response.storyboards; } - if (typeof options.captions_format === 'string' && player_response.captions?.caption_tracks) { + if (typeof manifest_options.captions_format === 'string' && player_response.captions?.caption_tracks) { captions = player_response.captions.caption_tracks; } return FormatUtils.toDash( this.streaming_data, this.page[0].video_details?.is_post_live_dvr, - url_transformer, - format_filter, + options.url_transformer, + options.format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards, captions, - options + manifest_options ); } - + /** * Get a cleaned up representation of the adaptive_formats */ @@ -238,7 +243,7 @@ export default class MediaInfo { /** * Parsed InnerTube response. */ - get page(): [IPlayerResponse, INextResponse?] { + get page(): [ IPlayerResponse, INextResponse? ] { return this.#page; } } \ No newline at end of file diff --git a/src/types/StreamingInfoOptions.ts b/src/types/StreamingInfoOptions.ts index e8a0c5d4..d3b9eaaa 100644 --- a/src/types/StreamingInfoOptions.ts +++ b/src/types/StreamingInfoOptions.ts @@ -27,4 +27,9 @@ export interface StreamingInfoOptions { * Defaults to `(audio_track_display_name) => audio_track_display_name + " (Stable Volume)"` */ label_drc_multiple?: (audio_track_display_name: string) => string; + + /** + * If `true`, the generated manifest will contain URLs that are suitable for use with the SABR protocol. + */ + is_sabr?: boolean; } \ No newline at end of file diff --git a/src/utils/StreamingInfo.ts b/src/utils/StreamingInfo.ts index 85eb566a..abe45a71 100644 --- a/src/utils/StreamingInfo.ts +++ b/src/utils/StreamingInfo.ts @@ -315,13 +315,20 @@ function getSegmentInfo( actions?: Actions, player?: Player, cpn?: string, - shared_post_live_dvr_info?: SharedPostLiveDvrInfo + shared_post_live_dvr_info?: SharedPostLiveDvrInfo, + is_sabr?: boolean ) { - const url = new URL(format.decipher(player)); - url.searchParams.set('cpn', cpn || ''); - - const transformed_url = url_transformer(url).toString(); - + let transformed_url = ''; + + if (is_sabr) { + const formatKey = `${format.itag || ''}:${format.xtags || ''}`; + transformed_url = `sabr://${format.has_video ? 'video' : 'audio'}?key=${formatKey}`; + } else { + const url = new URL(format.decipher(player)); + url.searchParams.set('cpn', cpn || ''); + transformed_url = url_transformer(url).toString(); + } + if (format.is_type_otf) { if (!actions) throw new InnertubeError('Unable to get segment durations for this OTF stream without an Actions instance', { format }); @@ -392,11 +399,9 @@ function getAudioRepresentation( actions?: Actions, player?: Player, cpn?: string, - shared_post_live_dvr_info?: SharedPostLiveDvrInfo + shared_post_live_dvr_info?: SharedPostLiveDvrInfo, + is_sabr?: boolean ) { - const url = new URL(format.decipher(player)); - url.searchParams.set('cpn', cpn || ''); - const uid_parts = [ format.itag.toString() ]; if (format.audio_track) { @@ -413,7 +418,7 @@ function getAudioRepresentation( codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined, audio_sample_rate: !hoisted.includes('audio_sample_rate') ? format.audio_sample_rate : undefined, channels: !hoisted.includes('AudioChannelConfiguration') ? format.audio_channels || 2 : undefined, - segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn, shared_post_live_dvr_info) + segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn, shared_post_live_dvr_info, is_sabr) }; return rep; @@ -447,7 +452,8 @@ function getAudioSet( player?: Player, cpn?: string, shared_post_live_dvr_info?: SharedPostLiveDvrInfo, - drc_labels?: DrcLabels + drc_labels?: DrcLabels, + is_sabr?: boolean ) { const first_format = formats[0]; const { audio_track } = first_format; @@ -475,7 +481,7 @@ function getAudioSet( 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)) + representations: formats.map((format) => getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn, shared_post_live_dvr_info, is_sabr)) }; return set; @@ -556,7 +562,8 @@ function getVideoRepresentation( player?: Player, actions?: Actions, cpn?: string, - shared_post_live_dvr_info?: SharedPostLiveDvrInfo + shared_post_live_dvr_info?: SharedPostLiveDvrInfo, + is_sabr?: boolean ) { const rep: VideoRepresentation = { uid: format.itag.toString(), @@ -565,7 +572,7 @@ function getVideoRepresentation( height: format.height, codecs: !hoisted.includes('codecs') ? getStringBetweenStrings(format.mime_type, 'codecs="', '"') : undefined, fps: !hoisted.includes('fps') ? format.fps : undefined, - segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn, shared_post_live_dvr_info) + segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn, shared_post_live_dvr_info, is_sabr) }; return rep; @@ -577,7 +584,8 @@ function getVideoSet( player?: Player, actions?: Actions, cpn?: string, - shared_post_live_dvr_info?: SharedPostLiveDvrInfo + shared_post_live_dvr_info?: SharedPostLiveDvrInfo, + is_sabr?: boolean ) { const first_format = formats[0]; const color_info = getColorInfo(first_format); @@ -588,7 +596,7 @@ function getVideoSet( color_info, codecs: hoistCodecsIfPossible(formats, hoisted), fps: hoistNumberAttributeIfPossible(formats, 'fps', hoisted), - representations: formats.map((format) => getVideoRepresentation(format, url_transformer, hoisted, player, actions, cpn, shared_post_live_dvr_info)) + representations: formats.map((format) => getVideoRepresentation(format, url_transformer, hoisted, player, actions, cpn, shared_post_live_dvr_info, is_sabr)) }; return set; @@ -860,9 +868,9 @@ export function getStreamingInfo( }; } - const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn, shared_post_live_dvr_info, drc_labels)); + const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn, shared_post_live_dvr_info, drc_labels, options?.is_sabr)); - const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn, shared_post_live_dvr_info)); + const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn, shared_post_live_dvr_info, options?.is_sabr)); let image_sets: ImageSet[] = [];