circleci commited on
Commit
6e37a4e
·
1 Parent(s): 63bd94f

deploy 91f0b54

Browse files
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 { getYoutubeClient } = await clientModule;
18
- const client = await getYoutubeClient();
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
- const fullInfo = await client.getInfo(videoId);
29
- const videoInfo = await normalizeVideoInfo(url, fullInfo, client);
30
-
31
- if (options.onProgress) {
32
- options.onProgress('fetching_info', 50, 'Metadata parsed');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
 
35
- return videoInfo;
 
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