Spaces:
Paused
Paused
circleci commited on
Commit ·
6e37a4e
1
Parent(s): 63bd94f
deploy 91f0b54
Browse files- backend/package-lock.json +16 -33
- backend/package.json +2 -0
- backend/src/services/extractors/youtube/client.ts +35 -0
- backend/src/services/extractors/youtube/index.ts +56 -8
- backend/src/services/extractors/youtube/potoken.ts +106 -0
- backend/src/services/ytdlp/info-core.ts +9 -0
- backend/src/types/custom.d.ts +1 -0
- backend/src/utils/infra/metrics.util.ts +51 -0
backend/package-lock.json
CHANGED
|
@@ -14,6 +14,7 @@
|
|
| 14 |
"@sentry/node": "^10.53.1",
|
| 15 |
"@sentry/profiling-node": "^10.53.1",
|
| 16 |
"better-sse": "^0.16.1",
|
|
|
|
| 17 |
"bullmq": "^5.73.0",
|
| 18 |
"cheerio": "^1.2.0",
|
| 19 |
"compression": "^1.8.1",
|
|
@@ -29,6 +30,7 @@
|
|
| 29 |
"helmet": "^8.2.0",
|
| 30 |
"ioredis": "^5.10.1",
|
| 31 |
"jiti": "^2.6.1",
|
|
|
|
| 32 |
"lru-cache": "^11.3.6",
|
| 33 |
"metascraper": "^5.50.3",
|
| 34 |
"metascraper-author": "^5.50.1",
|
|
@@ -1442,6 +1444,11 @@
|
|
| 1442 |
}
|
| 1443 |
},
|
| 1444 |
"node_modules/@libsql/android-arm64": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1445 |
"optional": true
|
| 1446 |
},
|
| 1447 |
"node_modules/@libsql/client": {
|
|
@@ -2505,9 +2512,6 @@
|
|
| 2505 |
"arm64"
|
| 2506 |
],
|
| 2507 |
"dev": true,
|
| 2508 |
-
"libc": [
|
| 2509 |
-
"glibc"
|
| 2510 |
-
],
|
| 2511 |
"license": "MIT",
|
| 2512 |
"optional": true,
|
| 2513 |
"os": [
|
|
@@ -2525,9 +2529,6 @@
|
|
| 2525 |
"arm64"
|
| 2526 |
],
|
| 2527 |
"dev": true,
|
| 2528 |
-
"libc": [
|
| 2529 |
-
"musl"
|
| 2530 |
-
],
|
| 2531 |
"license": "MIT",
|
| 2532 |
"optional": true,
|
| 2533 |
"os": [
|
|
@@ -2545,9 +2546,6 @@
|
|
| 2545 |
"ppc64"
|
| 2546 |
],
|
| 2547 |
"dev": true,
|
| 2548 |
-
"libc": [
|
| 2549 |
-
"glibc"
|
| 2550 |
-
],
|
| 2551 |
"license": "MIT",
|
| 2552 |
"optional": true,
|
| 2553 |
"os": [
|
|
@@ -2565,9 +2563,6 @@
|
|
| 2565 |
"s390x"
|
| 2566 |
],
|
| 2567 |
"dev": true,
|
| 2568 |
-
"libc": [
|
| 2569 |
-
"glibc"
|
| 2570 |
-
],
|
| 2571 |
"license": "MIT",
|
| 2572 |
"optional": true,
|
| 2573 |
"os": [
|
|
@@ -2585,9 +2580,6 @@
|
|
| 2585 |
"x64"
|
| 2586 |
],
|
| 2587 |
"dev": true,
|
| 2588 |
-
"libc": [
|
| 2589 |
-
"glibc"
|
| 2590 |
-
],
|
| 2591 |
"license": "MIT",
|
| 2592 |
"optional": true,
|
| 2593 |
"os": [
|
|
@@ -2605,9 +2597,6 @@
|
|
| 2605 |
"x64"
|
| 2606 |
],
|
| 2607 |
"dev": true,
|
| 2608 |
-
"libc": [
|
| 2609 |
-
"musl"
|
| 2610 |
-
],
|
| 2611 |
"license": "MIT",
|
| 2612 |
"optional": true,
|
| 2613 |
"os": [
|
|
@@ -2714,9 +2703,6 @@
|
|
| 2714 |
"cpu": [
|
| 2715 |
"x64"
|
| 2716 |
],
|
| 2717 |
-
"libc": [
|
| 2718 |
-
"glibc"
|
| 2719 |
-
],
|
| 2720 |
"license": "MIT",
|
| 2721 |
"optional": true,
|
| 2722 |
"os": [
|
|
@@ -4083,6 +4069,15 @@
|
|
| 4083 |
"pnpm": ">=10"
|
| 4084 |
}
|
| 4085 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4086 |
"node_modules/bidi-js": {
|
| 4087 |
"version": "1.0.3",
|
| 4088 |
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
|
@@ -7127,9 +7122,6 @@
|
|
| 7127 |
"arm64"
|
| 7128 |
],
|
| 7129 |
"dev": true,
|
| 7130 |
-
"libc": [
|
| 7131 |
-
"glibc"
|
| 7132 |
-
],
|
| 7133 |
"license": "MPL-2.0",
|
| 7134 |
"optional": true,
|
| 7135 |
"os": [
|
|
@@ -7151,9 +7143,6 @@
|
|
| 7151 |
"arm64"
|
| 7152 |
],
|
| 7153 |
"dev": true,
|
| 7154 |
-
"libc": [
|
| 7155 |
-
"musl"
|
| 7156 |
-
],
|
| 7157 |
"license": "MPL-2.0",
|
| 7158 |
"optional": true,
|
| 7159 |
"os": [
|
|
@@ -7175,9 +7164,6 @@
|
|
| 7175 |
"x64"
|
| 7176 |
],
|
| 7177 |
"dev": true,
|
| 7178 |
-
"libc": [
|
| 7179 |
-
"glibc"
|
| 7180 |
-
],
|
| 7181 |
"license": "MPL-2.0",
|
| 7182 |
"optional": true,
|
| 7183 |
"os": [
|
|
@@ -7199,9 +7185,6 @@
|
|
| 7199 |
"x64"
|
| 7200 |
],
|
| 7201 |
"dev": true,
|
| 7202 |
-
"libc": [
|
| 7203 |
-
"musl"
|
| 7204 |
-
],
|
| 7205 |
"license": "MPL-2.0",
|
| 7206 |
"optional": true,
|
| 7207 |
"os": [
|
|
|
|
| 14 |
"@sentry/node": "^10.53.1",
|
| 15 |
"@sentry/profiling-node": "^10.53.1",
|
| 16 |
"better-sse": "^0.16.1",
|
| 17 |
+
"bgutils-js": "3.2.0",
|
| 18 |
"bullmq": "^5.73.0",
|
| 19 |
"cheerio": "^1.2.0",
|
| 20 |
"compression": "^1.8.1",
|
|
|
|
| 30 |
"helmet": "^8.2.0",
|
| 31 |
"ioredis": "^5.10.1",
|
| 32 |
"jiti": "^2.6.1",
|
| 33 |
+
"jsdom": "27.4.0",
|
| 34 |
"lru-cache": "^11.3.6",
|
| 35 |
"metascraper": "^5.50.3",
|
| 36 |
"metascraper-author": "^5.50.1",
|
|
|
|
| 1444 |
}
|
| 1445 |
},
|
| 1446 |
"node_modules/@libsql/android-arm64": {
|
| 1447 |
+
"name": "null",
|
| 1448 |
+
"version": "2.0.0",
|
| 1449 |
+
"resolved": "https://registry.npmjs.org/null/-/null-2.0.0.tgz",
|
| 1450 |
+
"integrity": "sha512-CVSXPeB7Af0Sku7w8N8EKkgcwvQ6wGNfs6bly419A/q6dpIwzaHIk+65+La2g80ml21uxVXQo8xReTMI2WEYbA==",
|
| 1451 |
+
"license": "MIT",
|
| 1452 |
"optional": true
|
| 1453 |
},
|
| 1454 |
"node_modules/@libsql/client": {
|
|
|
|
| 2512 |
"arm64"
|
| 2513 |
],
|
| 2514 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2515 |
"license": "MIT",
|
| 2516 |
"optional": true,
|
| 2517 |
"os": [
|
|
|
|
| 2529 |
"arm64"
|
| 2530 |
],
|
| 2531 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2532 |
"license": "MIT",
|
| 2533 |
"optional": true,
|
| 2534 |
"os": [
|
|
|
|
| 2546 |
"ppc64"
|
| 2547 |
],
|
| 2548 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2549 |
"license": "MIT",
|
| 2550 |
"optional": true,
|
| 2551 |
"os": [
|
|
|
|
| 2563 |
"s390x"
|
| 2564 |
],
|
| 2565 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2566 |
"license": "MIT",
|
| 2567 |
"optional": true,
|
| 2568 |
"os": [
|
|
|
|
| 2580 |
"x64"
|
| 2581 |
],
|
| 2582 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2583 |
"license": "MIT",
|
| 2584 |
"optional": true,
|
| 2585 |
"os": [
|
|
|
|
| 2597 |
"x64"
|
| 2598 |
],
|
| 2599 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2600 |
"license": "MIT",
|
| 2601 |
"optional": true,
|
| 2602 |
"os": [
|
|
|
|
| 2703 |
"cpu": [
|
| 2704 |
"x64"
|
| 2705 |
],
|
|
|
|
|
|
|
|
|
|
| 2706 |
"license": "MIT",
|
| 2707 |
"optional": true,
|
| 2708 |
"os": [
|
|
|
|
| 4069 |
"pnpm": ">=10"
|
| 4070 |
}
|
| 4071 |
},
|
| 4072 |
+
"node_modules/bgutils-js": {
|
| 4073 |
+
"version": "3.2.0",
|
| 4074 |
+
"resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-3.2.0.tgz",
|
| 4075 |
+
"integrity": "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg==",
|
| 4076 |
+
"funding": [
|
| 4077 |
+
"https://github.com/sponsors/LuanRT"
|
| 4078 |
+
],
|
| 4079 |
+
"license": "MIT"
|
| 4080 |
+
},
|
| 4081 |
"node_modules/bidi-js": {
|
| 4082 |
"version": "1.0.3",
|
| 4083 |
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
|
|
|
| 7122 |
"arm64"
|
| 7123 |
],
|
| 7124 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7125 |
"license": "MPL-2.0",
|
| 7126 |
"optional": true,
|
| 7127 |
"os": [
|
|
|
|
| 7143 |
"arm64"
|
| 7144 |
],
|
| 7145 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7146 |
"license": "MPL-2.0",
|
| 7147 |
"optional": true,
|
| 7148 |
"os": [
|
|
|
|
| 7164 |
"x64"
|
| 7165 |
],
|
| 7166 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7167 |
"license": "MPL-2.0",
|
| 7168 |
"optional": true,
|
| 7169 |
"os": [
|
|
|
|
| 7185 |
"x64"
|
| 7186 |
],
|
| 7187 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7188 |
"license": "MPL-2.0",
|
| 7189 |
"optional": true,
|
| 7190 |
"os": [
|
backend/package.json
CHANGED
|
@@ -31,6 +31,7 @@
|
|
| 31 |
"@sentry/node": "^10.53.1",
|
| 32 |
"@sentry/profiling-node": "^10.53.1",
|
| 33 |
"better-sse": "^0.16.1",
|
|
|
|
| 34 |
"bullmq": "^5.73.0",
|
| 35 |
"cheerio": "^1.2.0",
|
| 36 |
"compression": "^1.8.1",
|
|
@@ -46,6 +47,7 @@
|
|
| 46 |
"helmet": "^8.2.0",
|
| 47 |
"ioredis": "^5.10.1",
|
| 48 |
"jiti": "^2.6.1",
|
|
|
|
| 49 |
"lru-cache": "^11.3.6",
|
| 50 |
"metascraper": "^5.50.3",
|
| 51 |
"metascraper-author": "^5.50.1",
|
|
|
|
| 31 |
"@sentry/node": "^10.53.1",
|
| 32 |
"@sentry/profiling-node": "^10.53.1",
|
| 33 |
"better-sse": "^0.16.1",
|
| 34 |
+
"bgutils-js": "3.2.0",
|
| 35 |
"bullmq": "^5.73.0",
|
| 36 |
"cheerio": "^1.2.0",
|
| 37 |
"compression": "^1.8.1",
|
|
|
|
| 47 |
"helmet": "^8.2.0",
|
| 48 |
"ioredis": "^5.10.1",
|
| 49 |
"jiti": "^2.6.1",
|
| 50 |
+
"jsdom": "27.4.0",
|
| 51 |
"lru-cache": "^11.3.6",
|
| 52 |
"metascraper": "^5.50.3",
|
| 53 |
"metascraper-author": "^5.50.1",
|
backend/src/services/extractors/youtube/client.ts
CHANGED
|
@@ -63,3 +63,38 @@ export async function getYoutubeClient(): Promise<Innertube> {
|
|
| 63 |
throw error;
|
| 64 |
}
|
| 65 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
throw error;
|
| 64 |
}
|
| 65 |
}
|
| 66 |
+
|
| 67 |
+
// extraction client, armed with a poToken so ANDROID_VR returns real urls.
|
| 68 |
+
// kept separate from the search client above.
|
| 69 |
+
let extractorClient: Innertube | null = null;
|
| 70 |
+
let extractorCreatedAt = 0;
|
| 71 |
+
let extractorPoToken: string | null = null;
|
| 72 |
+
|
| 73 |
+
// on by default; YT_POTOKEN=0 to disable
|
| 74 |
+
const POTOKEN_ENABLED = process.env.YT_POTOKEN !== '0';
|
| 75 |
+
|
| 76 |
+
export async function getYoutubeExtractorClient(): Promise<Innertube> {
|
| 77 |
+
let poToken: string | undefined;
|
| 78 |
+
let visitorData: string | undefined;
|
| 79 |
+
|
| 80 |
+
if (POTOKEN_ENABLED) {
|
| 81 |
+
const { getPoToken } = await import('./potoken.js');
|
| 82 |
+
const bundle = await getPoToken();
|
| 83 |
+
poToken = bundle?.poToken;
|
| 84 |
+
visitorData = bundle?.visitorData;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const reusable =
|
| 88 |
+
extractorClient &&
|
| 89 |
+
extractorPoToken === (poToken ?? null) &&
|
| 90 |
+
!isClientStale(extractorCreatedAt);
|
| 91 |
+
if (reusable && extractorClient) return extractorClient;
|
| 92 |
+
|
| 93 |
+
extractorClient = await _createInnertube({
|
| 94 |
+
po_token: poToken,
|
| 95 |
+
visitor_data: visitorData,
|
| 96 |
+
});
|
| 97 |
+
extractorCreatedAt = Date.now();
|
| 98 |
+
extractorPoToken = poToken ?? null;
|
| 99 |
+
return extractorClient;
|
| 100 |
+
}
|
backend/src/services/extractors/youtube/index.ts
CHANGED
|
@@ -2,20 +2,29 @@ import { getProxiedStream } from '../../../utils/network/proxy.util.js';
|
|
| 2 |
import { VideoInfo, Format, ExtractorOptions } from '../../../types/index.js';
|
| 3 |
import { Readable } from 'node:stream';
|
| 4 |
import { normalizeVideoInfo } from './normalizer.js';
|
|
|
|
| 5 |
|
| 6 |
// skip innertube; yt-dlp handles youtube
|
| 7 |
const YT_JS_DISABLED = process.env.DISABLE_YT_JS === '1';
|
| 8 |
// preload client for faster first request
|
| 9 |
const clientModule = YT_JS_DISABLED ? null : import('./client.js');
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
export async function getInfo(
|
| 12 |
url: string,
|
| 13 |
options: ExtractorOptions = {}
|
| 14 |
): Promise<VideoInfo | null> {
|
| 15 |
if (!clientModule) return null;
|
| 16 |
try {
|
| 17 |
-
const {
|
| 18 |
-
const client = await
|
| 19 |
const idMatch = url.match(
|
| 20 |
/(?:v=|\/v\/|youtu\.be\/|shorts\/|live\/)([0-9A-Za-z_-]{11})/u
|
| 21 |
);
|
|
@@ -25,14 +34,53 @@ export async function getInfo(
|
|
| 25 |
options.onProgress('fetching_info', 20, 'Decrypting streams...');
|
| 26 |
}
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
-
|
|
|
|
| 36 |
} catch (error: unknown) {
|
| 37 |
const message = error instanceof Error ? error.message : String(error);
|
| 38 |
console.error(`[JS-YT] Error extracting ${url}: ${message}`);
|
|
|
|
| 2 |
import { VideoInfo, Format, ExtractorOptions } from '../../../types/index.js';
|
| 3 |
import { Readable } from 'node:stream';
|
| 4 |
import { normalizeVideoInfo } from './normalizer.js';
|
| 5 |
+
import { recordExtraction } from '../../../utils/infra/metrics.util.js';
|
| 6 |
|
| 7 |
// skip innertube; yt-dlp handles youtube
|
| 8 |
const YT_JS_DISABLED = process.env.DISABLE_YT_JS === '1';
|
| 9 |
// preload client for faster first request
|
| 10 |
const clientModule = YT_JS_DISABLED ? null : import('./client.js');
|
| 11 |
|
| 12 |
+
// clients to try, in order. ANDROID_VR + poToken returns real urls; most
|
| 13 |
+
// others are SABR-only. override with YT_JS_CLIENTS.
|
| 14 |
+
type YtClient = 'ANDROID_VR' | 'IOS' | 'WEB' | 'MWEB' | 'TV' | 'ANDROID';
|
| 15 |
+
const JS_CLIENTS = (process.env.YT_JS_CLIENTS || 'ANDROID_VR,IOS')
|
| 16 |
+
.split(',')
|
| 17 |
+
.map((entry) => entry.trim())
|
| 18 |
+
.filter(Boolean) as YtClient[];
|
| 19 |
+
|
| 20 |
export async function getInfo(
|
| 21 |
url: string,
|
| 22 |
options: ExtractorOptions = {}
|
| 23 |
): Promise<VideoInfo | null> {
|
| 24 |
if (!clientModule) return null;
|
| 25 |
try {
|
| 26 |
+
const { getYoutubeExtractorClient } = await clientModule;
|
| 27 |
+
const client = await getYoutubeExtractorClient();
|
| 28 |
const idMatch = url.match(
|
| 29 |
/(?:v=|\/v\/|youtu\.be\/|shorts\/|live\/)([0-9A-Za-z_-]{11})/u
|
| 30 |
);
|
|
|
|
| 34 |
options.onProgress('fetching_info', 20, 'Decrypting streams...');
|
| 35 |
}
|
| 36 |
|
| 37 |
+
/*
|
| 38 |
+
* The bug that cost me hours: youtubei.js v17 takes the client in an
|
| 39 |
+
* options object — getInfo(id, { client }). Pass a bare string like
|
| 40 |
+
* getInfo(id, 'ANDROID_VR') and it's silently ignored, so every call
|
| 41 |
+
* quietly falls back to the WEB client, which YouTube now serves
|
| 42 |
+
* SABR-only (formats but no downloadable URLs). The { client } form +
|
| 43 |
+
* a poToken is the whole difference between "JS can't get URLs" and it
|
| 44 |
+
* working fine.
|
| 45 |
+
*/
|
| 46 |
+
// first client that returns real formats wins
|
| 47 |
+
for (const clientType of JS_CLIENTS) {
|
| 48 |
+
const startedAt = Date.now();
|
| 49 |
+
try {
|
| 50 |
+
const fullInfo = await client.getInfo(videoId, { client: clientType });
|
| 51 |
+
const videoInfo = await normalizeVideoInfo(url, fullInfo, client);
|
| 52 |
+
const playable =
|
| 53 |
+
(videoInfo.formats?.length || 0) +
|
| 54 |
+
(videoInfo.audioFormats?.length || 0) >
|
| 55 |
+
0;
|
| 56 |
+
recordExtraction(
|
| 57 |
+
`youtube:js:${clientType}`,
|
| 58 |
+
playable,
|
| 59 |
+
Date.now() - startedAt
|
| 60 |
+
);
|
| 61 |
+
if (playable) {
|
| 62 |
+
if (options.onProgress) {
|
| 63 |
+
options.onProgress('fetching_info', 50, 'Metadata parsed');
|
| 64 |
+
}
|
| 65 |
+
return videoInfo;
|
| 66 |
+
}
|
| 67 |
+
console.debug(
|
| 68 |
+
`[JS-YT] ${clientType} produced no playable formats for ${videoId}`
|
| 69 |
+
);
|
| 70 |
+
} catch (clientError: unknown) {
|
| 71 |
+
recordExtraction(
|
| 72 |
+
`youtube:js:${clientType}`,
|
| 73 |
+
false,
|
| 74 |
+
Date.now() - startedAt
|
| 75 |
+
);
|
| 76 |
+
console.debug(
|
| 77 |
+
`[JS-YT] ${clientType} failed: ${(clientError as Error).message}`
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
}
|
| 81 |
|
| 82 |
+
// nothing usable — caller falls back to yt-dlp
|
| 83 |
+
return null;
|
| 84 |
} catch (error: unknown) {
|
| 85 |
const message = error instanceof Error ? error.message : String(error);
|
| 86 |
console.error(`[JS-YT] Error extracting ${url}: ${message}`);
|
backend/src/services/extractors/youtube/potoken.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Innertube } from 'youtubei.js';
|
| 2 |
+
import { BG } from 'bgutils-js';
|
| 3 |
+
import { JSDOM } from 'jsdom';
|
| 4 |
+
|
| 5 |
+
// poToken generation. without it youtube serves SABR-only (no stream urls);
|
| 6 |
+
// with it ANDROID_VR returns real urls. token is bound to visitorData and
|
| 7 |
+
// lives a few hours, so generate once and cache.
|
| 8 |
+
|
| 9 |
+
// well-known YouTube web BotGuard request key
|
| 10 |
+
const REQUEST_KEY = 'O43z0dpjhgX20SCx4KAo';
|
| 11 |
+
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
|
| 12 |
+
const REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
| 13 |
+
|
| 14 |
+
export interface PoTokenBundle {
|
| 15 |
+
poToken: string;
|
| 16 |
+
visitorData: string;
|
| 17 |
+
expiresAt: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
let cached: PoTokenBundle | null = null;
|
| 21 |
+
let inflight: Promise<PoTokenBundle | null> | null = null;
|
| 22 |
+
let domReady = false;
|
| 23 |
+
|
| 24 |
+
// botguard's vm needs browser globals
|
| 25 |
+
function ensureDom(): void {
|
| 26 |
+
if (domReady) return;
|
| 27 |
+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
| 28 |
+
url: 'https://www.youtube.com/',
|
| 29 |
+
});
|
| 30 |
+
Object.assign(globalThis, {
|
| 31 |
+
window: dom.window,
|
| 32 |
+
document: dom.window.document,
|
| 33 |
+
});
|
| 34 |
+
domReady = true;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
async function fetchVisitorData(): Promise<string> {
|
| 38 |
+
const bootstrap = await Innertube.create({ retrieve_player: false });
|
| 39 |
+
const visitorData = bootstrap.session.context.client.visitorData;
|
| 40 |
+
if (!visitorData) throw new Error('no visitorData from bootstrap session');
|
| 41 |
+
return visitorData;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
async function generate(): Promise<PoTokenBundle | null> {
|
| 45 |
+
try {
|
| 46 |
+
const visitorData = await fetchVisitorData();
|
| 47 |
+
ensureDom();
|
| 48 |
+
|
| 49 |
+
const bgConfig = {
|
| 50 |
+
fetch: (...args: Parameters<typeof fetch>) => fetch(...args),
|
| 51 |
+
globalObj: globalThis as unknown as Record<string, unknown>,
|
| 52 |
+
identifier: visitorData,
|
| 53 |
+
requestKey: REQUEST_KEY,
|
| 54 |
+
} as Parameters<typeof BG.Challenge.create>[0];
|
| 55 |
+
|
| 56 |
+
const challenge = await BG.Challenge.create(bgConfig);
|
| 57 |
+
if (!challenge) throw new Error('challenge creation returned nothing');
|
| 58 |
+
|
| 59 |
+
const script =
|
| 60 |
+
challenge.interpreterJavascript
|
| 61 |
+
.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
| 62 |
+
if (!script) throw new Error('challenge missing interpreter script');
|
| 63 |
+
|
| 64 |
+
// run the botguard vm (bgutils needs it in the global realm)
|
| 65 |
+
// eslint-disable-next-line sonarjs/code-eval -- trusted botguard payload
|
| 66 |
+
new Function(script)();
|
| 67 |
+
|
| 68 |
+
const result = await BG.PoToken.generate({
|
| 69 |
+
program: challenge.program,
|
| 70 |
+
globalName: challenge.globalName,
|
| 71 |
+
bgConfig,
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
const ttlSecs = result.integrityTokenData?.estimatedTtlSecs;
|
| 75 |
+
const ttlMs = ttlSecs ? ttlSecs * 1000 : DEFAULT_TTL_MS;
|
| 76 |
+
const bundle: PoTokenBundle = {
|
| 77 |
+
poToken: result.poToken,
|
| 78 |
+
visitorData,
|
| 79 |
+
expiresAt: Date.now() + Math.max(ttlMs - REFRESH_MARGIN_MS, 60_000),
|
| 80 |
+
};
|
| 81 |
+
cached = bundle;
|
| 82 |
+
console.log(
|
| 83 |
+
`[poToken] generated (len=${bundle.poToken.length}, ttl=${Math.round(
|
| 84 |
+
(bundle.expiresAt - Date.now()) / 1000
|
| 85 |
+
)}s)`
|
| 86 |
+
);
|
| 87 |
+
return bundle;
|
| 88 |
+
} catch (err) {
|
| 89 |
+
console.warn('[poToken] generation failed:', (err as Error).message);
|
| 90 |
+
return null;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// cached token; regenerates on expiry, one in-flight gen at a time, null on failure
|
| 95 |
+
export function getPoToken(
|
| 96 |
+
forceRefresh = false
|
| 97 |
+
): Promise<PoTokenBundle | null> {
|
| 98 |
+
if (!forceRefresh && cached && Date.now() < cached.expiresAt) {
|
| 99 |
+
return Promise.resolve(cached);
|
| 100 |
+
}
|
| 101 |
+
if (inflight) return inflight;
|
| 102 |
+
inflight = generate().finally(() => {
|
| 103 |
+
inflight = null;
|
| 104 |
+
});
|
| 105 |
+
return inflight;
|
| 106 |
+
}
|
backend/src/services/ytdlp/info-core.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
| 13 |
processAudioFormats,
|
| 14 |
} from '../../utils/media/format.util.js';
|
| 15 |
import { createRedisClient } from '../../utils/infra/redis.util.js';
|
|
|
|
| 16 |
import { LRUCache } from 'lru-cache';
|
| 17 |
|
| 18 |
export type ProgressCallback = (
|
|
@@ -322,6 +323,7 @@ export async function runYtdlpInfo(
|
|
| 322 |
signal: AbortSignal | null = null,
|
| 323 |
isRetry = false
|
| 324 |
): Promise<VideoInfo> {
|
|
|
|
| 325 |
const refererResult = Object.entries(REFERER_MAP).find(([domain]) =>
|
| 326 |
targetUrl.includes(domain)
|
| 327 |
);
|
|
@@ -403,5 +405,12 @@ export async function runYtdlpInfo(
|
|
| 403 |
// cache for subsequent stream spawn
|
| 404 |
void persistInfoJsonToDisk(parsedData, stdout);
|
| 405 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
return parsedData;
|
| 407 |
}
|
|
|
|
| 13 |
processAudioFormats,
|
| 14 |
} from '../../utils/media/format.util.js';
|
| 15 |
import { createRedisClient } from '../../utils/infra/redis.util.js';
|
| 16 |
+
import { recordExtraction } from '../../utils/infra/metrics.util.js';
|
| 17 |
import { LRUCache } from 'lru-cache';
|
| 18 |
|
| 19 |
export type ProgressCallback = (
|
|
|
|
| 323 |
signal: AbortSignal | null = null,
|
| 324 |
isRetry = false
|
| 325 |
): Promise<VideoInfo> {
|
| 326 |
+
const ytdlpStart = Date.now();
|
| 327 |
const refererResult = Object.entries(REFERER_MAP).find(([domain]) =>
|
| 328 |
targetUrl.includes(domain)
|
| 329 |
);
|
|
|
|
| 405 |
// cache for subsequent stream spawn
|
| 406 |
void persistInfoJsonToDisk(parsedData, stdout);
|
| 407 |
|
| 408 |
+
const ytdlpLabel = targetUrl.includes('tiktok.com')
|
| 409 |
+
? 'tiktok:ytdlp'
|
| 410 |
+
: targetUrl.includes('youtube.com') || targetUrl.includes('youtu.be')
|
| 411 |
+
? 'youtube:ytdlp'
|
| 412 |
+
: 'other:ytdlp';
|
| 413 |
+
recordExtraction(ytdlpLabel, true, Date.now() - ytdlpStart);
|
| 414 |
+
|
| 415 |
return parsedData;
|
| 416 |
}
|
backend/src/types/custom.d.ts
CHANGED
|
@@ -8,3 +8,4 @@ declare module 'jiti';
|
|
| 8 |
declare module 'sucrase';
|
| 9 |
declare module 'node:vm';
|
| 10 |
declare module 'better-sse';
|
|
|
|
|
|
| 8 |
declare module 'sucrase';
|
| 9 |
declare module 'node:vm';
|
| 10 |
declare module 'better-sse';
|
| 11 |
+
declare module 'jsdom';
|
backend/src/utils/infra/metrics.util.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface MetricsSnapshot {
|
|
| 17 |
{ count: number; errors: number; avgMs: number; maxMs: number }
|
| 18 |
>;
|
| 19 |
failures: Record<string, number>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
// bound label cardinality against abuse
|
|
@@ -25,6 +29,14 @@ const startedAt = Date.now();
|
|
| 25 |
const routes = new Map<string, RouteStat>();
|
| 26 |
const failures = new Map<string, number>();
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// fold overflow keys into "other"
|
| 29 |
function capKey(store: Map<string, unknown>, rawKey: string): string {
|
| 30 |
if (store.has(rawKey) || store.size < MAX_LABELS) {
|
|
@@ -61,6 +73,30 @@ export function recordFailure(reason: string): void {
|
|
| 61 |
failures.set(key, (failures.get(key) ?? 0) + 1);
|
| 62 |
}
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
export function getMetrics(): MetricsSnapshot {
|
| 65 |
const perRoute: MetricsSnapshot['routes'] = {};
|
| 66 |
let totalRequests = 0;
|
|
@@ -81,12 +117,27 @@ export function getMetrics(): MetricsSnapshot {
|
|
| 81 |
totalErrors,
|
| 82 |
routes: perRoute,
|
| 83 |
failures: Object.fromEntries(failures),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
};
|
| 85 |
}
|
| 86 |
|
| 87 |
export function resetMetrics(): void {
|
| 88 |
routes.clear();
|
| 89 |
failures.clear();
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
// record latency + outcome per request
|
|
|
|
| 17 |
{ count: number; errors: number; avgMs: number; maxMs: number }
|
| 18 |
>;
|
| 19 |
failures: Record<string, number>;
|
| 20 |
+
extractions: Record<
|
| 21 |
+
string,
|
| 22 |
+
{ attempts: number; success: number; successRate: number; avgMs: number; maxMs: number }
|
| 23 |
+
>;
|
| 24 |
}
|
| 25 |
|
| 26 |
// bound label cardinality against abuse
|
|
|
|
| 29 |
const routes = new Map<string, RouteStat>();
|
| 30 |
const failures = new Map<string, number>();
|
| 31 |
|
| 32 |
+
interface ExtractionStat {
|
| 33 |
+
attempts: number;
|
| 34 |
+
success: number;
|
| 35 |
+
totalMs: number;
|
| 36 |
+
maxMs: number;
|
| 37 |
+
}
|
| 38 |
+
const extractions = new Map<string, ExtractionStat>();
|
| 39 |
+
|
| 40 |
// fold overflow keys into "other"
|
| 41 |
function capKey(store: Map<string, unknown>, rawKey: string): string {
|
| 42 |
if (store.has(rawKey) || store.size < MAX_LABELS) {
|
|
|
|
| 73 |
failures.set(key, (failures.get(key) ?? 0) + 1);
|
| 74 |
}
|
| 75 |
|
| 76 |
+
// track extraction outcomes (youtube:js:ANDROID_VR vs youtube:ytdlp) for /metrics
|
| 77 |
+
export function recordExtraction(
|
| 78 |
+
label: string,
|
| 79 |
+
success: boolean,
|
| 80 |
+
durationMs: number
|
| 81 |
+
): void {
|
| 82 |
+
const key = capKey(extractions, label);
|
| 83 |
+
const stat = extractions.get(key) ?? {
|
| 84 |
+
attempts: 0,
|
| 85 |
+
success: 0,
|
| 86 |
+
totalMs: 0,
|
| 87 |
+
maxMs: 0,
|
| 88 |
+
};
|
| 89 |
+
stat.attempts += 1;
|
| 90 |
+
if (success) {
|
| 91 |
+
stat.success += 1;
|
| 92 |
+
}
|
| 93 |
+
stat.totalMs += durationMs;
|
| 94 |
+
if (durationMs > stat.maxMs) {
|
| 95 |
+
stat.maxMs = durationMs;
|
| 96 |
+
}
|
| 97 |
+
extractions.set(key, stat);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
export function getMetrics(): MetricsSnapshot {
|
| 101 |
const perRoute: MetricsSnapshot['routes'] = {};
|
| 102 |
let totalRequests = 0;
|
|
|
|
| 117 |
totalErrors,
|
| 118 |
routes: perRoute,
|
| 119 |
failures: Object.fromEntries(failures),
|
| 120 |
+
extractions: Object.fromEntries(
|
| 121 |
+
[...extractions].map(([label, stat]) => [
|
| 122 |
+
label,
|
| 123 |
+
{
|
| 124 |
+
attempts: stat.attempts,
|
| 125 |
+
success: stat.success,
|
| 126 |
+
successRate: stat.attempts
|
| 127 |
+
? Math.round((stat.success / stat.attempts) * 100) / 100
|
| 128 |
+
: 0,
|
| 129 |
+
avgMs: Math.round(stat.totalMs / stat.attempts),
|
| 130 |
+
maxMs: stat.maxMs,
|
| 131 |
+
},
|
| 132 |
+
])
|
| 133 |
+
),
|
| 134 |
};
|
| 135 |
}
|
| 136 |
|
| 137 |
export function resetMetrics(): void {
|
| 138 |
routes.clear();
|
| 139 |
failures.clear();
|
| 140 |
+
extractions.clear();
|
| 141 |
}
|
| 142 |
|
| 143 |
// record latency + outcome per request
|