Skip to content

Commit 3e4d41b

Browse files
authored
feat!: Add support for OTF format streams (#351)
1 parent 9f1c31d commit 3e4d41b

File tree

6 files changed

+151
-52
lines changed

6 files changed

+151
-52
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ const videoInfo = await youtube.getInfo('videoId');
166166
// now convert to a dash manifest
167167
// again - to be able to stream the video in the browser - we must proxy the requests through our own server
168168
// to do this, we provide a method to transform the URLs before writing them to the manifest
169-
const manifest = videoInfo.toDash(url => {
169+
const manifest = await videoInfo.toDash(url => {
170170
// modify the url
171171
// and return it
172172
return url;

src/parser/classes/misc/Format.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { RawNode } from '../../index.js';
55
class Format {
66
itag: number;
77
mime_type: string;
8+
is_type_otf: boolean;
89
bitrate: number;
910
average_bitrate: number;
1011
width: number;
@@ -48,6 +49,7 @@ class Format {
4849
constructor(data: RawNode) {
4950
this.itag = data.itag;
5051
this.mime_type = data.mimeType;
52+
this.is_type_otf = data.type === 'FORMAT_STREAM_TYPE_OTF';
5153
this.bitrate = data.bitrate;
5254
this.average_bitrate = data.averageBitrate;
5355
this.width = data.width || undefined;

src/parser/youtube/VideoInfo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,8 @@ class VideoInfo {
363363
* @param format_filter - Function to filter the formats.
364364
* @returns DASH manifest
365365
*/
366-
toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): string {
367-
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#player);
366+
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise<string> {
367+
return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#player, this.#actions);
368368
}
369369

370370
/**

src/parser/ytkids/VideoInfo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ class VideoInfo {
7373
* @param format_filter - Function to filter the formats.
7474
* @returns DASH manifest
7575
*/
76-
toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): string {
77-
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player);
76+
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise<string> {
77+
return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions);
7878
}
7979

8080
/**

src/parser/ytmusic/TrackInfo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ class TrackInfo {
9595
* @param format_filter - Function to filter the formats.
9696
* @returns DASH manifest
9797
*/
98-
toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): string {
99-
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player);
98+
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise<string> {
99+
return await FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions);
100100
}
101101

102102
/**

src/utils/FormatUtils.ts

Lines changed: 142 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,13 @@ class FormatUtils {
239239
return candidates[0];
240240
}
241241

242-
static toDash(streaming_data?: {
242+
static async toDash(streaming_data?: {
243243
expires: Date;
244244
formats: Format[];
245245
adaptive_formats: Format[];
246246
dash_manifest_url: string | null;
247247
hls_manifest_url: string | null;
248-
}, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player): string {
248+
}, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions): Promise<string> {
249249
if (!streaming_data)
250250
throw new InnertubeError('Streaming data not available');
251251

@@ -288,7 +288,7 @@ class FormatUtils {
288288
period
289289
]));
290290

291-
this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player);
291+
await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions);
292292

293293
return Platform.shim.serializeDOM(document);
294294
}
@@ -305,12 +305,12 @@ class FormatUtils {
305305
return el;
306306
}
307307

308-
static #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player) {
308+
static async #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
309309
const mime_types: string[] = [];
310310
const mime_objects: Format[][] = [ [] ];
311311

312312
formats.forEach((video_format) => {
313-
if (!video_format.index_range || !video_format.init_range) {
313+
if ((!video_format.index_range || !video_format.init_range) && !video_format.is_type_otf) {
314314
return;
315315
}
316316
const mime_type = video_format.mime_type;
@@ -376,9 +376,9 @@ class FormatUtils {
376376

377377
period.appendChild(set);
378378

379-
track_objects[j].forEach((format) => {
380-
this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player);
381-
});
379+
for (const format of track_objects[j]) {
380+
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
381+
}
382382
}
383383
} else {
384384
const set = this.#el(document, 'AdaptationSet', {
@@ -390,57 +390,45 @@ class FormatUtils {
390390

391391
period.appendChild(set);
392392

393-
mime_objects[i].forEach((format) => {
393+
for (const format of mime_objects[i]) {
394394
if (format.has_video) {
395-
this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player);
395+
await this.#generateRepresentationVideo(document, set, format, url_transformer, cpn, player, actions);
396396
} else {
397-
this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player);
397+
await this.#generateRepresentationAudio(document, set, format, url_transformer, cpn, player, actions);
398398
}
399-
});
399+
}
400400
}
401401
}
402402
}
403403

404-
static #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player) {
404+
static async #generateRepresentationVideo(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
405405
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
406406

407-
if (!format.index_range || !format.init_range)
408-
throw new InnertubeError('Index and init ranges not available', { format });
409-
410407
const url = new URL(format.decipher(player));
411408
url.searchParams.set('cpn', cpn || '');
412409

413-
set.appendChild(this.#el(document, 'Representation', {
410+
const representation = this.#el(document, 'Representation', {
414411
id: format.itag?.toString(),
415412
codecs,
416413
bandwidth: format.bitrate?.toString(),
417414
width: format.width?.toString(),
418415
height: format.height?.toString(),
419416
maxPlayoutRate: '1',
420417
frameRate: format.fps?.toString()
421-
}, [
422-
this.#el(document, 'BaseURL', {}, [
423-
document.createTextNode(url_transformer(url)?.toString())
424-
]),
425-
this.#el(document, 'SegmentBase', {
426-
indexRange: `${format.index_range.start}-${format.index_range.end}`
427-
}, [
428-
this.#el(document, 'Initialization', {
429-
range: `${format.init_range.start}-${format.init_range.end}`
430-
})
431-
])
432-
]));
418+
});
419+
420+
set.appendChild(representation);
421+
422+
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
433423
}
434424

435-
static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player) {
425+
static async #generateRepresentationAudio(document: XMLDocument, set: Element, format: Format, url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
436426
const codecs = getStringBetweenStrings(format.mime_type, 'codecs="', '"');
437-
if (!format.index_range || !format.init_range)
438-
throw new InnertubeError('Index and init ranges not available', { format });
439427

440428
const url = new URL(format.decipher(player));
441429
url.searchParams.set('cpn', cpn || '');
442430

443-
set.appendChild(this.#el(document, 'Representation', {
431+
const representation = this.#el(document, 'Representation', {
444432
id: format.itag?.toString(),
445433
codecs,
446434
bandwidth: format.bitrate?.toString(),
@@ -449,18 +437,127 @@ class FormatUtils {
449437
this.#el(document, 'AudioChannelConfiguration', {
450438
schemeIdUri: 'urn:mpeg:dash:23003:3:audio_channel_configuration:2011',
451439
value: format.audio_channels?.toString() || '2'
452-
}),
453-
this.#el(document, 'BaseURL', {}, [
454-
document.createTextNode(url_transformer(url)?.toString())
455-
]),
456-
this.#el(document, 'SegmentBase', {
457-
indexRange: `${format.index_range.start}-${format.index_range.end}`
458-
}, [
459-
this.#el(document, 'Initialization', {
460-
range: `${format.init_range.start}-${format.init_range.end}`
461-
})
462-
])
463-
]));
440+
})
441+
]);
442+
443+
set.appendChild(representation);
444+
445+
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
446+
}
447+
448+
static async #generateSegmentInformation(document: XMLDocument, representation: Element, format: Format, url: string, actions?: Actions) {
449+
if (format.is_type_otf) {
450+
if (!actions) {
451+
throw new InnertubeError('Unable to get segment durations for this OTF stream without an Actions instance', { format });
452+
}
453+
454+
const { resolved_url, segment_durations } = await this.#getOTFSegmentInformation(url, actions);
455+
const segment_elements = [];
456+
457+
for (const segment_duration of segment_durations) {
458+
let attributes;
459+
460+
if (typeof segment_duration.repeat_count === 'undefined') {
461+
attributes = {
462+
d: segment_duration.duration.toString()
463+
};
464+
} else {
465+
attributes = {
466+
d: segment_duration.duration.toString(),
467+
r: segment_duration.repeat_count.toString()
468+
};
469+
}
470+
segment_elements.push(this.#el(document, 'S', attributes));
471+
}
472+
473+
representation.appendChild(
474+
this.#el(document, 'SegmentTemplate', {
475+
startNumber: '1',
476+
timescale: '1000',
477+
initialization: `${resolved_url}&sq=0`,
478+
media: `${resolved_url}&sq=$Number$`
479+
}, [
480+
this.#el(document, 'SegmentTimeline', {}, segment_elements)
481+
])
482+
);
483+
} else {
484+
if (!format.index_range || !format.init_range)
485+
throw new InnertubeError('Index and init ranges not available', { format });
486+
487+
representation.appendChild(
488+
this.#el(document, 'BaseURL', {}, [
489+
document.createTextNode(url)
490+
])
491+
);
492+
representation.appendChild(
493+
this.#el(document, 'SegmentBase', {
494+
indexRange: `${format.index_range.start}-${format.index_range.end}`
495+
}, [
496+
this.#el(document, 'Initialization', {
497+
range: `${format.init_range.start}-${format.init_range.end}`
498+
})
499+
])
500+
);
501+
}
502+
}
503+
504+
static async #getOTFSegmentInformation(url: string, actions: Actions): Promise<{
505+
resolved_url: string,
506+
segment_durations: {
507+
duration: number,
508+
repeat_count?: number
509+
}[]
510+
}> {
511+
// Fetch the first segment as it contains the segment durations which we need to generate the manifest
512+
const response = await actions.session.http.fetch_function(`${url}&rn=0&sq=0`, {
513+
method: 'GET',
514+
headers: Constants.STREAM_HEADERS,
515+
redirect: 'follow'
516+
});
517+
518+
// Example OTF video: https://www.youtube.com/watch?v=DJ8GQUNUXGM
519+
520+
// There might have been redirects, if there were we want to write the resolved URL to the manifest
521+
// So that the player doesn't have to follow the redirects every time it requests a segment
522+
const resolved_url = response.url.replace('&rn=0', '').replace('&sq=0', '');
523+
524+
// In this function we only need the segment durations and how often the durations are repeated
525+
// The segment count could be useful for other stuff though
526+
// The response body contains a lot of junk but the useful stuff looks like this:
527+
// Segment-Count: 922\r\n' +
528+
// 'Segment-Durations-Ms: 5120(r=920),3600,\r\n'
529+
const response_text = await response.text();
530+
531+
const segment_duration_strings = getStringBetweenStrings(response_text, 'Segment-Durations-Ms:', '\r\n')?.split(',');
532+
533+
if (!segment_duration_strings) {
534+
throw new InnertubeError('Failed to extract the segment durations from this OTF stream', { url });
535+
}
536+
537+
const segment_durations = [];
538+
for (const segment_duration_string of segment_duration_strings) {
539+
const trimmed_segment_duration = segment_duration_string.trim();
540+
if (trimmed_segment_duration.length === 0) {
541+
continue;
542+
}
543+
544+
let repeat_count;
545+
546+
const repeat_count_string = getStringBetweenStrings(trimmed_segment_duration, '(r=', ')');
547+
if (repeat_count_string) {
548+
repeat_count = parseInt(repeat_count_string);
549+
}
550+
551+
segment_durations.push({
552+
duration: parseInt(trimmed_segment_duration),
553+
repeat_count
554+
});
555+
}
556+
557+
return {
558+
resolved_url,
559+
segment_durations
560+
};
464561
}
465562
}
466563

0 commit comments

Comments
 (0)