├── .gitignore ├── LICENSE ├── README.md ├── deps.ts ├── mod.ts ├── src ├── cache.ts ├── format_util.ts ├── formats.ts ├── info.ts ├── info_extras.ts ├── request.ts ├── sig.ts ├── stream.ts ├── types.ts ├── url_utils.ts ├── utils.ts └── video.ts └── test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | video.mp4 3 | music.mp3 4 | audio.mp3 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 DjDeveloperr 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ytdl_core 2 | 3 | Deno port of [ytdl-core](https://www.npmjs.com/package/ytdl-core) using Web Streams API. 4 | 5 | ## Usage 6 | 7 | ```ts 8 | import ytdl from "https://deno.land/x/ytdl_core/mod.ts"; 9 | 10 | const stream = await ytdl("vRXZj0DzXIA"); 11 | 12 | const chunks: Uint8Array[] = []; 13 | 14 | for await (const chunk of stream) { 15 | chunks.push(chunk); 16 | } 17 | 18 | const blob = new Blob(chunks); 19 | await Deno.writeFile("video.mp4", new Uint8Array(await blob.arrayBuffer())); 20 | ``` 21 | 22 | ## License 23 | 24 | Check [License](./LICENSE) for more info. 25 | 26 | Copyright 2021 DjDeveloper, Copyright (C) 2012-present by fent 27 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export {default as querystring} from "https://esm.sh/querystring@0.2.1"; 2 | export { humanStr as parseTimestamp } from "https://raw.githubusercontent.com/fent/node-m3u8stream/master/src/parse-time.ts"; 3 | export * as sax from "https://deno.land/x/sax_ts@v1.2.10/src/sax.ts"; 4 | export { default as m3u8stream } from "https://esm.sh/m3u8stream@0.8.6"; 5 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/cache.ts"; 2 | export * from "./src/format_util.ts"; 3 | export * from "./src/formats.ts"; 4 | export * from "./src/info.ts"; 5 | export * from "./src/info_extras.ts"; 6 | export * from "./src/url_utils.ts"; 7 | export { 8 | decipherFormats, 9 | setDownloadURL, 10 | cache as sigCache, 11 | } from "./src/sig.ts"; 12 | export * from "./src/utils.ts"; 13 | export * from "./src/stream.ts"; 14 | export * from "./src/video.ts"; 15 | export { ytdl as default } from "./src/video.ts"; 16 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | export class Cache extends Map { 2 | constructor(public timeout = 1000) { 3 | super(); 4 | } 5 | 6 | set(key: string, value: any) { 7 | if (this.has(key)) { 8 | clearTimeout(super.get(key).tid); 9 | } 10 | 11 | super.set(key, { 12 | tid: setTimeout(this.delete.bind(this, key), this.timeout), 13 | value, 14 | }); 15 | 16 | return this; 17 | } 18 | 19 | get(key: string) { 20 | let entry = super.get(key); 21 | if (entry) { 22 | return entry.value; 23 | } 24 | return null; 25 | } 26 | 27 | getOrSet(key: string, fn: CallableFunction) { 28 | if (this.has(key)) { 29 | return this.get(key); 30 | } else { 31 | let value = fn(); 32 | this.set(key, value); 33 | (async () => { 34 | try { 35 | await value; 36 | } catch (err) { 37 | this.delete(key); 38 | } 39 | })(); 40 | return value; 41 | } 42 | } 43 | 44 | delete(key: string) { 45 | let entry = super.get(key); 46 | if (entry) { 47 | clearTimeout(entry.tid); 48 | return super.delete(key); 49 | } else return false; 50 | } 51 | 52 | clear() { 53 | for (let entry of this.values()) { 54 | clearTimeout(entry.tid); 55 | } 56 | super.clear(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/format_util.ts: -------------------------------------------------------------------------------- 1 | import { Format, formats as FORMATS } from "./formats.ts"; 2 | import { ChooseFormatOptions, Filter, VideoFormat } from "./types.ts"; 3 | import * as utils from "./utils.ts"; 4 | 5 | const audioEncodingRanks = ["mp4a", "mp3", "vorbis", "aac", "opus", "flac"]; 6 | const videoEncodingRanks = [ 7 | "mp4v", 8 | "avc1", 9 | "Sorenson H.283", 10 | "MPEG-4 Visual", 11 | "VP8", 12 | "VP9", 13 | "H.264", 14 | ]; 15 | 16 | const getVideoBitrate = (format: Format) => format.bitrate ?? 0; 17 | const getVideoEncodingRank = (format: any) => 18 | videoEncodingRanks.findIndex( 19 | (enc) => format.codecs && format.codecs.includes(enc) 20 | ); 21 | const getAudioBitrate = (format: Format) => format.audioBitrate || 0; 22 | const getAudioEncodingRank = (format: any) => 23 | audioEncodingRanks.findIndex( 24 | (enc) => format.codecs && format.codecs.includes(enc) 25 | ); 26 | 27 | /** 28 | * Sort formats by a list of functions. 29 | */ 30 | const sortFormatsBy = (a: any, b: any, sortBy: CallableFunction[]) => { 31 | let res = 0; 32 | for (let fn of sortBy) { 33 | res = fn(b) - fn(a); 34 | if (res !== 0) { 35 | break; 36 | } 37 | } 38 | return res; 39 | }; 40 | 41 | const sortFormatsByVideo = (a: any, b: any) => 42 | sortFormatsBy(a, b, [ 43 | (format: any) => parseInt(format.qualityLabel), 44 | getVideoBitrate, 45 | getVideoEncodingRank, 46 | ]); 47 | 48 | const sortFormatsByAudio = (a: any, b: any) => 49 | sortFormatsBy(a, b, [getAudioBitrate, getAudioEncodingRank]); 50 | 51 | export const sortFormats = (a: any, b: any) => 52 | sortFormatsBy(a, b, [ 53 | // Formats with both video and audio are ranked highest. 54 | (format: any) => +!!format.isHLS, 55 | (format: any) => +!!format.isDashMPD, 56 | (format: any) => +(format.contentLength > 0), 57 | (format: any) => +(format.hasVideo && format.hasAudio), 58 | (format: any) => +format.hasVideo, 59 | (format: any) => parseInt(format.qualityLabel) || 0, 60 | getVideoBitrate, 61 | getAudioBitrate, 62 | getVideoEncodingRank, 63 | getAudioEncodingRank, 64 | ]); 65 | 66 | export function chooseFormat( 67 | formats: VideoFormat[], 68 | options: ChooseFormatOptions 69 | ) { 70 | if (typeof options.format === "object") { 71 | if (!options.format.url) { 72 | throw Error("Invalid format given, did you use `ytdl.getInfo()`?"); 73 | } 74 | return options.format; 75 | } 76 | 77 | if (options.filter) { 78 | formats = filterFormats(formats, options.filter as any); 79 | } 80 | 81 | let format; 82 | const quality = options.quality || "highest"; 83 | switch (quality) { 84 | case "highest": 85 | format = formats[0]; 86 | break; 87 | 88 | case "lowest": 89 | format = formats[formats.length - 1]; 90 | break; 91 | 92 | case "highestaudio": 93 | formats = filterFormats(formats, "audio"); 94 | formats.sort(sortFormatsByAudio); 95 | format = formats[0]; 96 | break; 97 | 98 | case "lowestaudio": 99 | formats = filterFormats(formats, "audio"); 100 | formats.sort(sortFormatsByAudio); 101 | format = formats[formats.length - 1]; 102 | break; 103 | 104 | case "highestvideo": 105 | formats = filterFormats(formats, "video"); 106 | formats.sort(sortFormatsByVideo); 107 | format = formats[0]; 108 | break; 109 | 110 | case "lowestvideo": 111 | formats = filterFormats(formats, "video"); 112 | formats.sort(sortFormatsByVideo); 113 | format = formats[formats.length - 1]; 114 | break; 115 | 116 | default: 117 | format = getFormatByQuality( 118 | Array.isArray(quality) 119 | ? quality.map((e: number | string) => e.toString()) 120 | : quality.toString(), 121 | formats 122 | ); 123 | break; 124 | } 125 | 126 | if (!format) { 127 | throw Error(`No such format found: ${quality}`); 128 | } 129 | return format; 130 | } 131 | 132 | /** 133 | * Gets a format based on quality or array of quality's 134 | */ 135 | const getFormatByQuality = ( 136 | quality: string | string[], 137 | formats: VideoFormat[] 138 | ) => { 139 | let getFormat = (itag: any) => 140 | formats.find((format) => `${format.itag}` === `${itag}`); 141 | if (Array.isArray(quality)) { 142 | return getFormat(quality.find((q) => getFormat(q))); 143 | } else { 144 | return getFormat(quality); 145 | } 146 | }; 147 | 148 | export function filterFormats(formats: VideoFormat[], filter: Filter) { 149 | let fn: (format: VideoFormat) => boolean; 150 | switch (filter) { 151 | case "videoandaudio": 152 | case "audioandvideo": 153 | fn = (format) => format.hasVideo && format.hasAudio; 154 | break; 155 | 156 | case "video": 157 | fn = (format) => format.hasVideo; 158 | break; 159 | 160 | case "videoonly": 161 | fn = (format) => format.hasVideo && !format.hasAudio; 162 | break; 163 | 164 | case "audio": 165 | fn = (format) => format.hasAudio; 166 | break; 167 | 168 | case "audioonly": 169 | fn = (format) => !format.hasVideo && format.hasAudio; 170 | break; 171 | 172 | default: 173 | if (typeof filter === "function") { 174 | fn = filter; 175 | } else { 176 | throw TypeError(`Given filter (${filter}) is not supported`); 177 | } 178 | } 179 | return formats.filter((format) => !!format.url && fn(format)); 180 | } 181 | 182 | export function addFormatMeta(format: VideoFormat) { 183 | format = Object.assign({}, FORMATS[format.itag], format); 184 | format.hasVideo = !!format.qualityLabel; 185 | format.hasAudio = !!format.audioBitrate; 186 | format.container = (format.mimeType 187 | ? format.mimeType.split(";")[0].split("/")[1] 188 | : null) as any; 189 | format.codecs = format.mimeType 190 | ? utils.between(format.mimeType, 'codecs="', '"') 191 | : null!; 192 | format.videoCodec = 193 | format.hasVideo && format.codecs ? format.codecs.split(", ")[0] : null!; 194 | format.audioCodec = 195 | format.hasAudio && format.codecs 196 | ? format.codecs.split(", ").slice(-1)[0] 197 | : null!; 198 | format.isLive = /\bsource[/=]yt_live_broadcast\b/.test(format.url); 199 | format.isHLS = /\/manifest\/hls_(variant|playlist)\//.test(format.url); 200 | format.isDashMPD = /\/manifest\/dash\//.test(format.url); 201 | return format; 202 | } 203 | -------------------------------------------------------------------------------- /src/formats.ts: -------------------------------------------------------------------------------- 1 | export interface Format { 2 | mimeType: string; 3 | qualityLabel: string | null; 4 | bitrate: number | null; 5 | audioBitrate: number | null; 6 | } 7 | 8 | export const formats: { 9 | [name: string]: Format; 10 | } = { 11 | 5: { 12 | mimeType: 'video/flv; codecs="Sorenson H.283, mp3"', 13 | qualityLabel: "240p", 14 | bitrate: 250000, 15 | audioBitrate: 64, 16 | }, 17 | 18 | 6: { 19 | mimeType: 'video/flv; codecs="Sorenson H.263, mp3"', 20 | qualityLabel: "270p", 21 | bitrate: 800000, 22 | audioBitrate: 64, 23 | }, 24 | 25 | 13: { 26 | mimeType: 'video/3gp; codecs="MPEG-4 Visual, aac"', 27 | qualityLabel: null, 28 | bitrate: 500000, 29 | audioBitrate: null, 30 | }, 31 | 32 | 17: { 33 | mimeType: 'video/3gp; codecs="MPEG-4 Visual, aac"', 34 | qualityLabel: "144p", 35 | bitrate: 50000, 36 | audioBitrate: 24, 37 | }, 38 | 39 | 18: { 40 | mimeType: 'video/mp4; codecs="H.264, aac"', 41 | qualityLabel: "360p", 42 | bitrate: 500000, 43 | audioBitrate: 96, 44 | }, 45 | 46 | 22: { 47 | mimeType: 'video/mp4; codecs="H.264, aac"', 48 | qualityLabel: "720p", 49 | bitrate: 2000000, 50 | audioBitrate: 192, 51 | }, 52 | 53 | 34: { 54 | mimeType: 'video/flv; codecs="H.264, aac"', 55 | qualityLabel: "360p", 56 | bitrate: 500000, 57 | audioBitrate: 128, 58 | }, 59 | 60 | 35: { 61 | mimeType: 'video/flv; codecs="H.264, aac"', 62 | qualityLabel: "480p", 63 | bitrate: 800000, 64 | audioBitrate: 128, 65 | }, 66 | 67 | 36: { 68 | mimeType: 'video/3gp; codecs="MPEG-4 Visual, aac"', 69 | qualityLabel: "240p", 70 | bitrate: 175000, 71 | audioBitrate: 32, 72 | }, 73 | 74 | 37: { 75 | mimeType: 'video/mp4; codecs="H.264, aac"', 76 | qualityLabel: "1080p", 77 | bitrate: 3000000, 78 | audioBitrate: 192, 79 | }, 80 | 81 | 38: { 82 | mimeType: 'video/mp4; codecs="H.264, aac"', 83 | qualityLabel: "3072p", 84 | bitrate: 3500000, 85 | audioBitrate: 192, 86 | }, 87 | 88 | 43: { 89 | mimeType: 'video/webm; codecs="VP8, vorbis"', 90 | qualityLabel: "360p", 91 | bitrate: 500000, 92 | audioBitrate: 128, 93 | }, 94 | 95 | 44: { 96 | mimeType: 'video/webm; codecs="VP8, vorbis"', 97 | qualityLabel: "480p", 98 | bitrate: 1000000, 99 | audioBitrate: 128, 100 | }, 101 | 102 | 45: { 103 | mimeType: 'video/webm; codecs="VP8, vorbis"', 104 | qualityLabel: "720p", 105 | bitrate: 2000000, 106 | audioBitrate: 192, 107 | }, 108 | 109 | 46: { 110 | mimeType: 'audio/webm; codecs="vp8, vorbis"', 111 | qualityLabel: "1080p", 112 | bitrate: null, 113 | audioBitrate: 192, 114 | }, 115 | 116 | 82: { 117 | mimeType: 'video/mp4; codecs="H.264, aac"', 118 | qualityLabel: "360p", 119 | bitrate: 500000, 120 | audioBitrate: 96, 121 | }, 122 | 123 | 83: { 124 | mimeType: 'video/mp4; codecs="H.264, aac"', 125 | qualityLabel: "240p", 126 | bitrate: 500000, 127 | audioBitrate: 96, 128 | }, 129 | 130 | 84: { 131 | mimeType: 'video/mp4; codecs="H.264, aac"', 132 | qualityLabel: "720p", 133 | bitrate: 2000000, 134 | audioBitrate: 192, 135 | }, 136 | 137 | 85: { 138 | mimeType: 'video/mp4; codecs="H.264, aac"', 139 | qualityLabel: "1080p", 140 | bitrate: 3000000, 141 | audioBitrate: 192, 142 | }, 143 | 144 | 91: { 145 | mimeType: 'video/ts; codecs="H.264, aac"', 146 | qualityLabel: "144p", 147 | bitrate: 100000, 148 | audioBitrate: 48, 149 | }, 150 | 151 | 92: { 152 | mimeType: 'video/ts; codecs="H.264, aac"', 153 | qualityLabel: "240p", 154 | bitrate: 150000, 155 | audioBitrate: 48, 156 | }, 157 | 158 | 93: { 159 | mimeType: 'video/ts; codecs="H.264, aac"', 160 | qualityLabel: "360p", 161 | bitrate: 500000, 162 | audioBitrate: 128, 163 | }, 164 | 165 | 94: { 166 | mimeType: 'video/ts; codecs="H.264, aac"', 167 | qualityLabel: "480p", 168 | bitrate: 800000, 169 | audioBitrate: 128, 170 | }, 171 | 172 | 95: { 173 | mimeType: 'video/ts; codecs="H.264, aac"', 174 | qualityLabel: "720p", 175 | bitrate: 1500000, 176 | audioBitrate: 256, 177 | }, 178 | 179 | 96: { 180 | mimeType: 'video/ts; codecs="H.264, aac"', 181 | qualityLabel: "1080p", 182 | bitrate: 2500000, 183 | audioBitrate: 256, 184 | }, 185 | 186 | 100: { 187 | mimeType: 'audio/webm; codecs="VP8, vorbis"', 188 | qualityLabel: "360p", 189 | bitrate: null, 190 | audioBitrate: 128, 191 | }, 192 | 193 | 101: { 194 | mimeType: 'audio/webm; codecs="VP8, vorbis"', 195 | qualityLabel: "360p", 196 | bitrate: null, 197 | audioBitrate: 192, 198 | }, 199 | 200 | 102: { 201 | mimeType: 'audio/webm; codecs="VP8, vorbis"', 202 | qualityLabel: "720p", 203 | bitrate: null, 204 | audioBitrate: 192, 205 | }, 206 | 207 | 120: { 208 | mimeType: 'video/flv; codecs="H.264, aac"', 209 | qualityLabel: "720p", 210 | bitrate: 2000000, 211 | audioBitrate: 128, 212 | }, 213 | 214 | 127: { 215 | mimeType: 'audio/ts; codecs="aac"', 216 | qualityLabel: null, 217 | bitrate: null, 218 | audioBitrate: 96, 219 | }, 220 | 221 | 128: { 222 | mimeType: 'audio/ts; codecs="aac"', 223 | qualityLabel: null, 224 | bitrate: null, 225 | audioBitrate: 96, 226 | }, 227 | 228 | 132: { 229 | mimeType: 'video/ts; codecs="H.264, aac"', 230 | qualityLabel: "240p", 231 | bitrate: 150000, 232 | audioBitrate: 48, 233 | }, 234 | 235 | 133: { 236 | mimeType: 'video/mp4; codecs="H.264"', 237 | qualityLabel: "240p", 238 | bitrate: 200000, 239 | audioBitrate: null, 240 | }, 241 | 242 | 134: { 243 | mimeType: 'video/mp4; codecs="H.264"', 244 | qualityLabel: "360p", 245 | bitrate: 300000, 246 | audioBitrate: null, 247 | }, 248 | 249 | 135: { 250 | mimeType: 'video/mp4; codecs="H.264"', 251 | qualityLabel: "480p", 252 | bitrate: 500000, 253 | audioBitrate: null, 254 | }, 255 | 256 | 136: { 257 | mimeType: 'video/mp4; codecs="H.264"', 258 | qualityLabel: "720p", 259 | bitrate: 1000000, 260 | audioBitrate: null, 261 | }, 262 | 263 | 137: { 264 | mimeType: 'video/mp4; codecs="H.264"', 265 | qualityLabel: "1080p", 266 | bitrate: 2500000, 267 | audioBitrate: null, 268 | }, 269 | 270 | 138: { 271 | mimeType: 'video/mp4; codecs="H.264"', 272 | qualityLabel: "4320p", 273 | bitrate: 13500000, 274 | audioBitrate: null, 275 | }, 276 | 277 | 139: { 278 | mimeType: 'audio/mp4; codecs="aac"', 279 | qualityLabel: null, 280 | bitrate: null, 281 | audioBitrate: 48, 282 | }, 283 | 284 | 140: { 285 | mimeType: 'audio/m4a; codecs="aac"', 286 | qualityLabel: null, 287 | bitrate: null, 288 | audioBitrate: 128, 289 | }, 290 | 291 | 141: { 292 | mimeType: 'audio/mp4; codecs="aac"', 293 | qualityLabel: null, 294 | bitrate: null, 295 | audioBitrate: 256, 296 | }, 297 | 298 | 151: { 299 | mimeType: 'video/ts; codecs="H.264, aac"', 300 | qualityLabel: "720p", 301 | bitrate: 50000, 302 | audioBitrate: 24, 303 | }, 304 | 305 | 160: { 306 | mimeType: 'video/mp4; codecs="H.264"', 307 | qualityLabel: "144p", 308 | bitrate: 100000, 309 | audioBitrate: null, 310 | }, 311 | 312 | 171: { 313 | mimeType: 'audio/webm; codecs="vorbis"', 314 | qualityLabel: null, 315 | bitrate: null, 316 | audioBitrate: 128, 317 | }, 318 | 319 | 172: { 320 | mimeType: 'audio/webm; codecs="vorbis"', 321 | qualityLabel: null, 322 | bitrate: null, 323 | audioBitrate: 192, 324 | }, 325 | 326 | 242: { 327 | mimeType: 'video/webm; codecs="VP9"', 328 | qualityLabel: "240p", 329 | bitrate: 100000, 330 | audioBitrate: null, 331 | }, 332 | 333 | 243: { 334 | mimeType: 'video/webm; codecs="VP9"', 335 | qualityLabel: "360p", 336 | bitrate: 250000, 337 | audioBitrate: null, 338 | }, 339 | 340 | 244: { 341 | mimeType: 'video/webm; codecs="VP9"', 342 | qualityLabel: "480p", 343 | bitrate: 500000, 344 | audioBitrate: null, 345 | }, 346 | 347 | 247: { 348 | mimeType: 'video/webm; codecs="VP9"', 349 | qualityLabel: "720p", 350 | bitrate: 700000, 351 | audioBitrate: null, 352 | }, 353 | 354 | 248: { 355 | mimeType: 'video/webm; codecs="VP9"', 356 | qualityLabel: "1080p", 357 | bitrate: 1500000, 358 | audioBitrate: null, 359 | }, 360 | 361 | 249: { 362 | mimeType: 'audio/webm; codecs="opus"', 363 | qualityLabel: null, 364 | bitrate: null, 365 | audioBitrate: 48, 366 | }, 367 | 368 | 250: { 369 | mimeType: 'audio/webm; codecs="opus"', 370 | qualityLabel: null, 371 | bitrate: null, 372 | audioBitrate: 64, 373 | }, 374 | 375 | 251: { 376 | mimeType: 'audio/webm; codecs="opus"', 377 | qualityLabel: null, 378 | bitrate: null, 379 | audioBitrate: 160, 380 | }, 381 | 382 | 264: { 383 | mimeType: 'video/mp4; codecs="H.264"', 384 | qualityLabel: "1440p", 385 | bitrate: 4000000, 386 | audioBitrate: null, 387 | }, 388 | 389 | 266: { 390 | mimeType: 'video/mp4; codecs="H.264"', 391 | qualityLabel: "2160p", 392 | bitrate: 12500000, 393 | audioBitrate: null, 394 | }, 395 | 396 | 271: { 397 | mimeType: 'video/webm; codecs="VP9"', 398 | qualityLabel: "1440p", 399 | bitrate: 9000000, 400 | audioBitrate: null, 401 | }, 402 | 403 | 272: { 404 | mimeType: 'video/webm; codecs="VP9"', 405 | qualityLabel: "4320p", 406 | bitrate: 20000000, 407 | audioBitrate: null, 408 | }, 409 | 410 | 278: { 411 | mimeType: 'video/webm; codecs="VP9"', 412 | qualityLabel: "144p 30fps", 413 | bitrate: 80000, 414 | audioBitrate: null, 415 | }, 416 | 417 | 298: { 418 | mimeType: 'video/mp4; codecs="H.264"', 419 | qualityLabel: "720p", 420 | bitrate: 3000000, 421 | audioBitrate: null, 422 | }, 423 | 424 | 299: { 425 | mimeType: 'video/mp4; codecs="H.264"', 426 | qualityLabel: "1080p", 427 | bitrate: 5500000, 428 | audioBitrate: null, 429 | }, 430 | 431 | 300: { 432 | mimeType: 'video/ts; codecs="H.264, aac"', 433 | qualityLabel: "720p", 434 | bitrate: 1318000, 435 | audioBitrate: 48, 436 | }, 437 | 438 | 302: { 439 | mimeType: 'video/webm; codecs="VP9"', 440 | qualityLabel: "720p HFR", 441 | bitrate: 2500000, 442 | audioBitrate: null, 443 | }, 444 | 445 | 303: { 446 | mimeType: 'video/webm; codecs="VP9"', 447 | qualityLabel: "1080p HFR", 448 | bitrate: 5000000, 449 | audioBitrate: null, 450 | }, 451 | 452 | 308: { 453 | mimeType: 'video/webm; codecs="VP9"', 454 | qualityLabel: "1440p HFR", 455 | bitrate: 10000000, 456 | audioBitrate: null, 457 | }, 458 | 459 | 313: { 460 | mimeType: 'video/webm; codecs="VP9"', 461 | qualityLabel: "2160p", 462 | bitrate: 13000000, 463 | audioBitrate: null, 464 | }, 465 | 466 | 315: { 467 | mimeType: 'video/webm; codecs="VP9"', 468 | qualityLabel: "2160p HFR", 469 | bitrate: 20000000, 470 | audioBitrate: null, 471 | }, 472 | 473 | 330: { 474 | mimeType: 'video/webm; codecs="VP9"', 475 | qualityLabel: "144p HDR, HFR", 476 | bitrate: 80000, 477 | audioBitrate: null, 478 | }, 479 | 480 | 331: { 481 | mimeType: 'video/webm; codecs="VP9"', 482 | qualityLabel: "240p HDR, HFR", 483 | bitrate: 100000, 484 | audioBitrate: null, 485 | }, 486 | 487 | 332: { 488 | mimeType: 'video/webm; codecs="VP9"', 489 | qualityLabel: "360p HDR, HFR", 490 | bitrate: 250000, 491 | audioBitrate: null, 492 | }, 493 | 494 | 333: { 495 | mimeType: 'video/webm; codecs="VP9"', 496 | qualityLabel: "240p HDR, HFR", 497 | bitrate: 500000, 498 | audioBitrate: null, 499 | }, 500 | 501 | 334: { 502 | mimeType: 'video/webm; codecs="VP9"', 503 | qualityLabel: "720p HDR, HFR", 504 | bitrate: 1000000, 505 | audioBitrate: null, 506 | }, 507 | 508 | 335: { 509 | mimeType: 'video/webm; codecs="VP9"', 510 | qualityLabel: "1080p HDR, HFR", 511 | bitrate: 1500000, 512 | audioBitrate: null, 513 | }, 514 | 515 | 336: { 516 | mimeType: 'video/webm; codecs="VP9"', 517 | qualityLabel: "1440p HDR, HFR", 518 | bitrate: 5000000, 519 | audioBitrate: null, 520 | }, 521 | 522 | 337: { 523 | mimeType: 'video/webm; codecs="VP9"', 524 | qualityLabel: "2160p HDR, HFR", 525 | bitrate: 12000000, 526 | audioBitrate: null, 527 | }, 528 | }; 529 | -------------------------------------------------------------------------------- /src/info.ts: -------------------------------------------------------------------------------- 1 | import { querystring, sax } from "../deps.ts"; 2 | import { Cache } from "./cache.ts"; 3 | import * as sig from "./sig.ts"; 4 | import * as urlUtils from "./url_utils.ts"; 5 | import * as utils from "./utils.ts"; 6 | import * as formatUtils from "./format_util.ts"; 7 | import * as extras from "./info_extras.ts"; 8 | import { GetInfoOptions, VideoInfo } from "./types.ts"; 9 | import { request } from "./request.ts"; 10 | 11 | let cver = "2.20210622.10.00"; 12 | 13 | const BASE_URL = "https://www.youtube.com/watch?v="; 14 | 15 | export const cache = new Cache(); 16 | export const cookieCache = new Cache(1000 * 60 * 60 * 24); 17 | export const watchPageCache = new Cache(); 18 | 19 | export class UnrecoverableError extends Error { 20 | name = "UnrecoverableError"; 21 | } 22 | 23 | export const USER_AGENT = 24 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.101 Safari/537.36"; 25 | 26 | const AGE_RESTRICTED_URLS = [ 27 | "support.google.com/youtube/?p=age_restrictions", 28 | "youtube.com/t/community_guidelines", 29 | ]; 30 | 31 | export async function getBasicInfo( 32 | id: string, 33 | options: GetInfoOptions = {}, 34 | ): Promise { 35 | id = urlUtils.getVideoID(id); 36 | options.headers = Object.assign( 37 | {}, 38 | { 39 | // eslint-disable-next-line max-len 40 | "User-Agent": USER_AGENT, 41 | }, 42 | options.headers, 43 | ); 44 | const validate = (info: any) => { 45 | let playErr = utils.playError( 46 | info.player_response, 47 | ["ERROR"], 48 | UnrecoverableError as any, 49 | ); 50 | let privateErr = privateVideoError(info.player_response); 51 | if (playErr || privateErr) { 52 | throw playErr || privateErr; 53 | } 54 | return ( 55 | info && 56 | info.player_response && 57 | (info.player_response.streamingData || 58 | isRental(info.player_response) || 59 | isNotYetBroadcasted(info.player_response)) 60 | ); 61 | }; 62 | let info = await pipeline([id, options], validate, {}, [ 63 | getWatchHTMLPage, 64 | getWatchJSONPage, 65 | getVideoInfoPage, 66 | ]); 67 | 68 | if (info.player_response === undefined) throw new Error("404 - Not found"); 69 | 70 | Object.assign(info, { 71 | formats: parseFormats(info.player_response), 72 | related_videos: extras.getRelatedVideos(info), 73 | }); 74 | 75 | // Add additional properties to info. 76 | const media = extras.getMedia(info); 77 | let additional = { 78 | author: extras.getAuthor(info), 79 | media, 80 | likes: extras.getLikes(info), 81 | dislikes: extras.getDislikes(info), 82 | age_restricted: !!( 83 | media && 84 | media.notice_url && 85 | AGE_RESTRICTED_URLS.some((url) => media.notice_url.includes(url)) 86 | ), 87 | 88 | // Give the standard link to the video. 89 | video_url: BASE_URL + id, 90 | storyboards: extras.getStoryboards(info), 91 | }; 92 | 93 | info.videoDetails = extras.cleanVideoDetails( 94 | Object.assign( 95 | {}, 96 | info.player_response && 97 | info.player_response.microformat && 98 | info.player_response.microformat.playerMicroformatRenderer, 99 | info.player_response && info.player_response.videoDetails, 100 | additional, 101 | ), 102 | info, 103 | ); 104 | 105 | return info; 106 | } 107 | 108 | const privateVideoError = (player_response: any) => { 109 | let playability = player_response && player_response.playabilityStatus; 110 | if ( 111 | playability && 112 | playability.status === "LOGIN_REQUIRED" && 113 | playability.messages && 114 | playability.messages.filter((m: any) => /This is a private video/.test(m)) 115 | .length 116 | ) { 117 | return new UnrecoverableError( 118 | playability.reason || (playability.messages && playability.messages[0]), 119 | ); 120 | } else { 121 | return null; 122 | } 123 | }; 124 | 125 | const isRental = (player_response: any) => { 126 | let playability = player_response.playabilityStatus; 127 | return ( 128 | playability && 129 | playability.status === "UNPLAYABLE" && 130 | playability.errorScreen && 131 | playability.errorScreen.playerLegacyDesktopYpcOfferRenderer 132 | ); 133 | }; 134 | 135 | const isNotYetBroadcasted = (player_response: any) => { 136 | let playability = player_response.playabilityStatus; 137 | return playability && playability.status === "LIVE_STREAM_OFFLINE"; 138 | }; 139 | 140 | const getWatchHTMLURL = (id: string, options: any) => 141 | `${BASE_URL + id}&hl=${options.lang || "en"}`; 142 | const getWatchHTMLPageBody = (id: string, options: any) => { 143 | const url = getWatchHTMLURL(id, options); 144 | return watchPageCache.getOrSet(url, () => 145 | request(url, options) 146 | .then((r) => r.text()) 147 | .then((t) => { 148 | return t; 149 | })); 150 | }; 151 | 152 | const EMBED_URL = "https://www.youtube.com/embed/"; 153 | const getEmbedPageBody = (id: string, options: GetInfoOptions = {}) => { 154 | const embedUrl = `${EMBED_URL + id}?hl=${options.lang || "en"}`; 155 | return request(embedUrl, options).then((e) => e.text()); 156 | }; 157 | 158 | const getHTML5player = (body: string) => { 159 | let html5playerRes = 160 | /|"jsUrl":"([^"]+)"/ 161 | .exec( 162 | body, 163 | ); 164 | return html5playerRes ? html5playerRes[1] || html5playerRes[2] : null; 165 | }; 166 | 167 | const getIdentityToken = ( 168 | id: string, 169 | options: any, 170 | key: string, 171 | throwIfNotFound: boolean, 172 | ) => 173 | cookieCache.getOrSet(key, async () => { 174 | let page = await getWatchHTMLPageBody(id, options); 175 | let match = page.match(/(["'])ID_TOKEN\1[:,]\s?"([^"]+)"/); 176 | if (!match && throwIfNotFound) { 177 | throw new UnrecoverableError( 178 | "Cookie header used in request, but unable to find YouTube identity token", 179 | ); 180 | } 181 | return match && match[2]; 182 | }); 183 | 184 | /** 185 | * Goes through each endpoint in the pipeline, retrying on failure if the error is recoverable. 186 | * If unable to succeed with one endpoint, moves onto the next one. 187 | */ 188 | const pipeline = async ( 189 | args: any[], 190 | validate: CallableFunction, 191 | retryOptions: any, 192 | endpoints: CallableFunction[], 193 | ) => { 194 | let info; 195 | for (let func of endpoints) { 196 | try { 197 | const newInfo = await retryFunc(func, args.concat([info]), retryOptions); 198 | if (newInfo && newInfo.player_response) { 199 | newInfo.player_response.videoDetails = assign( 200 | info && info.player_response && info.player_response.videoDetails, 201 | newInfo.player_response.videoDetails, 202 | ); 203 | newInfo.player_response = assign( 204 | info && info.player_response, 205 | newInfo.player_response, 206 | ); 207 | } 208 | info = assign(info, newInfo); 209 | if (validate(info, false)) { 210 | break; 211 | } 212 | } catch (err) { 213 | if ( 214 | err instanceof UnrecoverableError || 215 | func === endpoints[endpoints.length - 1] 216 | ) { 217 | throw err; 218 | } 219 | // Unable to find video metadata... so try next endpoint. 220 | } 221 | } 222 | return info; 223 | }; 224 | 225 | /** 226 | * Like Object.assign(), but ignores `null` and `undefined` from `source`. 227 | * 228 | * @param {Object} target 229 | * @param {Object} source 230 | * @returns {Object} 231 | */ 232 | const assign = (target: any, source: any) => { 233 | if (!target || !source) { 234 | return target || source; 235 | } 236 | for (let [key, value] of Object.entries(source)) { 237 | if (value !== null && value !== undefined) { 238 | target[key] = value; 239 | } 240 | } 241 | return target; 242 | }; 243 | 244 | /** 245 | * Given a function, calls it with `args` until it's successful, 246 | * or until it encounters an unrecoverable error. 247 | * Currently, any error from miniget is considered unrecoverable. Errors such as 248 | * too many redirects, invalid URL, status code 404, status code 502. 249 | * 250 | * @param {Function} func 251 | * @param {Array.} args 252 | * @param {Object} options 253 | * @param {number} options.maxRetries 254 | * @param {Object} options.backoff 255 | * @param {number} options.backoff.inc 256 | */ 257 | const retryFunc = async (func: CallableFunction, args: any[], options: any) => { 258 | let currentTry = 0, 259 | result; 260 | while (currentTry <= (options.maxRetries ?? 1)) { 261 | try { 262 | result = await func(...args); 263 | break; 264 | } catch (err) { 265 | if ( 266 | err instanceof UnrecoverableError || 267 | err instanceof TypeError || 268 | err.statusCode < 500 || 269 | currentTry >= options.maxRetries 270 | ) { 271 | throw err; 272 | } 273 | let wait = Math.min( 274 | ++currentTry * (options.backoff?.inc ?? 0), 275 | options.backoff?.max ?? 0, 276 | ); 277 | await new Promise((resolve) => setTimeout(resolve, wait)); 278 | } 279 | } 280 | return result; 281 | }; 282 | 283 | const jsonClosingChars = /^[)\]}'\s]+/; 284 | const parseJSON = (source: any, varName: any, json: any) => { 285 | if (!json || typeof json === "object") { 286 | return json; 287 | } else { 288 | try { 289 | json = json.replace(jsonClosingChars, ""); 290 | return JSON.parse(json); 291 | } catch (err) { 292 | throw Error(`Error parsing ${varName} in ${source}: ${err.message}`); 293 | } 294 | } 295 | }; 296 | 297 | const findJSON = ( 298 | source: any, 299 | varName: string, 300 | body: any, 301 | left: any, 302 | right: any, 303 | prependJSON: any, 304 | ) => { 305 | let jsonStr = utils.between(body, left, right); 306 | if (!jsonStr) { 307 | throw Error(`Could not find ${varName} in ${source}`); 308 | } 309 | return parseJSON( 310 | source, 311 | varName, 312 | utils.cutAfterJSON(`${prependJSON}${jsonStr}`), 313 | ); 314 | }; 315 | 316 | const findPlayerResponse = (source: any, info: any) => { 317 | const player_response = info && 318 | ((info.args && info.args.player_response) || 319 | info.player_response || 320 | info.playerResponse || 321 | info.embedded_player_response); 322 | return parseJSON(source, "player_response", player_response); 323 | }; 324 | 325 | const getWatchJSONURL = (id: string, options: GetInfoOptions) => 326 | `${getWatchHTMLURL(id, options)}&pbj=1`; 327 | const getWatchJSONPage = async (id: string, options: GetInfoOptions) => { 328 | const reqOptions = Object.assign({ headers: {} }, options); 329 | let cookie = (reqOptions.headers as any).Cookie || 330 | (reqOptions.headers as any).cookie; 331 | reqOptions.headers = Object.assign( 332 | { 333 | "x-youtube-client-name": "1", 334 | "x-youtube-client-version": cver, 335 | "x-youtube-identity-token": cookieCache.get(cookie || "browser") || "", 336 | }, 337 | reqOptions.headers, 338 | ); 339 | 340 | const setIdentityToken = async (key: string, throwIfNotFound: boolean) => { 341 | if ((reqOptions.headers as any)["x-youtube-identity-token"]) { 342 | return; 343 | } 344 | (reqOptions.headers as any)[ 345 | "x-youtube-identity-token" 346 | ] = await getIdentityToken(id, options, key, throwIfNotFound); 347 | }; 348 | 349 | if (cookie) { 350 | await setIdentityToken(cookie, true); 351 | } 352 | 353 | const jsonUrl = getWatchJSONURL(id, options); 354 | let body = await request(jsonUrl, reqOptions).then((e) => e.text()); 355 | let parsedBody = parseJSON("watch.json", "body", body); 356 | if (parsedBody.reload === "now") { 357 | await setIdentityToken("browser", false); 358 | } 359 | if (parsedBody.reload === "now" || !Array.isArray(parsedBody)) { 360 | throw Error("Unable to retrieve video metadata in watch.json"); 361 | } 362 | let info = parsedBody.reduce((part, curr) => Object.assign(curr, part), {}); 363 | info.player_response = findPlayerResponse("watch.json", info); 364 | info.html5player = info.player && info.player.assets && info.player.assets.js; 365 | 366 | return info; 367 | }; 368 | 369 | const getWatchHTMLPage = async (id: string, options: GetInfoOptions) => { 370 | let body = await getWatchHTMLPageBody(id, options); 371 | let info: any = { page: "watch" }; 372 | try { 373 | cver = utils.between(body, '{"key":"cver","value":"', '"}'); 374 | info.player_response = findJSON( 375 | "watch.html", 376 | "player_response", 377 | body, 378 | /\bytInitialPlayerResponse\s*=\s*\{/i, 379 | "", 380 | "{", 381 | ); 382 | } catch (err) { 383 | let args = findJSON( 384 | "watch.html", 385 | "player_response", 386 | body, 387 | /\bytplayer\.config\s*=\s*{/, 388 | "", 389 | "{", 390 | ); 391 | info.player_response = findPlayerResponse("watch.html", args); 392 | } 393 | info.response = findJSON( 394 | "watch.html", 395 | "response", 396 | body, 397 | /\bytInitialData("\])?\s*=\s*\{/i, 398 | "", 399 | "{", 400 | ); 401 | info.html5player = getHTML5player(body); 402 | return info; 403 | }; 404 | 405 | const INFO_HOST = "www.youtube.com"; 406 | const INFO_PATH = "/get_video_info"; 407 | const VIDEO_EURL = "https://youtube.googleapis.com/v/"; 408 | const getVideoInfoPage = async (id: string, options: GetInfoOptions) => { 409 | const url = new URL(`https://${INFO_HOST}${INFO_PATH}`); 410 | url.searchParams.set("video_id", id); 411 | url.searchParams.set("eurl", VIDEO_EURL + id); 412 | url.searchParams.set("ps", "default"); 413 | url.searchParams.set("gl", "US"); 414 | url.searchParams.set("hl", options.lang || "en"); 415 | url.searchParams.set("c", "TVHTML5"); 416 | url.searchParams.set("cver", `7${cver.substr(1)}`); 417 | url.searchParams.set("html5", "1"); 418 | let body = await request(url.toString(), options).then((e) => e.text()); 419 | let info = querystring.decode(body); 420 | info.player_response = findPlayerResponse("get_video_info", info); 421 | return info; 422 | }; 423 | 424 | /** 425 | * @param {Object} player_response 426 | * @returns {Array.} 427 | */ 428 | const parseFormats = (player_response: any) => { 429 | let formats: any[] = []; 430 | if (player_response && player_response.streamingData) { 431 | formats = formats 432 | .concat(player_response.streamingData.formats || []) 433 | .concat(player_response.streamingData.adaptiveFormats || []); 434 | } 435 | return formats; 436 | }; 437 | 438 | /** 439 | * Gets info from a video additional formats and deciphered URLs. 440 | */ 441 | export const getInfo = async ( 442 | id: string, 443 | options: GetInfoOptions = {}, 444 | ): Promise => { 445 | let info = await getBasicInfo(id, options); 446 | const hasManifest = info.player_response && 447 | info.player_response.streamingData && 448 | (info.player_response.streamingData.dashManifestUrl || 449 | info.player_response.streamingData.hlsManifestUrl); 450 | let funcs = []; 451 | if (info.formats.length) { 452 | info.html5player = (info.html5player || 453 | getHTML5player(await getWatchHTMLPageBody(id, options)) || 454 | getHTML5player(await getEmbedPageBody(id, options)))!; 455 | if (!info.html5player) { 456 | throw Error("Unable to find html5player file"); 457 | } 458 | const html5player = new URL(info.html5player, BASE_URL).toString(); 459 | funcs.push(sig.decipherFormats(info.formats, html5player, options)); 460 | } 461 | if (hasManifest && info.player_response.streamingData.dashManifestUrl) { 462 | let url = info.player_response.streamingData.dashManifestUrl; 463 | funcs.push(getDashManifest(url, options)); 464 | } 465 | if (hasManifest && info.player_response.streamingData.hlsManifestUrl) { 466 | let url = info.player_response.streamingData.hlsManifestUrl; 467 | funcs.push(getM3U8(url, options)); 468 | } 469 | 470 | let results = await Promise.all(funcs); 471 | info.formats = Object.values(Object.assign({}, ...results)); 472 | info.formats = info.formats.map(formatUtils.addFormatMeta); 473 | info.formats.sort(formatUtils.sortFormats); 474 | info.full = true; 475 | return info; 476 | }; 477 | 478 | /** 479 | * Gets additional DASH formats. 480 | * 481 | * @param {string} url 482 | * @param {Object} options 483 | * @returns {Promise>} 484 | */ 485 | const getDashManifest = (url: string, options: any) => 486 | new Promise((resolve, reject) => { 487 | let formats: any = {}; 488 | const parser = new sax.SAXParser(false, {}); 489 | parser.onerror = reject; 490 | let adaptationSet: any; 491 | parser.onopentag = (node: any) => { 492 | if (node.name === "ADAPTATIONSET") { 493 | adaptationSet = node.attributes; 494 | } else if (node.name === "REPRESENTATION") { 495 | const itag = parseInt(node.attributes.ID as any); 496 | if (!isNaN(itag)) { 497 | formats[url] = Object.assign( 498 | { 499 | itag, 500 | url, 501 | bitrate: parseInt(node.attributes.BANDWIDTH as any), 502 | mimeType: 503 | `${adaptationSet.MIMETYPE}; codecs="${node.attributes.CODECS}"`, 504 | }, 505 | node.attributes.HEIGHT 506 | ? { 507 | width: parseInt(node.attributes.WIDTH as any), 508 | height: parseInt(node.attributes.HEIGHT as any), 509 | fps: parseInt(node.attributes.FRAMERATE as any), 510 | } 511 | : { 512 | audioSampleRate: node.attributes.AUDIOSAMPLINGRATE, 513 | }, 514 | ); 515 | } 516 | } 517 | }; 518 | parser.onend = () => { 519 | resolve(formats); 520 | }; 521 | const req = request(new URL(url, BASE_URL).toString(), options); 522 | 523 | req 524 | .then(async (res) => { 525 | for await (const chunk of res.body!) { 526 | parser.write(new TextDecoder().decode(chunk)); 527 | } 528 | parser.close.bind(parser)(); 529 | }) 530 | .catch(reject); 531 | }); 532 | 533 | /** 534 | * Gets additional formats. 535 | * 536 | * @param {string} url 537 | * @param {Object} options 538 | * @returns {Promise>} 539 | */ 540 | const getM3U8 = async (_url: string, options: any) => { 541 | let url = new URL(_url, BASE_URL); 542 | let body = await request(url.toString(), options.requestOptions).then((e) => 543 | e.text() 544 | ); 545 | let formats: any = {}; 546 | body 547 | .split("\n") 548 | .filter((line) => /^https?:\/\//.test(line)) 549 | .forEach((line) => { 550 | const itag = parseInt(line.match(/\/itag\/(\d+)\//)![1]); 551 | formats[line] = { itag, url: line }; 552 | }); 553 | return formats; 554 | }; 555 | -------------------------------------------------------------------------------- /src/info_extras.ts: -------------------------------------------------------------------------------- 1 | import { parseTimestamp, querystring as qs } from "../deps.ts"; 2 | import * as utils from "./utils.ts"; 3 | 4 | const BASE_URL = "https://www.youtube.com/watch?v="; 5 | const TITLE_TO_CATEGORY = { 6 | song: { name: "Music", url: "https://music.youtube.com/" }, 7 | }; 8 | 9 | const getText = (obj: any) => 10 | obj ? (obj.runs ? obj.runs[0].text : obj.simpleText) : null; 11 | 12 | export const getMedia = (info: any) => { 13 | let media: any = {}; 14 | let results: any[] = []; 15 | try { 16 | results = 17 | info.response.contents.twoColumnWatchNextResults.results.results.contents; 18 | } catch (err) { 19 | // Do nothing 20 | } 21 | 22 | let result = results.find((v: any) => v.videoSecondaryInfoRenderer); 23 | if (!result) { 24 | return {}; 25 | } 26 | 27 | try { 28 | let metadataRows = ( 29 | result.metadataRowContainer || 30 | result.videoSecondaryInfoRenderer.metadataRowContainer 31 | ).metadataRowContainerRenderer.rows; 32 | for (let row of metadataRows) { 33 | if (row.metadataRowRenderer) { 34 | let title = getText(row.metadataRowRenderer.title).toLowerCase(); 35 | let contents = row.metadataRowRenderer.contents[0]; 36 | media[title] = getText(contents); 37 | let runs = contents.runs; 38 | if (runs && runs[0].navigationEndpoint) { 39 | media[`${title}_url`] = new URL( 40 | runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url, 41 | BASE_URL, 42 | ).toString(); 43 | } 44 | if (title in TITLE_TO_CATEGORY) { 45 | media.category = (TITLE_TO_CATEGORY as any)[title].name; 46 | media.category_url = (TITLE_TO_CATEGORY as any)[title].url; 47 | } 48 | } else if (row.richMetadataRowRenderer) { 49 | let contents = row.richMetadataRowRenderer.contents; 50 | let boxArt = contents.filter( 51 | (meta: any) => 52 | meta.richMetadataRenderer.style === 53 | "RICH_METADATA_RENDERER_STYLE_BOX_ART", 54 | ); 55 | for (let { richMetadataRenderer } of boxArt) { 56 | let meta = richMetadataRenderer; 57 | media.year = getText(meta.subtitle); 58 | let type = getText(meta.callToAction).split(" ")[1]; 59 | media[type] = getText(meta.title); 60 | media[`${type}_url`] = new URL( 61 | meta.endpoint.commandMetadata.webCommandMetadata.url, 62 | BASE_URL, 63 | ).toString(); 64 | media.thumbnails = meta.thumbnail.thumbnails; 65 | } 66 | let topic = contents.filter( 67 | (meta: any) => 68 | meta.richMetadataRenderer.style === 69 | "RICH_METADATA_RENDERER_STYLE_TOPIC", 70 | ); 71 | for (let { richMetadataRenderer } of topic) { 72 | let meta = richMetadataRenderer; 73 | media.category = getText(meta.title); 74 | media.category_url = new URL( 75 | meta.endpoint.commandMetadata.webCommandMetadata.url, 76 | BASE_URL, 77 | ).toString(); 78 | } 79 | } 80 | } 81 | } catch (err) { 82 | // Do nothing. 83 | } 84 | 85 | return media; 86 | }; 87 | 88 | const isVerified = (badges: any[]) => 89 | !!( 90 | badges && badges.find((b) => b.metadataBadgeRenderer.tooltip === "Verified") 91 | ); 92 | 93 | export const getAuthor = (info: any) => { 94 | let channelId, 95 | thumbnails = [], 96 | subscriberCount, 97 | verified = false; 98 | try { 99 | let results = 100 | info.response.contents.twoColumnWatchNextResults.results.results.contents; 101 | let v = results.find( 102 | (v2: any) => 103 | v2.videoSecondaryInfoRenderer && 104 | v2.videoSecondaryInfoRenderer.owner && 105 | v2.videoSecondaryInfoRenderer.owner.videoOwnerRenderer, 106 | ); 107 | let videoOwnerRenderer = 108 | v.videoSecondaryInfoRenderer.owner.videoOwnerRenderer; 109 | channelId = videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId; 110 | thumbnails = videoOwnerRenderer.thumbnail.thumbnails.map( 111 | (thumbnail: any) => { 112 | thumbnail.url = new URL(thumbnail.url, BASE_URL).toString(); 113 | return thumbnail; 114 | }, 115 | ); 116 | subscriberCount = utils.parseAbbreviatedNumber( 117 | getText(videoOwnerRenderer.subscriberCountText), 118 | ); 119 | verified = isVerified(videoOwnerRenderer.badges); 120 | } catch (err) { 121 | // Do nothing. 122 | } 123 | try { 124 | let videoDetails = info.player_response.microformat && 125 | info.player_response.microformat.playerMicroformatRenderer; 126 | let id = (videoDetails && videoDetails.channelId) || 127 | channelId || 128 | info.player_response.videoDetails.channelId; 129 | let author = { 130 | id: id, 131 | name: videoDetails 132 | ? videoDetails.ownerChannelName 133 | : info.player_response.videoDetails.author, 134 | user: videoDetails 135 | ? videoDetails.ownerProfileUrl.split("/").slice(-1)[0] 136 | : null, 137 | channel_url: `https://www.youtube.com/channel/${id}`, 138 | external_channel_url: videoDetails 139 | ? `https://www.youtube.com/channel/${videoDetails.externalChannelId}` 140 | : "", 141 | user_url: videoDetails 142 | ? new URL(videoDetails.ownerProfileUrl, BASE_URL).toString() 143 | : "", 144 | thumbnails, 145 | verified, 146 | subscriber_count: subscriberCount, 147 | }; 148 | return author; 149 | } catch (err) { 150 | return {}; 151 | } 152 | }; 153 | 154 | const parseRelatedVideo = (details: any, rvsParams: any) => { 155 | if (!details) return; 156 | try { 157 | let viewCount = getText(details.viewCountText); 158 | let shortViewCount = getText(details.shortViewCountText); 159 | let rvsDetails = rvsParams.find((elem: any) => elem.id === details.videoId); 160 | if (!/^\d/.test(shortViewCount)) { 161 | shortViewCount = (rvsDetails && rvsDetails.short_view_count_text) || ""; 162 | } 163 | viewCount = (/^\d/.test(viewCount) ? viewCount : shortViewCount).split( 164 | " ", 165 | )[0]; 166 | let browseEndpoint = 167 | details.shortBylineText.runs[0].navigationEndpoint.browseEndpoint; 168 | let channelId = browseEndpoint.browseId; 169 | let name = getText(details.shortBylineText); 170 | let user = (browseEndpoint.canonicalBaseUrl || "").split("/").slice(-1)[0]; 171 | let video = { 172 | id: details.videoId, 173 | title: getText(details.title), 174 | published: getText(details.publishedTimeText), 175 | author: { 176 | id: channelId, 177 | name, 178 | user, 179 | channel_url: `https://www.youtube.com/channel/${channelId}`, 180 | user_url: `https://www.youtube.com/user/${user}`, 181 | thumbnails: details.channelThumbnail.thumbnails.map( 182 | (thumbnail: any) => { 183 | thumbnail.url = new URL(thumbnail.url, BASE_URL).toString(); 184 | return thumbnail; 185 | }, 186 | ), 187 | verified: isVerified(details.ownerBadges), 188 | 189 | [Symbol.toPrimitive]() { 190 | console.warn( 191 | `\`relatedVideo.author\` will be removed in a near future release, ` + 192 | `use \`relatedVideo.author.name\` instead.`, 193 | ); 194 | return video.author.name; 195 | }, 196 | }, 197 | short_view_count_text: shortViewCount.split(" ")[0], 198 | view_count: viewCount.replace(/,/g, ""), 199 | length_seconds: details.lengthText 200 | ? Math.floor(parseTimestamp(getText(details.lengthText)) / 1000) 201 | : rvsParams && `${rvsParams.length_seconds}`, 202 | thumbnails: details.thumbnail.thumbnails, 203 | richThumbnails: details.richThumbnail 204 | ? details.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails 205 | .thumbnails 206 | : [], 207 | isLive: !!( 208 | details.badges && 209 | details.badges.find( 210 | (b: any) => b.metadataBadgeRenderer.label === "LIVE NOW", 211 | ) 212 | ), 213 | }; 214 | return video; 215 | } catch (err) { 216 | // Skip. 217 | } 218 | }; 219 | 220 | export const getRelatedVideos = (info: any) => { 221 | let rvsParams = [], 222 | secondaryResults = []; 223 | try { 224 | rvsParams = info.response.webWatchNextResponseExtensionData.relatedVideoArgs 225 | .split(",") 226 | .map((e: any) => qs.parse(e)); 227 | } catch (err) { 228 | // Do nothing. 229 | } 230 | try { 231 | secondaryResults = 232 | info.response.contents.twoColumnWatchNextResults.secondaryResults 233 | .secondaryResults.results; 234 | } catch (err) { 235 | return []; 236 | } 237 | let videos = []; 238 | for (let result of secondaryResults || []) { 239 | let details = result.compactVideoRenderer; 240 | if (details) { 241 | let video = parseRelatedVideo(details, rvsParams); 242 | if (video) videos.push(video); 243 | } else { 244 | let autoplay = result.compactAutoplayRenderer || 245 | result.itemSectionRenderer; 246 | if (!autoplay || !Array.isArray(autoplay.contents)) continue; 247 | for (let content of autoplay.contents) { 248 | let video = parseRelatedVideo(content.compactVideoRenderer, rvsParams); 249 | if (video) videos.push(video); 250 | } 251 | } 252 | } 253 | return videos; 254 | }; 255 | 256 | /** 257 | * Get like count. 258 | */ 259 | export const getLikes = (info: any) => { 260 | try { 261 | let contents = 262 | info.response.contents.twoColumnWatchNextResults.results.results.contents; 263 | let video = contents.find((r: any) => r.videoPrimaryInfoRenderer); 264 | let buttons = 265 | video.videoPrimaryInfoRenderer.videoActions.menuRenderer.topLevelButtons; 266 | let like = buttons.find( 267 | (b: any) => 268 | b.toggleButtonRenderer && 269 | b.toggleButtonRenderer.defaultIcon.iconType === "LIKE", 270 | ); 271 | return parseInt( 272 | like.toggleButtonRenderer.defaultText.accessibility.accessibilityData 273 | .label.replace( 274 | /\D+/g, 275 | "", 276 | ), 277 | ); 278 | } catch (err) { 279 | return null; 280 | } 281 | }; 282 | 283 | export const getDislikes = (info: any) => { 284 | try { 285 | let contents = 286 | info.response.contents.twoColumnWatchNextResults.results.results.contents; 287 | let video = contents.find((r: any) => r.videoPrimaryInfoRenderer); 288 | let buttons = 289 | video.videoPrimaryInfoRenderer.videoActions.menuRenderer.topLevelButtons; 290 | let dislike = buttons.find( 291 | (b: any) => 292 | b.toggleButtonRenderer && 293 | b.toggleButtonRenderer.defaultIcon.iconType === "DISLIKE", 294 | ); 295 | return parseInt( 296 | dislike.toggleButtonRenderer.defaultText.accessibility.accessibilityData 297 | .label.replace( 298 | /\D+/g, 299 | "", 300 | ), 301 | ); 302 | } catch (err) { 303 | return null; 304 | } 305 | }; 306 | 307 | export const cleanVideoDetails = (videoDetails: any, info: any) => { 308 | videoDetails.thumbnails = videoDetails.thumbnail.thumbnails; 309 | delete videoDetails.thumbnail; 310 | videoDetails.description = videoDetails.shortDescription || 311 | getText(videoDetails.description); 312 | delete videoDetails.shortDescription; 313 | 314 | // Use more reliable `lengthSeconds` from `playerMicroformatRenderer`. 315 | videoDetails.lengthSeconds = (info.player_response.microformat && 316 | info.player_response.microformat.playerMicroformatRenderer.lengthSeconds) || 317 | info.player_response.videoDetails.lengthSeconds; 318 | return videoDetails; 319 | }; 320 | 321 | export const getStoryboards = (info: any) => { 322 | const parts = info.player_response?.storyboards && 323 | info.player_response.storyboards.playerStoryboardSpecRenderer && 324 | info.player_response.storyboards.playerStoryboardSpecRenderer.spec && 325 | info.player_response.storyboards.playerStoryboardSpecRenderer.spec.split( 326 | "|", 327 | ); 328 | 329 | if (!parts) return []; 330 | 331 | const url = new URL(parts.shift()); 332 | 333 | return parts.map((part: any, i: number) => { 334 | let [ 335 | thumbnailWidth, 336 | thumbnailHeight, 337 | thumbnailCount, 338 | columns, 339 | rows, 340 | interval, 341 | nameReplacement, 342 | sigh, 343 | ] = part.split("#"); 344 | 345 | url.searchParams.set("sigh", sigh); 346 | 347 | thumbnailCount = parseInt(thumbnailCount, 10); 348 | columns = parseInt(columns, 10); 349 | rows = parseInt(rows, 10); 350 | 351 | const storyboardCount = Math.ceil(thumbnailCount / (columns * rows)); 352 | 353 | return { 354 | templateUrl: url 355 | .toString() 356 | .replace("$L", i.toString()) 357 | .replace("$N", nameReplacement), 358 | thumbnailWidth: parseInt(thumbnailWidth, 10), 359 | thumbnailHeight: parseInt(thumbnailHeight, 10), 360 | thumbnailCount, 361 | interval: parseInt(interval, 10), 362 | columns, 363 | rows, 364 | storyboardCount, 365 | }; 366 | }); 367 | }; 368 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | export async function request( 2 | url: string, 3 | options?: RequestInit, 4 | ): Promise { 5 | const res = await fetch(url, options); 6 | if (res.status >= 400 && res.status < 600) { 7 | await res.arrayBuffer(); // use the body to prevent leak 8 | throw new Error( 9 | `Request to ${url} Failed: ${res.status} ${res.statusText}`, 10 | ); 11 | } 12 | return res; 13 | } 14 | -------------------------------------------------------------------------------- /src/sig.ts: -------------------------------------------------------------------------------- 1 | import { querystring } from "../deps.ts"; 2 | import { Cache } from "./cache.ts"; 3 | import { between, cutAfterJSON } from "./utils.ts"; 4 | import { request } from "./request.ts"; 5 | 6 | // A shared cache to keep track of html5player js functions. 7 | export const cache = new Cache(); 8 | 9 | /** 10 | * Extract signature deciphering and n parameter transform functions from html5player file. 11 | * 12 | * @param {string} html5playerfile 13 | * @param {Object} options 14 | * @returns {Promise>} 15 | */ 16 | export function getFunctions( 17 | html5playerfile: string, 18 | options: RequestInit 19 | ): string[] { 20 | return cache.getOrSet(html5playerfile, async () => { 21 | const res = await request(html5playerfile, options); 22 | const body = await res.text(); 23 | const functions = extractFunctions(body); 24 | if (!functions || !functions.length) { 25 | throw Error("Could not extract functions"); 26 | } 27 | cache.set(html5playerfile, functions); 28 | return functions; 29 | }); 30 | } 31 | 32 | /** 33 | * Extracts the actions that should be taken to decipher a signature 34 | * and tranform the n parameter 35 | * 36 | * @param {string} body 37 | * @returns {Array.} 38 | */ 39 | export function extractFunctions(body: string) { 40 | const functions: string[] = []; 41 | const extractManipulations = (caller: string) => { 42 | const functionName = between(caller, `a=a.split("");`, `.`); 43 | if (!functionName) return ""; 44 | const functionStart = `var ${functionName}={`; 45 | const ndx = body.indexOf(functionStart); 46 | if (ndx < 0) return ""; 47 | const subBody = body.slice(ndx + functionStart.length - 1); 48 | return `var ${functionName}=${cutAfterJSON(subBody)}`; 49 | }; 50 | const extractDecipher = () => { 51 | const functionName = between( 52 | body, 53 | `a.set("alr","yes");c&&(c=`, 54 | `(decodeURIC` 55 | ); 56 | if (functionName && functionName.length) { 57 | const functionStart = `${functionName}=function(a)`; 58 | const ndx = body.indexOf(functionStart); 59 | if (ndx >= 0) { 60 | const subBody = body.slice(ndx + functionStart.length); 61 | let functionBody = `var ${functionStart}${cutAfterJSON(subBody)}`; 62 | functionBody = `${extractManipulations( 63 | functionBody 64 | )};${functionBody};${functionName}(sig);`; 65 | functions.push(functionBody); 66 | } 67 | } 68 | }; 69 | const extractNCode = () => { 70 | let functionName = between(body, `&&(b=a.get("n"))&&(b=`, `(b)`); 71 | if (functionName.includes("[")) 72 | functionName = between(body, `${functionName.split("[")[0]}=[`, `]`); 73 | if (functionName && functionName.length) { 74 | const functionStart = `${functionName}=function(a)`; 75 | const ndx = body.indexOf(functionStart); 76 | if (ndx >= 0) { 77 | const end = body.indexOf('.join("")};', ndx); 78 | const subBody = body.slice(ndx, end); 79 | 80 | const functionBody = `${subBody}.join("")};${functionName}(ncode);`; 81 | functions.push(functionBody); 82 | } 83 | } 84 | }; 85 | extractDecipher(); 86 | extractNCode(); 87 | return functions; 88 | } 89 | 90 | /** 91 | * Apply decipher and n-transform to individual format 92 | * 93 | * @param {Object} format 94 | * @param {vm.Script} decipherScript 95 | * @param {vm.Script} nTransformScript 96 | */ 97 | export function setDownloadURL( 98 | format: any, 99 | decipherScript: ((sig: string) => string) | undefined, 100 | nTransformScript: ((ncode: string) => string) | undefined 101 | ) { 102 | const decipher = (url: string) => { 103 | const args = querystring.parse(url) as any; 104 | if (!args.s || !decipherScript) return args.url; 105 | const components = new URL(decodeURIComponent(args.url)); 106 | components.searchParams.set( 107 | args.sp ? args.sp : "signature", 108 | decipherScript(decodeURIComponent(args.s)) 109 | ); 110 | return components.toString(); 111 | }; 112 | const ncode = (url: string) => { 113 | const components = new URL(decodeURIComponent(url)); 114 | const n = components.searchParams.get("n"); 115 | if (!n || !nTransformScript) return url; 116 | components.searchParams.set("n", nTransformScript(n)); 117 | return components.toString(); 118 | }; 119 | const cipher = !format.url; 120 | const url = format.url || format.signatureCipher || format.cipher; 121 | format.url = cipher ? ncode(decipher(url)) : ncode(url); 122 | delete format.signatureCipher; 123 | delete format.cipher; 124 | } 125 | 126 | /** 127 | * Applies decipher and n parameter transforms to all format URL's. 128 | * 129 | * @param {Array.} formats 130 | * @param {string} html5player 131 | * @param {Object} options 132 | */ 133 | export async function decipherFormats( 134 | formats: any[], 135 | html5player: string, 136 | options: any 137 | ) { 138 | const decipheredFormats: any = {}; 139 | const functions = await getFunctions(html5player, options); 140 | const decipherScript = functions.length 141 | ? createFunc("sig")(functions[0]) 142 | : undefined; 143 | const nTransformScript = 144 | functions.length > 1 ? createFunc("ncode")(functions[1]) : undefined; 145 | formats.forEach((format) => { 146 | setDownloadURL(format, decipherScript as any, nTransformScript as any); 147 | decipheredFormats[format.url] = format; 148 | }); 149 | return decipheredFormats; 150 | } 151 | 152 | function createFunc(param: string) { 153 | return new Function("source", param, `return (${param}) => eval(source)`); 154 | } 155 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | import { VideoFormat, VideoInfo } from "./types.ts"; 2 | 3 | export class VideoStream extends ReadableStream { 4 | info!: VideoInfo; 5 | format!: VideoFormat; 6 | downloaded = 0; 7 | total = 0; 8 | } 9 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface VideoFormat { 2 | itag: number; 3 | url: string; 4 | mimeType?: string; 5 | bitrate?: number; 6 | audioBitrate?: number; 7 | width?: number; 8 | height?: number; 9 | initRange?: { start: string; end: string }; 10 | indexRange?: { start: string; end: string }; 11 | lastModified: string; 12 | contentLength: string; 13 | quality: 14 | | "tiny" 15 | | "small" 16 | | "medium" 17 | | "large" 18 | | "hd720" 19 | | "hd1080" 20 | | "hd1440" 21 | | "hd2160" 22 | | "highres" 23 | | string; 24 | qualityLabel: 25 | | "144p" 26 | | "144p 15fps" 27 | | "144p60 HDR" 28 | | "240p" 29 | | "240p60 HDR" 30 | | "270p" 31 | | "360p" 32 | | "360p60 HDR" 33 | | "480p" 34 | | "480p60 HDR" 35 | | "720p" 36 | | "720p60" 37 | | "720p60 HDR" 38 | | "1080p" 39 | | "1080p60" 40 | | "1080p60 HDR" 41 | | "1440p" 42 | | "1440p60" 43 | | "1440p60 HDR" 44 | | "2160p" 45 | | "2160p60" 46 | | "2160p60 HDR" 47 | | "4320p" 48 | | "4320p60"; 49 | projectionType?: "RECTANGULAR"; 50 | fps?: number; 51 | averageBitrate?: number; 52 | audioQuality?: "AUDIO_QUALITY_LOW" | "AUDIO_QUALITY_MEDIUM"; 53 | colorInfo?: { 54 | primaries: string; 55 | transferCharacteristics: string; 56 | matrixCoefficients: string; 57 | }; 58 | highReplication?: boolean; 59 | approxDurationMs?: string; 60 | targetDurationSec?: number; 61 | maxDvrDurationSec?: number; 62 | audioSampleRate?: string; 63 | audioChannels?: number; 64 | 65 | // Added by ytdl-core 66 | container: "flv" | "3gp" | "mp4" | "webm" | "ts"; 67 | hasVideo: boolean; 68 | hasAudio: boolean; 69 | codecs: string; 70 | videoCodec?: string; 71 | audioCodec?: string; 72 | 73 | isLive: boolean; 74 | isHLS: boolean; 75 | isDashMPD: boolean; 76 | } 77 | 78 | export type Filter = 79 | | "audioandvideo" 80 | | "videoandaudio" 81 | | "video" 82 | | "videoonly" 83 | | "audio" 84 | | "audioonly" 85 | | ((format: VideoFormat) => boolean); 86 | 87 | export interface GetInfoOptions extends RequestInit { 88 | lang?: string; 89 | } 90 | 91 | export interface ChooseFormatOptions { 92 | quality?: 93 | | "lowest" 94 | | "highest" 95 | | "highestaudio" 96 | | "lowestaudio" 97 | | "highestvideo" 98 | | "lowestvideo" 99 | | string 100 | | number 101 | | string[] 102 | | number[]; 103 | filter?: Filter; 104 | format?: VideoFormat; 105 | } 106 | 107 | export interface DownloadOptions extends GetInfoOptions, ChooseFormatOptions { 108 | range?: { 109 | start?: number; 110 | end?: number; 111 | }; 112 | begin?: string | number | Date; 113 | dlChunkSize?: number; 114 | IPv6Block?: string; 115 | requestOptions?: any; 116 | } 117 | 118 | export interface Thumbnail { 119 | url: string; 120 | width: number; 121 | height: number; 122 | } 123 | 124 | export interface CaptionTrack { 125 | baseUrl: string; 126 | name: { 127 | simpleText: 128 | | "Afrikaans" 129 | | "Albanian" 130 | | "Amharic" 131 | | "Arabic" 132 | | "Armenian" 133 | | "Azerbaijani" 134 | | "Bangla" 135 | | "Basque" 136 | | "Belarusian" 137 | | "Bosnian" 138 | | "Bulgarian" 139 | | "Burmese" 140 | | "Catalan" 141 | | "Cebuano" 142 | | "Chinese (Simplified)" 143 | | "Chinese (Traditional)" 144 | | "Corsican" 145 | | "Croatian" 146 | | "Czech" 147 | | "Danish" 148 | | "Dutch" 149 | | "English" 150 | | "English (auto-generated)" 151 | | "Esperanto" 152 | | "Estonian" 153 | | "Filipino" 154 | | "Finnish" 155 | | "French" 156 | | "Galician" 157 | | "Georgian" 158 | | "German" 159 | | "Greek" 160 | | "Gujarati" 161 | | "Haitian Creole" 162 | | "Hausa" 163 | | "Hawaiian" 164 | | "Hebrew" 165 | | "Hindi" 166 | | "Hmong" 167 | | "Hungarian" 168 | | "Icelandic" 169 | | "Igbo" 170 | | "Indonesian" 171 | | "Irish" 172 | | "Italian" 173 | | "Japanese" 174 | | "Javanese" 175 | | "Kannada" 176 | | "Kazakh" 177 | | "Khmer" 178 | | "Korean" 179 | | "Kurdish" 180 | | "Kyrgyz" 181 | | "Lao" 182 | | "Latin" 183 | | "Latvian" 184 | | "Lithuanian" 185 | | "Luxembourgish" 186 | | "Macedonian" 187 | | "Malagasy" 188 | | "Malay" 189 | | "Malayalam" 190 | | "Maltese" 191 | | "Maori" 192 | | "Marathi" 193 | | "Mongolian" 194 | | "Nepali" 195 | | "Norwegian" 196 | | "Nyanja" 197 | | "Pashto" 198 | | "Persian" 199 | | "Polish" 200 | | "Portuguese" 201 | | "Punjabi" 202 | | "Romanian" 203 | | "Russian" 204 | | "Samoan" 205 | | "Scottish Gaelic" 206 | | "Serbian" 207 | | "Shona" 208 | | "Sindhi" 209 | | "Sinhala" 210 | | "Slovak" 211 | | "Slovenian" 212 | | "Somali" 213 | | "Southern Sotho" 214 | | "Spanish" 215 | | "Spanish (Spain)" 216 | | "Sundanese" 217 | | "Swahili" 218 | | "Swedish" 219 | | "Tajik" 220 | | "Tamil" 221 | | "Telugu" 222 | | "Thai" 223 | | "Turkish" 224 | | "Ukrainian" 225 | | "Urdu" 226 | | "Uzbek" 227 | | "Vietnamese" 228 | | "Welsh" 229 | | "Western Frisian" 230 | | "Xhosa" 231 | | "Yiddish" 232 | | "Yoruba" 233 | | "Zulu" 234 | | string; 235 | }; 236 | vssId: string; 237 | languageCode: 238 | | "af" 239 | | "sq" 240 | | "am" 241 | | "ar" 242 | | "hy" 243 | | "az" 244 | | "bn" 245 | | "eu" 246 | | "be" 247 | | "bs" 248 | | "bg" 249 | | "my" 250 | | "ca" 251 | | "ceb" 252 | | "zh-Hans" 253 | | "zh-Hant" 254 | | "co" 255 | | "hr" 256 | | "cs" 257 | | "da" 258 | | "nl" 259 | | "en" 260 | | "eo" 261 | | "et" 262 | | "fil" 263 | | "fi" 264 | | "fr" 265 | | "gl" 266 | | "ka" 267 | | "de" 268 | | "el" 269 | | "gu" 270 | | "ht" 271 | | "ha" 272 | | "haw" 273 | | "iw" 274 | | "hi" 275 | | "hmn" 276 | | "hu" 277 | | "is" 278 | | "ig" 279 | | "id" 280 | | "ga" 281 | | "it" 282 | | "ja" 283 | | "jv" 284 | | "kn" 285 | | "kk" 286 | | "km" 287 | | "ko" 288 | | "ku" 289 | | "ky" 290 | | "lo" 291 | | "la" 292 | | "lv" 293 | | "lt" 294 | | "lb" 295 | | "mk" 296 | | "mg" 297 | | "ms" 298 | | "ml" 299 | | "mt" 300 | | "mi" 301 | | "mr" 302 | | "mn" 303 | | "ne" 304 | | "no" 305 | | "ny" 306 | | "ps" 307 | | "fa" 308 | | "pl" 309 | | "pt" 310 | | "pa" 311 | | "ro" 312 | | "ru" 313 | | "sm" 314 | | "gd" 315 | | "sr" 316 | | "sn" 317 | | "sd" 318 | | "si" 319 | | "sk" 320 | | "sl" 321 | | "so" 322 | | "st" 323 | | "es" 324 | | "su" 325 | | "sw" 326 | | "sv" 327 | | "tg" 328 | | "ta" 329 | | "te" 330 | | "th" 331 | | "tr" 332 | | "uk" 333 | | "ur" 334 | | "uz" 335 | | "vi" 336 | | "cy" 337 | | "fy" 338 | | "xh" 339 | | "yi" 340 | | "yo" 341 | | "zu" 342 | | string; 343 | kind: string; 344 | rtl?: boolean; 345 | isTranslatable: boolean; 346 | } 347 | 348 | export interface AudioTrack { 349 | captionTrackIndices: number[]; 350 | } 351 | 352 | export interface TranslationLanguage { 353 | languageCode: CaptionTrack["languageCode"]; 354 | languageName: CaptionTrack["name"]; 355 | } 356 | 357 | export interface VideoDetails { 358 | videoId: string; 359 | title: string; 360 | shortDescription: string; 361 | lengthSeconds: string; 362 | keywords?: string[]; 363 | channelId: string; 364 | isOwnerViewing: boolean; 365 | isCrawlable: boolean; 366 | thumbnail: { 367 | thumbnails: Thumbnail[]; 368 | }; 369 | averageRating: number; 370 | allowRatings: boolean; 371 | viewCount: string; 372 | author: string; 373 | isPrivate: boolean; 374 | isUnpluggedCorpus: boolean; 375 | isLiveContent: boolean; 376 | } 377 | 378 | export interface Media { 379 | category: string; 380 | category_url: string; 381 | game?: string; 382 | game_url?: string; 383 | year?: number; 384 | song?: string; 385 | artist?: string; 386 | artist_url?: string; 387 | writers?: string; 388 | licensed_by?: string; 389 | thumbnails: Thumbnail[]; 390 | } 391 | 392 | export interface Author { 393 | id: string; 394 | name: string; 395 | avatar: string; // to remove later 396 | thumbnails?: Thumbnail[]; 397 | verified: boolean; 398 | user?: string; 399 | channel_url: string; 400 | external_channel_url?: string; 401 | user_url?: string; 402 | subscriber_count?: number; 403 | } 404 | 405 | export interface MicroformatRenderer { 406 | thumbnail: { 407 | thumbnails: Thumbnail[]; 408 | }; 409 | embed: { 410 | iframeUrl: string; 411 | flashUrl: string; 412 | width: number; 413 | height: number; 414 | flashSecureUrl: string; 415 | }; 416 | title: { 417 | simpleText: string; 418 | }; 419 | description: { 420 | simpleText: string; 421 | }; 422 | lengthSeconds: string; 423 | ownerProfileUrl: string; 424 | ownerGplusProfileUrl?: string; 425 | externalChannelId: string; 426 | isFamilySafe: boolean; 427 | availableCountries: string[]; 428 | isUnlisted: boolean; 429 | hasYpcMetadata: boolean; 430 | viewCount: string; 431 | category: string; 432 | publishDate: string; 433 | ownerChannelName: string; 434 | liveBroadcastDetails?: { 435 | isLiveNow: boolean; 436 | startTimestamp: string; 437 | }; 438 | uploadDate: string; 439 | } 440 | 441 | export interface Storyboard { 442 | templateUrl: string; 443 | thumbnailWidth: number; 444 | thumbnailHeight: number; 445 | thumbnailCount: number; 446 | interval: number; 447 | columns: number; 448 | rows: number; 449 | storyboardCount: number; 450 | } 451 | 452 | export interface Chapter { 453 | title: string; 454 | start_time: number; 455 | } 456 | 457 | export interface MoreVideoDetails 458 | extends Omit, 459 | Omit { 460 | published: number; 461 | video_url: string; 462 | age_restricted: boolean; 463 | likes: number | null; 464 | dislikes: number | null; 465 | media: Media; 466 | author: Author; 467 | thumbnails: Thumbnail[]; 468 | storyboards: Storyboard[]; 469 | chapters: Chapter[]; 470 | description: string | null; 471 | } 472 | 473 | export interface VideoInfo { 474 | full?: boolean; 475 | iv_load_policy?: string; 476 | iv_allow_in_place_switch?: string; 477 | iv_endscreen_url?: string; 478 | iv_invideo_url?: string; 479 | iv3_module?: string; 480 | rmktEnabled?: string; 481 | uid?: string; 482 | vid?: string; 483 | focEnabled?: string; 484 | baseUrl?: string; 485 | storyboard_spec?: string; 486 | serialized_ad_ux_config?: string; 487 | player_error_log_fraction?: string; 488 | sffb?: string; 489 | ldpj?: string; 490 | videostats_playback_base_url?: string; 491 | innertube_context_client_version?: string; 492 | t?: string; 493 | fade_in_start_milliseconds: string; 494 | timestamp: string; 495 | ad3_module: string; 496 | relative_loudness: string; 497 | allow_below_the_player_companion: string; 498 | eventid: string; 499 | token: string; 500 | atc: string; 501 | cr: string; 502 | apply_fade_on_midrolls: string; 503 | cl: string; 504 | fexp: string[]; 505 | apiary_host: string; 506 | fade_in_duration_milliseconds: string; 507 | fflags: string; 508 | ssl: string; 509 | pltype: string; 510 | enabled_engage_types: string; 511 | hl: string; 512 | is_listed: string; 513 | gut_tag: string; 514 | apiary_host_firstparty: string; 515 | enablecsi: string; 516 | csn: string; 517 | status: string; 518 | afv_ad_tag: string; 519 | idpj: string; 520 | sfw_player_response: string; 521 | account_playback_token: string; 522 | encoded_ad_safety_reason: string; 523 | tag_for_children_directed: string; 524 | no_get_video_log: string; 525 | ppv_remarketing_url: string; 526 | fmt_list: string[][]; 527 | ad_slots: string; 528 | fade_out_duration_milliseconds: string; 529 | instream_long: string; 530 | allow_html5_ads: string; 531 | core_dbp: string; 532 | ad_device: string; 533 | itct: string; 534 | root_ve_type: string; 535 | excluded_ads: string; 536 | aftv: string; 537 | loeid: string; 538 | cver: string; 539 | shortform: string; 540 | dclk: string; 541 | csi_page_type: string; 542 | ismb: string; 543 | gpt_migration: string; 544 | loudness: string; 545 | ad_tag: string; 546 | of: string; 547 | probe_url: string; 548 | vm: string; 549 | afv_ad_tag_restricted_to_instream: string; 550 | gapi_hint_params: string; 551 | cid: string; 552 | c: string; 553 | oid: string; 554 | ptchn: string; 555 | as_launched_in_country: string; 556 | avg_rating: string; 557 | fade_out_start_milliseconds: string; 558 | midroll_prefetch_size: string; 559 | allow_ratings: string; 560 | thumbnail_url: string; 561 | iurlsd: string; 562 | iurlmq: string; 563 | iurlhq: string; 564 | iurlmaxres: string; 565 | ad_preroll: string; 566 | tmi: string; 567 | trueview: string; 568 | host_language: string; 569 | innertube_api_key: string; 570 | show_content_thumbnail: string; 571 | afv_instream_max: string; 572 | innertube_api_version: string; 573 | mpvid: string; 574 | allow_embed: string; 575 | ucid: string; 576 | plid: string; 577 | midroll_freqcap: string; 578 | ad_logging_flag: string; 579 | ptk: string; 580 | vmap: string; 581 | watermark: string[]; 582 | dbp: string; 583 | ad_flags: string; 584 | html5player: string; 585 | formats: VideoFormat[]; 586 | related_videos: RelatedVideo[]; 587 | no_embed_allowed?: boolean; 588 | player_response: { 589 | playabilityStatus: { 590 | status: string; 591 | playableInEmbed: boolean; 592 | miniplayer: { 593 | miniplayerRenderer: { 594 | playbackMode: string; 595 | }; 596 | }; 597 | contextParams: string; 598 | }; 599 | streamingData: { 600 | expiresInSeconds: string; 601 | formats: {}[]; 602 | adaptiveFormats: {}[]; 603 | dashManifestUrl?: string; 604 | hlsManifestUrl?: string; 605 | }; 606 | captions?: { 607 | playerCaptionsRenderer: { 608 | baseUrl: string; 609 | visibility: string; 610 | }; 611 | playerCaptionsTracklistRenderer: { 612 | captionTracks: CaptionTrack[]; 613 | audioTracks: AudioTrack[]; 614 | translationLanguages: TranslationLanguage[]; 615 | defaultAudioTrackIndex: number; 616 | }; 617 | }; 618 | microformat: { 619 | playerMicroformatRenderer: MicroformatRenderer; 620 | }; 621 | videoDetails: VideoDetails; 622 | }; 623 | videoDetails: MoreVideoDetails; 624 | } 625 | 626 | export interface RelatedVideo { 627 | id?: string; 628 | title?: string; 629 | published?: string; 630 | author: Author | "string"; // to remove the `string` part later 631 | ucid?: string; // to remove later 632 | author_thumbnail?: string; // to remove later 633 | short_view_count_text?: string; 634 | view_count?: string; 635 | length_seconds?: number; 636 | video_thumbnail?: string; // to remove later 637 | thumbnails: Thumbnail[]; 638 | richThumbnails: Thumbnail[]; 639 | isLive: boolean; 640 | } 641 | -------------------------------------------------------------------------------- /src/url_utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns true if given id satifies YouTube's id format. 3 | * 4 | * @param {string} id 5 | * @return {boolean} 6 | */ 7 | const idRegex = /^[a-zA-Z0-9-_]{11}$/; 8 | export const validateID = (id: string) => idRegex.test(id); 9 | 10 | const validQueryDomains = new Set([ 11 | "youtube.com", 12 | "www.youtube.com", 13 | "m.youtube.com", 14 | "music.youtube.com", 15 | "gaming.youtube.com", 16 | ]); 17 | const validPathDomains = /^https?:\/\/(youtu\.be\/|(www\.)?youtube.com\/(embed|v|shorts)\/)/; 18 | /** 19 | * Get video ID. 20 | * 21 | * There are a few type of video URL formats. 22 | * - https://www.youtube.com/watch?v=VIDEO_ID 23 | * - https://m.youtube.com/watch?v=VIDEO_ID 24 | * - https://youtu.be/VIDEO_ID 25 | * - https://www.youtube.com/v/VIDEO_ID 26 | * - https://www.youtube.com/embed/VIDEO_ID 27 | * - https://music.youtube.com/watch?v=VIDEO_ID 28 | * - https://gaming.youtube.com/watch?v=VIDEO_ID 29 | */ 30 | export const getURLVideoID = (link: string) => { 31 | const parsed = new URL(link); 32 | let id = parsed.searchParams.get("v"); 33 | if (validPathDomains.test(link) && !id) { 34 | const paths = parsed.pathname.split("/"); 35 | id = paths[paths.length - 1]; 36 | } else if (parsed.hostname && !validQueryDomains.has(parsed.hostname)) { 37 | throw Error("Not a YouTube domain"); 38 | } 39 | if (!id) { 40 | throw Error(`No video id found: ${link}`); 41 | } 42 | id = id.substring(0, 11); 43 | if (!validateID(id)) { 44 | throw TypeError( 45 | `Video id (${id}) does not match expected ` + 46 | `format (${idRegex.toString()})` 47 | ); 48 | } 49 | return id; 50 | }; 51 | 52 | /** 53 | * Gets video ID either from a url or by checking if the given string 54 | * matches the video ID format. 55 | */ 56 | const urlRegex = /^https?:\/\//; 57 | export const getVideoID = (str: string) => { 58 | if (validateID(str)) { 59 | return str; 60 | } else if (urlRegex.test(str)) { 61 | return getURLVideoID(str); 62 | } else { 63 | throw Error(`No video id found: ${str}`); 64 | } 65 | }; 66 | 67 | /** 68 | * Checks wether the input string includes a valid id. 69 | */ 70 | export const validateURL = (string: string) => { 71 | try { 72 | getURLVideoID(string); 73 | return true; 74 | } catch (e) { 75 | return false; 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extract string inbetween another. 3 | */ 4 | export function between( 5 | haystack: string, 6 | left: string | RegExp, 7 | right: string 8 | ): string { 9 | let pos; 10 | if (left instanceof RegExp) { 11 | const match = haystack.match(left); 12 | if (!match) { 13 | return ""; 14 | } 15 | pos = match.index! + match[0].length; 16 | } else { 17 | pos = haystack.indexOf(left); 18 | if (pos === -1) { 19 | return ""; 20 | } 21 | pos += left.length; 22 | } 23 | haystack = haystack.slice(pos); 24 | pos = haystack.indexOf(right); 25 | if (pos === -1) { 26 | return ""; 27 | } 28 | haystack = haystack.slice(0, pos); 29 | return haystack; 30 | } 31 | 32 | /** Get a number from an abbreviated number string. */ 33 | export function parseAbbreviatedNumber(str: string) { 34 | const match = str 35 | .replace(",", ".") 36 | .replace(" ", "") 37 | .match(/([\d,.]+)([MK]?)/); 38 | if (match) { 39 | let [, _num, multi] = match; 40 | let num = parseFloat(_num); 41 | return Math.round( 42 | multi === "M" ? num * 1000000 : multi === "K" ? num * 1000 : num 43 | ); 44 | } 45 | return null; 46 | } 47 | 48 | /** Match begin and end braces of input JSON, return only json */ 49 | export function cutAfterJSON(mixedJson: string) { 50 | let open, close; 51 | if (mixedJson[0] === "[") { 52 | open = "["; 53 | close = "]"; 54 | } else if (mixedJson[0] === "{") { 55 | open = "{"; 56 | close = "}"; 57 | } 58 | 59 | if (!open) { 60 | throw new Error( 61 | `Can't cut unsupported JSON (need to begin with [ or { ) but got: ${mixedJson[0]}` 62 | ); 63 | } 64 | 65 | // States if the loop is currently in a string 66 | let isString = false; 67 | 68 | // States if the current character is treated as escaped or not 69 | let isEscaped = false; 70 | 71 | // Current open brackets to be closed 72 | let counter = 0; 73 | 74 | let i; 75 | for (i = 0; i < mixedJson.length; i++) { 76 | // Toggle the isString boolean when leaving/entering string 77 | if (mixedJson[i] === '"' && !isEscaped) { 78 | isString = !isString; 79 | continue; 80 | } 81 | 82 | // Toggle the isEscaped boolean for every backslash 83 | // Reset for every regular character 84 | isEscaped = mixedJson[i] === "\\" && !isEscaped; 85 | 86 | if (isString) continue; 87 | 88 | if (mixedJson[i] === open) { 89 | counter++; 90 | } else if (mixedJson[i] === close) { 91 | counter--; 92 | } 93 | 94 | // All brackets have been closed, thus end of JSON is reached 95 | if (counter === 0) { 96 | // Return the cut JSON 97 | return mixedJson.substr(0, i + 1); 98 | } 99 | } 100 | 101 | // We ran through the whole string and ended up with an unclosed bracket 102 | throw Error("Can't cut unsupported JSON (no matching closing bracket found)"); 103 | } 104 | 105 | /** Checks if there is a playability error. */ 106 | export function playError( 107 | player_response: any, 108 | statuses: string[], 109 | ErrorType = Error 110 | ) { 111 | let playability = player_response && player_response.playabilityStatus; 112 | if (playability && statuses.includes(playability.status)) { 113 | return new ErrorType( 114 | playability.reason || (playability.messages && playability.messages[0]) 115 | ); 116 | } 117 | return null; 118 | } 119 | 120 | // eslint-disable-next-line max-len 121 | const IPV6_REGEX = 122 | /^(([0-9a-f]{1,4}:)(:[0-9a-f]{1,4}){1,6}|([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}|([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}|([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}|([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}|([0-9a-f]{1,4}:){1,6}(:[0-9a-f]{1,4})|([0-9a-f]{1,4}:){1,7}(([0-9a-f]{1,4})|:))\/(1[0-1]\d|12[0-8]|\d{1,2})$/; 123 | 124 | /** 125 | * Quick check for a valid IPv6 126 | * The Regex only accepts a subset of all IPv6 Addresses 127 | * 128 | * @param {string} ip the IPv6 block in CIDR-Notation to test 129 | * @returns {boolean} true if valid 130 | */ 131 | export function isIPv6(ip: string) { 132 | return IPV6_REGEX.test(ip); 133 | } 134 | 135 | /** 136 | * Gets random IPv6 Address from a block 137 | */ 138 | export function getRandomIPv6(ip: string) { 139 | // Start with a fast Regex-Check 140 | if (!isIPv6(ip)) throw Error("Invalid IPv6 format"); 141 | // Start by splitting and normalizing addr and mask 142 | const [rawAddr, rawMask] = ip.split("/"); 143 | let base10Mask = parseInt(rawMask); 144 | if (!base10Mask || base10Mask > 128 || base10Mask < 24) 145 | throw Error("Invalid IPv6 subnet"); 146 | const base10addr = normalizeIP(rawAddr); 147 | // Get random addr to pad with 148 | // using Math.random since we're not requiring high level of randomness 149 | const randomAddr = new Array(8) 150 | .fill(1) 151 | .map(() => Math.floor(Math.random() * 0xffff)); 152 | 153 | // Merge base10addr with randomAddr 154 | const mergedAddr = randomAddr.map((randomItem, idx) => { 155 | // Calculate the amount of static bits 156 | const staticBits = Math.min(base10Mask, 16); 157 | // Adjust the bitmask with the staticBits 158 | base10Mask -= staticBits; 159 | // Calculate the bitmask 160 | // lsb makes the calculation way more complicated 161 | const mask = 0xffff - (2 ** (16 - staticBits) - 1); 162 | // Combine base10addr and random 163 | return (base10addr[idx] & mask) + (randomItem & (mask ^ 0xffff)); 164 | }); 165 | // Return new addr 166 | return mergedAddr.map((x) => x.toString(16)).join(":"); 167 | } 168 | /** 169 | * Normalise an IP Address 170 | * 171 | * @param {string} ip the IPv6 Addr 172 | * @returns {number[]} the 8 parts of the IPv6 as Integers 173 | */ 174 | export function normalizeIP(ip: string) { 175 | // Split by fill position 176 | const parts = ip.split("::").map((x) => x.split(":")); 177 | // Normalize start and end 178 | const partStart = parts[0] || []; 179 | const partEnd = parts[1] || []; 180 | partEnd.reverse(); 181 | // Placeholder for full ip 182 | const fullIP = new Array(8).fill(0); 183 | // Fill in start and end parts 184 | for (let i = 0; i < Math.min(partStart.length, 8); i++) { 185 | fullIP[i] = parseInt(partStart[i], 16) || 0; 186 | } 187 | for (let i = 0; i < Math.min(partEnd.length, 8); i++) { 188 | fullIP[7 - i] = parseInt(partEnd[i], 16) || 0; 189 | } 190 | return fullIP; 191 | } 192 | -------------------------------------------------------------------------------- /src/video.ts: -------------------------------------------------------------------------------- 1 | import * as utils from "./utils.ts"; 2 | import * as formatUtils from "./format_util.ts"; 3 | import { parseTimestamp, m3u8stream } from "../deps.ts"; 4 | import { getInfo } from "./info.ts"; 5 | import { VideoStream } from "./stream.ts"; 6 | import { DownloadOptions, VideoFormat } from "./types.ts"; 7 | import { request } from "./request.ts"; 8 | 9 | export interface VideoStreamSource { 10 | stream: VideoStream; 11 | push: CallableFunction; 12 | close: CallableFunction; 13 | } 14 | 15 | function createVideoStreamSource(): VideoStreamSource { 16 | const src: any = { 17 | stream: undefined, 18 | push: () => {}, 19 | close: () => {}, 20 | }; 21 | 22 | src.stream = new VideoStream({ 23 | start: (controller) => { 24 | src.controller = controller; 25 | src.push = (data: Uint8Array) => { 26 | if (src.closed) return; 27 | try { 28 | controller.enqueue(data); 29 | } catch(_e) { 30 | src.closed = true; 31 | } 32 | }; 33 | src.close = () => { 34 | if (src.closed) return; 35 | try { 36 | controller.close(); 37 | } catch (_e) { 38 | // ignore 39 | } finally { 40 | src.closed = true; 41 | } 42 | }; 43 | }, 44 | }); 45 | 46 | return src; 47 | } 48 | 49 | async function downloadFromInfoInto( 50 | { stream, push, close }: VideoStreamSource, 51 | info: any, 52 | options: DownloadOptions = {}, 53 | ) { 54 | let err = utils.playError(info.player_response, [ 55 | "UNPLAYABLE", 56 | "LIVE_STREAM_OFFLINE", 57 | "LOGIN_REQUIRED", 58 | ]); 59 | if (err) { 60 | stream.cancel(err); 61 | return; 62 | } 63 | 64 | if (!info.formats.length) { 65 | stream.cancel(new Error("This video is unavailable")); 66 | return; 67 | } 68 | 69 | let format: VideoFormat; 70 | try { 71 | format = formatUtils.chooseFormat(info.formats, options); 72 | } catch (e) { 73 | stream.cancel(e); 74 | return; 75 | } 76 | 77 | stream.info = info; 78 | stream.format = format; 79 | 80 | if (stream.locked) return; 81 | 82 | let contentLength: number, 83 | downloaded = 0; 84 | 85 | const ondata = async (chunk: Uint8Array) => { 86 | downloaded += chunk.length; 87 | await push(chunk); 88 | }; 89 | 90 | if (options.IPv6Block) { 91 | options.requestOptions = Object.assign({}, options.requestOptions, { 92 | family: 6, 93 | localAddress: utils.getRandomIPv6(options.IPv6Block), 94 | }); 95 | } 96 | 97 | const dlChunkSize = options.dlChunkSize || 1024 * 1024 * 10; 98 | let req: Response; 99 | let shouldEnd = true; 100 | if (format.isHLS || format.isDashMPD) { 101 | const begin = options.begin || (format.isLive && Date.now()); 102 | const req = m3u8stream(format.url, { 103 | chunkReadahead: +info.live_chunk_readahead, 104 | begin: begin.toString(), 105 | requestOptions: options.requestOptions, 106 | parser: format.isDashMPD ? "dash-mpd" : "m3u8", 107 | id: format.itag.toString(), 108 | }); 109 | req.addListener("data", async (chunk) => { 110 | await push(chunk); 111 | }); 112 | req.addListener("end", async () => { 113 | await close(); 114 | }); 115 | } else { 116 | const requestOptions = Object.assign({}, options, { 117 | maxReconnects: 6, 118 | maxRetries: 3, 119 | backoff: { inc: 500, max: 10000 }, 120 | }); 121 | 122 | let shouldBeChunked = dlChunkSize !== 0 && 123 | (!format.hasAudio || !format.hasVideo); 124 | 125 | if (shouldBeChunked) { 126 | let start = (options.range && options.range.start) || 0; 127 | let end = start + dlChunkSize; 128 | const rangeEnd = options.range && options.range.end; 129 | 130 | contentLength = options.range 131 | ? (rangeEnd ? rangeEnd + 1 : parseInt(format.contentLength)) - start 132 | : parseInt(format.contentLength); 133 | 134 | stream.total = contentLength; 135 | 136 | const getNextChunk = async () => { 137 | if (!rangeEnd && end >= contentLength) end = 0; 138 | if (rangeEnd && end > rangeEnd) end = rangeEnd; 139 | shouldEnd = !end || end === rangeEnd; 140 | 141 | requestOptions.headers = Object.assign({}, requestOptions.headers, { 142 | Range: `bytes=${start}-${end || ""}`, 143 | }); 144 | 145 | req = await request(format.url, requestOptions); 146 | 147 | for await (const chunk of req.body!) { 148 | stream.downloaded += chunk.length; 149 | await ondata(chunk); 150 | } 151 | 152 | if (end && end !== rangeEnd) { 153 | start = end + 1; 154 | end += dlChunkSize; 155 | await getNextChunk(); 156 | } 157 | 158 | await close(); 159 | }; 160 | 161 | getNextChunk(); 162 | } else { 163 | // Audio only and video only formats don't support begin 164 | if (options.begin) { 165 | format.url += `&begin=${ 166 | parseTimestamp( 167 | typeof options.begin === "object" 168 | ? options.begin.getTime() 169 | : options.begin, 170 | ) 171 | }`; 172 | } 173 | if (options.range && (options.range.start || options.range.end)) { 174 | requestOptions.headers = Object.assign({}, requestOptions.headers, { 175 | Range: `bytes=${options.range.start || "0"}-${options.range.end || 176 | ""}`, 177 | }); 178 | } 179 | req = await request(format.url, requestOptions); 180 | contentLength = parseInt(format.contentLength); 181 | 182 | stream.total = contentLength; 183 | 184 | (async () => { 185 | for await (const chunk of req.body!) { 186 | stream.downloaded += chunk.length; 187 | await ondata(chunk); 188 | } 189 | await close(); 190 | })(); 191 | } 192 | } 193 | } 194 | 195 | export async function downloadFromInfo( 196 | info: any, 197 | options: DownloadOptions = {}, 198 | ) { 199 | const src = createVideoStreamSource(); 200 | await downloadFromInfoInto(src, info, options); 201 | return src.stream; 202 | } 203 | 204 | export async function ytdl(id: string, options: DownloadOptions = {}) { 205 | const info = await getInfo(id, options); 206 | return await downloadFromInfo(info, options); 207 | } 208 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | import ytdl from "./mod.ts"; 2 | 3 | const stream = await ytdl("vRXZj0DzXIA"); 4 | console.log("Size:", stream.total); 5 | 6 | const chunks: Uint8Array[] = []; 7 | 8 | for await (const chunk of stream) { 9 | chunks.push(chunk); 10 | } 11 | 12 | const blob = new Blob(chunks); 13 | console.log("Saving as video.mp4..."); 14 | await Deno.writeFile("video.mp4", new Uint8Array(await blob.arrayBuffer())); 15 | --------------------------------------------------------------------------------