feat(toDash)!: Add support for generating manifests for Post Live DVR videos (#580)

BREAKING CHANGES: The `duration` property in `StreamingInfo` has been
replaced by the asynchronous `getDuration()` function, as getting the duration
of Post Live DVR videos requires making a fetch request.
This commit is contained in:
absidue
2024-01-18 18:51:42 +01:00
committed by GitHub
parent 2073aa910a
commit 6dd03e1658
5 changed files with 231 additions and 51 deletions

View File

@@ -8,7 +8,6 @@ import type Format from '../../parser/classes/misc/Format.js';
import type { INextResponse, IPlayerConfig, IPlayerResponse } from '../../parser/index.js';
import { Parser } from '../../parser/index.js';
import type { DashOptions } from '../../types/DashOptions.js';
import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js';
import { getStreamingInfo } from '../../utils/StreamingInfo.js';
import ContinuationItem from '../../parser/classes/ContinuationItem.js';
import TranscriptInfo from '../../parser/youtube/TranscriptInfo.js';
@@ -50,17 +49,17 @@ export default class MediaInfo {
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise<string> {
const player_response = this.#page[0];
if (player_response.video_details && (player_response.video_details.is_live || player_response.video_details.is_post_live_dvr)) {
throw new InnertubeError('Generating DASH manifests for live and Post-Live-DVR 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.');
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.');
}
let storyboards;
if (options.include_thumbnails && player_response.storyboards?.is(PlayerStoryboardSpec)) {
if (options.include_thumbnails && player_response.storyboards) {
storyboards = player_response.storyboards;
}
return FormatUtils.toDash(this.streaming_data, 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);
}
/**
@@ -69,12 +68,13 @@ export default class MediaInfo {
getStreamingInfo(url_transformer?: URLTransformer, format_filter?: FormatFilter) {
return getStreamingInfo(
this.streaming_data,
this.page[0].video_details?.is_post_live_dvr,
url_transformer,
format_filter,
this.cpn,
this.#actions.session.player,
this.#actions,
this.#page[0].storyboards?.is(PlayerStoryboardSpec) ? this.#page[0].storyboards : undefined
this.#page[0].storyboards ? this.#page[0].storyboards : undefined
);
}

View File

@@ -1,11 +1,32 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export interface LiveStoryboardData {
type: 'live',
template_url: string,
thumbnail_width: number,
thumbnail_height: number,
columns: number,
rows: number
}
export default class PlayerLiveStoryboardSpec extends YTNode {
static type = 'PlayerLiveStoryboardSpec';
constructor() {
board: LiveStoryboardData;
constructor(data: RawNode) {
super();
// TODO: A little bit different from PlayerLiveStoryboardSpec
// https://i.ytimg.com/sb/5qap5aO4i9A/storyboard_live_90_2x2_b2/M$M.jpg?rs=AOn4CLC9s6IeOsw_gKvEbsbU9y-e2FVRTw#159#90#2#2
const [ template_url, thumbnail_width, thumbnail_height, columns, rows ] = data.spec.split('#');
this.board = {
type: 'live',
template_url,
thumbnail_width: parseInt(thumbnail_width, 10),
thumbnail_height: parseInt(thumbnail_height, 10),
columns: parseInt(columns, 10),
rows: parseInt(rows, 10)
};
}
}

View File

@@ -2,6 +2,7 @@ import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export interface StoryboardData {
type: 'vod'
template_url: string;
thumbnail_width: number;
thumbnail_height: number;
@@ -31,6 +32,7 @@ export default class PlayerStoryboardSpec extends YTNode {
const storyboard_count = Math.ceil(parseInt(thumbnail_count, 10) / (parseInt(columns, 10) * parseInt(rows, 10)));
return {
type: 'vod',
template_url: url.toString().replace('$L', i).replace('$N', name),
thumbnail_width: parseInt(thumbnail_width, 10),
thumbnail_height: parseInt(thumbnail_height, 10),

View File

@@ -4,7 +4,7 @@
import type Actions from '../core/Actions.js';
import type Player from '../core/Player.js';
import type { IStreamingData } from '../parser/index.js';
import type { PlayerStoryboardSpec } from '../parser/nodes.js';
import type { PlayerLiveStoryboardSpec, PlayerStoryboardSpec } from '../parser/nodes.js';
import * as DashUtils from './DashUtils.js';
import type { SegmentInfo as FSegmentInfo } from './StreamingInfo.js';
import { getStreamingInfo } from './StreamingInfo.js';
@@ -13,21 +13,22 @@ import { InnertubeError } from './Utils.js';
interface DashManifestProps {
streamingData: IStreamingData;
isPostLiveDvr: boolean;
transformURL?: URLTransformer;
rejectFormat?: FormatFilter;
cpn?: string;
player?: Player;
actions?: Actions;
storyboards?: PlayerStoryboardSpec;
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
}
async function OTFSegmentInfo({ info }: { info: FSegmentInfo }) {
if (!info.is_oft) return null;
async function OTFPostLiveDvrSegmentInfo({ info }: { info: FSegmentInfo }) {
if (!info.is_oft && !info.is_post_live_dvr) return null;
const template = await info.getSegmentTemplate();
return <segment-template
startNumber="1"
startNumber={template.init_url ? '1' : '0'}
timescale="1000"
initialization={template.init_url}
media={template.media_url}
@@ -46,8 +47,8 @@ async function OTFSegmentInfo({ info }: { info: FSegmentInfo }) {
}
function SegmentInfo({ info }: { info: FSegmentInfo }) {
if (info.is_oft) {
return <OTFSegmentInfo info={info} />;
if (info.is_oft || info.is_post_live_dvr) {
return <OTFPostLiveDvrSegmentInfo info={info} />;
}
return <>
<base-url>
@@ -59,8 +60,9 @@ function SegmentInfo({ info }: { info: FSegmentInfo }) {
</>;
}
function DashManifest({
async function DashManifest({
streamingData,
isPostLiveDvr,
transformURL,
rejectFormat,
cpn,
@@ -69,11 +71,11 @@ function DashManifest({
storyboards
}: DashManifestProps) {
const {
duration,
getDuration,
audio_sets,
video_sets,
image_sets
} = getStreamingInfo(streamingData, transformURL, rejectFormat, cpn, player, actions, storyboards);
} = getStreamingInfo(streamingData, isPostLiveDvr, transformURL, rejectFormat, cpn, player, actions, storyboards);
// XXX: DASH spec: https://standards.iso.org/ittf/PubliclyAvailableStandards/c083314_ISO_IEC%2023009-1_2022(en).zip
@@ -82,7 +84,7 @@ function DashManifest({
minBufferTime="PT1.500S"
profiles="urn:mpeg:dash:profile:isoff-main:2011"
type="static"
mediaPresentationDuration={`PT${duration}S`}
mediaPresentationDuration={`PT${await getDuration()}S`}
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 http://standards.iso.org/ittf/PubliclyAvailableStandards/MPEG-DASH_schema_files/DASH-MPD.xsd"
>
@@ -227,12 +229,13 @@ function DashManifest({
export function toDash(
streaming_data?: IStreamingData,
is_post_live_dvr = false,
url_transformer: URLTransformer = (url) => url,
format_filter?: FormatFilter,
cpn?: string,
player?: Player,
actions?: Actions,
storyboards?: PlayerStoryboardSpec
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec
) {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
@@ -240,6 +243,7 @@ export function toDash(
return DashUtils.renderToString(
<DashManifest
streamingData={streaming_data}
isPostLiveDvr={is_post_live_dvr}
transformURL={url_transformer}
rejectFormat={format_filter}
cpn={cpn}

View File

@@ -1,15 +1,17 @@
import type Actions from '../core/Actions.js';
import type Player from '../core/Player.js';
import type { LiveStoryboardData } from '../parser/classes/PlayerLiveStoryboardSpec.js';
import type { StoryboardData } from '../parser/classes/PlayerStoryboardSpec.js';
import type { IStreamingData } from '../parser/index.js';
import type { Format } from '../parser/misc.js';
import type { PlayerStoryboardSpec } from '../parser/nodes.js';
import type { PlayerLiveStoryboardSpec } from '../parser/nodes.js';
import type { FormatFilter, URLTransformer } from '../types/FormatUtils.js';
import PlayerStoryboardSpec from '../parser/classes/PlayerStoryboardSpec.js';
import { InnertubeError, Platform, getStringBetweenStrings } from './Utils.js';
import { Constants } from './index.js';
export interface StreamingInfo {
duration: number;
getDuration(): Promise<number>;
audio_sets: AudioSet[];
video_sets: VideoSet[];
image_sets: ImageSet[];
@@ -33,11 +35,17 @@ export interface Range {
export type SegmentInfo = {
is_oft: false,
is_post_live_dvr: false
base_url: string;
index_range: Range;
init_range: Range;
} | {
is_oft: true,
is_post_live_dvr: false
getSegmentTemplate(): Promise<SegmentTemplate>
} | {
is_oft: false,
is_post_live_dvr: true,
getSegmentTemplate(): Promise<SegmentTemplate>
}
@@ -47,7 +55,7 @@ export interface Segment {
}
export interface SegmentTemplate {
init_url: string,
init_url?: string,
media_url: string,
timeline: Segment[]
}
@@ -109,13 +117,22 @@ export interface ImageRepresentation {
getURL(n: number): string;
}
function getFormatGroupings(formats: Format[]) {
interface PostLiveDvrInfo {
duration: number,
segment_count: number
}
interface SharedPostLiveDvrInfo {
item?: PostLiveDvrInfo
}
function getFormatGroupings(formats: Format[], is_post_live_dvr: boolean) {
const group_info = new Map<string, Format[]>();
const has_multiple_audio_tracks = formats.some((fmt) => !!fmt.audio_track);
for (const format of formats) {
if ((!format.index_range || !format.init_range) && !format.is_type_otf) {
if ((!format.index_range || !format.init_range) && !format.is_type_otf && !is_post_live_dvr) {
continue;
}
const mime_type = format.mime_type.split(';')[0];
@@ -224,12 +241,53 @@ async function getOTFSegmentTemplate(url: string, actions: Actions): Promise<Seg
};
}
async function getPostLiveDvrInfo(transformed_url: string, actions: Actions): Promise<PostLiveDvrInfo> {
const response = await actions.session.http.fetch_function(`${transformed_url}&rn=0&sq=0`, {
method: 'HEAD',
headers: Constants.STREAM_HEADERS,
redirect: 'follow'
});
const duration_ms = parseInt(response.headers.get('X-Head-Time-Millis') || '');
const segment_count = parseInt(response.headers.get('X-Head-Seqnum') || '');
if (isNaN(duration_ms) || isNaN(segment_count)) {
throw new InnertubeError('Failed to extract the duration or segment count for this Post Live DVR video');
}
return {
duration: duration_ms / 1000,
segment_count
};
}
async function getPostLiveDvrDuration(
shared_post_live_dvr_info: SharedPostLiveDvrInfo,
format: Format,
url_transformer: URLTransformer,
actions: Actions,
player?: Player,
cpn?: string
) {
if (!shared_post_live_dvr_info.item) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
const transformed_url = url_transformer(url).toString();
shared_post_live_dvr_info.item = await getPostLiveDvrInfo(transformed_url, actions);
}
return shared_post_live_dvr_info.item.duration;
}
function getSegmentInfo(
format: Format,
url_transformer: URLTransformer,
actions?: Actions,
player?: Player,
cpn?: string
cpn?: string,
shared_post_live_dvr_info?: SharedPostLiveDvrInfo
) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
@@ -242,6 +300,7 @@ function getSegmentInfo(
const info: SegmentInfo = {
is_oft: true,
is_post_live_dvr: false,
getSegmentTemplate() {
return getOTFSegmentTemplate(transformed_url, actions);
}
@@ -250,11 +309,46 @@ function getSegmentInfo(
return info;
}
if (shared_post_live_dvr_info) {
if (!actions) {
throw new InnertubeError('Unable to get segment count for this Post Live DVR video without an Actions instance', { format });
}
const target_duration_dec = format.target_duration_dec;
if (typeof target_duration_dec !== 'number') {
throw new InnertubeError('Format is missing target_duration_dec', { format });
}
const info: SegmentInfo = {
is_oft: false,
is_post_live_dvr: true,
async getSegmentTemplate(): Promise<SegmentTemplate> {
if (!shared_post_live_dvr_info.item) {
shared_post_live_dvr_info.item = await getPostLiveDvrInfo(transformed_url, actions);
}
return {
media_url: `${transformed_url}&sq=$Number$`,
timeline: [
{
duration: target_duration_dec * 1000,
repeat_count: shared_post_live_dvr_info.item.segment_count
}
]
};
}
};
return info;
}
if (!format.index_range || !format.init_range)
throw new InnertubeError('Index and init ranges not available', { format });
const info: SegmentInfo = {
is_oft: false,
is_post_live_dvr: false,
base_url: transformed_url,
index_range: format.index_range,
init_range: format.init_range
@@ -269,7 +363,8 @@ function getAudioRepresentation(
url_transformer: URLTransformer,
actions?: Actions,
player?: Player,
cpn?: string
cpn?: string,
shared_post_live_dvr_info?: SharedPostLiveDvrInfo
) {
const url = new URL(format.decipher(player));
url.searchParams.set('cpn', cpn || '');
@@ -280,7 +375,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)
segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn, shared_post_live_dvr_info)
};
return rep;
@@ -309,7 +404,8 @@ function getAudioSet(
url_transformer: URLTransformer,
actions?: Actions,
player?: Player,
cpn?: string
cpn?: string,
shared_post_live_dvr_info?: SharedPostLiveDvrInfo
) {
const first_format = formats[0];
const { audio_track } = first_format;
@@ -323,7 +419,7 @@ function getAudioSet(
track_name: audio_track?.display_name,
track_role: getTrackRole(first_format),
channels: hoistAudioChannelsIfPossible(formats, hoisted),
representations: formats.map((format) => getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn))
representations: formats.map((format) => getAudioRepresentation(format, hoisted, url_transformer, actions, player, cpn, shared_post_live_dvr_info))
};
return set;
@@ -403,7 +499,8 @@ function getVideoRepresentation(
hoisted: string[],
player?: Player,
actions?: Actions,
cpn?: string
cpn?: string,
shared_post_live_dvr_info?: SharedPostLiveDvrInfo
) {
const rep: VideoRepresentation = {
uid: format.itag.toString(),
@@ -412,7 +509,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)
segment_info: getSegmentInfo(format, url_transformer, actions, player, cpn, shared_post_live_dvr_info)
};
return rep;
@@ -423,7 +520,8 @@ function getVideoSet(
url_transformer: URLTransformer,
player?: Player,
actions?: Actions,
cpn?: string
cpn?: string,
shared_post_live_dvr_info?: SharedPostLiveDvrInfo
) {
const first_format = formats[0];
const color_info = getColorInfo(first_format);
@@ -434,18 +532,23 @@ 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))
representations: formats.map((format) => getVideoRepresentation(format, url_transformer, hoisted, player, actions, cpn, shared_post_live_dvr_info))
};
return set;
}
function getStoryboardInfo(
storyboards: PlayerStoryboardSpec
storyboards: PlayerStoryboardSpec | PlayerLiveStoryboardSpec
) {
const mime_info = new Map<string, StoryboardData[]>();
// Can't seem to combine the types in the Map, so create an alias here
type AnyStoryboardData = StoryboardData | LiveStoryboardData
for (const storyboard of storyboards.boards) {
const mime_info = new Map<string, AnyStoryboardData[]>();
const boards = storyboards.is(PlayerStoryboardSpec) ? storyboards.boards : [ storyboards.board ];
for (const storyboard of boards) {
const extension = new URL(storyboard.template_url).pathname.split('.').pop();
const mime_type = `image/${extension === 'jpg' ? 'jpeg' : extension}`;
@@ -465,7 +568,7 @@ interface SharedStoryboardResponse {
async function getStoryboardMimeType(
actions: Actions,
board: StoryboardData,
board: StoryboardData | LiveStoryboardData,
transform_url: URLTransformer,
probable_mime_type: string,
shared_response: SharedStoryboardResponse
@@ -488,7 +591,7 @@ async function getStoryboardMimeType(
async function getStoryboardBitrate(
actions: Actions,
board: StoryboardData,
board: StoryboardData | LiveStoryboardData,
shared_response: SharedStoryboardResponse
) {
const url = board.template_url;
@@ -496,7 +599,7 @@ async function getStoryboardBitrate(
const response_promises: Promise<Response>[] = [];
// Set a limit so we don't take forever for long videos
const request_limit = Math.min(board.storyboard_count, 10);
const request_limit = Math.min(board.type === 'vod' ? board.storyboard_count : 5, 10);
for (let i = 0; i < request_limit; i++) {
const req_url = new URL(url.replace('$M', i.toString()));
@@ -533,13 +636,24 @@ async function getStoryboardBitrate(
function getImageRepresentation(
duration: number,
actions: Actions,
board: StoryboardData,
board: StoryboardData | LiveStoryboardData,
transform_url: URLTransformer,
shared_response: SharedStoryboardResponse
) {
const url = board.template_url;
const template_url = new URL(url.replace('$M', '$Number$'));
let template_duration;
if (board.type === 'vod') {
// Here duration is the duration of the video
template_duration = duration / board.storyboard_count;
} else {
// Here duration is the duration of one of the video/audio segments,
// As there is one tile per segment, we need to multiple it by the number of tiles
template_duration = duration * board.columns * board.rows;
}
const rep: ImageRepresentation = {
uid: `thumbnails_${board.thumbnail_width}x${board.thumbnail_height}`,
getBitrate() {
@@ -551,7 +665,7 @@ function getImageRepresentation(
thumbnail_width: board.thumbnail_width,
rows: board.rows,
columns: board.columns,
template_duration: duration / board.storyboard_count,
template_duration: template_duration,
template_url: transform_url(template_url).toString(),
getURL(n) {
return template_url.toString().replace('$Number$', n.toString());
@@ -564,7 +678,7 @@ function getImageRepresentation(
function getImageSets(
duration: number,
actions: Actions,
storyboards: PlayerStoryboardSpec,
storyboards: PlayerStoryboardSpec | PlayerLiveStoryboardSpec,
transform_url: URLTransformer
) {
const mime_info = getStoryboardInfo(storyboards);
@@ -582,12 +696,13 @@ function getImageSets(
export function getStreamingInfo(
streaming_data?: IStreamingData,
is_post_live_dvr = false,
url_transformer: URLTransformer = (url) => url,
format_filter?: FormatFilter,
cpn?: string,
player?: Player,
actions?: Actions,
storyboards?: PlayerStoryboardSpec
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec
) {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
@@ -596,12 +711,34 @@ export function getStreamingInfo(
streaming_data.adaptive_formats.filter((fmt) => !format_filter(fmt)) :
streaming_data.adaptive_formats;
const duration = formats[0].approx_duration_ms / 1000;
let getDuration;
let shared_post_live_dvr_info: SharedPostLiveDvrInfo | undefined;
if (is_post_live_dvr) {
shared_post_live_dvr_info = {};
if (!actions) {
throw new InnertubeError('Unable to get duration or segment count for this Post Live DVR video without an Actions instance');
}
getDuration = () => {
// Should never happen, as we set it just a few lines above, but this stops TypeScript complaining
if (!shared_post_live_dvr_info) {
return Promise.resolve(0);
}
return getPostLiveDvrDuration(shared_post_live_dvr_info, formats[0], url_transformer, actions, player, cpn);
};
} else {
const duration = formats[0].approx_duration_ms / 1000;
getDuration = () => Promise.resolve(duration);
}
const {
groups,
has_multiple_audio_tracks
} = getFormatGroupings(formats);
} = getFormatGroupings(formats, is_post_live_dvr);
const {
video_groups,
@@ -627,15 +764,31 @@ export function getStreamingInfo(
audio_groups: [] as Format[][]
});
const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn));
const audio_sets = audio_groups.map((formats) => getAudioSet(formats, url_transformer, actions, player, cpn, shared_post_live_dvr_info));
const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn));
const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn, shared_post_live_dvr_info));
let image_sets: ImageSet[] = [];
// XXX: We need to make requests to get the image sizes, so we'll skip the storyboards if we don't have an Actions instance
const image_sets = storyboards && actions ? getImageSets(duration, actions, storyboards, url_transformer) : [];
if (storyboards && actions) {
let duration;
if (storyboards.is(PlayerStoryboardSpec)) {
duration = formats[0].approx_duration_ms / 1000;
} else {
const target_duration_dec = formats[0].target_duration_dec;
if (typeof target_duration_dec !== 'number') {
throw new InnertubeError('Format is missing target_duration_dec', { format: formats[0] });
}
duration = target_duration_dec;
}
image_sets = getImageSets(duration, actions, storyboards, url_transformer);
}
const info : StreamingInfo = {
duration,
getDuration,
audio_sets,
video_sets,
image_sets