@oreohq/ytdl-core 4.15.1

Sign up to get free protection for your applications and to get access to all the features.
package/lib/index.js ADDED
@@ -0,0 +1,228 @@
1
+ const PassThrough = require("stream").PassThrough;
2
+ const getInfo = require("./info");
3
+ const utils = require("./utils");
4
+ const formatUtils = require("./format-utils");
5
+ const urlUtils = require("./url-utils");
6
+ const miniget = require("miniget");
7
+ const m3u8stream = require("m3u8stream");
8
+ const { parseTimestamp } = require("m3u8stream");
9
+ const agent = require("./agent");
10
+
11
+ /**
12
+ * @param {string} link
13
+ * @param {!Object} options
14
+ * @returns {ReadableStream}
15
+ */
16
+ const ytdl = (link, options) => {
17
+ const stream = createStream(options);
18
+ ytdl.getInfo(link, options).then(
19
+ info => {
20
+ downloadFromInfoCallback(stream, info, options);
21
+ },
22
+ stream.emit.bind(stream, "error"),
23
+ );
24
+ return stream;
25
+ };
26
+ module.exports = ytdl;
27
+
28
+ ytdl.getBasicInfo = getInfo.getBasicInfo;
29
+ ytdl.getInfo = getInfo.getInfo;
30
+ ytdl.chooseFormat = formatUtils.chooseFormat;
31
+ ytdl.filterFormats = formatUtils.filterFormats;
32
+ ytdl.validateID = urlUtils.validateID;
33
+ ytdl.validateURL = urlUtils.validateURL;
34
+ ytdl.getURLVideoID = urlUtils.getURLVideoID;
35
+ ytdl.getVideoID = urlUtils.getVideoID;
36
+ ytdl.createAgent = agent.createAgent;
37
+ ytdl.createProxyAgent = agent.createProxyAgent;
38
+ ytdl.cache = {
39
+ info: getInfo.cache,
40
+ watch: getInfo.watchPageCache,
41
+ };
42
+ ytdl.version = require("../package.json").version;
43
+
44
+ const createStream = options => {
45
+ const stream = new PassThrough({
46
+ highWaterMark: (options && options.highWaterMark) || 1024 * 512,
47
+ });
48
+ stream._destroy = () => {
49
+ stream.destroyed = true;
50
+ };
51
+ return stream;
52
+ };
53
+
54
+ const pipeAndSetEvents = (req, stream, end) => {
55
+ // Forward events from the request to the stream.
56
+ ["abort", "request", "response", "error", "redirect", "retry", "reconnect"].forEach(event => {
57
+ req.prependListener(event, stream.emit.bind(stream, event));
58
+ });
59
+ req.pipe(stream, { end });
60
+ };
61
+
62
+ /**
63
+ * Chooses a format to download.
64
+ *
65
+ * @param {stream.Readable} stream
66
+ * @param {Object} info
67
+ * @param {Object} options
68
+ */
69
+ const downloadFromInfoCallback = (stream, info, options) => {
70
+ options = options || {};
71
+
72
+ let err = utils.playError(info.player_response);
73
+ if (err) {
74
+ stream.emit("error", err);
75
+ return;
76
+ }
77
+
78
+ if (!info.formats.length) {
79
+ stream.emit("error", Error("This video is unavailable"));
80
+ return;
81
+ }
82
+
83
+ let format;
84
+ try {
85
+ format = formatUtils.chooseFormat(info.formats, options);
86
+ } catch (e) {
87
+ stream.emit("error", e);
88
+ return;
89
+ }
90
+ stream.emit("info", info, format);
91
+ if (stream.destroyed) {
92
+ return;
93
+ }
94
+
95
+ let contentLength,
96
+ downloaded = 0;
97
+ const ondata = chunk => {
98
+ downloaded += chunk.length;
99
+ stream.emit("progress", chunk.length, downloaded, contentLength);
100
+ };
101
+
102
+ utils.applyDefaultHeaders(options);
103
+ if (options.IPv6Block) {
104
+ options.requestOptions = Object.assign({}, options.requestOptions, {
105
+ localAddress: utils.getRandomIPv6(options.IPv6Block),
106
+ });
107
+ }
108
+ if (options.agent) {
109
+ if (options.agent.jar) {
110
+ utils.setPropInsensitive(
111
+ options.requestOptions.headers,
112
+ "cookie",
113
+ options.agent.jar.getCookieStringSync("https://www.youtube.com"),
114
+ );
115
+ }
116
+ if (options.agent.localAddress) {
117
+ options.requestOptions.localAddress = options.agent.localAddress;
118
+ }
119
+ }
120
+
121
+ // Download the file in chunks, in this case the default is 10MB,
122
+ // anything over this will cause youtube to throttle the download
123
+ const dlChunkSize = typeof options.dlChunkSize === "number" ? options.dlChunkSize : 1024 * 1024 * 10;
124
+ let req;
125
+ let shouldEnd = true;
126
+
127
+ if (format.isHLS || format.isDashMPD) {
128
+ req = m3u8stream(format.url, {
129
+ chunkReadahead: +info.live_chunk_readahead,
130
+ begin: options.begin || (format.isLive && Date.now()),
131
+ liveBuffer: options.liveBuffer,
132
+ requestOptions: options.requestOptions,
133
+ parser: format.isDashMPD ? "dash-mpd" : "m3u8",
134
+ id: format.itag,
135
+ });
136
+
137
+ req.on("progress", (segment, totalSegments) => {
138
+ stream.emit("progress", segment.size, segment.num, totalSegments);
139
+ });
140
+ pipeAndSetEvents(req, stream, shouldEnd);
141
+ } else {
142
+ const requestOptions = Object.assign({}, options.requestOptions, {
143
+ maxReconnects: 6,
144
+ maxRetries: 3,
145
+ backoff: { inc: 500, max: 10000 },
146
+ });
147
+
148
+ let shouldBeChunked = dlChunkSize !== 0 && (!format.hasAudio || !format.hasVideo);
149
+
150
+ if (shouldBeChunked) {
151
+ let start = (options.range && options.range.start) || 0;
152
+ let end = start + dlChunkSize;
153
+ const rangeEnd = options.range && options.range.end;
154
+
155
+ contentLength = options.range
156
+ ? (rangeEnd ? rangeEnd + 1 : parseInt(format.contentLength)) - start
157
+ : parseInt(format.contentLength);
158
+
159
+ const getNextChunk = () => {
160
+ if (stream.destroyed) return;
161
+ if (!rangeEnd && end >= contentLength) end = 0;
162
+ if (rangeEnd && end > rangeEnd) end = rangeEnd;
163
+ shouldEnd = !end || end === rangeEnd;
164
+
165
+ requestOptions.headers = Object.assign({}, requestOptions.headers, {
166
+ Range: `bytes=${start}-${end || ""}`,
167
+ });
168
+ req = miniget(format.url, requestOptions);
169
+ req.on("data", ondata);
170
+ req.on("end", () => {
171
+ if (stream.destroyed) return;
172
+ if (end && end !== rangeEnd) {
173
+ start = end + 1;
174
+ end += dlChunkSize;
175
+ getNextChunk();
176
+ }
177
+ });
178
+ pipeAndSetEvents(req, stream, shouldEnd);
179
+ };
180
+ getNextChunk();
181
+ } else {
182
+ // Audio only and video only formats don't support begin
183
+ if (options.begin) {
184
+ format.url += `&begin=${parseTimestamp(options.begin)}`;
185
+ }
186
+ if (options.range && (options.range.start || options.range.end)) {
187
+ requestOptions.headers = Object.assign({}, requestOptions.headers, {
188
+ Range: `bytes=${options.range.start || "0"}-${options.range.end || ""}`,
189
+ });
190
+ }
191
+ req = miniget(format.url, requestOptions);
192
+ req.on("response", res => {
193
+ if (stream.destroyed) return;
194
+ contentLength = contentLength || parseInt(res.headers["content-length"]);
195
+ });
196
+ req.on("data", ondata);
197
+ pipeAndSetEvents(req, stream, shouldEnd);
198
+ }
199
+ }
200
+
201
+ stream._destroy = () => {
202
+ stream.destroyed = true;
203
+ if (req) {
204
+ req.destroy();
205
+ req.end();
206
+ }
207
+ };
208
+ };
209
+
210
+ /**
211
+ * Can be used to download video after its `info` is gotten through
212
+ * `ytdl.getInfo()`. In case the user might want to look at the
213
+ * `info` object before deciding to download.
214
+ *
215
+ * @param {Object} info
216
+ * @param {!Object} options
217
+ * @returns {ReadableStream}
218
+ */
219
+ ytdl.downloadFromInfo = (info, options) => {
220
+ const stream = createStream(options);
221
+ if (!info.full) {
222
+ throw Error("Cannot use `ytdl.downloadFromInfo()` when called with info from `ytdl.getBasicInfo()`");
223
+ }
224
+ setImmediate(() => {
225
+ downloadFromInfoCallback(stream, info, options);
226
+ });
227
+ return stream;
228
+ };
@@ -0,0 +1,362 @@
1
+ const utils = require("./utils");
2
+ const qs = require("querystring");
3
+ const { parseTimestamp } = require("m3u8stream");
4
+
5
+ const BASE_URL = "https://www.youtube.com/watch?v=";
6
+ const TITLE_TO_CATEGORY = {
7
+ song: { name: "Music", url: "https://music.youtube.com/" },
8
+ };
9
+
10
+ const getText = obj => (obj ? (obj.runs ? obj.runs[0].text : obj.simpleText) : null);
11
+
12
+ /**
13
+ * Get video media.
14
+ *
15
+ * @param {Object} info
16
+ * @returns {Object}
17
+ */
18
+ exports.getMedia = info => {
19
+ let media = {};
20
+ let results = [];
21
+ try {
22
+ results = info.response.contents.twoColumnWatchNextResults.results.results.contents;
23
+ } catch (err) {
24
+ // Do nothing
25
+ }
26
+
27
+ let result = results.find(v => v.videoSecondaryInfoRenderer);
28
+ if (!result) {
29
+ return {};
30
+ }
31
+
32
+ try {
33
+ let metadataRows = (result.metadataRowContainer || result.videoSecondaryInfoRenderer.metadataRowContainer)
34
+ .metadataRowContainerRenderer.rows;
35
+ for (let row of metadataRows) {
36
+ if (row.metadataRowRenderer) {
37
+ let title = getText(row.metadataRowRenderer.title).toLowerCase();
38
+ let contents = row.metadataRowRenderer.contents[0];
39
+ media[title] = getText(contents);
40
+ let runs = contents.runs;
41
+ if (runs && runs[0].navigationEndpoint) {
42
+ media[`${title}_url`] = new URL(
43
+ runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url,
44
+ BASE_URL,
45
+ ).toString();
46
+ }
47
+ if (title in TITLE_TO_CATEGORY) {
48
+ media.category = TITLE_TO_CATEGORY[title].name;
49
+ media.category_url = TITLE_TO_CATEGORY[title].url;
50
+ }
51
+ } else if (row.richMetadataRowRenderer) {
52
+ let contents = row.richMetadataRowRenderer.contents;
53
+ let boxArt = contents.filter(
54
+ meta => meta.richMetadataRenderer.style === "RICH_METADATA_RENDERER_STYLE_BOX_ART",
55
+ );
56
+ for (let { richMetadataRenderer } of boxArt) {
57
+ let meta = richMetadataRenderer;
58
+ media.year = getText(meta.subtitle);
59
+ let type = getText(meta.callToAction).split(" ")[1];
60
+ media[type] = getText(meta.title);
61
+ media[`${type}_url`] = new URL(meta.endpoint.commandMetadata.webCommandMetadata.url, BASE_URL).toString();
62
+ media.thumbnails = meta.thumbnail.thumbnails;
63
+ }
64
+ let topic = contents.filter(meta => meta.richMetadataRenderer.style === "RICH_METADATA_RENDERER_STYLE_TOPIC");
65
+ for (let { richMetadataRenderer } of topic) {
66
+ let meta = richMetadataRenderer;
67
+ media.category = getText(meta.title);
68
+ media.category_url = new URL(meta.endpoint.commandMetadata.webCommandMetadata.url, BASE_URL).toString();
69
+ }
70
+ }
71
+ }
72
+ } catch (err) {
73
+ // Do nothing.
74
+ }
75
+
76
+ return media;
77
+ };
78
+
79
+ const isVerified = badges => !!(badges && badges.find(b => b.metadataBadgeRenderer.tooltip === "Verified"));
80
+
81
+ /**
82
+ * Get video author.
83
+ *
84
+ * @param {Object} info
85
+ * @returns {Object}
86
+ */
87
+ exports.getAuthor = info => {
88
+ let channelId,
89
+ thumbnails = [],
90
+ subscriberCount,
91
+ verified = false;
92
+ try {
93
+ let results = info.response.contents.twoColumnWatchNextResults.results.results.contents;
94
+ let v = results.find(
95
+ v2 =>
96
+ v2.videoSecondaryInfoRenderer &&
97
+ v2.videoSecondaryInfoRenderer.owner &&
98
+ v2.videoSecondaryInfoRenderer.owner.videoOwnerRenderer,
99
+ );
100
+ let videoOwnerRenderer = v.videoSecondaryInfoRenderer.owner.videoOwnerRenderer;
101
+ channelId = videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId;
102
+ thumbnails = videoOwnerRenderer.thumbnail.thumbnails.map(thumbnail => {
103
+ thumbnail.url = new URL(thumbnail.url, BASE_URL).toString();
104
+ return thumbnail;
105
+ });
106
+ subscriberCount = utils.parseAbbreviatedNumber(getText(videoOwnerRenderer.subscriberCountText));
107
+ verified = isVerified(videoOwnerRenderer.badges);
108
+ } catch (err) {
109
+ // Do nothing.
110
+ }
111
+ try {
112
+ let videoDetails = info.player_response.microformat && info.player_response.microformat.playerMicroformatRenderer;
113
+ let id = (videoDetails && videoDetails.channelId) || channelId || info.player_response.videoDetails.channelId;
114
+ let author = {
115
+ id: id,
116
+ name: videoDetails ? videoDetails.ownerChannelName : info.player_response.videoDetails.author,
117
+ user: videoDetails ? videoDetails.ownerProfileUrl.split("/").slice(-1)[0] : null,
118
+ channel_url: `https://www.youtube.com/channel/${id}`,
119
+ external_channel_url: videoDetails ? `https://www.youtube.com/channel/${videoDetails.externalChannelId}` : "",
120
+ user_url: videoDetails ? new URL(videoDetails.ownerProfileUrl, BASE_URL).toString() : "",
121
+ thumbnails,
122
+ verified,
123
+ subscriber_count: subscriberCount,
124
+ };
125
+ if (thumbnails.length) {
126
+ utils.deprecate(author, "avatar", author.thumbnails[0].url, "author.avatar", "author.thumbnails[0].url");
127
+ }
128
+ return author;
129
+ } catch (err) {
130
+ return {};
131
+ }
132
+ };
133
+
134
+ const parseRelatedVideo = (details, rvsParams) => {
135
+ if (!details) return;
136
+ try {
137
+ let viewCount = getText(details.viewCountText);
138
+ let shortViewCount = getText(details.shortViewCountText);
139
+ let rvsDetails = rvsParams.find(elem => elem.id === details.videoId);
140
+ if (!/^\d/.test(shortViewCount)) {
141
+ shortViewCount = (rvsDetails && rvsDetails.short_view_count_text) || "";
142
+ }
143
+ viewCount = (/^\d/.test(viewCount) ? viewCount : shortViewCount).split(" ")[0];
144
+ let browseEndpoint = details.shortBylineText.runs[0].navigationEndpoint.browseEndpoint;
145
+ let channelId = browseEndpoint.browseId;
146
+ let name = getText(details.shortBylineText);
147
+ let user = (browseEndpoint.canonicalBaseUrl || "").split("/").slice(-1)[0];
148
+ let video = {
149
+ id: details.videoId,
150
+ title: getText(details.title),
151
+ published: getText(details.publishedTimeText),
152
+ author: {
153
+ id: channelId,
154
+ name,
155
+ user,
156
+ channel_url: `https://www.youtube.com/channel/${channelId}`,
157
+ user_url: `https://www.youtube.com/user/${user}`,
158
+ thumbnails: details.channelThumbnail.thumbnails.map(thumbnail => {
159
+ thumbnail.url = new URL(thumbnail.url, BASE_URL).toString();
160
+ return thumbnail;
161
+ }),
162
+ verified: isVerified(details.ownerBadges),
163
+
164
+ [Symbol.toPrimitive]() {
165
+ console.warn(
166
+ `\`relatedVideo.author\` will be removed in a near future release, ` +
167
+ `use \`relatedVideo.author.name\` instead.`,
168
+ );
169
+ return video.author.name;
170
+ },
171
+ },
172
+ short_view_count_text: shortViewCount.split(" ")[0],
173
+ view_count: viewCount.replace(/,/g, ""),
174
+ length_seconds: details.lengthText
175
+ ? Math.floor(parseTimestamp(getText(details.lengthText)) / 1000)
176
+ : rvsParams && `${rvsParams.length_seconds}`,
177
+ thumbnails: details.thumbnail.thumbnails,
178
+ richThumbnails: details.richThumbnail
179
+ ? details.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails
180
+ : [],
181
+ isLive: !!(details.badges && details.badges.find(b => b.metadataBadgeRenderer.label === "LIVE NOW")),
182
+ };
183
+
184
+ utils.deprecate(
185
+ video,
186
+ "author_thumbnail",
187
+ video.author.thumbnails[0].url,
188
+ "relatedVideo.author_thumbnail",
189
+ "relatedVideo.author.thumbnails[0].url",
190
+ );
191
+ utils.deprecate(video, "ucid", video.author.id, "relatedVideo.ucid", "relatedVideo.author.id");
192
+ utils.deprecate(
193
+ video,
194
+ "video_thumbnail",
195
+ video.thumbnails[0].url,
196
+ "relatedVideo.video_thumbnail",
197
+ "relatedVideo.thumbnails[0].url",
198
+ );
199
+ return video;
200
+ } catch (err) {
201
+ // Skip.
202
+ }
203
+ };
204
+
205
+ /**
206
+ * Get related videos.
207
+ *
208
+ * @param {Object} info
209
+ * @returns {Array.<Object>}
210
+ */
211
+ exports.getRelatedVideos = info => {
212
+ let rvsParams = [],
213
+ secondaryResults = [];
214
+ try {
215
+ rvsParams = info.response.webWatchNextResponseExtensionData.relatedVideoArgs.split(",").map(e => qs.parse(e));
216
+ } catch (err) {
217
+ // Do nothing.
218
+ }
219
+ try {
220
+ secondaryResults = info.response.contents.twoColumnWatchNextResults.secondaryResults.secondaryResults.results;
221
+ } catch (err) {
222
+ return [];
223
+ }
224
+ let videos = [];
225
+ for (let result of secondaryResults || []) {
226
+ let details = result.compactVideoRenderer;
227
+ if (details) {
228
+ let video = parseRelatedVideo(details, rvsParams);
229
+ if (video) videos.push(video);
230
+ } else {
231
+ let autoplay = result.compactAutoplayRenderer || result.itemSectionRenderer;
232
+ if (!autoplay || !Array.isArray(autoplay.contents)) continue;
233
+ for (let content of autoplay.contents) {
234
+ let video = parseRelatedVideo(content.compactVideoRenderer, rvsParams);
235
+ if (video) videos.push(video);
236
+ }
237
+ }
238
+ }
239
+ return videos;
240
+ };
241
+
242
+ /**
243
+ * Get like count.
244
+ *
245
+ * @param {Object} info
246
+ * @returns {number}
247
+ */
248
+ exports.getLikes = info => {
249
+ try {
250
+ let contents = info.response.contents.twoColumnWatchNextResults.results.results.contents;
251
+ let video = contents.find(r => r.videoPrimaryInfoRenderer);
252
+ let buttons = video.videoPrimaryInfoRenderer.videoActions.menuRenderer.topLevelButtons;
253
+ let accessibilityText = buttons.find(b => b.segmentedLikeDislikeButtonViewModel).segmentedLikeDislikeButtonViewModel
254
+ .likeButtonViewModel.likeButtonViewModel.toggleButtonViewModel.toggleButtonViewModel.defaultButtonViewModel
255
+ .buttonViewModel.accessibilityText;
256
+ return parseInt(accessibilityText.match(/[\d,.]+/)[0].replace(/\D+/g, ""));
257
+ } catch (err) {
258
+ return null;
259
+ }
260
+ };
261
+
262
+ /**
263
+ * Cleans up a few fields on `videoDetails`.
264
+ *
265
+ * @param {Object} videoDetails
266
+ * @param {Object} info
267
+ * @returns {Object}
268
+ */
269
+ exports.cleanVideoDetails = (videoDetails, info) => {
270
+ videoDetails.thumbnails = videoDetails.thumbnail.thumbnails;
271
+ delete videoDetails.thumbnail;
272
+ utils.deprecate(
273
+ videoDetails,
274
+ "thumbnail",
275
+ { thumbnails: videoDetails.thumbnails },
276
+ "videoDetails.thumbnail.thumbnails",
277
+ "videoDetails.thumbnails",
278
+ );
279
+ videoDetails.description = videoDetails.shortDescription || getText(videoDetails.description);
280
+ delete videoDetails.shortDescription;
281
+ utils.deprecate(
282
+ videoDetails,
283
+ "shortDescription",
284
+ videoDetails.description,
285
+ "videoDetails.shortDescription",
286
+ "videoDetails.description",
287
+ );
288
+
289
+ // Use more reliable `lengthSeconds` from `playerMicroformatRenderer`.
290
+ videoDetails.lengthSeconds =
291
+ (info.player_response.microformat && info.player_response.microformat.playerMicroformatRenderer.lengthSeconds) ||
292
+ info.player_response.videoDetails.lengthSeconds;
293
+ return videoDetails;
294
+ };
295
+
296
+ /**
297
+ * Get storyboards info.
298
+ *
299
+ * @param {Object} info
300
+ * @returns {Array.<Object>}
301
+ */
302
+ exports.getStoryboards = info => {
303
+ const parts =
304
+ info.player_response.storyboards &&
305
+ info.player_response.storyboards.playerStoryboardSpecRenderer &&
306
+ info.player_response.storyboards.playerStoryboardSpecRenderer.spec &&
307
+ info.player_response.storyboards.playerStoryboardSpecRenderer.spec.split("|");
308
+
309
+ if (!parts) return [];
310
+
311
+ const url = new URL(parts.shift());
312
+
313
+ return parts.map((part, i) => {
314
+ let [thumbnailWidth, thumbnailHeight, thumbnailCount, columns, rows, interval, nameReplacement, sigh] =
315
+ part.split("#");
316
+
317
+ url.searchParams.set("sigh", sigh);
318
+
319
+ thumbnailCount = parseInt(thumbnailCount, 10);
320
+ columns = parseInt(columns, 10);
321
+ rows = parseInt(rows, 10);
322
+
323
+ const storyboardCount = Math.ceil(thumbnailCount / (columns * rows));
324
+
325
+ return {
326
+ templateUrl: url.toString().replace("$L", i).replace("$N", nameReplacement),
327
+ thumbnailWidth: parseInt(thumbnailWidth, 10),
328
+ thumbnailHeight: parseInt(thumbnailHeight, 10),
329
+ thumbnailCount,
330
+ interval: parseInt(interval, 10),
331
+ columns,
332
+ rows,
333
+ storyboardCount,
334
+ };
335
+ });
336
+ };
337
+
338
+ /**
339
+ * Get chapters info.
340
+ *
341
+ * @param {Object} info
342
+ * @returns {Array.<Object>}
343
+ */
344
+ exports.getChapters = info => {
345
+ const playerOverlayRenderer =
346
+ info.response && info.response.playerOverlays && info.response.playerOverlays.playerOverlayRenderer;
347
+ const playerBar =
348
+ playerOverlayRenderer &&
349
+ playerOverlayRenderer.decoratedPlayerBarRenderer &&
350
+ playerOverlayRenderer.decoratedPlayerBarRenderer.decoratedPlayerBarRenderer &&
351
+ playerOverlayRenderer.decoratedPlayerBarRenderer.decoratedPlayerBarRenderer.playerBar;
352
+ const markersMap =
353
+ playerBar && playerBar.multiMarkersPlayerBarRenderer && playerBar.multiMarkersPlayerBarRenderer.markersMap;
354
+ const marker = Array.isArray(markersMap) && markersMap.find(m => m.value && Array.isArray(m.value.chapters));
355
+ if (!marker) return [];
356
+ const chapters = marker.value.chapters;
357
+
358
+ return chapters.map(chapter => ({
359
+ title: getText(chapter.chapterRenderer.title),
360
+ start_time: chapter.chapterRenderer.timeRangeStartMillis / 1000,
361
+ }));
362
+ };