diff --git a/src/core/mixins/MediaInfo.ts b/src/core/mixins/MediaInfo.ts index 39718b083..a34a3dc33 100644 --- a/src/core/mixins/MediaInfo.ts +++ b/src/core/mixins/MediaInfo.ts @@ -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 { 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 ); } diff --git a/src/parser/classes/PlayerLiveStoryboardSpec.ts b/src/parser/classes/PlayerLiveStoryboardSpec.ts index a714cb82c..b5d8ec754 100644 --- a/src/parser/classes/PlayerLiveStoryboardSpec.ts +++ b/src/parser/classes/PlayerLiveStoryboardSpec.ts @@ -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) + }; } } \ No newline at end of file diff --git a/src/parser/classes/PlayerStoryboardSpec.ts b/src/parser/classes/PlayerStoryboardSpec.ts index 29e4058ef..15950c3cf 100644 --- a/src/parser/classes/PlayerStoryboardSpec.ts +++ b/src/parser/classes/PlayerStoryboardSpec.ts @@ -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), diff --git a/src/utils/DashManifest.tsx b/src/utils/DashManifest.tsx index bd5488072..d5ed564fd 100644 --- a/src/utils/DashManifest.tsx +++ b/src/utils/DashManifest.tsx @@ -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 ; + if (info.is_oft || info.is_post_live_dvr) { + return ; } return <> @@ -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( ; 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 +} | { + is_oft: false, + is_post_live_dvr: true, getSegmentTemplate(): Promise } @@ -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(); 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 { + 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 { + 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(); + // Can't seem to combine the types in the Map, so create an alias here + type AnyStoryboardData = StoryboardData | LiveStoryboardData + + const mime_info = new Map(); - for (const storyboard of storyboards.boards) { + 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[] = []; // 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, shared_post_live_dvr_info)); - const video_sets = video_groups.map((formats) => getVideoSet(formats, url_transformer, player, actions, cpn)); + 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