From 3e4d41bf06ba16232979977c705444f2032bcde6 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Mon, 13 Mar 2023 03:48:58 +0100 Subject: [PATCH] feat!: Add support for OTF format streams (#351) --- README.md | 2 +- src/parser/classes/misc/Format.ts | 2 + src/parser/youtube/VideoInfo.ts | 4 +- src/parser/ytkids/VideoInfo.ts | 4 +- src/parser/ytmusic/TrackInfo.ts | 4 +- src/utils/FormatUtils.ts | 187 +++++++++++++++++++++++------- 6 files changed, 151 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 6b8d9e37..8eb887c1 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ const videoInfo = await youtube.getInfo('videoId'); // now convert to a dash manifest // again - to be able to stream the video in the browser - we must proxy the requests through our own server // to do this, we provide a method to transform the URLs before writing them to the manifest -const manifest = videoInfo.toDash(url => { +const manifest = await videoInfo.toDash(url => { // modify the url // and return it return url; diff --git a/src/parser/classes/misc/Format.ts b/src/parser/classes/misc/Format.ts index 3497c8e6..6a0b65b4 100644 --- a/src/parser/classes/misc/Format.ts +++ b/src/parser/classes/misc/Format.ts @@ -5,6 +5,7 @@ import type { RawNode } from '../../index.js'; class Format { itag: number; mime_type: string; + is_type_otf: boolean; bitrate: number; average_bitrate: number; width: number; @@ -48,6 +49,7 @@ class Format { constructor(data: RawNode) { this.itag = data.itag; this.mime_type = data.mimeType; + this.is_type_otf = data.type === 'FORMAT_STREAM_TYPE_OTF'; this.bitrate = data.bitrate; this.average_bitrate = data.averageBitrate; this.width = data.width || undefined; diff --git a/src/parser/youtube/VideoInfo.ts b/src/parser/youtube/VideoInfo.ts index efa160c1..ed1b14ad 100644 --- a/src/parser/youtube/VideoInfo.ts +++ b/src/parser/youtube/VideoInfo.ts @@ -363,8 +363,8 @@ class VideoInfo { * @param format_filter - Function to filter the formats. * @returns DASH manifest */ - toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): string { - return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#player); + async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise { + return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#player, this.#actions); } /** diff --git a/src/parser/ytkids/VideoInfo.ts b/src/parser/ytkids/VideoInfo.ts index a84dc825..255da3c5 100644 --- a/src/parser/ytkids/VideoInfo.ts +++ b/src/parser/ytkids/VideoInfo.ts @@ -73,8 +73,8 @@ class VideoInfo { * @param format_filter - Function to filter the formats. * @returns DASH manifest */ - toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): string { - return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player); + async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise { + return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions); } /** diff --git a/src/parser/ytmusic/TrackInfo.ts b/src/parser/ytmusic/TrackInfo.ts index bc1282c9..850f9b8a 100644 --- a/src/parser/ytmusic/TrackInfo.ts +++ b/src/parser/ytmusic/TrackInfo.ts @@ -95,8 +95,8 @@ class TrackInfo { * @param format_filter - Function to filter the formats. * @returns DASH manifest */ - toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): string { - return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player); + async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise { + return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions); } /** diff --git a/src/utils/FormatUtils.ts b/src/utils/FormatUtils.ts index 678a3fbe..4500109d 100644 --- a/src/utils/FormatUtils.ts +++ b/src/utils/FormatUtils.ts @@ -239,13 +239,13 @@ class FormatUtils { return candidates[0]; } - static toDash(streaming_data?: { + static async toDash(streaming_data?: { expires: Date; formats: Format[]; adaptive_formats: Format[]; dash_manifest_url: string | null; hls_manifest_url: string | null; - }, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player): string { + }, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions): Promise { if (!streaming_data) throw new InnertubeError('Streaming data not available'); @@ -288,7 +288,7 @@ class FormatUtils { period ])); - this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player); + await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions); return Platform.shim.serializeDOM(document); } @@ -305,12 +305,12 @@ class FormatUtils { return el; } - static #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player) { + static async #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) { const mime_types: string[] = []; const mime_objects: Format[][] = [ [] ]; formats.forEach((video_format) => { - if (!video_format.index_range || !video_format.init_range) { + if ((!video_format.index_range || !video_format.init_range) && !video_format.is_type_otf) { return; } const mime_type = video_format.mime_type; @@ -376,9 +376,9 @@ class FormatUtils { period.appendChild(set); - track_objects[j].forEach((format) => { - this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player); - }); + for (const format of track_objects[j]) { + await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions); + } } } else { const set = this.#el(document, 'AdaptationSet', { @@ -390,27 +390,24 @@ class FormatUtils { period.appendChild(set); - mime_objects[i].forEach((format) => { + for (const format of mime_objects[i]) { if (format.has_video) { - this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player); + await this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player, actions); } else { - this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player); + await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions); } - }); + } } } } - static #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player) { + 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="', '"'); - if (!format.index_range || !format.init_range) - throw new InnertubeError('Index and init ranges not available', { format }); - const url = new URL(format.decipher(player)); url.searchParams.set('cpn', cpn || ''); - set.appendChild(this.#el(document, 'Representation', { + const representation = this.#el(document, 'Representation', { id: format.itag?.toString(), codecs, bandwidth: format.bitrate?.toString(), @@ -418,29 +415,20 @@ class FormatUtils { height: format.height?.toString(), maxPlayoutRate: '1', frameRate: format.fps?.toString() - }, [ - this.#el(document, 'BaseURL', {}, [ - document.createTextNode(url_transformer(url)?.toString()) - ]), - this.#el(document, 'SegmentBase', { - indexRange: `${format.index_range.start}-${format.index_range.end}` - }, [ - this.#el(document, 'Initialization', { - range: `${format.init_range.start}-${format.init_range.end}` - }) - ]) - ])); + }); + + 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) { + 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="', '"'); - if (!format.index_range || !format.init_range) - throw new InnertubeError('Index and init ranges not available', { format }); const url = new URL(format.decipher(player)); url.searchParams.set('cpn', cpn || ''); - set.appendChild(this.#el(document, 'Representation', { + const representation = this.#el(document, 'Representation', { id: format.itag?.toString(), codecs, bandwidth: format.bitrate?.toString(), @@ -449,18 +437,127 @@ class FormatUtils { this.#el(document, 'AudioChannelConfiguration', { schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011', value: format.audio_channels?.toString() || '2' - }), - this.#el(document, 'BaseURL', {}, [ - document.createTextNode(url_transformer(url)?.toString()) - ]), - this.#el(document, 'SegmentBase', { - indexRange: `${format.index_range.start}-${format.index_range.end}` - }, [ - this.#el(document, 'Initialization', { - range: `${format.init_range.start}-${format.init_range.end}` - }) - ]) - ])); + }) + ]); + + set.appendChild(representation); + + await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions); + } + + static async #generateSegmentInformation(document: XMLDocument, representation: Element, format: Format, url: string, actions?: Actions) { + if (format.is_type_otf) { + if (!actions) { + throw new InnertubeError('Unable to get segment durations for this OTF stream without an Actions instance', { format }); + } + + const { resolved_url, segment_durations } = await this.#getOTFSegmentInformation(url, actions); + const segment_elements = []; + + for (const segment_duration of segment_durations) { + let attributes; + + if (typeof segment_duration.repeat_count === 'undefined') { + attributes = { + d: segment_duration.duration.toString() + }; + } else { + attributes = { + d: segment_duration.duration.toString(), + r: segment_duration.repeat_count.toString() + }; + } + segment_elements.push(this.#el(document, 'S', attributes)); + } + + representation.appendChild( + this.#el(document, 'SegmentTemplate', { + startNumber: '1', + timescale: '1000', + initialization: `${resolved_url}&sq=0`, + media: `${resolved_url}&sq=$Number$` + }, [ + this.#el(document, 'SegmentTimeline', {}, segment_elements) + ]) + ); + } else { + if (!format.index_range || !format.init_range) + throw new InnertubeError('Index and init ranges not available', { format }); + + representation.appendChild( + this.#el(document, 'BaseURL', {}, [ + document.createTextNode(url) + ]) + ); + representation.appendChild( + this.#el(document, 'SegmentBase', { + indexRange: `${format.index_range.start}-${format.index_range.end}` + }, [ + this.#el(document, 'Initialization', { + range: `${format.init_range.start}-${format.init_range.end}` + }) + ]) + ); + } + } + + static async #getOTFSegmentInformation(url: string, actions: Actions): Promise<{ + resolved_url: string, + segment_durations: { + duration: number, + repeat_count?: number + }[] + }> { + // Fetch the first segment as it contains the segment durations which we need to generate the manifest + const response = await actions.session.http.fetch_function(`${url}&rn=0&sq=0`, { + method: 'GET', + headers: Constants.STREAM_HEADERS, + redirect: 'follow' + }); + + // Example OTF video: https://www.youtube.com/watch?v=DJ8GQUNUXGM + + // There might have been redirects, if there were we want to write the resolved URL to the manifest + // So that the player doesn't have to follow the redirects every time it requests a segment + const resolved_url = response.url.replace('&rn=0', '').replace('&sq=0', ''); + + // In this function we only need the segment durations and how often the durations are repeated + // The segment count could be useful for other stuff though + // The response body contains a lot of junk but the useful stuff looks like this: + // Segment-Count: 922\r\n' + + // 'Segment-Durations-Ms: 5120(r=920),3600,\r\n' + const response_text = await response.text(); + + const segment_duration_strings = getStringBetweenStrings(response_text, 'Segment-Durations-Ms:', '\r\n')?.split(','); + + if (!segment_duration_strings) { + throw new InnertubeError('Failed to extract the segment durations from this OTF stream', { url }); + } + + const segment_durations = []; + for (const segment_duration_string of segment_duration_strings) { + const trimmed_segment_duration = segment_duration_string.trim(); + if (trimmed_segment_duration.length === 0) { + continue; + } + + let repeat_count; + + const repeat_count_string = getStringBetweenStrings(trimmed_segment_duration, '(r=', ')'); + if (repeat_count_string) { + repeat_count = parseInt(repeat_count_string); + } + + segment_durations.push({ + duration: parseInt(trimmed_segment_duration), + repeat_count + }); + } + + return { + resolved_url, + segment_durations + }; } }