@oreohq/ytdl-core 4.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ };