Skip to content

Commit

Permalink
feat!: Add support for OTF format streams (#351)
Browse files Browse the repository at this point in the history
  • Loading branch information
absidue authored Mar 13, 2023
1 parent 9f1c31d commit 3e4d41b
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 52 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/parser/classes/misc/Format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/parser/youtube/VideoInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#player, this.#actions);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/parser/ytkids/VideoInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/parser/ytmusic/TrackInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions);
}

/**
Expand Down
187 changes: 142 additions & 45 deletions src/utils/FormatUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');

Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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', {
Expand All @@ -390,57 +390,45 @@ 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(),
width: format.width?.toString(),
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(),
Expand All @@ -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
};
}
}

Expand Down

0 comments on commit 3e4d41b

Please sign in to comment.