getraw 0.2.0 → 0.3.0
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/.github/workflows/release.yml +1 -0
- package/bun.lock +11 -0
- package/package.json +5 -2
- package/src/cli/index.ts +2 -1
- package/src/cli/options.ts +2 -2
- package/src/core/orchestrator.ts +1 -1
- package/src/extractors/base.ts +70 -1
- package/src/extractors/youtube/index.ts +166 -187
- package/src/extractors/youtube/innertube.ts +51 -23
- package/src/extractors/youtube/js-analyzer.ts +798 -0
- package/src/extractors/youtube/player.ts +139 -0
- package/src/extractors/youtube/sabr-download.ts +155 -0
- package/video/.hyperframes/expanded-prompt.md +0 -173
- package/video/design.md +0 -82
- package/video/index.html +0 -684
- package/video/renders/video_2026-06-16_23-50-45.meta.json +0 -1
- package/video/renders/video_2026-06-16_23-50-45.mp4 +0 -0
package/bun.lock
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
"": {
|
|
6
6
|
"name": "dlpx",
|
|
7
7
|
"dependencies": {
|
|
8
|
+
"googlevideo": "^4.0.4",
|
|
8
9
|
"hls-parser": "^0.13.6",
|
|
10
|
+
"meriyah": "^6.0.7",
|
|
9
11
|
"mpd-parser": "^1.3.0",
|
|
12
|
+
"youtubei.js": "^17.0.1",
|
|
10
13
|
},
|
|
11
14
|
"devDependencies": {
|
|
12
15
|
"@types/bun": "latest",
|
|
@@ -19,6 +22,8 @@
|
|
|
19
22
|
"packages": {
|
|
20
23
|
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
|
21
24
|
|
|
25
|
+
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="],
|
|
26
|
+
|
|
22
27
|
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
|
23
28
|
|
|
24
29
|
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
|
|
@@ -33,8 +38,12 @@
|
|
|
33
38
|
|
|
34
39
|
"global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="],
|
|
35
40
|
|
|
41
|
+
"googlevideo": ["googlevideo@4.0.4", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S/rfuoPBI+qXCEUPJeVhXsHoISMgVhOz8hHSpGWa0OztfHhh+g9EKaEcqAb/+ttO7meoNQNqIy9dfIpz7HPc4g=="],
|
|
42
|
+
|
|
36
43
|
"hls-parser": ["hls-parser@0.13.6", "", {}, "sha512-I40sl22E2muqeSTpG8kMN2dAegAhubkXPXtnsUXFwdKwZK47d1Q+XwuX32VMZ++AZU5oeQIZqAnGNHxSG1sWaw=="],
|
|
37
44
|
|
|
45
|
+
"meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="],
|
|
46
|
+
|
|
38
47
|
"min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="],
|
|
39
48
|
|
|
40
49
|
"mpd-parser": ["mpd-parser@1.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@videojs/vhs-utils": "^4.0.0", "@xmldom/xmldom": "^0.8.3", "global": "^4.4.0" }, "bin": { "mpd-to-m3u8-json": "bin/parse.js" } }, "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw=="],
|
|
@@ -46,5 +55,7 @@
|
|
|
46
55
|
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
|
47
56
|
|
|
48
57
|
"url-toolkit": ["url-toolkit@2.2.5", "", {}, "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg=="],
|
|
58
|
+
|
|
59
|
+
"youtubei.js": ["youtubei.js@17.0.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0", "meriyah": "^6.1.4" } }, "sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg=="],
|
|
49
60
|
}
|
|
50
61
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getraw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Fast media downloader CLI built natively in Bun/TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
"dashboard": "bun run tools/dashboard.ts"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
+
"googlevideo": "^4.0.4",
|
|
16
17
|
"hls-parser": "^0.13.6",
|
|
17
|
-
"
|
|
18
|
+
"meriyah": "^6.0.7",
|
|
19
|
+
"mpd-parser": "^1.3.0",
|
|
20
|
+
"youtubei.js": "^17.0.1"
|
|
18
21
|
},
|
|
19
22
|
"devDependencies": {
|
|
20
23
|
"@types/bun": "latest"
|
package/src/cli/index.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { parseArgs, printHelp } from "./options";
|
|
|
3
3
|
import { Orchestrator } from "../core/orchestrator";
|
|
4
4
|
import { logger } from "../core/logger";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import pkg from "../../package.json";
|
|
7
|
+
const VERSION = pkg.version;
|
|
7
8
|
|
|
8
9
|
async function main(): Promise<void> {
|
|
9
10
|
const args = process.argv.slice(2);
|
package/src/cli/options.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const FLAG_DEFS: FlagDef[] = [
|
|
|
20
20
|
{ long: "--list-formats", short: "-F", description: "List available formats", type: "boolean", key: "listFormats" },
|
|
21
21
|
{ long: "--dump-json", short: "-j", description: "Dump info JSON to stdout", type: "boolean", key: "dumpJson" },
|
|
22
22
|
{ long: "--quiet", short: "-q", description: "Suppress output", type: "boolean", key: "quiet" },
|
|
23
|
-
{ long: "--verbose",
|
|
23
|
+
{ long: "--verbose", description: "Verbose output", type: "boolean", key: "verbose" },
|
|
24
24
|
{ long: "--no-progress", description: "Disable progress bar", type: "boolean", key: "noProgress" },
|
|
25
25
|
{ long: "--retries", short: "-R", description: "Number of retries", type: "number", key: "retries" },
|
|
26
26
|
{ long: "--rate-limit", short: "-r", description: "Rate limit in bytes/sec", type: "number", key: "rateLimit" },
|
|
@@ -32,7 +32,7 @@ export const FLAG_DEFS: FlagDef[] = [
|
|
|
32
32
|
{ long: "--embed-subs", description: "Embed subtitles in output", type: "boolean", key: "embedSubs" },
|
|
33
33
|
{ long: "--merge-output-format", description: "Output container for merging", type: "string", key: "mergeOutputFormat" },
|
|
34
34
|
{ long: "--ffmpeg-location", description: "Path to ffmpeg binary", type: "string", key: "ffmpegLocation" },
|
|
35
|
-
{ long: "--version", short: "-
|
|
35
|
+
{ long: "--version", short: "-v", description: "Print version", type: "boolean", key: "version" },
|
|
36
36
|
{ long: "--help", short: "-h", description: "Show help", type: "boolean", key: "help" },
|
|
37
37
|
];
|
|
38
38
|
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -102,7 +102,7 @@ export class Orchestrator {
|
|
|
102
102
|
? `${filepath}.f${format.format_id}.${format.ext}`
|
|
103
103
|
: filepath;
|
|
104
104
|
|
|
105
|
-
await downloader.download(
|
|
105
|
+
await downloader.download(format.url, targetPath, {
|
|
106
106
|
headers: { ...info.http_headers, ...format.http_headers },
|
|
107
107
|
rateLimit: options.rateLimit,
|
|
108
108
|
retries: options.retries,
|
package/src/extractors/base.ts
CHANGED
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
import { BaseExtractor } from "../core/types";
|
|
2
2
|
import { GenericExtractor } from "./generic";
|
|
3
|
+
import { YouTubeExtractor } from "./youtube/index";
|
|
4
|
+
import { TwitterExtractor } from "./twitter/index";
|
|
5
|
+
import { TwitterSpacesExtractor } from "./twitter/spaces";
|
|
6
|
+
import { TikTokExtractor } from "./tiktok/index";
|
|
7
|
+
import { TikTokUserExtractor } from "./tiktok/user";
|
|
8
|
+
import { InstagramExtractor } from "./instagram/index";
|
|
9
|
+
import { InstagramReelsExtractor } from "./instagram/reels";
|
|
10
|
+
import { RedditExtractor } from "./reddit/index";
|
|
11
|
+
import { RedditGalleryExtractor } from "./reddit/gallery";
|
|
12
|
+
import { TwitchVODExtractor } from "./twitch/index";
|
|
13
|
+
import { TwitchClipExtractor } from "./twitch/clips";
|
|
14
|
+
import { TwitchLiveExtractor } from "./twitch/live";
|
|
15
|
+
import { VimeoExtractor } from "./vimeo/index";
|
|
16
|
+
import { SoundCloudExtractor } from "./soundcloud/index";
|
|
17
|
+
import { SoundCloudPlaylistExtractor } from "./soundcloud/playlist";
|
|
18
|
+
import { BilibiliExtractor } from "./bilibili/index";
|
|
19
|
+
import { BilibiliBangumiExtractor } from "./bilibili/bangumi";
|
|
20
|
+
import { KickExtractor } from "./kick/index";
|
|
21
|
+
import { KickClipsExtractor } from "./kick/clips";
|
|
22
|
+
import { KickLiveExtractor } from "./kick/live";
|
|
23
|
+
import { NiconicoExtractor } from "./niconico/index";
|
|
24
|
+
import { DailymotionExtractor } from "./dailymotion";
|
|
25
|
+
import { RumbleExtractor } from "./rumble";
|
|
26
|
+
import { BandcampExtractor } from "./bandcamp";
|
|
27
|
+
import { SpotifyExtractor } from "./spotify";
|
|
28
|
+
import { PeerTubeExtractor } from "./peertube";
|
|
29
|
+
import { OdyseeExtractor } from "./odysee";
|
|
30
|
+
import { StreamableExtractor } from "./streamable";
|
|
31
|
+
import { ImgurExtractor } from "./imgur";
|
|
32
|
+
import { CoubExtractor } from "./coub";
|
|
33
|
+
import { TEDExtractor } from "./ted";
|
|
34
|
+
import { ArchiveOrgExtractor } from "./archive-org";
|
|
35
|
+
import { DropboxExtractor } from "./dropbox";
|
|
36
|
+
import { GoogleDriveExtractor } from "./google-drive";
|
|
3
37
|
|
|
4
38
|
export { BaseExtractor };
|
|
5
39
|
|
|
6
|
-
const extractors: BaseExtractor[] = [
|
|
40
|
+
const extractors: BaseExtractor[] = [
|
|
41
|
+
new YouTubeExtractor(),
|
|
42
|
+
new TwitterExtractor(),
|
|
43
|
+
new TwitterSpacesExtractor(),
|
|
44
|
+
new TikTokExtractor(),
|
|
45
|
+
new TikTokUserExtractor(),
|
|
46
|
+
new InstagramExtractor(),
|
|
47
|
+
new InstagramReelsExtractor(),
|
|
48
|
+
new RedditExtractor(),
|
|
49
|
+
new RedditGalleryExtractor(),
|
|
50
|
+
new TwitchVODExtractor(),
|
|
51
|
+
new TwitchClipExtractor(),
|
|
52
|
+
new TwitchLiveExtractor(),
|
|
53
|
+
new VimeoExtractor(),
|
|
54
|
+
new SoundCloudExtractor(),
|
|
55
|
+
new SoundCloudPlaylistExtractor(),
|
|
56
|
+
new BilibiliExtractor(),
|
|
57
|
+
new BilibiliBangumiExtractor(),
|
|
58
|
+
new KickExtractor(),
|
|
59
|
+
new KickClipsExtractor(),
|
|
60
|
+
new KickLiveExtractor(),
|
|
61
|
+
new NiconicoExtractor(),
|
|
62
|
+
new DailymotionExtractor(),
|
|
63
|
+
new RumbleExtractor(),
|
|
64
|
+
new BandcampExtractor(),
|
|
65
|
+
new SpotifyExtractor(),
|
|
66
|
+
new PeerTubeExtractor(),
|
|
67
|
+
new OdyseeExtractor(),
|
|
68
|
+
new StreamableExtractor(),
|
|
69
|
+
new ImgurExtractor(),
|
|
70
|
+
new CoubExtractor(),
|
|
71
|
+
new TEDExtractor(),
|
|
72
|
+
new ArchiveOrgExtractor(),
|
|
73
|
+
new DropboxExtractor(),
|
|
74
|
+
new GoogleDriveExtractor(),
|
|
75
|
+
];
|
|
7
76
|
const genericExtractor = new GenericExtractor();
|
|
8
77
|
|
|
9
78
|
export function registerExtractor(extractor: BaseExtractor): void {
|
|
@@ -1,17 +1,51 @@
|
|
|
1
1
|
import { BaseExtractor, ExtractorError } from "../../core/types";
|
|
2
2
|
import type { InfoDict, Format, Thumbnail } from "../../core/types";
|
|
3
|
-
import { InnerTubeClient } from "./innertube";
|
|
4
|
-
import type { PlayerResponse, VideoDetails, StreamingData } from "./innertube";
|
|
5
|
-
import { fetchPlayerJs, decipherSignatureUrl, clearCache as clearSigCache } from "./signature";
|
|
6
|
-
import { transformNsig, clearNsigCache } from "./nsig";
|
|
7
3
|
import { parseCaptionTracks } from "./captions";
|
|
8
|
-
import {
|
|
4
|
+
import { InnerTubeClient } from "./innertube";
|
|
5
|
+
import type { RawFormat, PlayerResponse, StreamingData } from "./innertube";
|
|
6
|
+
import { decipherStreamUrl, setPageHtmlForPlayerExtraction } from "./player";
|
|
9
7
|
|
|
10
8
|
const VALID_URL = /^https?:\/\/(?:(?:www|m|music)\.)?(?:youtube\.com\/(?:watch\?.*v=|shorts\/|live\/|embed\/|v\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
|
11
9
|
const PLAYLIST_URL = /^https?:\/\/(?:(?:www|m|music)\.)?youtube\.com\/playlist\?.*list=([a-zA-Z0-9_-]+)/;
|
|
12
10
|
const CHANNEL_URL = /^https?:\/\/(?:(?:www|m|music)\.)?youtube\.com\/(?:channel\/|@)([a-zA-Z0-9_-]+)/;
|
|
13
11
|
|
|
14
|
-
const
|
|
12
|
+
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
|
|
13
|
+
|
|
14
|
+
function generateCpn(): string {
|
|
15
|
+
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
|
|
16
|
+
return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * 64)]).join("");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PageData {
|
|
20
|
+
playerResponse: PlayerResponse;
|
|
21
|
+
html: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchPageData(videoId: string): Promise<PageData> {
|
|
25
|
+
const resp = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
|
|
26
|
+
headers: {
|
|
27
|
+
"User-Agent": USER_AGENT,
|
|
28
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!resp.ok) {
|
|
33
|
+
throw new ExtractorError(`Failed to fetch YouTube page: ${resp.status}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const html = await resp.text();
|
|
37
|
+
setPageHtmlForPlayerExtraction(html);
|
|
38
|
+
|
|
39
|
+
const prMatch = html.match(/var\s+ytInitialPlayerResponse\s*=\s*(\{.+?\});/s);
|
|
40
|
+
if (!prMatch) {
|
|
41
|
+
throw new ExtractorError("Could not extract player response from page");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
playerResponse: JSON.parse(prMatch[1]) as PlayerResponse,
|
|
46
|
+
html,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
15
49
|
|
|
16
50
|
export class YouTubeExtractor extends BaseExtractor {
|
|
17
51
|
readonly _VALID_URL = new RegExp(
|
|
@@ -19,234 +53,179 @@ export class YouTubeExtractor extends BaseExtractor {
|
|
|
19
53
|
);
|
|
20
54
|
readonly _NAME = "youtube";
|
|
21
55
|
|
|
22
|
-
private playlistExtractor = new PlaylistExtractor();
|
|
23
|
-
|
|
24
56
|
protected async _real_extract(url: string): Promise<InfoDict> {
|
|
25
|
-
const playlistMatch = url.match(PLAYLIST_URL);
|
|
26
|
-
if (playlistMatch) {
|
|
27
|
-
return this.playlistExtractor.extractPlaylist(playlistMatch[1]);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const channelMatch = url.match(CHANNEL_URL);
|
|
31
|
-
if (channelMatch && !url.match(VALID_URL)) {
|
|
32
|
-
return this.playlistExtractor.extractChannelVideos(channelMatch[1]);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
57
|
const videoMatch = url.match(VALID_URL);
|
|
36
58
|
if (!videoMatch) {
|
|
37
|
-
throw new ExtractorError(`
|
|
59
|
+
throw new ExtractorError(`Unsupported YouTube URL: ${url}`);
|
|
38
60
|
}
|
|
39
61
|
|
|
40
62
|
return this.extractVideo(videoMatch[1]);
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
private async extractVideo(videoId: string): Promise<InfoDict> {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const status = playerResponse.playabilityStatus?.status;
|
|
48
|
-
if (status === "LOGIN_REQUIRED" || status === "CONTENT_CHECK_REQUIRED") {
|
|
49
|
-
playerResponse = await this.tryAgeGateBypass(videoId, playerResponse);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (playerResponse.playabilityStatus?.status === "ERROR") {
|
|
53
|
-
throw new ExtractorError(
|
|
54
|
-
playerResponse.playabilityStatus.reason ?? "Video unavailable"
|
|
55
|
-
);
|
|
56
|
-
}
|
|
66
|
+
const pageData = await fetchPageData(videoId);
|
|
67
|
+
const playerResponse = pageData.playerResponse;
|
|
57
68
|
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
throw new ExtractorError(
|
|
69
|
+
const status = playerResponse.playabilityStatus;
|
|
70
|
+
if (status?.status !== "OK") {
|
|
71
|
+
throw new ExtractorError(status?.reason ?? "Video unavailable");
|
|
61
72
|
}
|
|
62
73
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const androidClient = InnerTubeClient.withClient("ANDROID");
|
|
67
|
-
const androidResponse = await androidClient.getPlayerResponse(videoId);
|
|
68
|
-
if (androidResponse.streamingData) {
|
|
69
|
-
formats = androidClient.parseFormats(androidResponse.streamingData);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const info = this.buildInfoDict(videoId, videoDetails, playerResponse, formats);
|
|
74
|
-
return info;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
private async tryAgeGateBypass(
|
|
78
|
-
videoId: string,
|
|
79
|
-
originalResponse: PlayerResponse,
|
|
80
|
-
): Promise<PlayerResponse> {
|
|
81
|
-
const tvClient = InnerTubeClient.withClient("TVHTML5_EMBED");
|
|
82
|
-
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
|
|
83
|
-
const tvResponse = await tvClient.getPlayerResponse(videoId, embedUrl);
|
|
84
|
-
|
|
85
|
-
if (tvResponse.playabilityStatus?.status === "OK" && tvResponse.streamingData) {
|
|
86
|
-
return {
|
|
87
|
-
...tvResponse,
|
|
88
|
-
videoDetails: originalResponse.videoDetails ?? tvResponse.videoDetails,
|
|
89
|
-
captions: originalResponse.captions ?? tvResponse.captions,
|
|
90
|
-
microformat: originalResponse.microformat ?? tvResponse.microformat,
|
|
91
|
-
};
|
|
74
|
+
const details = playerResponse.videoDetails;
|
|
75
|
+
if (!details?.title) {
|
|
76
|
+
throw new ExtractorError("Could not extract video info");
|
|
92
77
|
}
|
|
93
78
|
|
|
94
|
-
|
|
95
|
-
}
|
|
79
|
+
const formats = await this.extractFormats(playerResponse.streamingData, pageData.html, videoId);
|
|
96
80
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const streamingData = playerResponse.streamingData;
|
|
103
|
-
if (!streamingData) return [];
|
|
81
|
+
const thumbnails: Thumbnail[] = (details.thumbnail?.thumbnails ?? []).map((t) => ({
|
|
82
|
+
url: t.url,
|
|
83
|
+
width: t.width,
|
|
84
|
+
height: t.height,
|
|
85
|
+
}));
|
|
104
86
|
|
|
105
|
-
|
|
87
|
+
const result: InfoDict = {
|
|
88
|
+
id: videoId,
|
|
89
|
+
title: details.title,
|
|
90
|
+
formats,
|
|
91
|
+
thumbnails,
|
|
92
|
+
description: details.shortDescription,
|
|
93
|
+
channel: details.author,
|
|
94
|
+
channel_id: details.channelId,
|
|
95
|
+
duration: parseInt(details.lengthSeconds, 10) || undefined,
|
|
96
|
+
view_count: parseInt(details.viewCount, 10) || undefined,
|
|
97
|
+
webpage_url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
98
|
+
live_status: details.isLive ? "is_live" : "not_live",
|
|
99
|
+
};
|
|
106
100
|
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
|
|
101
|
+
const captionTracks = playerResponse.captions?.playerCaptionsTracklistRenderer?.captionTracks;
|
|
102
|
+
if (captionTracks?.length) {
|
|
103
|
+
const { subtitles, automatic_captions } = parseCaptionTracks(captionTracks);
|
|
104
|
+
result.subtitles = subtitles;
|
|
105
|
+
result.automatic_captions = automatic_captions;
|
|
110
106
|
}
|
|
111
107
|
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private formatsNeedDecipher(streamingData: StreamingData): boolean {
|
|
116
|
-
const allFormats = [
|
|
117
|
-
...(streamingData.formats ?? []),
|
|
118
|
-
...(streamingData.adaptiveFormats ?? []),
|
|
119
|
-
];
|
|
120
|
-
return allFormats.some((f) => f.signatureCipher && !f.url);
|
|
108
|
+
return result;
|
|
121
109
|
}
|
|
122
110
|
|
|
123
|
-
private async
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
videoId: string,
|
|
127
|
-
): Promise<Format[]> {
|
|
128
|
-
const playerJsUrl = await this.getPlayerJsUrl(videoId);
|
|
129
|
-
if (!playerJsUrl) return formats;
|
|
130
|
-
|
|
131
|
-
const playerJs = await fetchPlayerJs(playerJsUrl);
|
|
132
|
-
|
|
133
|
-
const allRaw = [
|
|
134
|
-
...(streamingData.formats ?? []),
|
|
135
|
-
...(streamingData.adaptiveFormats ?? []),
|
|
136
|
-
];
|
|
111
|
+
private async extractFormats(streamingData: StreamingData | undefined, pageHtml: string, videoId?: string): Promise<Format[]> {
|
|
112
|
+
const cpn = generateCpn();
|
|
113
|
+
const formats: Format[] = [];
|
|
137
114
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
115
|
+
// First: get formats from page response (muxed formats with signatureCipher)
|
|
116
|
+
if (streamingData) {
|
|
117
|
+
const pageFormats: RawFormat[] = [
|
|
118
|
+
...(streamingData.formats ?? []),
|
|
119
|
+
...(streamingData.adaptiveFormats ?? []),
|
|
120
|
+
];
|
|
141
121
|
|
|
142
|
-
|
|
122
|
+
for (const raw of pageFormats) {
|
|
123
|
+
if (!raw.url && !raw.signatureCipher) continue;
|
|
143
124
|
try {
|
|
144
|
-
|
|
125
|
+
const url = await decipherStreamUrl(raw.url, raw.signatureCipher, pageHtml);
|
|
126
|
+
if (!url) continue;
|
|
127
|
+
const parsed = new URL(url);
|
|
128
|
+
parsed.searchParams.set("cpn", cpn);
|
|
129
|
+
formats.push(this.buildFormat(raw, parsed.toString()));
|
|
145
130
|
} catch {
|
|
146
131
|
continue;
|
|
147
132
|
}
|
|
148
133
|
}
|
|
134
|
+
}
|
|
149
135
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
136
|
+
if (videoId) {
|
|
137
|
+
try {
|
|
138
|
+
const iosFormats = await this.fetchIosFormats(videoId, pageHtml, cpn);
|
|
139
|
+
const existingItags = new Set(formats.map((f) => f.format_id));
|
|
140
|
+
for (const f of iosFormats) {
|
|
141
|
+
if (!existingItags.has(f.format_id)) {
|
|
142
|
+
formats.push(f);
|
|
143
|
+
}
|
|
155
144
|
}
|
|
145
|
+
} catch {
|
|
146
|
+
// IOS client failed, continue with page formats
|
|
156
147
|
}
|
|
157
148
|
}
|
|
158
149
|
|
|
159
150
|
return formats;
|
|
160
151
|
}
|
|
161
152
|
|
|
162
|
-
private async
|
|
163
|
-
const
|
|
153
|
+
private async fetchIosFormats(videoId: string, pageHtml: string, cpn: string): Promise<Format[]> {
|
|
154
|
+
const iosClient = InnerTubeClient.withClient("IOS");
|
|
155
|
+
let response: PlayerResponse;
|
|
164
156
|
try {
|
|
165
|
-
|
|
166
|
-
headers: {
|
|
167
|
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
168
|
-
},
|
|
169
|
-
});
|
|
170
|
-
const html = await response.text();
|
|
171
|
-
const match = html.match(PLAYER_URL_RE);
|
|
172
|
-
return match ? `https://www.youtube.com${match[1]}` : null;
|
|
157
|
+
response = await iosClient.getPlayerResponse(videoId);
|
|
173
158
|
} catch {
|
|
174
|
-
|
|
159
|
+
const androidClient = InnerTubeClient.withClient("ANDROID");
|
|
160
|
+
response = await androidClient.getPlayerResponse(videoId);
|
|
175
161
|
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
private buildInfoDict(
|
|
179
|
-
videoId: string,
|
|
180
|
-
details: VideoDetails,
|
|
181
|
-
response: PlayerResponse,
|
|
182
|
-
formats: Format[],
|
|
183
|
-
): InfoDict {
|
|
184
|
-
const microformat = response.microformat?.playerMicroformatRenderer;
|
|
185
|
-
|
|
186
|
-
const thumbnails: Thumbnail[] = (details.thumbnail?.thumbnails ?? []).map((t) => ({
|
|
187
|
-
url: t.url,
|
|
188
|
-
width: t.width,
|
|
189
|
-
height: t.height,
|
|
190
|
-
}));
|
|
191
162
|
|
|
192
|
-
|
|
163
|
+
if (response.playabilityStatus?.status !== "OK") return [];
|
|
193
164
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
thumbnails,
|
|
199
|
-
description: details.shortDescription ?? microformat?.description?.simpleText,
|
|
200
|
-
channel: details.author,
|
|
201
|
-
channel_id: details.channelId,
|
|
202
|
-
channel_url: `https://www.youtube.com/channel/${details.channelId}`,
|
|
203
|
-
uploader: details.author,
|
|
204
|
-
uploader_id: details.channelId,
|
|
205
|
-
uploader_url: microformat?.ownerProfileUrl,
|
|
206
|
-
duration: parseInt(details.lengthSeconds, 10) || undefined,
|
|
207
|
-
view_count: parseInt(details.viewCount, 10) || undefined,
|
|
208
|
-
upload_date: microformat?.uploadDate?.replace(/-/g, ""),
|
|
209
|
-
live_status: liveStatus,
|
|
210
|
-
webpage_url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
211
|
-
age_limit: 0,
|
|
212
|
-
categories: microformat?.category ? [microformat.category] : undefined,
|
|
213
|
-
};
|
|
165
|
+
const allRaw: RawFormat[] = [
|
|
166
|
+
...(response.streamingData?.formats ?? []),
|
|
167
|
+
...(response.streamingData?.adaptiveFormats ?? []),
|
|
168
|
+
];
|
|
214
169
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
170
|
+
const formats: Format[] = [];
|
|
171
|
+
const IOS_UA = "com.google.ios.youtube/19.45.4 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X;)";
|
|
172
|
+
|
|
173
|
+
for (const raw of allRaw) {
|
|
174
|
+
if (!raw.url) continue;
|
|
175
|
+
let finalUrl: string;
|
|
176
|
+
try {
|
|
177
|
+
const deciphered = await decipherStreamUrl(raw.url, undefined, pageHtml);
|
|
178
|
+
finalUrl = deciphered ?? raw.url;
|
|
179
|
+
} catch {
|
|
180
|
+
finalUrl = raw.url;
|
|
181
|
+
}
|
|
220
182
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
183
|
+
const parsed = new URL(finalUrl);
|
|
184
|
+
parsed.searchParams.set("cpn", cpn);
|
|
185
|
+
const format = this.buildFormat(raw, parsed.toString());
|
|
186
|
+
format.http_headers = {
|
|
187
|
+
"User-Agent": IOS_UA,
|
|
188
|
+
"Origin": "https://www.youtube.com",
|
|
189
|
+
"Referer": "https://www.youtube.com/",
|
|
190
|
+
};
|
|
191
|
+
formats.push(format);
|
|
226
192
|
}
|
|
227
193
|
|
|
228
|
-
return
|
|
194
|
+
return formats;
|
|
229
195
|
}
|
|
230
196
|
|
|
231
|
-
private
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
197
|
+
private buildFormat(raw: RawFormat, url: string): Format {
|
|
198
|
+
const mime = raw.mimeType;
|
|
199
|
+
const mimeMatch = mime.match(/^(video|audio)\/(\w+);\s*codecs="([^"]+)"/);
|
|
200
|
+
const ext = mimeMatch?.[2] ?? "mp4";
|
|
201
|
+
const codecs = mimeMatch?.[3] ?? "";
|
|
202
|
+
const isVideo = mime.startsWith("video");
|
|
203
|
+
const isAudio = mime.startsWith("audio");
|
|
204
|
+
|
|
205
|
+
const format: Format = {
|
|
206
|
+
format_id: String(raw.itag),
|
|
207
|
+
url,
|
|
208
|
+
ext: isAudio && ext === "mp4" ? "m4a" : ext,
|
|
209
|
+
vcodec: isVideo ? codecs.split(",")[0]?.trim() : "none",
|
|
210
|
+
acodec: isAudio ? codecs : (isVideo && codecs.includes(",") ? codecs.split(",")[1]?.trim() : undefined),
|
|
211
|
+
width: raw.width,
|
|
212
|
+
height: raw.height,
|
|
213
|
+
fps: raw.fps,
|
|
214
|
+
tbr: raw.bitrate ? Math.round(raw.bitrate / 1000) : undefined,
|
|
215
|
+
filesize: raw.contentLength ? parseInt(raw.contentLength, 10) : undefined,
|
|
216
|
+
format_note: raw.qualityLabel ?? raw.quality ?? undefined,
|
|
217
|
+
audio_channels: raw.audioChannels,
|
|
218
|
+
http_headers: {
|
|
219
|
+
"Origin": "https://www.youtube.com",
|
|
220
|
+
"Referer": "https://www.youtube.com/",
|
|
221
|
+
"User-Agent": USER_AGENT,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
241
224
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
225
|
+
if (raw.width && raw.height) {
|
|
226
|
+
format.resolution = `${raw.width}x${raw.height}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return format;
|
|
245
230
|
}
|
|
246
231
|
}
|
|
247
|
-
|
|
248
|
-
export { InnerTubeClient } from "./innertube";
|
|
249
|
-
export { PlaylistExtractor } from "./playlist";
|
|
250
|
-
export { parseCaptionTracks, convertToSrt, convertToVtt } from "./captions";
|
|
251
|
-
export { decipherSignatureUrl, fetchPlayerJs } from "./signature";
|
|
252
|
-
export { transformNsig } from "./nsig";
|