MoMo commited on
Commit
11fcc5a
·
1 Parent(s): 724121b
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .DS_Store +0 -0
  2. api/README.md +2 -1
  3. api/package.json +4 -3
  4. api/src/.env +0 -0
  5. api/src/cobalt.js +5 -0
  6. api/src/config.js +22 -78
  7. api/src/core/api.js +58 -92
  8. api/src/core/env.js +289 -0
  9. api/src/core/itunnel.js +61 -0
  10. api/src/misc/cluster.js +2 -1
  11. api/src/misc/file-watcher.js +43 -0
  12. api/src/misc/language-codes.js +54 -0
  13. api/src/misc/utils.js +33 -8
  14. api/src/processing/cookie/manager.js +1 -0
  15. api/src/processing/create-filename.js +20 -5
  16. api/src/processing/match-action.js +71 -8
  17. api/src/processing/match.js +55 -18
  18. api/src/processing/request.js +47 -3
  19. api/src/processing/schema.js +20 -7
  20. api/src/processing/service-alias.js +1 -0
  21. api/src/processing/service-config.js +21 -9
  22. api/src/processing/service-patterns.js +46 -38
  23. api/src/processing/services/bilibili.js +21 -7
  24. api/src/processing/services/loom.js +90 -21
  25. api/src/processing/services/newgrounds.js +103 -0
  26. api/src/processing/services/pinterest.js +5 -0
  27. api/src/processing/services/rutube.js +13 -0
  28. api/src/processing/services/snapchat.js +2 -2
  29. api/src/processing/services/soundcloud.js +92 -41
  30. api/src/processing/services/tiktok.js +22 -2
  31. api/src/processing/services/twitter.js +122 -71
  32. api/src/processing/services/vimeo.js +82 -8
  33. api/src/processing/services/vk.js +13 -1
  34. api/src/processing/services/xiaohongshu.js +2 -2
  35. api/src/processing/services/youtube.js +129 -41
  36. api/src/processing/url.js +13 -9
  37. api/src/security/api-keys.js +64 -25
  38. api/src/stream/ffmpeg.js +215 -0
  39. api/src/stream/internal-hls.js +70 -2
  40. api/src/stream/internal.js +46 -10
  41. api/src/stream/manage.js +79 -6
  42. api/src/stream/proxy.js +43 -0
  43. api/src/stream/shared.js +42 -0
  44. api/src/stream/stream.js +7 -8
  45. api/src/stream/types.js +0 -340
  46. api/src/util/test.js +5 -4
  47. api/src/util/tests/bilibili.json +9 -0
  48. api/src/util/tests/facebook.json +1 -1
  49. api/src/util/tests/loom.json +28 -1
  50. api/src/util/tests/newgrounds.json +42 -0
.DS_Store CHANGED
Binary files a/.DS_Store and b/.DS_Store differ
 
api/README.md CHANGED
@@ -23,6 +23,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i
23
  | instagram | ✅ | ✅ | ✅ | ➖ | ➖ |
24
  | facebook | ✅ | ❌ | ✅ | ➖ | ➖ |
25
  | loom | ✅ | ❌ | ✅ | ✅ | ➖ |
 
26
  | ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
27
  | pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
28
  | reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
@@ -71,7 +72,7 @@ as long as you:
71
 
72
  ## open source acknowledgements
73
  ### ffmpeg
74
- cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have an ability to use it for free, just like anyone else. we believe it should be way more recognized.
75
 
76
  you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
77
 
 
23
  | instagram | ✅ | ✅ | ✅ | ➖ | ➖ |
24
  | facebook | ✅ | ❌ | ✅ | ➖ | ➖ |
25
  | loom | ✅ | ❌ | ✅ | ✅ | ➖ |
26
+ | newgrounds | ✅ | ✅ | ✅ | ✅ | ✅ |
27
  | ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
28
  | pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
29
  | reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
 
72
 
73
  ## open source acknowledgements
74
  ### ffmpeg
75
+ cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have the ability to use it for free, just like anyone else. we believe it should be way more recognized.
76
 
77
  you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
78
 
api/package.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "name": "@imput/cobalt-api",
3
  "description": "save what you love",
4
- "version": "10.9.1",
5
  "author": "imput",
6
  "exports": "./src/cobalt.js",
7
  "type": "module",
@@ -34,11 +34,12 @@
34
  "ffmpeg-static": "^5.1.0",
35
  "hls-parser": "^0.10.7",
36
  "ipaddr.js": "2.2.0",
 
37
  "nanoid": "^5.0.9",
38
  "set-cookie-parser": "2.6.0",
39
- "undici": "^5.19.1",
40
  "url-pattern": "1.0.3",
41
- "youtubei.js": "^13.3.0",
42
  "zod": "^3.23.8"
43
  },
44
  "optionalDependencies": {
 
1
  {
2
  "name": "@imput/cobalt-api",
3
  "description": "save what you love",
4
+ "version": "11.5",
5
  "author": "imput",
6
  "exports": "./src/cobalt.js",
7
  "type": "module",
 
34
  "ffmpeg-static": "^5.1.0",
35
  "hls-parser": "^0.10.7",
36
  "ipaddr.js": "2.2.0",
37
+ "mime": "^4.0.4",
38
  "nanoid": "^5.0.9",
39
  "set-cookie-parser": "2.6.0",
40
+ "undici": "^6.21.3",
41
  "url-pattern": "1.0.3",
42
+ "youtubei.js": "15.1.1",
43
  "zod": "^3.23.8"
44
  },
45
  "optionalDependencies": {
api/src/.env DELETED
File without changes
api/src/cobalt.js CHANGED
@@ -9,6 +9,7 @@ import { fileURLToPath } from "url";
9
  import { env, isCluster } from "./config.js"
10
  import { Red } from "./misc/console-text.js";
11
  import { initCluster } from "./misc/cluster.js";
 
12
 
13
  const app = express();
14
 
@@ -24,6 +25,10 @@ if (env.apiURL) {
24
  await initCluster();
25
  }
26
 
 
 
 
 
27
  runAPI(express, app, __dirname, cluster.isPrimary);
28
  } else {
29
  console.log(
 
9
  import { env, isCluster } from "./config.js"
10
  import { Red } from "./misc/console-text.js";
11
  import { initCluster } from "./misc/cluster.js";
12
+ import { setupEnvWatcher } from "./core/env.js";
13
 
14
  const app = express();
15
 
 
25
  await initCluster();
26
  }
27
 
28
+ if (env.envFile) {
29
+ setupEnvWatcher();
30
+ }
31
+
32
  runAPI(express, app, __dirname, cluster.isPrimary);
33
  } else {
34
  console.log(
api/src/config.js CHANGED
@@ -1,97 +1,41 @@
1
- import { Constants } from "youtubei.js";
2
  import { getVersion } from "@imput/version-info";
3
- import { services } from "./processing/service-config.js";
4
- import { supportsReusePort } from "./misc/cluster.js";
5
 
6
  const version = await getVersion();
7
 
8
- const disabledServices = process.env.DISABLED_SERVICES?.split(',') || [];
9
- const enabledServices = new Set(Object.keys(services).filter(e => {
10
- if (!disabledServices.includes(e)) {
11
- return e;
12
- }
13
- }));
14
-
15
- const env = {
16
- apiURL: process.env.API_URL || '',
17
- apiPort: process.env.API_PORT || 7860,
18
- tunnelPort: process.env.API_PORT || 7860,
19
- appKey: process.env.APPKEY,
20
-
21
- listenAddress: process.env.API_LISTEN_ADDRESS,
22
- freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
23
-
24
- corsWildcard: process.env.CORS_WILDCARD !== '0',
25
- corsURL: process.env.CORS_URL,
26
-
27
- cookiePath: process.env.COOKIE_PATH,
28
-
29
- rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
30
- rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
31
-
32
- sessionRateLimitWindow: (process.env.SESSION_RATELIMIT_WINDOW && parseInt(process.env.SESSION_RATELIMIT_WINDOW)) || 60,
33
- sessionRateLimit: (process.env.SESSION_RATELIMIT && parseInt(process.env.SESSION_RATELIMIT)) || 10,
34
-
35
- durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
36
- streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90,
37
-
38
- processingPriority: process.platform !== 'win32'
39
- && process.env.PROCESSING_PRIORITY
40
- && parseInt(process.env.PROCESSING_PRIORITY),
41
-
42
- externalProxy: process.env.API_EXTERNAL_PROXY,
43
-
44
- turnstileSitekey: process.env.TURNSTILE_SITEKEY,
45
- turnstileSecret: process.env.TURNSTILE_SECRET,
46
- jwtSecret: process.env.JWT_SECRET,
47
- jwtLifetime: process.env.JWT_EXPIRY || 120,
48
 
49
- sessionEnabled: process.env.TURNSTILE_SITEKEY
50
- && process.env.TURNSTILE_SECRET
51
- && process.env.JWT_SECRET,
52
-
53
- apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL),
54
- authRequired: process.env.API_AUTH_REQUIRED === '1',
55
- redisURL: process.env.API_REDIS_URL,
56
- instanceCount: (process.env.API_INSTANCE_COUNT && parseInt(process.env.API_INSTANCE_COUNT)) || 1,
57
- keyReloadInterval: 900,
58
-
59
- enabledServices,
60
-
61
- customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
62
- ytSessionServer: process.env.YOUTUBE_SESSION_SERVER,
63
- ytSessionReloadInterval: 300,
64
- ytSessionInnertubeClient: process.env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
65
- }
66
-
67
- const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
68
  const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
69
 
70
  export const setTunnelPort = (port) => env.tunnelPort = port;
71
  export const isCluster = env.instanceCount > 1;
 
 
72
 
73
- if (env.sessionEnabled && env.jwtSecret.length < 16) {
74
- throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
75
- }
76
 
77
- if (env.instanceCount > 1 && !env.redisURL) {
78
- throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
79
- } else if (env.instanceCount > 1 && !await supportsReusePort()) {
80
- console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
81
- console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
82
- console.error('(or other OS that supports it). for more info, see `reusePort` option on');
83
- console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
84
- throw new Error('SO_REUSEPORT is not supported');
85
- }
86
 
87
- if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
88
- console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
89
- console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
90
- throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
 
 
 
91
  }
92
 
 
 
93
  export {
94
  env,
 
95
  genericUserAgent,
96
  cobaltUserAgent,
97
  }
 
 
1
  import { getVersion } from "@imput/version-info";
2
+ import { loadEnvs, validateEnvs } from "./core/env.js";
 
3
 
4
  const version = await getVersion();
5
 
6
+ const canonicalEnv = Object.freeze(structuredClone(process.env));
7
+ const env = loadEnvs();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
11
 
12
  export const setTunnelPort = (port) => env.tunnelPort = port;
13
  export const isCluster = env.instanceCount > 1;
14
+ export const updateEnv = (newEnv) => {
15
+ const changes = [];
16
 
17
+ // tunnelPort is special and needs to get carried over here
18
+ newEnv.tunnelPort = env.tunnelPort;
 
19
 
20
+ for (const key in env) {
21
+ if (key === 'subscribe') {
22
+ continue;
23
+ }
 
 
 
 
 
24
 
25
+ if (String(env[key]) !== String(newEnv[key])) {
26
+ changes.push(key);
27
+ }
28
+ env[key] = newEnv[key];
29
+ }
30
+
31
+ return changes;
32
  }
33
 
34
+ await validateEnvs(env);
35
+
36
  export {
37
  env,
38
+ canonicalEnv,
39
  genericUserAgent,
40
  cobaltUserAgent,
41
  }
api/src/core/api.js CHANGED
@@ -1,23 +1,24 @@
1
  import cors from "cors";
2
  import http from "node:http";
3
  import rateLimit from "express-rate-limit";
4
- import { setGlobalDispatcher, ProxyAgent } from "undici";
5
  import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
6
 
7
  import jwt from "../security/jwt.js";
8
  import stream from "../stream/stream.js";
9
  import match from "../processing/match.js";
10
 
11
- import { env, isCluster, setTunnelPort } from "../config.js";
12
  import { extract } from "../processing/url.js";
13
- import { Green, Bright, Cyan } from "../misc/console-text.js";
14
  import { hashHmac } from "../security/secrets.js";
15
  import { createStore } from "../store/redis-ratelimit.js";
16
  import { randomizeCiphers } from "../misc/randomize-ciphers.js";
17
  import { verifyTurnstileToken } from "../security/turnstile.js";
18
  import { friendlyServiceName } from "../processing/service-alias.js";
19
- import { verifyStream, getInternalStream } from "../stream/manage.js";
20
  import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
 
21
 
22
  import * as APIKeys from "../security/api-keys.js";
23
  import * as Cookies from "../processing/cookie/manager.js";
@@ -47,28 +48,31 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
47
  const startTime = new Date();
48
  const startTimestamp = startTime.getTime();
49
 
50
- const serverInfo = JSON.stringify({
51
- cobalt: {
52
- version: version,
53
- url: env.apiURL,
54
- startTime: `${startTimestamp}`,
55
- durationLimit: env.durationLimit,
56
- turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
57
- services: [...env.enabledServices].map(e => {
58
- return friendlyServiceName(e);
59
- }),
60
- },
61
- git,
62
- })
 
 
 
63
 
64
  const handleRateExceeded = (_, res) => {
65
- const { status, body } = createResponse("error", {
66
  code: "error.api.rate_exceeded",
67
  context: {
68
  limit: env.rateLimitWindow
69
  }
70
  });
71
- return res.status(status).json(body);
72
  };
73
 
74
  const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
@@ -94,14 +98,14 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
94
  });
95
 
96
  const apiTunnelLimiter = rateLimit({
97
- windowMs: env.rateLimitWindow * 1000,
98
- limit: (req) => req.rateLimitMax || env.rateLimitMax,
99
  standardHeaders: 'draft-6',
100
  legacyHeaders: false,
101
- keyGenerator: req => req.rateLimitKey || keyGenerator(req),
102
  store: await createStore('tunnel'),
103
  handler: (_, res) => {
104
- return res.sendStatus(429)
105
  }
106
  });
107
 
@@ -128,20 +132,6 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
128
  next();
129
  });
130
 
131
- app.post('/', (req, res, next) => {
132
- const appkey = req.query.appkey;
133
-
134
- if (!appkey) {
135
- return fail(res, "error.api.auth.appkey.missing");
136
- }
137
-
138
- if (appkey !== env.appKey) {
139
- return fail(res, "error.api.auth.appkey.invalid");
140
- }
141
-
142
- next();
143
- });
144
-
145
  app.post('/', (req, res, next) => {
146
  if (!env.apiKeyURL) {
147
  return next();
@@ -166,6 +156,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
166
  return fail(res, `error.api.auth.key.${error}`);
167
  }
168
 
 
169
  return next();
170
  });
171
 
@@ -184,7 +175,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
184
  return fail(res, "error.api.auth.jwt.invalid");
185
  }
186
 
187
- const [type, token, ...rest] = authorization.split(" ");
188
  if (!token || type.toLowerCase() !== 'bearer' || rest.length) {
189
  return fail(res, "error.api.auth.jwt.invalid");
190
  }
@@ -194,6 +185,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
194
  }
195
 
196
  req.rateLimitKey = hashHmac(token, 'rate');
 
197
  } catch {
198
  return fail(res, "error.api.generic");
199
  }
@@ -253,11 +245,15 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
253
  return fail(res, "error.api.invalid_body");
254
  }
255
 
256
- const parsed = extract(normalizedRequest.url);
 
 
 
257
 
258
  if (!parsed) {
259
  return fail(res, "error.api.link.invalid");
260
  }
 
261
  if ("error" in parsed) {
262
  let context;
263
  if (parsed?.context) {
@@ -271,13 +267,23 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
271
  host: parsed.host,
272
  patternMatch: parsed.patternMatch,
273
  params: normalizedRequest,
 
274
  });
275
 
276
  res.status(result.status).json(result.body);
277
  } catch {
278
  fail(res, "error.api.generic");
279
  }
280
- })
 
 
 
 
 
 
 
 
 
281
 
282
  app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
283
  const id = String(req.query.id);
@@ -308,43 +314,11 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
308
  }
309
 
310
  return stream(res, streamInfo);
311
- })
312
-
313
- const itunnelHandler = (req, res) => {
314
- if (!req.ip.endsWith('127.0.0.1')) {
315
- return res.sendStatus(403);
316
- }
317
-
318
- if (String(req.query.id).length !== 21) {
319
- return res.sendStatus(400);
320
- }
321
-
322
- const streamInfo = getInternalStream(req.query.id);
323
- if (!streamInfo) {
324
- return res.sendStatus(404);
325
- }
326
-
327
- streamInfo.headers = new Map([
328
- ...(streamInfo.headers || []),
329
- ...Object.entries(req.headers)
330
- ]);
331
-
332
- return stream(res, { type: 'internal', data: streamInfo });
333
- };
334
-
335
- app.get('/itunnel', itunnelHandler);
336
 
337
  app.get('/', (_, res) => {
338
  res.type('json');
339
- res.status(200).send(JSON.stringify({
340
- status: 'success',
341
- message: 'Hello, world!'
342
- }));
343
- })
344
-
345
- app.get('/api-status', (_, res) => {
346
- res.type('json');
347
- res.status(200).send(serverInfo);
348
  })
349
 
350
  app.get('/favicon.ico', (req, res) => {
@@ -363,13 +337,17 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
363
  randomizeCiphers();
364
  setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
365
 
366
- if (env.externalProxy) {
367
- if (env.freebindCIDR) {
368
- throw new Error('Freebind is not available when external proxy is enabled')
 
 
369
  }
370
 
371
- setGlobalDispatcher(new ProxyAgent(env.externalProxy))
372
- }
 
 
373
 
374
  http.createServer(app).listen({
375
  port: env.apiPort,
@@ -406,17 +384,5 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
406
  }
407
  });
408
 
409
- if (isCluster) {
410
- const istreamer = express();
411
- istreamer.get('/itunnel', itunnelHandler);
412
- const server = istreamer.listen({
413
- port: 0,
414
- host: '127.0.0.1',
415
- exclusive: true
416
- }, () => {
417
- const { port } = server.address();
418
- console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`);
419
- setTunnelPort(port);
420
- });
421
- }
422
  }
 
1
  import cors from "cors";
2
  import http from "node:http";
3
  import rateLimit from "express-rate-limit";
4
+ import { setGlobalDispatcher, EnvHttpProxyAgent } from "undici";
5
  import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
6
 
7
  import jwt from "../security/jwt.js";
8
  import stream from "../stream/stream.js";
9
  import match from "../processing/match.js";
10
 
11
+ import { env } from "../config.js";
12
  import { extract } from "../processing/url.js";
13
+ import { Bright, Cyan } from "../misc/console-text.js";
14
  import { hashHmac } from "../security/secrets.js";
15
  import { createStore } from "../store/redis-ratelimit.js";
16
  import { randomizeCiphers } from "../misc/randomize-ciphers.js";
17
  import { verifyTurnstileToken } from "../security/turnstile.js";
18
  import { friendlyServiceName } from "../processing/service-alias.js";
19
+ import { verifyStream } from "../stream/manage.js";
20
  import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
21
+ import { setupTunnelHandler } from "./itunnel.js";
22
 
23
  import * as APIKeys from "../security/api-keys.js";
24
  import * as Cookies from "../processing/cookie/manager.js";
 
48
  const startTime = new Date();
49
  const startTimestamp = startTime.getTime();
50
 
51
+ const getServerInfo = () => {
52
+ return JSON.stringify({
53
+ cobalt: {
54
+ version: version,
55
+ url: env.apiURL,
56
+ startTime: `${startTimestamp}`,
57
+ turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
58
+ services: [...env.enabledServices].map(e => {
59
+ return friendlyServiceName(e);
60
+ }),
61
+ },
62
+ git,
63
+ });
64
+ }
65
+
66
+ const serverInfo = getServerInfo();
67
 
68
  const handleRateExceeded = (_, res) => {
69
+ const { body } = createResponse("error", {
70
  code: "error.api.rate_exceeded",
71
  context: {
72
  limit: env.rateLimitWindow
73
  }
74
  });
75
+ return res.status(429).json(body);
76
  };
77
 
78
  const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
 
98
  });
99
 
100
  const apiTunnelLimiter = rateLimit({
101
+ windowMs: env.tunnelRateLimitWindow * 1000,
102
+ limit: env.tunnelRateLimitMax,
103
  standardHeaders: 'draft-6',
104
  legacyHeaders: false,
105
+ keyGenerator: req => keyGenerator(req),
106
  store: await createStore('tunnel'),
107
  handler: (_, res) => {
108
+ return res.sendStatus(429);
109
  }
110
  });
111
 
 
132
  next();
133
  });
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  app.post('/', (req, res, next) => {
136
  if (!env.apiKeyURL) {
137
  return next();
 
156
  return fail(res, `error.api.auth.key.${error}`);
157
  }
158
 
159
+ req.authType = "key";
160
  return next();
161
  });
162
 
 
175
  return fail(res, "error.api.auth.jwt.invalid");
176
  }
177
 
178
+ const [ type, token, ...rest ] = authorization.split(" ");
179
  if (!token || type.toLowerCase() !== 'bearer' || rest.length) {
180
  return fail(res, "error.api.auth.jwt.invalid");
181
  }
 
185
  }
186
 
187
  req.rateLimitKey = hashHmac(token, 'rate');
188
+ req.authType = "session";
189
  } catch {
190
  return fail(res, "error.api.generic");
191
  }
 
245
  return fail(res, "error.api.invalid_body");
246
  }
247
 
248
+ const parsed = extract(
249
+ normalizedRequest.url,
250
+ APIKeys.getAllowedServices(req.rateLimitKey),
251
+ );
252
 
253
  if (!parsed) {
254
  return fail(res, "error.api.link.invalid");
255
  }
256
+
257
  if ("error" in parsed) {
258
  let context;
259
  if (parsed?.context) {
 
267
  host: parsed.host,
268
  patternMatch: parsed.patternMatch,
269
  params: normalizedRequest,
270
+ authType: req.authType ?? "none",
271
  });
272
 
273
  res.status(result.status).json(result.body);
274
  } catch {
275
  fail(res, "error.api.generic");
276
  }
277
+ });
278
+
279
+ app.use('/tunnel', cors({
280
+ methods: ['GET'],
281
+ exposedHeaders: [
282
+ 'Estimated-Content-Length',
283
+ 'Content-Disposition'
284
+ ],
285
+ ...corsConfig,
286
+ }));
287
 
288
  app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
289
  const id = String(req.query.id);
 
314
  }
315
 
316
  return stream(res, streamInfo);
317
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
  app.get('/', (_, res) => {
320
  res.type('json');
321
+ res.status(200).send(env.envFile ? getServerInfo() : serverInfo);
 
 
 
 
 
 
 
 
322
  })
323
 
324
  app.get('/favicon.ico', (req, res) => {
 
337
  randomizeCiphers();
338
  setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
339
 
340
+ env.subscribe(['externalProxy', 'httpProxyValues'], () => {
341
+ // TODO: remove env.externalProxy in a future version
342
+ const options = {};
343
+ if (env.externalProxy) {
344
+ options.httpProxy = env.externalProxy;
345
  }
346
 
347
+ setGlobalDispatcher(
348
+ new EnvHttpProxyAgent(options)
349
+ );
350
+ });
351
 
352
  http.createServer(app).listen({
353
  port: env.apiPort,
 
384
  }
385
  });
386
 
387
+ setupTunnelHandler();
 
 
 
 
 
 
 
 
 
 
 
 
388
  }
api/src/core/env.js ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Constants } from "youtubei.js";
2
+ import { services } from "../processing/service-config.js";
3
+ import { updateEnv, canonicalEnv, env as currentEnv } from "../config.js";
4
+
5
+ import { FileWatcher } from "../misc/file-watcher.js";
6
+ import { isURL } from "../misc/utils.js";
7
+ import * as cluster from "../misc/cluster.js";
8
+ import { Green, Yellow } from "../misc/console-text.js";
9
+
10
+ const forceLocalProcessingOptions = ["never", "session", "always"];
11
+ const youtubeHlsOptions = ["never", "key", "always"];
12
+
13
+ const httpProxyVariables = ["NO_PROXY", "HTTP_PROXY", "HTTPS_PROXY"].flatMap(
14
+ k => [ k, k.toLowerCase() ]
15
+ );
16
+
17
+ const changeCallbacks = {};
18
+
19
+ const onEnvChanged = (changes) => {
20
+ for (const key of changes) {
21
+ if (changeCallbacks[key]) {
22
+ changeCallbacks[key].map(fn => {
23
+ try { fn() } catch {}
24
+ });
25
+ }
26
+ }
27
+ }
28
+
29
+ const subscribe = (keys, fn) => {
30
+ keys = [keys].flat();
31
+
32
+ for (const key of keys) {
33
+ if (key in currentEnv && key !== 'subscribe') {
34
+ changeCallbacks[key] ??= [];
35
+ changeCallbacks[key].push(fn);
36
+ fn();
37
+ } else throw `invalid env key ${key}`;
38
+ }
39
+ }
40
+
41
+ export const loadEnvs = (env = process.env) => {
42
+ const allServices = new Set(Object.keys(services));
43
+ const disabledServices = env.DISABLED_SERVICES?.split(',') || [];
44
+ const enabledServices = new Set(Object.keys(services).filter(e => {
45
+ if (!disabledServices.includes(e)) {
46
+ return e;
47
+ }
48
+ }));
49
+
50
+ // we need to copy the proxy envs (HTTP_PROXY, HTTPS_PROXY)
51
+ // back into process.env, so that EnvHttpProxyAgent can pick
52
+ // them up later
53
+ for (const key of httpProxyVariables) {
54
+ const value = env[key] ?? canonicalEnv[key];
55
+ if (value !== undefined) {
56
+ process.env[key] = env[key];
57
+ } else {
58
+ delete process.env[key];
59
+ }
60
+ }
61
+
62
+ return {
63
+ apiURL: env.API_URL || '',
64
+ apiPort: env.API_PORT || 9000,
65
+ tunnelPort: env.API_PORT || 9000,
66
+
67
+ listenAddress: env.API_LISTEN_ADDRESS,
68
+ freebindCIDR: process.platform === 'linux' && env.FREEBIND_CIDR,
69
+
70
+ corsWildcard: env.CORS_WILDCARD !== '0',
71
+ corsURL: env.CORS_URL,
72
+
73
+ cookiePath: env.COOKIE_PATH,
74
+
75
+ rateLimitWindow: (env.RATELIMIT_WINDOW && parseInt(env.RATELIMIT_WINDOW)) || 60,
76
+ rateLimitMax: (env.RATELIMIT_MAX && parseInt(env.RATELIMIT_MAX)) || 20,
77
+
78
+ tunnelRateLimitWindow: (env.TUNNEL_RATELIMIT_WINDOW && parseInt(env.TUNNEL_RATELIMIT_WINDOW)) || 60,
79
+ tunnelRateLimitMax: (env.TUNNEL_RATELIMIT_MAX && parseInt(env.TUNNEL_RATELIMIT_MAX)) || 40,
80
+
81
+ sessionRateLimitWindow: (env.SESSION_RATELIMIT_WINDOW && parseInt(env.SESSION_RATELIMIT_WINDOW)) || 60,
82
+ sessionRateLimit:
83
+ // backwards compatibility with SESSION_RATELIMIT
84
+ // till next major due to an error in docs
85
+ (env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX))
86
+ || (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT))
87
+ || 10,
88
+
89
+ durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800,
90
+ streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90,
91
+
92
+ processingPriority: process.platform !== 'win32'
93
+ && env.PROCESSING_PRIORITY
94
+ && parseInt(env.PROCESSING_PRIORITY),
95
+
96
+ externalProxy: env.API_EXTERNAL_PROXY,
97
+
98
+ // used only for comparing against old values when envs are being updated
99
+ httpProxyValues: httpProxyVariables.map(k => String(env[k])).join(''),
100
+
101
+ turnstileSitekey: env.TURNSTILE_SITEKEY,
102
+ turnstileSecret: env.TURNSTILE_SECRET,
103
+ jwtSecret: env.JWT_SECRET,
104
+ jwtLifetime: env.JWT_EXPIRY || 120,
105
+
106
+ sessionEnabled: env.TURNSTILE_SITEKEY
107
+ && env.TURNSTILE_SECRET
108
+ && env.JWT_SECRET,
109
+
110
+ apiKeyURL: env.API_KEY_URL && new URL(env.API_KEY_URL),
111
+ authRequired: env.API_AUTH_REQUIRED === '1',
112
+ redisURL: env.API_REDIS_URL,
113
+ instanceCount: (env.API_INSTANCE_COUNT && parseInt(env.API_INSTANCE_COUNT)) || 1,
114
+ keyReloadInterval: 900,
115
+
116
+ allServices,
117
+ enabledServices,
118
+
119
+ customInnertubeClient: env.CUSTOM_INNERTUBE_CLIENT,
120
+ ytSessionServer: env.YOUTUBE_SESSION_SERVER,
121
+ ytSessionReloadInterval: 300,
122
+ ytSessionInnertubeClient: env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
123
+ ytAllowBetterAudio: env.YOUTUBE_ALLOW_BETTER_AUDIO !== "0",
124
+
125
+ // "never" | "session" | "always"
126
+ forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never",
127
+
128
+ // "never" | "key" | "always"
129
+ enableDeprecatedYoutubeHls: env.ENABLE_DEPRECATED_YOUTUBE_HLS ?? "never",
130
+
131
+ envFile: env.API_ENV_FILE,
132
+ envRemoteReloadInterval: 300,
133
+
134
+ subscribe,
135
+ };
136
+ }
137
+
138
+ let loggedProxyWarning = false;
139
+
140
+ export const validateEnvs = async (env) => {
141
+ if (env.sessionEnabled && env.jwtSecret.length < 16) {
142
+ throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
143
+ }
144
+
145
+ if (env.instanceCount > 1 && !env.redisURL) {
146
+ throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
147
+ } else if (env.instanceCount > 1 && !await cluster.supportsReusePort()) {
148
+ console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
149
+ console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
150
+ console.error('(or other OS that supports it). for more info, see `reusePort` option on');
151
+ console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
152
+ throw new Error('SO_REUSEPORT is not supported');
153
+ }
154
+
155
+ if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
156
+ console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
157
+ console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
158
+ throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
159
+ }
160
+
161
+ if (env.forceLocalProcessing && !forceLocalProcessingOptions.includes(env.forceLocalProcessing)) {
162
+ console.error("FORCE_LOCAL_PROCESSING is invalid.");
163
+ console.error(`Supported options are are: ${forceLocalProcessingOptions.join(', ')}\n`);
164
+ throw new Error("Invalid FORCE_LOCAL_PROCESSING");
165
+ }
166
+
167
+ if (env.enableDeprecatedYoutubeHls && !youtubeHlsOptions.includes(env.enableDeprecatedYoutubeHls)) {
168
+ console.error("ENABLE_DEPRECATED_YOUTUBE_HLS is invalid.");
169
+ console.error(`Supported options are are: ${youtubeHlsOptions.join(', ')}\n`);
170
+ throw new Error("Invalid ENABLE_DEPRECATED_YOUTUBE_HLS");
171
+ }
172
+
173
+ if (env.externalProxy && env.freebindCIDR) {
174
+ throw new Error('freebind is not available when external proxy is enabled')
175
+ }
176
+
177
+ if (env.externalProxy && !loggedProxyWarning) {
178
+ console.error('API_EXTERNAL_PROXY is deprecated and will be removed in a future release.');
179
+ console.error('Use HTTP_PROXY or HTTPS_PROXY instead.');
180
+ console.error('You can read more about the new proxy variables in docs/api-env-variables.md\n');
181
+
182
+ // prevent the warning from being printed on every env validation
183
+ loggedProxyWarning = true;
184
+ }
185
+
186
+ return env;
187
+ }
188
+
189
+ const reloadEnvs = async (contents) => {
190
+ const newEnvs = {};
191
+ const resolvedContents = await contents;
192
+
193
+ for (let line of resolvedContents.split('\n')) {
194
+ line = line.trim();
195
+ if (line === '') {
196
+ continue;
197
+ }
198
+
199
+ let [ key, value ] = line.split(/=(.+)?/);
200
+ if (key) {
201
+ if (value.match(/^['"]/) && value.match(/['"]$/)) {
202
+ value = JSON.parse(value);
203
+ }
204
+
205
+ newEnvs[key] = value || '';
206
+ }
207
+ }
208
+
209
+ const candidate = {
210
+ ...canonicalEnv,
211
+ ...newEnvs,
212
+ };
213
+
214
+ const parsed = await validateEnvs(
215
+ loadEnvs(candidate)
216
+ );
217
+
218
+ cluster.broadcast({ env_update: resolvedContents });
219
+ return updateEnv(parsed);
220
+ }
221
+
222
+ const wrapReload = (contents) => {
223
+ reloadEnvs(contents)
224
+ .then(changes => {
225
+ if (changes.length === 0) {
226
+ return;
227
+ }
228
+
229
+ onEnvChanged(changes);
230
+
231
+ console.log(`${Green('[✓]')} envs reloaded successfully!`);
232
+ for (const key of changes) {
233
+ const value = currentEnv[key];
234
+ const isSecret = key.toLowerCase().includes('apikey')
235
+ || key.toLowerCase().includes('secret')
236
+ || key === 'httpProxyValues';
237
+
238
+ if (!value) {
239
+ console.log(` removed: ${key}`);
240
+ } else {
241
+ console.log(` changed: ${key} -> ${isSecret ? '***' : value}`);
242
+ }
243
+ }
244
+ })
245
+ .catch((e) => {
246
+ console.error(`${Yellow('[!]')} Failed reloading environment variables at ${new Date().toISOString()}.`);
247
+ console.error('Error:', e);
248
+ });
249
+ }
250
+
251
+ let watcher;
252
+ const setupWatcherFromFile = (path) => {
253
+ const load = () => wrapReload(watcher.read());
254
+
255
+ if (isURL(path)) {
256
+ watcher = FileWatcher.fromFileProtocol(path);
257
+ } else {
258
+ watcher = new FileWatcher({ path });
259
+ }
260
+
261
+ watcher.on('file-updated', load);
262
+ load();
263
+ }
264
+
265
+ const setupWatcherFromFetch = (url) => {
266
+ const load = () => wrapReload(fetch(url).then(r => r.text()));
267
+ setInterval(load, currentEnv.envRemoteReloadInterval);
268
+ load();
269
+ }
270
+
271
+ export const setupEnvWatcher = () => {
272
+ if (cluster.isPrimary) {
273
+ const envFile = currentEnv.envFile;
274
+ const isFile = !isURL(envFile)
275
+ || new URL(envFile).protocol === 'file:';
276
+
277
+ if (isFile) {
278
+ setupWatcherFromFile(envFile);
279
+ } else {
280
+ setupWatcherFromFetch(envFile);
281
+ }
282
+ } else if (cluster.isWorker) {
283
+ process.on('message', (message) => {
284
+ if ('env_update' in message) {
285
+ reloadEnvs(message.env_update);
286
+ }
287
+ });
288
+ }
289
+ }
api/src/core/itunnel.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import stream from "../stream/stream.js";
2
+ import { getInternalTunnel } from "../stream/manage.js";
3
+ import { setTunnelPort } from "../config.js";
4
+ import { Green } from "../misc/console-text.js";
5
+ import express from "express";
6
+
7
+ const validateTunnel = (req, res) => {
8
+ if (!req.ip.endsWith('127.0.0.1')) {
9
+ res.sendStatus(403);
10
+ return;
11
+ }
12
+
13
+ if (String(req.query.id).length !== 21) {
14
+ res.sendStatus(400);
15
+ return;
16
+ }
17
+
18
+ const streamInfo = getInternalTunnel(req.query.id);
19
+ if (!streamInfo) {
20
+ res.sendStatus(404);
21
+ return;
22
+ }
23
+
24
+ return streamInfo;
25
+ }
26
+
27
+ const streamTunnel = (req, res) => {
28
+ const streamInfo = validateTunnel(req, res);
29
+ if (!streamInfo) {
30
+ return;
31
+ }
32
+
33
+ streamInfo.headers = new Map([
34
+ ...(streamInfo.headers || []),
35
+ ...Object.entries(req.headers)
36
+ ]);
37
+
38
+ return stream(res, { type: 'internal', data: streamInfo });
39
+ }
40
+
41
+ export const setupTunnelHandler = () => {
42
+ const tunnelHandler = express();
43
+
44
+ tunnelHandler.get('/itunnel', streamTunnel);
45
+
46
+ // fallback
47
+ tunnelHandler.use((_, res) => res.sendStatus(400));
48
+ // error handler
49
+ tunnelHandler.use((_, __, res, ____) => res.socket.end());
50
+
51
+
52
+ const server = tunnelHandler.listen({
53
+ port: 0,
54
+ host: '127.0.0.1',
55
+ exclusive: true
56
+ }, () => {
57
+ const { port } = server.address();
58
+ console.log(`${Green('[✓]')} internal tunnel handler running on 127.0.0.1:${port}`);
59
+ setTunnelPort(port);
60
+ });
61
+ }
api/src/misc/cluster.js CHANGED
@@ -13,7 +13,8 @@ export const supportsReusePort = async () => {
13
  server.on('error', (err) => (server.close(), reject(err)));
14
  });
15
 
16
- return true;
 
17
  } catch {
18
  return false;
19
  }
 
13
  server.on('error', (err) => (server.close(), reject(err)));
14
  });
15
 
16
+ const [major, minor] = process.versions.node.split('.').map(Number);
17
+ return major > 23 || (major === 23 && minor >= 1);
18
  } catch {
19
  return false;
20
  }
api/src/misc/file-watcher.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { EventEmitter } from 'node:events';
2
+ import * as fs from 'node:fs/promises';
3
+
4
+ export class FileWatcher extends EventEmitter {
5
+ #path;
6
+ #hasWatcher = false;
7
+ #lastChange = new Date().getTime();
8
+
9
+ constructor({ path, ...rest }) {
10
+ super(rest);
11
+ this.#path = path;
12
+ }
13
+
14
+ async #setupWatcher() {
15
+ if (this.#hasWatcher)
16
+ return;
17
+
18
+ this.#hasWatcher = true;
19
+ const watcher = fs.watch(this.#path);
20
+ for await (const _ of watcher) {
21
+ if (new Date() - this.#lastChange > 50) {
22
+ this.emit('file-updated');
23
+ this.#lastChange = new Date().getTime();
24
+ }
25
+ }
26
+ }
27
+
28
+ read() {
29
+ this.#setupWatcher();
30
+ return fs.readFile(this.#path, 'utf8');
31
+ }
32
+
33
+ static fromFileProtocol(url_) {
34
+ const url = new URL(url_);
35
+ if (url.protocol !== 'file:') {
36
+ return;
37
+ }
38
+
39
+ const pathname = url.pathname === '/' ? '' : url.pathname;
40
+ const file_path = decodeURIComponent(url.host + pathname);
41
+ return new this({ path: file_path });
42
+ }
43
+ }
api/src/misc/language-codes.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt
2
+ const iso639_1to2 = {
3
+ 'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi',
4
+ 'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm',
5
+ 'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak',
6
+ 'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis',
7
+ 'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat',
8
+ 'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv',
9
+ 'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan',
10
+ 'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo',
11
+ 'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin',
12
+ 'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu',
13
+ 'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell',
14
+ 'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb',
15
+ 'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun',
16
+ 'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku',
17
+ 'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita',
18
+ 'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas',
19
+ 'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin',
20
+ 'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua',
21
+ 'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim',
22
+ 'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug',
23
+ 'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar',
24
+ 'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau',
25
+ 'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep',
26
+ 'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci',
27
+ 'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan',
28
+ 'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus',
29
+ 'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus',
30
+ 'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv',
31
+ 'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som',
32
+ 'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw',
33
+ 'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam',
34
+ 'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha',
35
+ 'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso',
36
+ 'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr',
37
+ 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol',
38
+ 'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid',
39
+ 'yo': 'yor', 'za': 'zha', 'zu': 'zul',
40
+ }
41
+
42
+ const iso639_2to1 = Object.fromEntries(
43
+ Object.entries(iso639_1to2).map(([k, v]) => [v, k])
44
+ );
45
+
46
+ const maps = {
47
+ 2: iso639_1to2,
48
+ 3: iso639_2to1,
49
+ }
50
+
51
+ export const convertLanguageCode = (code) => {
52
+ code = code?.split("-")[0]?.split("_")[0] || "";
53
+ return maps[code.length]?.[code.toLowerCase()] || null;
54
+ }
api/src/misc/utils.js CHANGED
@@ -1,4 +1,4 @@
1
- import { request } from 'undici';
2
  const redirectStatuses = new Set([301, 302, 303, 307, 308]);
3
 
4
  export async function getRedirectingURL(url, dispatcher, headers) {
@@ -8,18 +8,34 @@ export async function getRedirectingURL(url, dispatcher, headers) {
8
  headers,
9
  redirect: 'manual'
10
  };
 
 
 
 
11
 
12
- let location = await request(url, params).then(r => {
13
  if (redirectStatuses.has(r.statusCode) && r.headers['location']) {
14
  return r.headers['location'];
15
  }
16
- }).catch(() => null);
17
 
18
- location ??= await fetch(url, params).then(r => {
19
- if (redirectStatuses.has(r.status) && r.headers.has('location')) {
20
- return r.headers.get('location');
21
- }
22
- }).catch(() => null);
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  return location;
25
  }
@@ -52,3 +68,12 @@ export function splitFilenameExtension(filename) {
52
  export function zip(a, b) {
53
  return a.map((value, i) => [ value, b[i] ]);
54
  }
 
 
 
 
 
 
 
 
 
 
1
+ import { request } from "undici";
2
  const redirectStatuses = new Set([301, 302, 303, 307, 308]);
3
 
4
  export async function getRedirectingURL(url, dispatcher, headers) {
 
8
  headers,
9
  redirect: 'manual'
10
  };
11
+ const getParams = {
12
+ ...params,
13
+ method: 'GET',
14
+ };
15
 
16
+ const callback = (r) => {
17
  if (redirectStatuses.has(r.statusCode) && r.headers['location']) {
18
  return r.headers['location'];
19
  }
20
+ }
21
 
22
+ /*
23
+ try request() with HEAD & GET,
24
+ then do the same with fetch
25
+ (fetch is required for shortened reddit links)
26
+ */
27
+
28
+ let location = await request(url, params)
29
+ .then(callback).catch(() => null);
30
+
31
+ location ??= await request(url, getParams)
32
+ .then(callback).catch(() => null);
33
+
34
+ location ??= await fetch(url, params)
35
+ .then(callback).catch(() => null);
36
+
37
+ location ??= await fetch(url, getParams)
38
+ .then(callback).catch(() => null);
39
 
40
  return location;
41
  }
 
68
  export function zip(a, b) {
69
  return a.map((value, i) => [ value, b[i] ]);
70
  }
71
+
72
+ export function isURL(input) {
73
+ try {
74
+ new URL(input);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
api/src/processing/cookie/manager.js CHANGED
@@ -13,6 +13,7 @@ const VALID_SERVICES = new Set([
13
  'reddit',
14
  'twitter',
15
  'youtube',
 
16
  ]);
17
 
18
  const invalidCookies = {};
 
13
  'reddit',
14
  'twitter',
15
  'youtube',
16
+ 'vimeo_bearer',
17
  ]);
18
 
19
  const invalidCookies = {};
api/src/processing/create-filename.js CHANGED
@@ -1,10 +1,25 @@
1
- const illegalCharacters = ['}', '{', '%', '>', '<', '^', ';', ':', '`', '$', '"', "@", '=', '?', '|', '*'];
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- const sanitizeString = (string) => {
4
- for (const i in illegalCharacters) {
5
- string = string.replaceAll("/", "_").replaceAll("\\", "_")
6
- .replaceAll(illegalCharacters[i], '')
 
 
7
  }
 
8
  return string;
9
  }
10
 
 
1
+ // characters that are disallowed on windows:
2
+ // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
3
+ const characterMap = {
4
+ '<': '<',
5
+ '>': '>',
6
+ ':': ':',
7
+ '"': '"',
8
+ '/': '/',
9
+ '\\': '\',
10
+ '|': '|',
11
+ '?': '?',
12
+ '*': '*'
13
+ };
14
 
15
+ export const sanitizeString = (string) => {
16
+ // remove any potential control characters the string might contain
17
+ string = string.replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
18
+
19
+ for (const [ char, replacement ] of Object.entries(characterMap)) {
20
+ string = string.replaceAll(char, replacement);
21
  }
22
+
23
  return string;
24
  }
25
 
api/src/processing/match-action.js CHANGED
@@ -4,8 +4,24 @@ import { createResponse } from "./request.js";
4
  import { audioIgnore } from "./service-config.js";
5
  import { createStream } from "../stream/manage.js";
6
  import { splitFilenameExtension } from "../misc/utils.js";
 
7
 
8
- export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  let action,
10
  responseType = "tunnel",
11
  defaultParams = {
@@ -16,13 +32,16 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
16
  createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
17
  fileMetadata: !disableMetadata ? r.fileMetadata : false,
18
  requestIP,
19
- originalRequest: r.originalRequest
 
 
 
20
  },
21
  params = {};
22
 
23
  if (r.isPhoto) action = "photo";
24
  else if (r.picker) action = "picker"
25
- else if (r.isGif && twitterGif) action = "gif";
26
  else if (isAudioOnly) action = "audio";
27
  else if (isAudioMuted) action = "muteVideo";
28
  else if (r.isHLS) action = "hls";
@@ -128,7 +147,9 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
128
 
129
  case "vimeo":
130
  if (Array.isArray(r.urls)) {
131
- params = { type: "merge" }
 
 
132
  } else {
133
  responseType = "redirect";
134
  }
@@ -142,10 +163,24 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
142
  }
143
  break;
144
 
145
- case "ok":
 
 
 
 
 
 
 
146
  case "vk":
147
  case "tiktok":
 
 
 
 
 
 
148
  case "xiaohongshu":
 
149
  params = { type: "proxy" };
150
  break;
151
 
@@ -155,7 +190,6 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
155
  case "pinterest":
156
  case "streamable":
157
  case "snapchat":
158
- case "loom":
159
  case "twitch":
160
  responseType = "redirect";
161
  break;
@@ -163,7 +197,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
163
  break;
164
 
165
  case "audio":
166
- if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
167
  return createResponse("error", {
168
  code: "error.api.service.audio_not_supported"
169
  })
@@ -211,10 +245,39 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
211
  defaultParams.filename += `.${audioFormat}`;
212
  }
213
 
 
214
  if (alwaysProxy && responseType === "redirect") {
215
  responseType = "tunnel";
216
  params.type = "proxy";
217
  }
218
 
219
- return createResponse(responseType, {...defaultParams, ...params})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  }
 
4
  import { audioIgnore } from "./service-config.js";
5
  import { createStream } from "../stream/manage.js";
6
  import { splitFilenameExtension } from "../misc/utils.js";
7
+ import { convertLanguageCode } from "../misc/language-codes.js";
8
 
9
+ const extraProcessingTypes = new Set(["merge", "remux", "mute", "audio", "gif"]);
10
+
11
+ export default function({
12
+ r,
13
+ host,
14
+ audioFormat,
15
+ isAudioOnly,
16
+ isAudioMuted,
17
+ disableMetadata,
18
+ filenameStyle,
19
+ convertGif,
20
+ requestIP,
21
+ audioBitrate,
22
+ alwaysProxy,
23
+ localProcessing,
24
+ }) {
25
  let action,
26
  responseType = "tunnel",
27
  defaultParams = {
 
32
  createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
33
  fileMetadata: !disableMetadata ? r.fileMetadata : false,
34
  requestIP,
35
+ originalRequest: r.originalRequest,
36
+ subtitles: r.subtitles,
37
+ cover: !disableMetadata ? r.cover : false,
38
+ cropCover: !disableMetadata ? r.cropCover : false,
39
  },
40
  params = {};
41
 
42
  if (r.isPhoto) action = "photo";
43
  else if (r.picker) action = "picker"
44
+ else if (r.isGif && convertGif) action = "gif";
45
  else if (isAudioOnly) action = "audio";
46
  else if (isAudioMuted) action = "muteVideo";
47
  else if (r.isHLS) action = "hls";
 
147
 
148
  case "vimeo":
149
  if (Array.isArray(r.urls)) {
150
+ params = { type: "merge" };
151
+ } else if (r.subtitles) {
152
+ params = { type: "remux" };
153
  } else {
154
  responseType = "redirect";
155
  }
 
163
  }
164
  break;
165
 
166
+ case "loom":
167
+ if (r.subtitles) {
168
+ params = { type: "remux" };
169
+ } else {
170
+ responseType = "redirect";
171
+ }
172
+ break;
173
+
174
  case "vk":
175
  case "tiktok":
176
+ params = {
177
+ type: r.subtitles ? "remux" : "proxy"
178
+ };
179
+ break;
180
+
181
+ case "ok":
182
  case "xiaohongshu":
183
+ case "newgrounds":
184
  params = { type: "proxy" };
185
  break;
186
 
 
190
  case "pinterest":
191
  case "streamable":
192
  case "snapchat":
 
193
  case "twitch":
194
  responseType = "redirect";
195
  break;
 
197
  break;
198
 
199
  case "audio":
200
+ if (audioIgnore.has(host) || (host === "reddit" && r.typeId === "redirect")) {
201
  return createResponse("error", {
202
  code: "error.api.service.audio_not_supported"
203
  })
 
245
  defaultParams.filename += `.${audioFormat}`;
246
  }
247
 
248
+ // alwaysProxy is set to true in match.js if localProcessing is forced
249
  if (alwaysProxy && responseType === "redirect") {
250
  responseType = "tunnel";
251
  params.type = "proxy";
252
  }
253
 
254
+ // TODO: add support for HLS
255
+ // (very painful)
256
+ if (!params.isHLS && responseType !== "picker") {
257
+ const isPreferredWithExtra =
258
+ localProcessing === "preferred" && extraProcessingTypes.has(params.type);
259
+
260
+ if (localProcessing === "forced" || isPreferredWithExtra) {
261
+ responseType = "local-processing";
262
+ }
263
+ }
264
+
265
+ // extractors usually return ISO 639-1 language codes,
266
+ // but video players expect ISO 639-2, so we convert them here
267
+ const sublanguage = defaultParams.fileMetadata?.sublanguage;
268
+ if (sublanguage && sublanguage.length !== 3) {
269
+ const code = convertLanguageCode(sublanguage);
270
+ if (code) {
271
+ defaultParams.fileMetadata.sublanguage = code;
272
+ } else {
273
+ // if a language code couldn't be converted,
274
+ // then we don't want it at all
275
+ delete defaultParams.fileMetadata.sublanguage;
276
+ }
277
+ }
278
+
279
+ return createResponse(
280
+ responseType,
281
+ { ...defaultParams, ...params }
282
+ );
283
  }
api/src/processing/match.js CHANGED
@@ -29,10 +29,11 @@ import loom from "./services/loom.js";
29
  import facebook from "./services/facebook.js";
30
  import bluesky from "./services/bluesky.js";
31
  import xiaohongshu from "./services/xiaohongshu.js";
 
32
 
33
  let freebind;
34
 
35
- export default async function({ host, patternMatch, params }) {
36
  const { url } = params;
37
  assert(url instanceof URL);
38
  let dispatcher, requestIP;
@@ -65,14 +66,26 @@ export default async function({ host, patternMatch, params }) {
65
  });
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
68
  switch (host) {
69
  case "twitter":
70
  r = await twitter({
71
  id: patternMatch.id,
72
  index: patternMatch.index - 1,
73
- toGif: !!params.twitterGif,
74
  alwaysProxy: params.alwaysProxy,
75
- dispatcher
 
76
  });
77
  break;
78
 
@@ -81,7 +94,8 @@ export default async function({ host, patternMatch, params }) {
81
  ownerId: patternMatch.ownerId,
82
  videoId: patternMatch.videoId,
83
  accessKey: patternMatch.accessKey,
84
- quality: params.videoQuality
 
85
  });
86
  break;
87
 
@@ -101,18 +115,24 @@ export default async function({ host, patternMatch, params }) {
101
  dispatcher,
102
  id: patternMatch.id.slice(0, 11),
103
  quality: params.videoQuality,
104
- format: params.youtubeVideoCodec,
 
105
  isAudioOnly,
106
  isAudioMuted,
107
  dubLang: params.youtubeDubLang,
108
- youtubeHLS: params.youtubeHLS,
 
109
  }
110
 
111
  if (url.hostname === "music.youtube.com" || isAudioOnly) {
112
  fetchInfo.quality = "1080";
113
- fetchInfo.format = "vp9";
114
  fetchInfo.isAudioOnly = true;
115
  fetchInfo.isAudioMuted = false;
 
 
 
 
116
  }
117
 
118
  r = await youtube(fetchInfo);
@@ -131,8 +151,9 @@ export default async function({ host, patternMatch, params }) {
131
  shortLink: patternMatch.shortLink,
132
  fullAudio: params.tiktokFullAudio,
133
  isAudioOnly,
134
- h265: params.tiktokH265,
135
  alwaysProxy: params.alwaysProxy,
 
136
  });
137
  break;
138
 
@@ -150,6 +171,7 @@ export default async function({ host, patternMatch, params }) {
150
  password: patternMatch.password,
151
  quality: params.videoQuality,
152
  isAudioOnly,
 
153
  });
154
  break;
155
 
@@ -157,12 +179,8 @@ export default async function({ host, patternMatch, params }) {
157
  isAudioOnly = true;
158
  isAudioMuted = false;
159
  r = await soundcloud({
160
- url,
161
- author: patternMatch.author,
162
- song: patternMatch.song,
163
  format: params.audioFormat,
164
- shortLink: patternMatch.shortLink || false,
165
- accessKey: patternMatch.accessKey || false
166
  });
167
  break;
168
 
@@ -205,6 +223,7 @@ export default async function({ host, patternMatch, params }) {
205
  key: patternMatch.key,
206
  quality: params.videoQuality,
207
  isAudioOnly,
 
208
  });
209
  break;
210
 
@@ -221,7 +240,8 @@ export default async function({ host, patternMatch, params }) {
221
 
222
  case "loom":
223
  r = await loom({
224
- id: patternMatch.id
 
225
  });
226
  break;
227
 
@@ -243,12 +263,19 @@ export default async function({ host, patternMatch, params }) {
243
  case "xiaohongshu":
244
  r = await xiaohongshu({
245
  ...patternMatch,
246
- h265: params.tiktokH265,
247
  isAudioOnly,
248
  dispatcher,
249
  });
250
  break;
251
 
 
 
 
 
 
 
 
252
  default:
253
  return createResponse("error", {
254
  code: "error.api.service.unsupported"
@@ -271,7 +298,7 @@ export default async function({ host, patternMatch, params }) {
271
  switch(r.error) {
272
  case "content.too_long":
273
  context = {
274
- limit: env.durationLimit / 60,
275
  }
276
  break;
277
 
@@ -292,6 +319,15 @@ export default async function({ host, patternMatch, params }) {
292
  })
293
  }
294
 
 
 
 
 
 
 
 
 
 
295
  return matchAction({
296
  r,
297
  host,
@@ -300,10 +336,11 @@ export default async function({ host, patternMatch, params }) {
300
  isAudioMuted,
301
  disableMetadata: params.disableMetadata,
302
  filenameStyle: params.filenameStyle,
303
- twitterGif: params.twitterGif,
304
  requestIP,
305
  audioBitrate: params.audioBitrate,
306
- alwaysProxy: params.alwaysProxy,
 
307
  })
308
  } catch {
309
  return createResponse("error", {
 
29
  import facebook from "./services/facebook.js";
30
  import bluesky from "./services/bluesky.js";
31
  import xiaohongshu from "./services/xiaohongshu.js";
32
+ import newgrounds from "./services/newgrounds.js";
33
 
34
  let freebind;
35
 
36
+ export default async function({ host, patternMatch, params, authType }) {
37
  const { url } = params;
38
  assert(url instanceof URL);
39
  let dispatcher, requestIP;
 
66
  });
67
  }
68
 
69
+ // youtubeHLS will be fully removed in the future
70
+ let youtubeHLS = params.youtubeHLS;
71
+ const hlsEnv = env.enableDeprecatedYoutubeHls;
72
+
73
+ if (hlsEnv === "never" || (hlsEnv === "key" && authType !== "key")) {
74
+ youtubeHLS = false;
75
+ }
76
+
77
+ const subtitleLang =
78
+ params.subtitleLang !== "none" ? params.subtitleLang : undefined;
79
+
80
  switch (host) {
81
  case "twitter":
82
  r = await twitter({
83
  id: patternMatch.id,
84
  index: patternMatch.index - 1,
85
+ toGif: !!params.convertGif,
86
  alwaysProxy: params.alwaysProxy,
87
+ dispatcher,
88
+ subtitleLang
89
  });
90
  break;
91
 
 
94
  ownerId: patternMatch.ownerId,
95
  videoId: patternMatch.videoId,
96
  accessKey: patternMatch.accessKey,
97
+ quality: params.videoQuality,
98
+ subtitleLang,
99
  });
100
  break;
101
 
 
115
  dispatcher,
116
  id: patternMatch.id.slice(0, 11),
117
  quality: params.videoQuality,
118
+ codec: params.youtubeVideoCodec,
119
+ container: params.youtubeVideoContainer,
120
  isAudioOnly,
121
  isAudioMuted,
122
  dubLang: params.youtubeDubLang,
123
+ youtubeHLS,
124
+ subtitleLang,
125
  }
126
 
127
  if (url.hostname === "music.youtube.com" || isAudioOnly) {
128
  fetchInfo.quality = "1080";
129
+ fetchInfo.codec = "vp9";
130
  fetchInfo.isAudioOnly = true;
131
  fetchInfo.isAudioMuted = false;
132
+
133
+ if (env.ytAllowBetterAudio && params.youtubeBetterAudio) {
134
+ fetchInfo.quality = "max";
135
+ }
136
  }
137
 
138
  r = await youtube(fetchInfo);
 
151
  shortLink: patternMatch.shortLink,
152
  fullAudio: params.tiktokFullAudio,
153
  isAudioOnly,
154
+ h265: params.allowH265,
155
  alwaysProxy: params.alwaysProxy,
156
+ subtitleLang,
157
  });
158
  break;
159
 
 
171
  password: patternMatch.password,
172
  quality: params.videoQuality,
173
  isAudioOnly,
174
+ subtitleLang,
175
  });
176
  break;
177
 
 
179
  isAudioOnly = true;
180
  isAudioMuted = false;
181
  r = await soundcloud({
182
+ ...patternMatch,
 
 
183
  format: params.audioFormat,
 
 
184
  });
185
  break;
186
 
 
223
  key: patternMatch.key,
224
  quality: params.videoQuality,
225
  isAudioOnly,
226
+ subtitleLang,
227
  });
228
  break;
229
 
 
240
 
241
  case "loom":
242
  r = await loom({
243
+ id: patternMatch.id,
244
+ subtitleLang,
245
  });
246
  break;
247
 
 
263
  case "xiaohongshu":
264
  r = await xiaohongshu({
265
  ...patternMatch,
266
+ h265: params.allowH265,
267
  isAudioOnly,
268
  dispatcher,
269
  });
270
  break;
271
 
272
+ case "newgrounds":
273
+ r = await newgrounds({
274
+ ...patternMatch,
275
+ quality: params.videoQuality,
276
+ });
277
+ break;
278
+
279
  default:
280
  return createResponse("error", {
281
  code: "error.api.service.unsupported"
 
298
  switch(r.error) {
299
  case "content.too_long":
300
  context = {
301
+ limit: parseFloat((env.durationLimit / 60).toFixed(2)),
302
  }
303
  break;
304
 
 
319
  })
320
  }
321
 
322
+ let localProcessing = params.localProcessing;
323
+ const lpEnv = env.forceLocalProcessing;
324
+ const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session");
325
+ const localDisabled = (!localProcessing || localProcessing === "disabled");
326
+
327
+ if (shouldForceLocal && localDisabled) {
328
+ localProcessing = "preferred";
329
+ }
330
+
331
  return matchAction({
332
  r,
333
  host,
 
336
  isAudioMuted,
337
  disableMetadata: params.disableMetadata,
338
  filenameStyle: params.filenameStyle,
339
+ convertGif: params.convertGif,
340
  requestIP,
341
  audioBitrate: params.audioBitrate,
342
+ alwaysProxy: params.alwaysProxy || localProcessing === "forced",
343
+ localProcessing,
344
  })
345
  } catch {
346
  return createResponse("error", {
api/src/processing/request.js CHANGED
@@ -1,7 +1,8 @@
 
1
  import ipaddr from "ipaddr.js";
2
 
3
- import { createStream } from "../stream/manage.js";
4
  import { apiSchema } from "./schema.js";
 
5
 
6
  export function createResponse(responseType, responseData) {
7
  const internalError = (code) => {
@@ -10,7 +11,7 @@ export function createResponse(responseType, responseData) {
10
  body: {
11
  status: "error",
12
  error: {
13
- code: code || "error.api.fetch.critical",
14
  },
15
  critical: true
16
  }
@@ -49,6 +50,44 @@ export function createResponse(responseType, responseData) {
49
  }
50
  break;
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  case "picker":
53
  response = {
54
  picker: responseData?.picker,
@@ -72,11 +111,16 @@ export function createResponse(responseType, responseData) {
72
  }
73
  }
74
  } catch {
75
- return internalError()
76
  }
77
  }
78
 
79
  export function normalizeRequest(request) {
 
 
 
 
 
80
  return apiSchema.safeParseAsync(request).catch(() => (
81
  { success: false }
82
  ));
 
1
+ import mime from "mime";
2
  import ipaddr from "ipaddr.js";
3
 
 
4
  import { apiSchema } from "./schema.js";
5
+ import { createProxyTunnels, createStream } from "../stream/manage.js";
6
 
7
  export function createResponse(responseType, responseData) {
8
  const internalError = (code) => {
 
11
  body: {
12
  status: "error",
13
  error: {
14
+ code: code || "error.api.fetch.critical.core",
15
  },
16
  critical: true
17
  }
 
50
  }
51
  break;
52
 
53
+ case "local-processing":
54
+ response = {
55
+ type: responseData?.type,
56
+ service: responseData?.service,
57
+ tunnel: createProxyTunnels(responseData),
58
+
59
+ output: {
60
+ type: mime.getType(responseData?.filename) || undefined,
61
+ filename: responseData?.filename,
62
+ metadata: responseData?.fileMetadata || undefined,
63
+ subtitles: !!responseData?.subtitles || undefined,
64
+ },
65
+
66
+ audio: {
67
+ copy: responseData?.audioCopy,
68
+ format: responseData?.audioFormat,
69
+ bitrate: responseData?.audioBitrate,
70
+ cover: !!responseData?.cover || undefined,
71
+ cropCover: !!responseData?.cropCover || undefined,
72
+ },
73
+
74
+ isHLS: responseData?.isHLS,
75
+ }
76
+
77
+ if (!response.audio.format) {
78
+ if (response.type === "audio") {
79
+ // audio response without a format is invalid
80
+ return internalError();
81
+ }
82
+ delete response.audio;
83
+ }
84
+
85
+ if (!response.output.type || !response.output.filename) {
86
+ // response without a type or filename is invalid
87
+ return internalError();
88
+ }
89
+ break;
90
+
91
  case "picker":
92
  response = {
93
  picker: responseData?.picker,
 
111
  }
112
  }
113
  } catch {
114
+ return internalError();
115
  }
116
  }
117
 
118
  export function normalizeRequest(request) {
119
+ // TODO: remove after backwards compatibility period
120
+ if ("localProcessing" in request && typeof request.localProcessing === "boolean") {
121
+ request.localProcessing = request.localProcessing ? "preferred" : "disabled";
122
+ }
123
+
124
  return apiSchema.safeParseAsync(request).catch(() => (
125
  { success: false }
126
  ));
api/src/processing/schema.js CHANGED
@@ -20,32 +20,45 @@ export const apiSchema = z.object({
20
 
21
  filenameStyle: z.enum(
22
  ["classic", "pretty", "basic", "nerdy"]
23
- ).default("classic"),
24
 
25
  youtubeVideoCodec: z.enum(
26
  ["h264", "av1", "vp9"]
27
  ).default("h264"),
28
 
 
 
 
 
29
  videoQuality: z.enum(
30
  ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
31
  ).default("1080"),
32
 
 
 
 
 
33
  youtubeDubLang: z.string()
34
  .min(2)
35
  .max(8)
36
  .regex(/^[0-9a-zA-Z\-]+$/)
37
  .optional(),
38
 
39
- // TODO: remove this variable as it's no longer used
40
- // and is kept for schema compatibility reasons
41
- youtubeDubBrowserLang: z.boolean().default(false),
 
 
42
 
43
- alwaysProxy: z.boolean().default(false),
44
  disableMetadata: z.boolean().default(false),
 
 
 
45
  tiktokFullAudio: z.boolean().default(false),
46
- tiktokH265: z.boolean().default(false),
47
- twitterGif: z.boolean().default(true),
48
 
49
  youtubeHLS: z.boolean().default(false),
 
50
  })
51
  .strict();
 
20
 
21
  filenameStyle: z.enum(
22
  ["classic", "pretty", "basic", "nerdy"]
23
+ ).default("basic"),
24
 
25
  youtubeVideoCodec: z.enum(
26
  ["h264", "av1", "vp9"]
27
  ).default("h264"),
28
 
29
+ youtubeVideoContainer: z.enum(
30
+ ["auto", "mp4", "webm", "mkv"]
31
+ ).default("auto"),
32
+
33
  videoQuality: z.enum(
34
  ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
35
  ).default("1080"),
36
 
37
+ localProcessing: z.enum(
38
+ ["disabled", "preferred", "forced"]
39
+ ).default("disabled"),
40
+
41
  youtubeDubLang: z.string()
42
  .min(2)
43
  .max(8)
44
  .regex(/^[0-9a-zA-Z\-]+$/)
45
  .optional(),
46
 
47
+ subtitleLang: z.string()
48
+ .min(2)
49
+ .max(8)
50
+ .regex(/^[0-9a-zA-Z\-]+$/)
51
+ .optional(),
52
 
 
53
  disableMetadata: z.boolean().default(false),
54
+
55
+ allowH265: z.boolean().default(false),
56
+ convertGif: z.boolean().default(true),
57
  tiktokFullAudio: z.boolean().default(false),
58
+
59
+ alwaysProxy: z.boolean().default(false),
60
 
61
  youtubeHLS: z.boolean().default(false),
62
+ youtubeBetterAudio: z.boolean().default(false),
63
  })
64
  .strict();
api/src/processing/service-alias.js CHANGED
@@ -1,5 +1,6 @@
1
  const friendlyNames = {
2
  bsky: "bluesky",
 
3
  }
4
 
5
  export const friendlyServiceName = (service) => {
 
1
  const friendlyNames = {
2
  bsky: "bluesky",
3
+ twitch: "twitch clips"
4
  }
5
 
6
  export const friendlyServiceName = (service) => {
api/src/processing/service-config.js CHANGED
@@ -1,12 +1,13 @@
1
  import UrlPattern from "url-pattern";
2
 
3
- export const audioIgnore = ["vk", "ok", "loom"];
4
- export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"];
5
 
6
  export const services = {
7
  bilibili: {
8
  patterns: [
9
  "video/:comId",
 
10
  "_shortLink/:comShortLink",
11
  "_tv/:lang/video/:tvId",
12
  "_tv/video/:tvId"
@@ -74,6 +75,12 @@ export const services = {
74
  "url_shortener/:shortLink"
75
  ],
76
  },
 
 
 
 
 
 
77
  reddit: {
78
  patterns: [
79
  "comments/:id",
@@ -116,6 +123,7 @@ export const services = {
116
  "add/:username",
117
  "u/:username",
118
  "t/:shortLink",
 
119
  ],
120
  subdomains: ["t", "story"],
121
  },
@@ -158,6 +166,7 @@ export const services = {
158
  twitch: {
159
  patterns: [":channel/clip/:clip"],
160
  tld: "tv",
 
161
  },
162
  twitter: {
163
  patterns: [
@@ -176,7 +185,8 @@ export const services = {
176
  ":id",
177
  "video/:id",
178
  ":id/:password",
179
- "/channels/:user/:id"
 
180
  ],
181
  subdomains: ["player"],
182
  },
@@ -184,12 +194,13 @@ export const services = {
184
  patterns: [
185
  "video:ownerId_:videoId",
186
  "clip:ownerId_:videoId",
187
- "clips:duplicate?z=clip:ownerId_:videoId",
188
- "videos:duplicate?z=video:ownerId_:videoId",
189
  "video:ownerId_:videoId_:accessKey",
190
  "clip:ownerId_:videoId_:accessKey",
191
- "clips:duplicate?z=clip:ownerId_:videoId_:accessKey",
192
- "videos:duplicate?z=video:ownerId_:videoId_:accessKey"
 
 
 
193
  ],
194
  subdomains: ["m"],
195
  altDomains: ["vkvideo.ru", "vk.ru"],
@@ -198,7 +209,7 @@ export const services = {
198
  patterns: [
199
  "explore/:id?xsec_token=:token",
200
  "discovery/item/:id?xsec_token=:token",
201
- "a/:shareId"
202
  ],
203
  altDomains: ["xhslink.com"],
204
  },
@@ -206,7 +217,8 @@ export const services = {
206
  patterns: [
207
  "watch?v=:id",
208
  "embed/:id",
209
- "watch/:id"
 
210
  ],
211
  subdomains: ["music", "m"],
212
  }
 
1
  import UrlPattern from "url-pattern";
2
 
3
+ export const audioIgnore = new Set(["vk", "ok", "loom"]);
4
+ export const hlsExceptions = new Set(["dailymotion", "vimeo", "rutube", "bsky", "youtube"]);
5
 
6
  export const services = {
7
  bilibili: {
8
  patterns: [
9
  "video/:comId",
10
+ "video/:comId?p=:partId",
11
  "_shortLink/:comShortLink",
12
  "_tv/:lang/video/:tvId",
13
  "_tv/video/:tvId"
 
75
  "url_shortener/:shortLink"
76
  ],
77
  },
78
+ newgrounds: {
79
+ patterns: [
80
+ "portal/view/:id",
81
+ "audio/listen/:audioId",
82
+ ]
83
+ },
84
  reddit: {
85
  patterns: [
86
  "comments/:id",
 
123
  "add/:username",
124
  "u/:username",
125
  "t/:shortLink",
126
+ "o/:spotlightId",
127
  ],
128
  subdomains: ["t", "story"],
129
  },
 
166
  twitch: {
167
  patterns: [":channel/clip/:clip"],
168
  tld: "tv",
169
+ subdomains: ["clips", "www", "m"],
170
  },
171
  twitter: {
172
  patterns: [
 
185
  ":id",
186
  "video/:id",
187
  ":id/:password",
188
+ "/channels/:user/:id",
189
+ "groups/:groupId/videos/:id"
190
  ],
191
  subdomains: ["player"],
192
  },
 
194
  patterns: [
195
  "video:ownerId_:videoId",
196
  "clip:ownerId_:videoId",
 
 
197
  "video:ownerId_:videoId_:accessKey",
198
  "clip:ownerId_:videoId_:accessKey",
199
+
200
+ // links with a duplicate author id and/or zipper query param
201
+ "clips:duplicateId",
202
+ "videos:duplicateId",
203
+ "search/video"
204
  ],
205
  subdomains: ["m"],
206
  altDomains: ["vkvideo.ru", "vk.ru"],
 
209
  patterns: [
210
  "explore/:id?xsec_token=:token",
211
  "discovery/item/:id?xsec_token=:token",
212
+ ":shareType/:shareId",
213
  ],
214
  altDomains: ["xhslink.com"],
215
  },
 
217
  patterns: [
218
  "watch?v=:id",
219
  "embed/:id",
220
+ "watch/:id",
221
+ "v/:id"
222
  ],
223
  subdomains: ["music", "m"],
224
  }
api/src/processing/service-patterns.js CHANGED
@@ -1,53 +1,72 @@
1
  export const testers = {
2
  "bilibili": pattern =>
3
- pattern.comId?.length <= 12 || pattern.comShortLink?.length <= 16
4
- || pattern.tvId?.length <= 24,
 
 
 
 
 
5
 
6
  "dailymotion": pattern => pattern.id?.length <= 32,
7
 
 
 
 
 
 
 
 
8
  "instagram": pattern =>
9
- pattern.postId?.length <= 48
10
- || pattern.shareId?.length <= 16
11
- || (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
12
 
13
  "loom": pattern =>
14
  pattern.id?.length <= 32,
15
 
 
 
 
 
16
  "ok": pattern =>
17
  pattern.id?.length <= 16,
18
 
19
  "pinterest": pattern =>
20
- pattern.id?.length <= 128 || pattern.shortLink?.length <= 32,
 
21
 
22
  "reddit": pattern =>
23
- pattern.id?.length <= 16 && !pattern.sub && !pattern.user
24
- || (pattern.sub?.length <= 22 && pattern.id?.length <= 16)
25
- || (pattern.user?.length <= 22 && pattern.id?.length <= 16)
26
- || (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16)
27
- || (pattern.shortId?.length <= 16),
28
 
29
  "rutube": pattern =>
30
  (pattern.id?.length === 32 && pattern.key?.length <= 32) ||
31
- pattern.id?.length === 32 || pattern.yappyId?.length === 32,
32
-
33
- "soundcloud": pattern =>
34
- (pattern.author?.length <= 255 && pattern.song?.length <= 255)
35
- || pattern.shortLink?.length <= 32,
36
 
37
  "snapchat": pattern =>
38
- (pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255))
39
- || pattern.spotlightId?.length <= 255
40
- || pattern.shortLink?.length <= 16,
 
 
 
 
41
 
42
  "streamable": pattern =>
43
  pattern.id?.length <= 6,
44
 
45
  "tiktok": pattern =>
46
- pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13,
 
47
 
48
  "tumblr": pattern =>
49
- pattern.id?.length < 21
50
- || (pattern.id?.length < 21 && pattern.user?.length <= 32),
51
 
52
  "twitch": pattern =>
53
  pattern.channel && pattern.clip?.length <= 100,
@@ -56,27 +75,16 @@ export const testers = {
56
  pattern.id?.length < 20,
57
 
58
  "vimeo": pattern =>
59
- pattern.id?.length <= 11
60
- && (!pattern.password || pattern.password.length < 16),
61
 
62
  "vk": pattern =>
63
  (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||
64
  (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),
65
 
 
 
 
 
66
  "youtube": pattern =>
67
  pattern.id?.length <= 11,
68
-
69
- "facebook": pattern =>
70
- pattern.shortLink?.length <= 11
71
- || pattern.username?.length <= 30
72
- || pattern.caption?.length <= 255
73
- || pattern.id?.length <= 20 && !pattern.shareType
74
- || pattern.id?.length <= 20 && pattern.shareType?.length === 1,
75
-
76
- "bsky": pattern =>
77
- pattern.user?.length <= 128 && pattern.post?.length <= 128,
78
-
79
- "xiaohongshu": pattern =>
80
- pattern.id?.length <= 24 && pattern.token?.length <= 64
81
- || pattern.shareId?.length <= 12,
82
  }
 
1
  export const testers = {
2
  "bilibili": pattern =>
3
+ (pattern.comId?.length <= 12 && pattern.partId?.length <= 3) ||
4
+ (pattern.comId?.length <= 12 && !pattern.partId) ||
5
+ pattern.comShortLink?.length <= 16 ||
6
+ pattern.tvId?.length <= 24,
7
+
8
+ "bsky": pattern =>
9
+ pattern.user?.length <= 128 && pattern.post?.length <= 128,
10
 
11
  "dailymotion": pattern => pattern.id?.length <= 32,
12
 
13
+ "facebook": pattern =>
14
+ pattern.shortLink?.length <= 11 ||
15
+ pattern.username?.length <= 30 ||
16
+ pattern.caption?.length <= 255 ||
17
+ pattern.id?.length <= 20 && !pattern.shareType ||
18
+ pattern.id?.length <= 20 && pattern.shareType?.length === 1,
19
+
20
  "instagram": pattern =>
21
+ pattern.postId?.length <= 48 ||
22
+ pattern.shareId?.length <= 16 ||
23
+ (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
24
 
25
  "loom": pattern =>
26
  pattern.id?.length <= 32,
27
 
28
+ "newgrounds": pattern =>
29
+ pattern.id?.length <= 12 ||
30
+ pattern.audioId?.length <= 12,
31
+
32
  "ok": pattern =>
33
  pattern.id?.length <= 16,
34
 
35
  "pinterest": pattern =>
36
+ pattern.id?.length <= 128 ||
37
+ pattern.shortLink?.length <= 32,
38
 
39
  "reddit": pattern =>
40
+ pattern.id?.length <= 16 && !pattern.sub && !pattern.user ||
41
+ (pattern.sub?.length <= 22 && pattern.id?.length <= 16) ||
42
+ (pattern.user?.length <= 22 && pattern.id?.length <= 16) ||
43
+ (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16) ||
44
+ (pattern.shortId?.length <= 16),
45
 
46
  "rutube": pattern =>
47
  (pattern.id?.length === 32 && pattern.key?.length <= 32) ||
48
+ pattern.id?.length === 32 ||
49
+ pattern.yappyId?.length === 32,
 
 
 
50
 
51
  "snapchat": pattern =>
52
+ (pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255)) ||
53
+ pattern.spotlightId?.length <= 255 ||
54
+ pattern.shortLink?.length <= 16,
55
+
56
+ "soundcloud": pattern =>
57
+ (pattern.author?.length <= 255 && pattern.song?.length <= 255) ||
58
+ pattern.shortLink?.length <= 32,
59
 
60
  "streamable": pattern =>
61
  pattern.id?.length <= 6,
62
 
63
  "tiktok": pattern =>
64
+ pattern.postId?.length <= 21 ||
65
+ pattern.shortLink?.length <= 21,
66
 
67
  "tumblr": pattern =>
68
+ pattern.id?.length < 21 ||
69
+ (pattern.id?.length < 21 && pattern.user?.length <= 32),
70
 
71
  "twitch": pattern =>
72
  pattern.channel && pattern.clip?.length <= 100,
 
75
  pattern.id?.length < 20,
76
 
77
  "vimeo": pattern =>
78
+ pattern.id?.length <= 11 && (!pattern.password || pattern.password.length < 16),
 
79
 
80
  "vk": pattern =>
81
  (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||
82
  (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),
83
 
84
+ "xiaohongshu": pattern =>
85
+ pattern.id?.length <= 24 && pattern.token?.length <= 64 ||
86
+ pattern.shareId?.length <= 24 && pattern.shareType?.length === 1,
87
+
88
  "youtube": pattern =>
89
  pattern.id?.length <= 11,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  }
api/src/processing/services/bilibili.js CHANGED
@@ -17,8 +17,14 @@ function extractBestQuality(dashData) {
17
  return [ bestVideo, bestAudio ];
18
  }
19
 
20
- async function com_download(id) {
21
- let html = await fetch(`https://bilibili.com/video/${id}`, {
 
 
 
 
 
 
22
  headers: {
23
  "user-agent": genericUserAgent
24
  }
@@ -34,7 +40,10 @@ async function com_download(id) {
34
  return { error: "fetch.empty" };
35
  }
36
 
37
- let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
 
 
 
38
  if (streamData.data.timelength > env.durationLimit * 1000) {
39
  return { error: "content.too_long" };
40
  }
@@ -44,10 +53,15 @@ async function com_download(id) {
44
  return { error: "fetch.empty" };
45
  }
46
 
 
 
 
 
 
47
  return {
48
  urls: [video.baseUrl, audio.baseUrl],
49
- audioFilename: `bilibili_${id}_audio`,
50
- filename: `bilibili_${id}_${video.width}x${video.height}.mp4`
51
  };
52
  }
53
 
@@ -86,14 +100,14 @@ async function tv_download(id) {
86
  };
87
  }
88
 
89
- export default async function({ comId, tvId, comShortLink }) {
90
  if (comShortLink) {
91
  const patternMatch = await resolveRedirectingURL(`https://b23.tv/${comShortLink}`);
92
  comId = patternMatch?.comId;
93
  }
94
 
95
  if (comId) {
96
- return com_download(comId);
97
  } else if (tvId) {
98
  return tv_download(tvId);
99
  }
 
17
  return [ bestVideo, bestAudio ];
18
  }
19
 
20
+ async function com_download(id, partId) {
21
+ const url = new URL(`https://bilibili.com/video/${id}`);
22
+
23
+ if (partId) {
24
+ url.searchParams.set('p', partId);
25
+ }
26
+
27
+ const html = await fetch(url, {
28
  headers: {
29
  "user-agent": genericUserAgent
30
  }
 
40
  return { error: "fetch.empty" };
41
  }
42
 
43
+ const streamData = JSON.parse(
44
+ html.split('<script>window.__playinfo__=')[1].split('</script>')[0]
45
+ );
46
+
47
  if (streamData.data.timelength > env.durationLimit * 1000) {
48
  return { error: "content.too_long" };
49
  }
 
53
  return { error: "fetch.empty" };
54
  }
55
 
56
+ let filenameBase = `bilibili_${id}`;
57
+ if (partId) {
58
+ filenameBase += `_${partId}`;
59
+ }
60
+
61
  return {
62
  urls: [video.baseUrl, audio.baseUrl],
63
+ audioFilename: `${filenameBase}_audio`,
64
+ filename: `${filenameBase}_${video.width}x${video.height}.mp4`,
65
  };
66
  }
67
 
 
100
  };
101
  }
102
 
103
+ export default async function({ comId, tvId, comShortLink, partId }) {
104
  if (comShortLink) {
105
  const patternMatch = await resolveRedirectingURL(`https://b23.tv/${comShortLink}`);
106
  comId = patternMatch?.comId;
107
  }
108
 
109
  if (comId) {
110
+ return com_download(comId, partId);
111
  } else if (tvId) {
112
  return tv_download(tvId);
113
  }
api/src/processing/services/loom.js CHANGED
@@ -1,18 +1,18 @@
1
  import { genericUserAgent } from "../../config.js";
2
 
3
- export default async function({ id }) {
 
 
 
 
 
 
 
 
 
4
  const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, {
5
  method: "POST",
6
- headers: {
7
- "user-agent": genericUserAgent,
8
- origin: "https://www.loom.com",
9
- referer: `https://www.loom.com/share/${id}`,
10
- cookie: `loom_referral_video=${id};`,
11
-
12
- "apollographql-client-name": "web",
13
- "apollographql-client-version": "14c0b42",
14
- "x-loom-request-source": "loom_web_14c0b42",
15
- },
16
  body: JSON.stringify({
17
  force_original: false,
18
  password: null,
@@ -20,20 +20,89 @@ export default async function({ id }) {
20
  deviceID: null
21
  })
22
  })
23
- .then(r => r.status === 200 ? r.json() : false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  .catch(() => {});
25
 
26
- if (!gql) return { error: "fetch.empty" };
 
 
 
 
 
 
 
27
 
28
- const videoUrl = gql?.url;
 
 
29
 
30
- if (videoUrl?.includes('.mp4?')) {
31
- return {
32
- urls: videoUrl,
33
- filename: `loom_${id}.mp4`,
34
- audioFilename: `loom_${id}_audio`
35
- }
36
  }
37
 
38
- return { error: "fetch.empty" }
 
 
 
 
 
39
  }
 
1
  import { genericUserAgent } from "../../config.js";
2
 
3
+ const craftHeaders = id => ({
4
+ "user-agent": genericUserAgent,
5
+ "content-type": "application/json",
6
+ origin: "https://www.loom.com",
7
+ referer: `https://www.loom.com/share/${id}`,
8
+ cookie: `loom_referral_video=${id};`,
9
+ "x-loom-request-source": "loom_web_be851af",
10
+ });
11
+
12
+ async function fromTranscodedURL(id) {
13
  const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, {
14
  method: "POST",
15
+ headers: craftHeaders(id),
 
 
 
 
 
 
 
 
 
16
  body: JSON.stringify({
17
  force_original: false,
18
  password: null,
 
20
  deviceID: null
21
  })
22
  })
23
+ .then(r => r.status === 200 && r.json())
24
+ .catch(() => {});
25
+
26
+ if (gql?.url?.includes('.mp4?')) {
27
+ return gql.url;
28
+ }
29
+ }
30
+
31
+ async function fromRawURL(id) {
32
+ const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/raw-url`, {
33
+ method: "POST",
34
+ headers: craftHeaders(id),
35
+ body: JSON.stringify({
36
+ anonID: crypto.randomUUID(),
37
+ client_name: "web",
38
+ client_version: "be851af",
39
+ deviceID: null,
40
+ force_original: false,
41
+ password: null,
42
+ supported_mime_types: ["video/mp4"],
43
+ })
44
+ })
45
+ .then(r => r.status === 200 && r.json())
46
+ .catch(() => {});
47
+
48
+ if (gql?.url?.includes('.mp4?')) {
49
+ return gql.url;
50
+ }
51
+ }
52
+
53
+ async function getTranscript(id) {
54
+ const gql = await fetch(`https://www.loom.com/graphql`, {
55
+ method: "POST",
56
+ headers: craftHeaders(id),
57
+ body: JSON.stringify({
58
+ operationName: "FetchVideoTranscriptForFetchTranscript",
59
+ variables: {
60
+ videoId: id,
61
+ password: null,
62
+ },
63
+ query: `
64
+ query FetchVideoTranscriptForFetchTranscript($videoId: ID!, $password: String) {
65
+ fetchVideoTranscript(videoId: $videoId, password: $password) {
66
+ ... on VideoTranscriptDetails {
67
+ captions_source_url
68
+ language
69
+ __typename
70
+ }
71
+ ... on GenericError {
72
+ message
73
+ __typename
74
+ }
75
+ __typename
76
+ }
77
+ }`,
78
+ })
79
+ })
80
+ .then(r => r.status === 200 && r.json())
81
  .catch(() => {});
82
 
83
+ if (gql?.data?.fetchVideoTranscript?.captions_source_url?.includes('.vtt?')) {
84
+ return gql.data.fetchVideoTranscript.captions_source_url;
85
+ }
86
+ }
87
+
88
+ export default async function({ id, subtitleLang }) {
89
+ let url = await fromTranscodedURL(id);
90
+ url ??= await fromRawURL(id);
91
 
92
+ if (!url) {
93
+ return { error: "fetch.empty" }
94
+ }
95
 
96
+ let subtitles;
97
+ if (subtitleLang) {
98
+ const transcript = await getTranscript(id);
99
+ if (transcript) subtitles = transcript;
 
 
100
  }
101
 
102
+ return {
103
+ urls: url,
104
+ subtitles,
105
+ filename: `loom_${id}.mp4`,
106
+ audioFilename: `loom_${id}_audio`
107
+ }
108
  }
api/src/processing/services/newgrounds.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { genericUserAgent } from "../../config.js";
2
+
3
+ const getVideo = async ({ id, quality }) => {
4
+ const json = await fetch(`https://www.newgrounds.com/portal/video/${id}`, {
5
+ headers: {
6
+ "User-Agent": genericUserAgent,
7
+ "X-Requested-With": "XMLHttpRequest", // required to get the JSON response
8
+ }
9
+ })
10
+ .then(r => r.json())
11
+ .catch(() => {});
12
+
13
+ if (!json) return { error: "fetch.empty" };
14
+
15
+ const videoSources = json.sources;
16
+ const videoQualities = Object.keys(videoSources);
17
+
18
+ if (videoQualities.length === 0) {
19
+ return { error: "fetch.empty" };
20
+ }
21
+
22
+ const bestVideo = videoSources[videoQualities[0]]?.[0],
23
+ userQuality = quality === "2160" ? "4k" : `${quality}p`,
24
+ preferredVideo = videoSources[userQuality]?.[0],
25
+ video = preferredVideo || bestVideo,
26
+ videoQuality = preferredVideo ? userQuality : videoQualities[0];
27
+
28
+ if (!bestVideo || !video.type.includes("mp4")) {
29
+ return { error: "fetch.empty" };
30
+ }
31
+
32
+ const fileMetadata = {
33
+ title: decodeURIComponent(json.title),
34
+ artist: decodeURIComponent(json.author),
35
+ }
36
+
37
+ return {
38
+ urls: video.src,
39
+ filenameAttributes: {
40
+ service: "newgrounds",
41
+ id,
42
+ title: fileMetadata.title,
43
+ author: fileMetadata.artist,
44
+ extension: "mp4",
45
+ qualityLabel: videoQuality,
46
+ resolution: videoQuality,
47
+ },
48
+ fileMetadata,
49
+ }
50
+ }
51
+
52
+ const getMusic = async ({ id }) => {
53
+ const html = await fetch(`https://www.newgrounds.com/audio/listen/${id}`, {
54
+ headers: {
55
+ "User-Agent": genericUserAgent,
56
+ }
57
+ })
58
+ .then(r => r.text())
59
+ .catch(() => {});
60
+
61
+ if (!html) return { error: "fetch.fail" };
62
+
63
+ const params = JSON.parse(
64
+ `{${html.split(',"params":{')[1]?.split(',"images":')[0]}}`
65
+ );
66
+ if (!params) return { error: "fetch.empty" };
67
+
68
+ if (!params.name || !params.artist || !params.filename || !params.icon) {
69
+ return { error: "fetch.empty" };
70
+ }
71
+
72
+ const fileMetadata = {
73
+ title: decodeURIComponent(params.name),
74
+ artist: decodeURIComponent(params.artist),
75
+ }
76
+
77
+ return {
78
+ urls: params.filename,
79
+ filenameAttributes: {
80
+ service: "newgrounds",
81
+ id,
82
+ title: fileMetadata.title,
83
+ author: fileMetadata.artist,
84
+ },
85
+ fileMetadata,
86
+ cover:
87
+ params.icon.includes(".png?") || params.icon.includes(".jpg?")
88
+ ? params.icon
89
+ : undefined,
90
+ isAudioOnly: true,
91
+ bestAudio: "mp3",
92
+ }
93
+ }
94
+
95
+ export default function({ id, audioId, quality }) {
96
+ if (id) {
97
+ return getVideo({ id, quality });
98
+ } else if (audioId) {
99
+ return getMusic({ id: audioId });
100
+ }
101
+
102
+ return { error: "fetch.empty" };
103
+ }
api/src/processing/services/pinterest.js CHANGED
@@ -3,6 +3,7 @@ import { resolveRedirectingURL } from "../url.js";
3
 
4
  const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g;
5
  const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
 
6
 
7
  export default async function(o) {
8
  let id = o.id;
@@ -19,6 +20,10 @@ export default async function(o) {
19
  headers: { "user-agent": genericUserAgent }
20
  }).then(r => r.text()).catch(() => {});
21
 
 
 
 
 
22
  if (!html) return { error: "fetch.fail" };
23
 
24
  const videoLink = [...html.matchAll(videoRegex)]
 
3
 
4
  const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g;
5
  const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
6
+ const notFoundRegex = /"__typename"\s*:\s*"PinNotFound"/;
7
 
8
  export default async function(o) {
9
  let id = o.id;
 
20
  headers: { "user-agent": genericUserAgent }
21
  }).then(r => r.text()).catch(() => {});
22
 
23
+ const invalidPin = html.match(notFoundRegex);
24
+
25
+ if (invalidPin) return { error: "fetch.empty" };
26
+
27
  if (!html) return { error: "fetch.fail" };
28
 
29
  const videoLink = [...html.matchAll(videoRegex)]
api/src/processing/services/rutube.js CHANGED
@@ -65,8 +65,21 @@ export default async function(obj) {
65
  artist: play.author.name.trim(),
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  return {
69
  urls: matchingQuality.uri,
 
70
  isHLS: true,
71
  filenameAttributes: {
72
  service: "rutube",
 
65
  artist: play.author.name.trim(),
66
  }
67
 
68
+ let subtitles;
69
+ if (obj.subtitleLang && play.captions?.length) {
70
+ const subtitle = play.captions.find(
71
+ s => ["webvtt", "srt"].includes(s.format) && s.code.startsWith(obj.subtitleLang)
72
+ );
73
+
74
+ if (subtitle) {
75
+ subtitles = subtitle.file;
76
+ fileMetadata.sublanguage = obj.subtitleLang;
77
+ }
78
+ }
79
+
80
  return {
81
  urls: matchingQuality.uri,
82
+ subtitles,
83
  isHLS: true,
84
  filenameAttributes: {
85
  service: "rutube",
api/src/processing/services/snapchat.js CHANGED
@@ -102,10 +102,10 @@ export default async function (obj) {
102
  params = await resolveRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
103
  }
104
 
105
- if (params.spotlightId) {
106
  const result = await getSpotlight(params.spotlightId);
107
  if (result) return result;
108
- } else if (params.username) {
109
  const result = await getStory(params.username, params.storyId, obj.alwaysProxy);
110
  if (result) return result;
111
  }
 
102
  params = await resolveRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
103
  }
104
 
105
+ if (params?.spotlightId) {
106
  const result = await getSpotlight(params.spotlightId);
107
  if (result) return result;
108
+ } else if (params?.username) {
109
  const result = await getStory(params.username, params.storyId, obj.alwaysProxy);
110
  if (result) return result;
111
  }
api/src/processing/services/soundcloud.js CHANGED
@@ -1,4 +1,5 @@
1
  import { env } from "../../config.js";
 
2
 
3
  const cachedID = {
4
  version: '',
@@ -7,22 +8,25 @@ const cachedID = {
7
 
8
  async function findClientID() {
9
  try {
10
- let sc = await fetch('https://soundcloud.com/').then(r => r.text()).catch(() => {});
11
- let scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
12
 
13
- if (cachedID.version === scVersion) return cachedID.id;
 
 
 
 
14
 
15
- let scripts = sc.matchAll(/<script.+src="(.+)">/g);
16
  let clientid;
17
  for (let script of scripts) {
18
- let url = script[1];
19
 
20
  if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
21
  return;
22
  }
23
 
24
- let scrf = await fetch(url).then(r => r.text()).catch(() => {});
25
- let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
26
 
27
  if (id && typeof id[0] === 'string') {
28
  clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
@@ -36,47 +40,79 @@ async function findClientID() {
36
  } catch {}
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  export default async function(obj) {
40
- let clientId = await findClientID();
41
  if (!clientId) return { error: "fetch.fail" };
42
 
43
  let link;
44
- if (obj.url.hostname === 'on.soundcloud.com' && obj.shortLink) {
45
- link = await fetch(`https://on.soundcloud.com/${obj.shortLink}/`, { redirect: "manual" }).then(r => {
46
- if (r.status === 302 && r.headers.get("location").startsWith("https://soundcloud.com/")) {
47
- return r.headers.get("location").split('?', 1)[0]
48
- }
49
- }).catch(() => {});
 
 
50
  }
51
 
52
- if (!link && obj.author && obj.song) {
53
- link = `https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`
 
 
 
54
  }
55
 
56
  if (!link && obj.shortLink) return { error: "fetch.short_link" };
57
  if (!link) return { error: "link.unsupported" };
58
 
59
- let json = await fetch(`https://api-v2.soundcloud.com/resolve?url=${link}&client_id=${clientId}`)
60
- .then(r => r.status === 200 ? r.json() : false)
61
- .catch(() => {});
62
 
 
63
  if (!json) return { error: "fetch.fail" };
64
 
65
- if (json?.policy === "BLOCK") {
 
 
 
 
66
  return { error: "content.region" };
67
  }
68
 
69
- if (json?.policy === "SNIP") {
70
  return { error: "content.paid" };
71
  }
72
 
73
- if (!json?.media?.transcodings || !json?.media?.transcodings.length === 0) {
74
  return { error: "fetch.empty" };
75
  }
76
 
77
  let bestAudio = "opus",
78
- selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"),
79
- mp3Media = json.media.transcodings.find(v => v.preset === "mp3_0_0");
 
80
 
81
  // use mp3 if present if user prefers it or if opus isn't available
82
  if (mp3Media && (obj.format === "mp3" || !selectedStream)) {
@@ -88,35 +124,50 @@ export default async function(obj) {
88
  return { error: "fetch.empty" };
89
  }
90
 
91
- let fileUrlBase = selectedStream.url;
92
- let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
 
93
 
94
- if (!fileUrl.startsWith("https://api-v2.soundcloud.com/media/soundcloud:tracks:"))
95
- return { error: "fetch.empty" };
 
96
 
97
- if (json.duration > env.durationLimit * 1000) {
98
- return { error: "content.too_long" };
 
 
 
 
 
 
 
 
 
 
99
  }
100
 
101
- let file = await fetch(fileUrl)
102
- .then(async r => (await r.json()).url)
103
- .catch(() => {});
104
- if (!file) return { error: "fetch.empty" };
 
 
105
 
106
- let fileMetadata = {
107
- title: json.title.trim(),
108
- artist: json.user.username.trim(),
109
  }
110
 
111
  return {
112
- urls: file,
 
113
  filenameAttributes: {
114
  service: "soundcloud",
115
  id: json.id,
116
- title: fileMetadata.title,
117
- author: fileMetadata.artist
118
  },
119
  bestAudio,
120
- fileMetadata
 
121
  }
122
  }
 
1
  import { env } from "../../config.js";
2
+ import { resolveRedirectingURL } from "../url.js";
3
 
4
  const cachedID = {
5
  version: '',
 
8
 
9
  async function findClientID() {
10
  try {
11
+ const sc = await fetch('https://soundcloud.com/').then(r => r.text()).catch(() => {});
12
+ const scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
13
 
14
+ if (cachedID.version === scVersion) {
15
+ return cachedID.id;
16
+ }
17
+
18
+ const scripts = sc.matchAll(/<script.+src="(.+)">/g);
19
 
 
20
  let clientid;
21
  for (let script of scripts) {
22
+ const url = script[1];
23
 
24
  if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
25
  return;
26
  }
27
 
28
+ const scrf = await fetch(url).then(r => r.text()).catch(() => {});
29
+ const id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
30
 
31
  if (id && typeof id[0] === 'string') {
32
  clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
 
40
  } catch {}
41
  }
42
 
43
+ const findBestForPreset = (transcodings, preset) => {
44
+ let inferior;
45
+ for (const entry of transcodings) {
46
+ const protocol = entry?.format?.protocol;
47
+
48
+ if (entry.snipped || protocol?.includes('encrypted')) {
49
+ continue;
50
+ }
51
+
52
+ if (entry?.preset?.startsWith(`${preset}_`)) {
53
+ if (protocol === 'progressive') {
54
+ return entry;
55
+ }
56
+
57
+ inferior = entry;
58
+ }
59
+ }
60
+
61
+ return inferior;
62
+ }
63
+
64
  export default async function(obj) {
65
+ const clientId = await findClientID();
66
  if (!clientId) return { error: "fetch.fail" };
67
 
68
  let link;
69
+
70
+ if (obj.shortLink) {
71
+ obj = {
72
+ ...obj,
73
+ ...await resolveRedirectingURL(
74
+ `https://on.soundcloud.com/${obj.shortLink}`
75
+ )
76
+ }
77
  }
78
 
79
+ if (obj.author && obj.song) {
80
+ link = `https://soundcloud.com/${obj.author}/${obj.song}`;
81
+ if (obj.accessKey) {
82
+ link += `/s-${obj.accessKey}`;
83
+ }
84
  }
85
 
86
  if (!link && obj.shortLink) return { error: "fetch.short_link" };
87
  if (!link) return { error: "link.unsupported" };
88
 
89
+ const resolveURL = new URL("https://api-v2.soundcloud.com/resolve");
90
+ resolveURL.searchParams.set("url", link);
91
+ resolveURL.searchParams.set("client_id", clientId);
92
 
93
+ const json = await fetch(resolveURL).then(r => r.json()).catch(() => {});
94
  if (!json) return { error: "fetch.fail" };
95
 
96
+ if (json.duration > env.durationLimit * 1000) {
97
+ return { error: "content.too_long" };
98
+ }
99
+
100
+ if (json.policy === "BLOCK") {
101
  return { error: "content.region" };
102
  }
103
 
104
+ if (json.policy === "SNIP") {
105
  return { error: "content.paid" };
106
  }
107
 
108
+ if (!json.media?.transcodings || !json.media?.transcodings.length === 0) {
109
  return { error: "fetch.empty" };
110
  }
111
 
112
  let bestAudio = "opus",
113
+ selectedStream = findBestForPreset(json.media.transcodings, "opus");
114
+
115
+ const mp3Media = findBestForPreset(json.media.transcodings, "mp3");
116
 
117
  // use mp3 if present if user prefers it or if opus isn't available
118
  if (mp3Media && (obj.format === "mp3" || !selectedStream)) {
 
124
  return { error: "fetch.empty" };
125
  }
126
 
127
+ const fileUrl = new URL(selectedStream.url);
128
+ fileUrl.searchParams.set("client_id", clientId);
129
+ fileUrl.searchParams.set("track_authorization", json.track_authorization);
130
 
131
+ const file = await fetch(fileUrl)
132
+ .then(async r => new URL((await r.json()).url))
133
+ .catch(() => {});
134
 
135
+ if (!file) return { error: "fetch.empty" };
136
+
137
+ const artist = json.user?.username?.trim();
138
+ const fileMetadata = {
139
+ title: json.title?.trim(),
140
+ album: json.publisher_metadata?.album_title?.trim(),
141
+ artist,
142
+ album_artist: artist,
143
+ composer: json.publisher_metadata?.writer_composer?.trim(),
144
+ genre: json.genre?.trim(),
145
+ date: json.display_date?.trim().slice(0, 10),
146
+ copyright: json.license?.trim(),
147
  }
148
 
149
+ let cover;
150
+ if (json.artwork_url) {
151
+ const coverUrl = json.artwork_url.replace(/-large/, "-t1080x1080");
152
+ const testCover = await fetch(coverUrl)
153
+ .then(r => r.status === 200)
154
+ .catch(() => {});
155
 
156
+ if (testCover) {
157
+ cover = coverUrl;
158
+ }
159
  }
160
 
161
  return {
162
+ urls: file.toString(),
163
+ cover,
164
  filenameAttributes: {
165
  service: "soundcloud",
166
  id: json.id,
167
+ ...fileMetadata
 
168
  },
169
  bestAudio,
170
+ fileMetadata,
171
+ isHLS: file.pathname.endsWith('.m3u8'),
172
  }
173
  }
api/src/processing/services/tiktok.js CHANGED
@@ -4,6 +4,7 @@ import { extract, normalizeURL } from "../url.js";
4
  import { genericUserAgent } from "../../config.js";
5
  import { updateCookie } from "../cookie/manager.js";
6
  import { createStream } from "../../stream/manage.js";
 
7
 
8
  const shortDomain = "https://vt.tiktok.com/";
9
 
@@ -23,8 +24,10 @@ export default async function(obj) {
23
 
24
  if (html.startsWith('<a href="https://')) {
25
  const extractedURL = html.split('<a href="')[1].split('?')[0];
26
- const { patternMatch } = extract(normalizeURL(extractedURL));
27
- postId = patternMatch?.postId;
 
 
28
  }
29
  }
30
  if (!postId) return { error: "fetch.short_link" };
@@ -97,8 +100,23 @@ export default async function(obj) {
97
  }
98
 
99
  if (video) {
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  return {
101
  urls: video,
 
 
102
  filename: videoFilename,
103
  headers: { cookie }
104
  }
@@ -150,4 +168,6 @@ export default async function(obj) {
150
  headers: { cookie }
151
  }
152
  }
 
 
153
  }
 
4
  import { genericUserAgent } from "../../config.js";
5
  import { updateCookie } from "../cookie/manager.js";
6
  import { createStream } from "../../stream/manage.js";
7
+ import { convertLanguageCode } from "../../misc/language-codes.js";
8
 
9
  const shortDomain = "https://vt.tiktok.com/";
10
 
 
24
 
25
  if (html.startsWith('<a href="https://')) {
26
  const extractedURL = html.split('<a href="')[1].split('?')[0];
27
+ const { host, patternMatch } = extract(normalizeURL(extractedURL));
28
+ if (host === "tiktok") {
29
+ postId = patternMatch?.postId;
30
+ }
31
  }
32
  }
33
  if (!postId) return { error: "fetch.short_link" };
 
100
  }
101
 
102
  if (video) {
103
+ let subtitles, fileMetadata;
104
+ if (obj.subtitleLang && detail?.video?.subtitleInfos?.length) {
105
+ const langCode = convertLanguageCode(obj.subtitleLang);
106
+ const subtitle = detail?.video?.subtitleInfos.find(
107
+ s => s.LanguageCodeName.startsWith(langCode) && s.Format === "webvtt"
108
+ )
109
+ if (subtitle) {
110
+ subtitles = subtitle.Url;
111
+ fileMetadata = {
112
+ sublanguage: langCode,
113
+ }
114
+ }
115
+ }
116
  return {
117
  urls: video,
118
+ subtitles,
119
+ fileMetadata,
120
  filename: videoFilename,
121
  headers: { cookie }
122
  }
 
168
  headers: { cookie }
169
  }
170
  }
171
+
172
+ return { error: "fetch.empty" };
173
  }
api/src/processing/services/twitter.js CHANGED
@@ -1,13 +1,14 @@
 
1
  import { genericUserAgent } from "../../config.js";
2
  import { createStream } from "../../stream/manage.js";
3
  import { getCookie, updateCookie } from "../cookie/manager.js";
4
 
5
- const graphqlURL = 'https://api.x.com/graphql/I9GDzyCGZL2wSoYFFrrTVw/TweetResultByRestId';
6
  const tokenURL = 'https://api.x.com/1.1/guest/activate.json';
7
 
8
- const tweetFeatures = JSON.stringify({"creator_subscriptions_tweet_preview_api_enabled":true,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"articles_preview_enabled":true,"tweetypie_unmention_optimization_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_enhance_cards_enabled":false});
9
 
10
- const tweetFieldToggles = JSON.stringify({"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false});
11
 
12
  const commonHeaders = {
13
  "user-agent": genericUserAgent,
@@ -99,10 +100,14 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
99
 
100
  graphqlTweetURL.searchParams.set('variables',
101
  JSON.stringify({
102
- tweetId,
103
- withCommunity: false,
104
- includePromotedContent: false,
105
- withVoice: false
 
 
 
 
106
  })
107
  );
108
  graphqlTweetURL.searchParams.set('features', tweetFeatures);
@@ -128,24 +133,48 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
128
  return result
129
  }
130
 
131
- const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) => {
132
- let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
  if (!tweetTypename) {
135
  return { error: "fetch.empty" }
136
  }
137
 
138
- if (tweetTypename === "TweetUnavailable") {
139
- const reason = tweet?.data?.tweetResult?.result?.reason;
140
- switch(reason) {
141
- case "Protected":
142
- return { error: "content.post.private" };
143
- case "NsfwLoggedOut":
144
- if (cookie) {
145
- tweet = await requestTweet(dispatcher, id, guestToken, cookie);
146
- tweet = await tweet.json();
147
- tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
148
- } else return { error: "content.post.age" };
149
  }
150
  }
151
 
@@ -153,8 +182,7 @@ const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) =>
153
  return { error: "content.post.unavailable" }
154
  }
155
 
156
- let tweetResult = tweet.data.tweetResult.result,
157
- baseTweet = tweetResult.legacy,
158
  repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;
159
 
160
  if (tweetTypename === "TweetWithVisibilityResults") {
@@ -162,69 +190,52 @@ const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) =>
162
  repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
163
  }
164
 
165
- return (repostedTweet?.media || baseTweet?.extended_entities?.media);
166
- }
167
-
168
- const testResponse = (result) => {
169
- const contentLength = result.headers.get("content-length");
170
-
171
- if (!contentLength || contentLength === '0') {
172
- return false;
173
- }
174
-
175
- if (!result.headers.get("content-type").startsWith("application/json")) {
176
- return false;
177
  }
178
 
179
- return true;
180
  }
181
 
182
- export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
183
  const cookie = await getCookie('twitter');
184
 
185
- let syndication = false;
186
-
187
  let guestToken = await getGuestToken(dispatcher);
188
  if (!guestToken) return { error: "fetch.fail" };
189
 
190
- // for now we assume that graphql api will come back after some time,
191
- // so we try it first
192
-
193
  let tweet = await requestTweet(dispatcher, id, guestToken);
194
 
195
- // get new token & retry if old one expired
196
- if ([403, 429].includes(tweet.status)) {
197
- guestToken = await getGuestToken(dispatcher, true);
198
- if (cookie) {
199
- tweet = await requestTweet(dispatcher, id, guestToken, cookie);
200
- } else {
201
- tweet = await requestTweet(dispatcher, id, guestToken);
202
  }
 
203
  }
204
 
205
- const testGraphql = testResponse(tweet);
 
 
 
 
206
 
207
  // if graphql requests fail, then resort to tweet embed api
208
- if (!testGraphql) {
209
- syndication = true;
210
- tweet = await requestSyndication(dispatcher, id);
 
211
 
212
- const testSyndication = testResponse(tweet);
 
 
 
213
 
214
- // if even syndication request failed, then cry out loud
215
- if (!testSyndication) {
216
- return { error: "fetch.fail" };
217
- }
218
  }
219
 
220
- tweet = await tweet.json();
221
-
222
- let media =
223
- syndication
224
- ? tweet.mediaDetails
225
- : await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
226
-
227
- if (!media) return { error: "fetch.empty" };
228
 
229
  // check if there's a video at given index (/video/<index>)
230
  if (index >= 0 && index < media?.length) {
@@ -239,6 +250,30 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
239
  url, filename,
240
  });
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  switch (media?.length) {
243
  case undefined:
244
  case 0:
@@ -246,21 +281,37 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
246
  error: "fetch.empty"
247
  }
248
  case 1:
249
- if (media[0].type === "photo") {
 
250
  return {
251
  type: "proxy",
252
  isPhoto: true,
253
- filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`,
254
- urls: `${media[0].media_url_https}?name=4096x4096`
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  }
256
  }
257
 
258
  return {
259
- type: needsFixing(media[0]) ? "remux" : "proxy",
260
- urls: bestQuality(media[0].video_info.variants),
261
  filename: `twitter_${id}.mp4`,
262
  audioFilename: `twitter_${id}_audio`,
263
- isGif: media[0].type === "animated_gif"
 
 
264
  }
265
  default:
266
  const proxyThumb = (url, i) =>
 
1
+ import HLS from "hls-parser";
2
  import { genericUserAgent } from "../../config.js";
3
  import { createStream } from "../../stream/manage.js";
4
  import { getCookie, updateCookie } from "../cookie/manager.js";
5
 
6
+ const graphqlURL = 'https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail';
7
  const tokenURL = 'https://api.x.com/1.1/guest/activate.json';
8
 
9
+ const tweetFeatures = JSON.stringify({"rweb_video_screen_enabled":false,"payments_enabled":false,"rweb_xchat_enabled":false,"profile_label_improvements_pcf_label_in_post_enabled":true,"rweb_tipjar_consumption_enabled":true,"verified_phone_label_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_analyze_post_followups_enabled":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_analysis_button_from_backend":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"responsive_web_enhance_cards_enabled":false});
10
 
11
+ const tweetFieldToggles = JSON.stringify({"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false,"withDisallowedReplyControls":false});
12
 
13
  const commonHeaders = {
14
  "user-agent": genericUserAgent,
 
100
 
101
  graphqlTweetURL.searchParams.set('variables',
102
  JSON.stringify({
103
+ focalTweetId: tweetId,
104
+ with_rux_injections: false,
105
+ rankingMode: "Relevance",
106
+ includePromotedContent: true,
107
+ withCommunity: true,
108
+ withQuickPromoteEligibilityTweetFields: true,
109
+ withBirdwatchNotes: true,
110
+ withVoice: true
111
  })
112
  );
113
  graphqlTweetURL.searchParams.set('features', tweetFeatures);
 
133
  return result
134
  }
135
 
136
+ const parseCard = (cardOuter) => {
137
+ const card = JSON.parse(
138
+ (cardOuter?.legacy?.binding_values[0].value
139
+ || cardOuter?.binding_values?.unified_card)?.string_value,
140
+ );
141
+
142
+ if (!["video_website", "image_website"].includes(card?.type)
143
+ || !card?.media_entities
144
+ || card?.component_objects?.media_1?.type !== "media") {
145
+ return;
146
+ }
147
+
148
+ const mediaId = card.component_objects?.media_1?.data?.id;
149
+ return [card.media_entities[mediaId]];
150
+ };
151
+
152
+ const extractGraphqlMedia = async (thread, dispatcher, id, guestToken, cookie) => {
153
+ const addInsn = thread?.data?.threaded_conversation_with_injections_v2?.instructions?.find(
154
+ insn => insn.type === 'TimelineAddEntries'
155
+ );
156
+
157
+ const tweetResult = addInsn?.entries?.find(
158
+ entry => entry.entryId === `tweet-${id}`
159
+ )?.content?.itemContent?.tweet_results?.result;
160
+
161
+ let tweetTypename = tweetResult?.__typename;
162
 
163
  if (!tweetTypename) {
164
  return { error: "fetch.empty" }
165
  }
166
 
167
+ if (tweetTypename === "TweetUnavailable" || tweetTypename === "TweetTombstone") {
168
+ const reason = tweetResult?.result?.reason;
169
+ if (reason === 'Protected') {
170
+ return { error: "content.post.private" };
171
+ } else if (reason === "NsfwLoggedOut" || tweetResult?.tombstone?.text?.text?.startsWith('Age-restricted')) {
172
+ if (!cookie) {
173
+ return { error: "content.post.age" };
174
+ }
175
+
176
+ const tweet = await requestTweet(dispatcher, id, guestToken, cookie).then(t => t.json());
177
+ return extractGraphqlMedia(tweet, dispatcher, id, guestToken);
178
  }
179
  }
180
 
 
182
  return { error: "content.post.unavailable" }
183
  }
184
 
185
+ let baseTweet = tweetResult.legacy,
 
186
  repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;
187
 
188
  if (tweetTypename === "TweetWithVisibilityResults") {
 
190
  repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
191
  }
192
 
193
+ if (tweetResult.card?.legacy?.binding_values?.length) {
194
+ return parseCard(tweetResult.card);
 
 
 
 
 
 
 
 
 
 
195
  }
196
 
197
+ return (repostedTweet?.media || baseTweet?.extended_entities?.media);
198
  }
199
 
200
+ export default async function({ id, index, toGif, dispatcher, alwaysProxy, subtitleLang }) {
201
  const cookie = await getCookie('twitter');
202
 
 
 
203
  let guestToken = await getGuestToken(dispatcher);
204
  if (!guestToken) return { error: "fetch.fail" };
205
 
 
 
 
206
  let tweet = await requestTweet(dispatcher, id, guestToken);
207
 
208
+ if ([403, 404, 429].includes(tweet.status)) {
209
+ // get new token & retry if old one expired
210
+ if ([403, 429].includes(tweet.status)) {
211
+ guestToken = await getGuestToken(dispatcher, true);
 
 
 
212
  }
213
+ tweet = await requestTweet(dispatcher, id, guestToken, cookie);
214
  }
215
 
216
+ let media;
217
+ try {
218
+ tweet = await tweet.json();
219
+ media = await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
220
+ } catch {}
221
 
222
  // if graphql requests fail, then resort to tweet embed api
223
+ if (!media || 'error' in media) {
224
+ try {
225
+ tweet = await requestSyndication(dispatcher, id);
226
+ tweet = await tweet.json();
227
 
228
+ if (tweet?.card) {
229
+ media = parseCard(tweet.card);
230
+ }
231
+ } catch {}
232
 
233
+ media = tweet?.mediaDetails ?? media;
 
 
 
234
  }
235
 
236
+ if (!media || 'error' in media) {
237
+ return { error: media?.error || "fetch.empty" };
238
+ }
 
 
 
 
 
239
 
240
  // check if there's a video at given index (/video/<index>)
241
  if (index >= 0 && index < media?.length) {
 
250
  url, filename,
251
  });
252
 
253
+ const extractSubtitles = async (hlsUrl) => {
254
+ const mainHls = await fetch(hlsUrl).then(r => r.text()).catch(() => {});
255
+ if (!mainHls) return;
256
+
257
+ const subtitle = HLS.parse(mainHls)?.variants[0]?.subtitles?.find(
258
+ s => s.language.startsWith(subtitleLang)
259
+ );
260
+ if (!subtitle) return;
261
+
262
+ const subtitleUrl = new URL(subtitle.uri, hlsUrl).toString();
263
+ const subtitleHls = await fetch(subtitleUrl).then(r => r.text());
264
+ if (!subtitleHls) return;
265
+
266
+ const finalSubtitlePath = HLS.parse(subtitleHls)?.segments?.[0].uri;
267
+ if (!finalSubtitlePath) return;
268
+
269
+ const finalSubtitleUrl = new URL(finalSubtitlePath, hlsUrl).toString();
270
+
271
+ return {
272
+ url: finalSubtitleUrl,
273
+ language: subtitle.language,
274
+ };
275
+ }
276
+
277
  switch (media?.length) {
278
  case undefined:
279
  case 0:
 
281
  error: "fetch.empty"
282
  }
283
  case 1:
284
+ const mediaItem = media[0];
285
+ if (mediaItem.type === "photo") {
286
  return {
287
  type: "proxy",
288
  isPhoto: true,
289
+ filename: `twitter_${id}.${getFileExt(mediaItem.media_url_https)}`,
290
+ urls: `${mediaItem.media_url_https}?name=4096x4096`
291
+ }
292
+ }
293
+
294
+ let subtitles;
295
+ let fileMetadata;
296
+ if (mediaItem.type === "video" && subtitleLang) {
297
+ const hlsVariant = mediaItem.video_info?.variants?.find(
298
+ v => v.content_type === "application/x-mpegURL"
299
+ );
300
+ if (hlsVariant) {
301
+ const { url, language } = await extractSubtitles(hlsVariant.url) || {};
302
+ subtitles = url;
303
+ if (language) fileMetadata = { sublanguage: language };
304
  }
305
  }
306
 
307
  return {
308
+ type: subtitles || needsFixing(mediaItem) ? "remux" : "proxy",
309
+ urls: bestQuality(mediaItem.video_info.variants),
310
  filename: `twitter_${id}.mp4`,
311
  audioFilename: `twitter_${id}_audio`,
312
+ isGif: mediaItem.type === "animated_gif",
313
+ subtitles,
314
+ fileMetadata,
315
  }
316
  default:
317
  const proxyThumb = (url, i) =>
api/src/processing/services/vimeo.js CHANGED
@@ -1,6 +1,7 @@
1
  import HLS from "hls-parser";
2
  import { env } from "../../config.js";
3
  import { merge } from '../../misc/utils.js';
 
4
 
5
  const resolutionMatch = {
6
  "3840": 2160,
@@ -15,7 +16,44 @@ const resolutionMatch = {
15
  "426": 240
16
  }
17
 
18
- const requestApiInfo = (videoId, password) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  if (password) {
20
  videoId += `:${password}`
21
  }
@@ -24,10 +62,8 @@ const requestApiInfo = (videoId, password) => {
24
  `https://api.vimeo.com/videos/${videoId}`,
25
  {
26
  headers: {
27
- Accept: 'application/vnd.vimeo.*+json; version=3.4.2',
28
- 'User-Agent': 'Vimeo/10.19.0 (com.vimeo; build:101900.57.0; iOS 17.5.1) Alamofire/5.9.0 VimeoNetworking/5.0.0',
29
- Authorization: 'Basic MTMxNzViY2Y0NDE0YTQ5YzhjZTc0YmU0NjVjNDQxYzNkYWVjOWRlOTpHKzRvMmgzVUh4UkxjdU5FRW80cDNDbDhDWGR5dVJLNUJZZ055dHBHTTB4V1VzaG41bEx1a2hiN0NWYWNUcldSSW53dzRUdFRYZlJEZmFoTTArOTBUZkJHS3R4V2llYU04Qnl1bERSWWxUdXRidjNqR2J4SHFpVmtFSUcyRktuQw==',
30
- 'Accept-Language': 'en'
31
  }
32
  }
33
  )
@@ -40,7 +76,7 @@ const compareQuality = (rendition, requestedQuality) => {
40
  return Math.abs(quality - requestedQuality);
41
  }
42
 
43
- const getDirectLink = (data, quality) => {
44
  if (!data.files) return;
45
 
46
  const match = data.files
@@ -56,8 +92,23 @@ const getDirectLink = (data, quality) => {
56
 
57
  if (!match) return;
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  return {
60
  urls: match.link,
 
61
  filenameAttributes: {
62
  resolution: `${match.width}x${match.height}`,
63
  qualityLabel: match.rendition,
@@ -136,14 +187,33 @@ export default async function(obj) {
136
  if (quality < 240) quality = 240;
137
  if (!quality || obj.isAudioOnly) quality = 9000;
138
 
139
- const info = await requestApiInfo(obj.id, obj.password);
 
 
 
 
 
140
  let response;
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  if (obj.isAudioOnly) {
143
  response = await getHLS(info.config_url, { ...obj, quality });
144
  }
145
 
146
- if (!response) response = getDirectLink(info, quality);
147
  if (!response) response = { error: "fetch.empty" };
148
 
149
  if (response.error) {
@@ -155,6 +225,10 @@ export default async function(obj) {
155
  artist: info.user.name,
156
  };
157
 
 
 
 
 
158
  return merge(
159
  {
160
  fileMetadata,
 
1
  import HLS from "hls-parser";
2
  import { env } from "../../config.js";
3
  import { merge } from '../../misc/utils.js';
4
+ import { getCookie } from "../cookie/manager.js";
5
 
6
  const resolutionMatch = {
7
  "3840": 2160,
 
16
  "426": 240
17
  }
18
 
19
+ const genericHeaders = {
20
+ Accept: 'application/vnd.vimeo.*+json; version=3.4.10',
21
+ 'User-Agent': 'com.vimeo.android.videoapp (Google, Pixel 7a, google, Android 16/36 Version 11.8.1) Kotlin VimeoNetworking/3.12.0',
22
+ Authorization: 'Basic NzRmYTg5YjgxMWExY2JiNzUwZDg1MjhkMTYzZjQ4YWYyOGEyZGJlMTp4OGx2NFd3QnNvY1lkamI2UVZsdjdDYlNwSDUrdm50YzdNNThvWDcwN1JrenJGZC9tR1lReUNlRjRSVklZeWhYZVpRS0tBcU9YYzRoTGY2Z1dlVkJFYkdJc0dMRHpoZWFZbU0reDRqZ1dkZ1diZmdIdGUrNUM5RVBySlM0VG1qcw==',
23
+ 'Accept-Language': 'en',
24
+ }
25
+
26
+ let bearer = '';
27
+
28
+ const getBearer = async (refresh = false) => {
29
+ const cookie = getCookie('vimeo_bearer')?.values?.()?.access_token;
30
+ if ((bearer || cookie) && !refresh) return bearer || cookie;
31
+
32
+ const oauthResponse = await fetch(
33
+ 'https://api.vimeo.com/oauth/authorize/client',
34
+ {
35
+ method: 'POST',
36
+ body: new URLSearchParams({
37
+ scope: 'private public create edit delete interact upload purchased stats',
38
+ grant_type: 'client_credentials',
39
+ }).toString(),
40
+ headers: {
41
+ ...genericHeaders,
42
+ 'Content-Type': 'application/x-www-form-urlencoded',
43
+ }
44
+ }
45
+ )
46
+ .then(a => a.json())
47
+ .catch(() => {});
48
+
49
+ if (!oauthResponse || !oauthResponse.access_token) {
50
+ return;
51
+ }
52
+
53
+ return bearer = oauthResponse.access_token;
54
+ }
55
+
56
+ const requestApiInfo = (bearerToken, videoId, password) => {
57
  if (password) {
58
  videoId += `:${password}`
59
  }
 
62
  `https://api.vimeo.com/videos/${videoId}`,
63
  {
64
  headers: {
65
+ ...genericHeaders,
66
+ Authorization: `Bearer ${bearerToken}`,
 
 
67
  }
68
  }
69
  )
 
76
  return Math.abs(quality - requestedQuality);
77
  }
78
 
79
+ const getDirectLink = async (data, quality, subtitleLang) => {
80
  if (!data.files) return;
81
 
82
  const match = data.files
 
92
 
93
  if (!match) return;
94
 
95
+ let subtitles;
96
+ if (subtitleLang && data.config_url) {
97
+ const config = await fetch(data.config_url)
98
+ .then(r => r.json())
99
+ .catch(() => {});
100
+
101
+ if (config && config.request?.text_tracks?.length) {
102
+ subtitles = config.request.text_tracks.find(
103
+ t => t.lang.startsWith(subtitleLang)
104
+ );
105
+ subtitles = new URL(subtitles.url, "https://player.vimeo.com/").toString();
106
+ }
107
+ }
108
+
109
  return {
110
  urls: match.link,
111
+ subtitles,
112
  filenameAttributes: {
113
  resolution: `${match.width}x${match.height}`,
114
  qualityLabel: match.rendition,
 
187
  if (quality < 240) quality = 240;
188
  if (!quality || obj.isAudioOnly) quality = 9000;
189
 
190
+ const bearerToken = await getBearer();
191
+ if (!bearerToken) {
192
+ return { error: "fetch.fail" };
193
+ }
194
+
195
+ let info = await requestApiInfo(bearerToken, obj.id, obj.password);
196
  let response;
197
 
198
+ // auth error, try to refresh the token
199
+ if (info?.error_code === 8003) {
200
+ const newBearer = await getBearer(true);
201
+ if (!newBearer) {
202
+ return { error: "fetch.fail" };
203
+ }
204
+ info = await requestApiInfo(newBearer, obj.id, obj.password);
205
+ }
206
+
207
+ // if there's still no info, then return a generic error
208
+ if (!info || info.error_code) {
209
+ return { error: "fetch.empty" };
210
+ }
211
+
212
  if (obj.isAudioOnly) {
213
  response = await getHLS(info.config_url, { ...obj, quality });
214
  }
215
 
216
+ if (!response) response = await getDirectLink(info, quality, obj.subtitleLang);
217
  if (!response) response = { error: "fetch.empty" };
218
 
219
  if (response.error) {
 
225
  artist: info.user.name,
226
  };
227
 
228
+ if (response.subtitles) {
229
+ fileMetadata.sublanguage = obj.subtitleLang;
230
+ }
231
+
232
  return merge(
233
  {
234
  fileMetadata,
api/src/processing/services/vk.js CHANGED
@@ -76,7 +76,7 @@ const getVideo = async (ownerId, videoId, accessKey) => {
76
  return video;
77
  }
78
 
79
- export default async function ({ ownerId, videoId, accessKey, quality }) {
80
  const token = await getToken();
81
  if (!token) return { error: "fetch.fail" };
82
 
@@ -125,8 +125,20 @@ export default async function ({ ownerId, videoId, accessKey, quality }) {
125
  title: video.title.trim(),
126
  }
127
 
 
 
 
 
 
 
 
 
 
 
 
128
  return {
129
  urls: url,
 
130
  fileMetadata,
131
  filenameAttributes: {
132
  service: "vk",
 
76
  return video;
77
  }
78
 
79
+ export default async function ({ ownerId, videoId, accessKey, quality, subtitleLang }) {
80
  const token = await getToken();
81
  if (!token) return { error: "fetch.fail" };
82
 
 
125
  title: video.title.trim(),
126
  }
127
 
128
+ let subtitles;
129
+ if (subtitleLang && video.subtitles?.length) {
130
+ const subtitle = video.subtitles.find(
131
+ s => s.title.endsWith(".vtt") && s.lang.startsWith(subtitleLang)
132
+ );
133
+ if (subtitle) {
134
+ subtitles = subtitle.url;
135
+ fileMetadata.sublanguage = subtitleLang;
136
+ }
137
+ }
138
+
139
  return {
140
  urls: url,
141
+ subtitles,
142
  fileMetadata,
143
  filenameAttributes: {
144
  service: "vk",
api/src/processing/services/xiaohongshu.js CHANGED
@@ -6,13 +6,13 @@ const https = (url) => {
6
  return url.replace(/^http:/i, 'https:');
7
  }
8
 
9
- export default async function ({ id, token, shareId, h265, isAudioOnly, dispatcher }) {
10
  let noteId = id;
11
  let xsecToken = token;
12
 
13
  if (!noteId) {
14
  const patternMatch = await resolveRedirectingURL(
15
- `https://xhslink.com/a/${shareId}`,
16
  dispatcher
17
  );
18
 
 
6
  return url.replace(/^http:/i, 'https:');
7
  }
8
 
9
+ export default async function ({ id, token, shareType, shareId, h265, isAudioOnly, dispatcher }) {
10
  let noteId = id;
11
  let xsecToken = token;
12
 
13
  if (!noteId) {
14
  const patternMatch = await resolveRedirectingURL(
15
+ `https://xhslink.com/${shareType}/${shareId}`,
16
  dispatcher
17
  );
18
 
api/src/processing/services/youtube.js CHANGED
@@ -72,19 +72,98 @@ const cloneInnertube = async (customFetch, useSession) => {
72
 
73
  const session = new Session(
74
  innertube.session.context,
75
- innertube.session.key,
76
  innertube.session.api_version,
77
  innertube.session.account_index,
 
78
  innertube.session.player,
79
  cookie,
80
  customFetch ?? innertube.session.http.fetch,
81
- innertube.session.cache
 
82
  );
83
 
84
  const yt = new Innertube(session);
85
  return yt;
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  export default async function (o) {
89
  const quality = o.quality === "max" ? 9000 : Number(o.quality);
90
 
@@ -92,7 +171,7 @@ export default async function (o) {
92
  let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS";
93
 
94
  // HLS playlists from the iOS client don't contain the av1 video format.
95
- if (useHLS && o.format === "av1") {
96
  useHLS = false;
97
  }
98
 
@@ -102,18 +181,24 @@ export default async function (o) {
102
 
103
  // iOS client doesn't have adaptive formats of resolution >1080p,
104
  // so we use the WEB_EMBEDDED client instead for those cases
105
- const useSession =
106
  env.ytSessionServer && (
107
  (
108
  !useHLS
109
  && innertubeClient === "IOS"
110
  && (
111
- (quality > 1080 && o.format !== "h264")
112
- || (quality > 1080 && o.format !== "vp9")
113
  )
114
  )
115
  );
116
 
 
 
 
 
 
 
117
  if (useSession) {
118
  innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
119
  }
@@ -139,7 +224,7 @@ export default async function (o) {
139
 
140
  let info;
141
  try {
142
- info = await yt.getBasicInfo(o.id, innertubeClient);
143
  } catch (e) {
144
  if (e?.info) {
145
  let errorInfo;
@@ -220,37 +305,16 @@ export default async function (o) {
220
  return videoQualities.find(qual => qual >= shortestSide);
221
  }
222
 
223
- let video, audio, dubbedLanguage,
224
- codec = o.format || "h264", itag = o.itag;
225
 
226
  if (useHLS) {
227
- const hlsManifest = info.streaming_data.hls_manifest_url;
228
-
229
- if (!hlsManifest) {
230
- return { error: "youtube.no_hls_streams" };
231
- }
232
-
233
- const fetchedHlsManifest = await fetch(hlsManifest, {
234
- dispatcher: o.dispatcher,
235
- }).then(r => {
236
- if (r.status === 200) {
237
- return r.text();
238
- } else {
239
- throw new Error("couldn't fetch the HLS playlist");
240
- }
241
- }).catch(() => { });
242
-
243
- if (!fetchedHlsManifest) {
244
- return { error: "youtube.no_hls_streams" };
245
- }
246
-
247
- const variants = HLS.parse(fetchedHlsManifest).variants.sort(
248
- (a, b) => Number(b.bandwidth) - Number(a.bandwidth)
249
  );
250
 
251
- if (!variants || variants.length === 0) {
252
- return { error: "youtube.no_hls_streams" };
253
- }
254
 
255
  const matchHlsCodec = codecs => (
256
  codecs.includes(hlsCodecList[codec].videoCodec)
@@ -278,7 +342,7 @@ export default async function (o) {
278
  // some videos (mainly those with AI dubs) don't have any tracks marked as default
279
  // why? god knows, but we assume that a default track is marked as such in the title
280
  if (!audio) {
281
- audio = selected.audio.find(i => i.name.endsWith("- original"));
282
  }
283
 
284
  if (o.dubLang) {
@@ -367,9 +431,9 @@ export default async function (o) {
367
 
368
  audio = sorted_formats[codec].bestAudio;
369
 
370
- if (audio?.audio_track && !audio?.audio_track?.audio_is_default) {
371
  audio = sorted_formats[codec].audio.find(i =>
372
- i?.audio_track?.audio_is_default
373
  );
374
  }
375
 
@@ -378,7 +442,7 @@ export default async function (o) {
378
  i.language?.startsWith(o.dubLang) && i.audio_track
379
  );
380
 
381
- if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
382
  audio = dubbedAudio;
383
  dubbedLanguage = dubbedAudio.language;
384
  }
@@ -401,6 +465,13 @@ export default async function (o) {
401
 
402
  if (!video) video = sorted_formats[codec].bestVideo;
403
  }
 
 
 
 
 
 
 
404
  }
405
 
406
  if (video?.drm_families || audio?.drm_families) {
@@ -424,6 +495,10 @@ export default async function (o) {
424
  }
425
  }
426
 
 
 
 
 
427
  const filenameAttributes = {
428
  service: "youtube",
429
  id: o.id,
@@ -457,6 +532,15 @@ export default async function (o) {
457
  urls = audio.decipher(innertube.session.player);
458
  }
459
 
 
 
 
 
 
 
 
 
 
460
  return {
461
  type: "audio",
462
  isAudioOnly: true,
@@ -465,7 +549,10 @@ export default async function (o) {
465
  fileMetadata,
466
  bestAudio,
467
  isHLS: useHLS,
468
- originalRequest
 
 
 
469
  }
470
  }
471
 
@@ -475,7 +562,7 @@ export default async function (o) {
475
  if (useHLS) {
476
  resolution = normalizeQuality(video.resolution);
477
  filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
478
- filenameAttributes.extension = hlsCodecList[codec].container;
479
 
480
  video = video.uri;
481
  audio = audio.uri;
@@ -486,7 +573,7 @@ export default async function (o) {
486
  });
487
 
488
  filenameAttributes.resolution = `${video.width}x${video.height}`;
489
- filenameAttributes.extension = codecList[codec].container;
490
 
491
  if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
492
  video = video.decipher(innertube.session.player);
@@ -506,6 +593,7 @@ export default async function (o) {
506
  video,
507
  audio,
508
  ],
 
509
  filenameAttributes,
510
  fileMetadata,
511
  isHLS: useHLS,
 
72
 
73
  const session = new Session(
74
  innertube.session.context,
75
+ innertube.session.api_key,
76
  innertube.session.api_version,
77
  innertube.session.account_index,
78
+ innertube.session.config_data,
79
  innertube.session.player,
80
  cookie,
81
  customFetch ?? innertube.session.http.fetch,
82
+ innertube.session.cache,
83
+ sessionTokens?.potoken
84
  );
85
 
86
  const yt = new Innertube(session);
87
  return yt;
88
  }
89
 
90
+ const getHlsVariants = async (hlsManifest, dispatcher) => {
91
+ if (!hlsManifest) {
92
+ return { error: "youtube.no_hls_streams" };
93
+ }
94
+
95
+ const fetchedHlsManifest =
96
+ await fetch(hlsManifest, { dispatcher })
97
+ .then(r => r.status === 200 ? r.text() : undefined)
98
+ .catch(() => {});
99
+
100
+ if (!fetchedHlsManifest) {
101
+ return { error: "youtube.no_hls_streams" };
102
+ }
103
+
104
+ const variants = HLS.parse(fetchedHlsManifest).variants.sort(
105
+ (a, b) => Number(b.bandwidth) - Number(a.bandwidth)
106
+ );
107
+
108
+ if (!variants || variants.length === 0) {
109
+ return { error: "youtube.no_hls_streams" };
110
+ }
111
+
112
+ return variants;
113
+ }
114
+
115
+ const getSubtitles = async (info, dispatcher, subtitleLang) => {
116
+ const preferredCap = info.captions.caption_tracks.find(caption =>
117
+ caption.kind !== 'asr' && caption.language_code.startsWith(subtitleLang)
118
+ );
119
+
120
+ const captionsUrl = preferredCap?.base_url;
121
+ if (!captionsUrl) return;
122
+
123
+ if (!captionsUrl.includes("exp=xpe")) {
124
+ let url = new URL(captionsUrl);
125
+ url.searchParams.set('fmt', 'vtt');
126
+
127
+ return {
128
+ url: url.toString(),
129
+ language: preferredCap.language_code,
130
+ }
131
+ }
132
+
133
+ // if we have exp=xpe in the url, then captions are
134
+ // locked down and can't be accessed without a yummy potoken,
135
+ // so instead we just use subtitles from HLS
136
+
137
+ const hlsVariants = await getHlsVariants(
138
+ info.streaming_data.hls_manifest_url,
139
+ dispatcher
140
+ );
141
+ if (hlsVariants?.error) return;
142
+
143
+ // all variants usually have the same set of subtitles
144
+ const hlsSubtitles = hlsVariants[0]?.subtitles;
145
+ if (!hlsSubtitles?.length) return;
146
+
147
+ const preferredHls = hlsSubtitles.find(
148
+ subtitle => subtitle.language.startsWith(subtitleLang)
149
+ );
150
+
151
+ if (!preferredHls) return;
152
+
153
+ const fetchedHlsSubs =
154
+ await fetch(preferredHls.uri, { dispatcher })
155
+ .then(r => r.status === 200 ? r.text() : undefined)
156
+ .catch(() => {});
157
+
158
+ const parsedSubs = HLS.parse(fetchedHlsSubs);
159
+ if (!parsedSubs) return;
160
+
161
+ return {
162
+ url: parsedSubs.segments[0]?.uri,
163
+ language: preferredHls.language,
164
+ }
165
+ }
166
+
167
  export default async function (o) {
168
  const quality = o.quality === "max" ? 9000 : Number(o.quality);
169
 
 
171
  let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS";
172
 
173
  // HLS playlists from the iOS client don't contain the av1 video format.
174
+ if (useHLS && o.codec === "av1") {
175
  useHLS = false;
176
  }
177
 
 
181
 
182
  // iOS client doesn't have adaptive formats of resolution >1080p,
183
  // so we use the WEB_EMBEDDED client instead for those cases
184
+ let useSession =
185
  env.ytSessionServer && (
186
  (
187
  !useHLS
188
  && innertubeClient === "IOS"
189
  && (
190
+ (quality > 1080 && o.codec !== "h264")
191
+ || (quality > 1080 && o.codec !== "vp9")
192
  )
193
  )
194
  );
195
 
196
+ // we can get subtitles reliably only from the iOS client
197
+ if (o.subtitleLang) {
198
+ innertubeClient = "IOS";
199
+ useSession = false;
200
+ }
201
+
202
  if (useSession) {
203
  innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
204
  }
 
224
 
225
  let info;
226
  try {
227
+ info = await yt.getBasicInfo(o.id, { client: innertubeClient });
228
  } catch (e) {
229
  if (e?.info) {
230
  let errorInfo;
 
305
  return videoQualities.find(qual => qual >= shortestSide);
306
  }
307
 
308
+ let video, audio, subtitles, dubbedLanguage,
309
+ codec = o.codec || "h264", itag = o.itag;
310
 
311
  if (useHLS) {
312
+ const variants = await getHlsVariants(
313
+ info.streaming_data.hls_manifest_url,
314
+ o.dispatcher
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  );
316
 
317
+ if (variants?.error) return variants;
 
 
318
 
319
  const matchHlsCodec = codecs => (
320
  codecs.includes(hlsCodecList[codec].videoCodec)
 
342
  // some videos (mainly those with AI dubs) don't have any tracks marked as default
343
  // why? god knows, but we assume that a default track is marked as such in the title
344
  if (!audio) {
345
+ audio = selected.audio.find(i => i.name.endsWith("original"));
346
  }
347
 
348
  if (o.dubLang) {
 
431
 
432
  audio = sorted_formats[codec].bestAudio;
433
 
434
+ if (audio?.audio_track && !audio?.is_original) {
435
  audio = sorted_formats[codec].audio.find(i =>
436
+ i?.is_original
437
  );
438
  }
439
 
 
442
  i.language?.startsWith(o.dubLang) && i.audio_track
443
  );
444
 
445
+ if (dubbedAudio && !dubbedAudio?.is_original) {
446
  audio = dubbedAudio;
447
  dubbedLanguage = dubbedAudio.language;
448
  }
 
465
 
466
  if (!video) video = sorted_formats[codec].bestVideo;
467
  }
468
+
469
+ if (o.subtitleLang && !o.isAudioOnly && info.captions?.caption_tracks?.length) {
470
+ const videoSubtitles = await getSubtitles(info, o.dispatcher, o.subtitleLang);
471
+ if (videoSubtitles) {
472
+ subtitles = videoSubtitles;
473
+ }
474
+ }
475
  }
476
 
477
  if (video?.drm_families || audio?.drm_families) {
 
495
  }
496
  }
497
 
498
+ if (subtitles) {
499
+ fileMetadata.sublanguage = subtitles.language;
500
+ }
501
+
502
  const filenameAttributes = {
503
  service: "youtube",
504
  id: o.id,
 
532
  urls = audio.decipher(innertube.session.player);
533
  }
534
 
535
+ let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
536
+ const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher })
537
+ .then(r => r.status === 200)
538
+ .catch(() => {});
539
+
540
+ if (!testMaxCover) {
541
+ cover = basicInfo.thumbnail?.[0]?.url;
542
+ }
543
+
544
  return {
545
  type: "audio",
546
  isAudioOnly: true,
 
549
  fileMetadata,
550
  bestAudio,
551
  isHLS: useHLS,
552
+ originalRequest,
553
+
554
+ cover,
555
+ cropCover: basicInfo.author.endsWith("- Topic"),
556
  }
557
  }
558
 
 
562
  if (useHLS) {
563
  resolution = normalizeQuality(video.resolution);
564
  filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
565
+ filenameAttributes.extension = o.container === "auto" ? hlsCodecList[codec].container : o.container;
566
 
567
  video = video.uri;
568
  audio = audio.uri;
 
573
  });
574
 
575
  filenameAttributes.resolution = `${video.width}x${video.height}`;
576
+ filenameAttributes.extension = o.container === "auto" ? codecList[codec].container : o.container;
577
 
578
  if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
579
  video = video.decipher(innertube.session.player);
 
593
  video,
594
  audio,
595
  ],
596
+ subtitles: subtitles?.url,
597
  filenameAttributes,
598
  fileMetadata,
599
  isHLS: useHLS,
api/src/processing/url.js CHANGED
@@ -17,7 +17,7 @@ function aliasURL(url) {
17
  if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) {
18
  url.pathname = '/watch';
19
  // parts := ['', 'live' || 'shorts', id, ...rest]
20
- url.search = `?v=${encodeURIComponent(parts[2])}`
21
  }
22
  break;
23
 
@@ -61,23 +61,23 @@ function aliasURL(url) {
61
 
62
  case "b23":
63
  if (url.hostname === 'b23.tv' && parts.length === 2) {
64
- url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
65
  }
66
  break;
67
 
68
  case "dai":
69
  if (url.hostname === 'dai.ly' && parts.length === 2) {
70
- url = new URL(`https://dailymotion.com/video/${parts[1]}`)
71
  }
72
  break;
73
 
74
  case "facebook":
75
  case "fb":
76
  if (url.searchParams.get('v')) {
77
- url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`)
78
  }
79
  if (url.hostname === 'fb.watch') {
80
- url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`)
81
  }
82
  break;
83
 
@@ -92,11 +92,14 @@ function aliasURL(url) {
92
  if (services.vk.altDomains.includes(url.hostname)) {
93
  url.hostname = 'vk.com';
94
  }
 
 
 
95
  break;
96
 
97
  case "xhslink":
98
  if (url.hostname === 'xhslink.com' && parts.length === 3) {
99
- url = new URL(`https://www.xiaohongshu.com/a/${parts[2]}`);
100
  }
101
  break;
102
 
@@ -106,7 +109,7 @@ function aliasURL(url) {
106
  url.pathname = `/share/${idPart.slice(-32)}`;
107
  }
108
  break;
109
-
110
  case "redd":
111
  /* reddit short video links can be treated by changing https://v.redd.it/<id>
112
  to https://reddit.com/video/<id>.*/
@@ -144,6 +147,7 @@ function cleanURL(url) {
144
  limitQuery('v');
145
  }
146
  break;
 
147
  case "rutube":
148
  if (url.searchParams.get('p')) {
149
  limitQuery('p');
@@ -196,7 +200,7 @@ export function normalizeURL(url) {
196
  );
197
  }
198
 
199
- export function extract(url) {
200
  if (!(url instanceof URL)) {
201
  url = new URL(url);
202
  }
@@ -207,7 +211,7 @@ export function extract(url) {
207
  return { error: "link.invalid" };
208
  }
209
 
210
- if (!env.enabledServices.has(host)) {
211
  // show a different message when youtube is disabled on official instances
212
  // as it only happens when shit hits the fan
213
  if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
 
17
  if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) {
18
  url.pathname = '/watch';
19
  // parts := ['', 'live' || 'shorts', id, ...rest]
20
+ url.search = `?v=${encodeURIComponent(parts[2])}`;
21
  }
22
  break;
23
 
 
61
 
62
  case "b23":
63
  if (url.hostname === 'b23.tv' && parts.length === 2) {
64
+ url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`);
65
  }
66
  break;
67
 
68
  case "dai":
69
  if (url.hostname === 'dai.ly' && parts.length === 2) {
70
+ url = new URL(`https://dailymotion.com/video/${parts[1]}`);
71
  }
72
  break;
73
 
74
  case "facebook":
75
  case "fb":
76
  if (url.searchParams.get('v')) {
77
+ url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`);
78
  }
79
  if (url.hostname === 'fb.watch') {
80
+ url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`);
81
  }
82
  break;
83
 
 
92
  if (services.vk.altDomains.includes(url.hostname)) {
93
  url.hostname = 'vk.com';
94
  }
95
+ if (url.searchParams.get('z')) {
96
+ url = new URL(`https://vk.com/${url.searchParams.get('z')}`);
97
+ }
98
  break;
99
 
100
  case "xhslink":
101
  if (url.hostname === 'xhslink.com' && parts.length === 3) {
102
+ url = new URL(`https://www.xiaohongshu.com/${parts[1]}/${parts[2]}`);
103
  }
104
  break;
105
 
 
109
  url.pathname = `/share/${idPart.slice(-32)}`;
110
  }
111
  break;
112
+
113
  case "redd":
114
  /* reddit short video links can be treated by changing https://v.redd.it/<id>
115
  to https://reddit.com/video/<id>.*/
 
147
  limitQuery('v');
148
  }
149
  break;
150
+ case "bilibili":
151
  case "rutube":
152
  if (url.searchParams.get('p')) {
153
  limitQuery('p');
 
200
  );
201
  }
202
 
203
+ export function extract(url, enabledServices = env.enabledServices) {
204
  if (!(url instanceof URL)) {
205
  url = new URL(url);
206
  }
 
211
  return { error: "link.invalid" };
212
  }
213
 
214
+ if (!enabledServices.has(host)) {
215
  // show a different message when youtube is disabled on official instances
216
  // as it only happens when shit hits the fan
217
  if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
api/src/security/api-keys.js CHANGED
@@ -1,8 +1,8 @@
1
  import { env } from "../config.js";
2
- import { readFile } from "node:fs/promises";
3
  import { Green, Yellow } from "../misc/console-text.js";
4
  import ip from "ipaddr.js";
5
  import * as cluster from "../misc/cluster.js";
 
6
 
7
  // this function is a modified variation of code
8
  // from https://stackoverflow.com/a/32402438/14855621
@@ -13,9 +13,9 @@ const generateWildcardRegex = rule => {
13
 
14
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
15
 
16
- let keys = {};
17
 
18
- const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
19
 
20
  /* Expected format pseudotype:
21
  ** type KeyFileContents = Record<
@@ -24,7 +24,8 @@ const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
24
  ** name?: string,
25
  ** limit?: number | "unlimited",
26
  ** ips?: CIDRString[],
27
- ** userAgents?: string[]
 
28
  ** }
29
  ** >;
30
  */
@@ -77,6 +78,19 @@ const validateKeys = (input) => {
77
  throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
78
  }
79
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  });
81
  }
82
 
@@ -112,40 +126,53 @@ const formatKeys = (keyData) => {
112
  if (data.userAgents) {
113
  formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);
114
  }
 
 
 
 
 
 
 
 
115
  }
116
 
117
  return formatted;
118
  }
119
 
120
  const updateKeys = (newKeys) => {
121
- keys = formatKeys(newKeys);
122
- }
123
 
124
- const loadKeys = async (source) => {
125
- let updated;
126
- if (source.protocol === 'file:') {
127
- const pathname = source.pathname === '/' ? '' : source.pathname;
128
- updated = JSON.parse(
129
- await readFile(
130
- decodeURIComponent(source.host + pathname),
131
- 'utf8'
132
- )
133
- );
134
- } else {
135
- updated = await fetch(source).then(a => a.json());
136
- }
137
 
138
- validateKeys(updated);
 
139
 
140
- cluster.broadcast({ api_keys: updated });
 
 
 
 
141
 
142
- updateKeys(updated);
 
 
 
143
  }
144
 
145
  const wrapLoad = (url, initial = false) => {
146
- loadKeys(url)
147
- .then(() => {
 
148
  if (initial) {
 
 
 
 
 
 
 
 
 
149
  console.log(`${Green('[✓]')} api keys loaded successfully!`)
150
  }
151
  })
@@ -214,7 +241,7 @@ export const validateAuthorization = (req) => {
214
  export const setup = (url) => {
215
  if (cluster.isPrimary) {
216
  wrapLoad(url, true);
217
- if (env.keyReloadInterval > 0) {
218
  setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000);
219
  }
220
  } else if (cluster.isWorker) {
@@ -225,3 +252,15 @@ export const setup = (url) => {
225
  });
226
  }
227
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { env } from "../config.js";
 
2
  import { Green, Yellow } from "../misc/console-text.js";
3
  import ip from "ipaddr.js";
4
  import * as cluster from "../misc/cluster.js";
5
+ import { FileWatcher } from "../misc/file-watcher.js";
6
 
7
  // this function is a modified variation of code
8
  // from https://stackoverflow.com/a/32402438/14855621
 
13
 
14
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
15
 
16
+ let keys = {}, reader = null;
17
 
18
+ const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit', 'allowedServices']);
19
 
20
  /* Expected format pseudotype:
21
  ** type KeyFileContents = Record<
 
24
  ** name?: string,
25
  ** limit?: number | "unlimited",
26
  ** ips?: CIDRString[],
27
+ ** userAgents?: string[],
28
+ ** allowedServices?: "all" | string[],
29
  ** }
30
  ** >;
31
  */
 
78
  throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
79
  }
80
  }
81
+
82
+ if (details.allowedServices) {
83
+ if (Array.isArray(details.allowedServices)) {
84
+ const invalid_services = details.allowedServices.some(
85
+ service => !env.allServices.has(service)
86
+ );
87
+ if (invalid_services) {
88
+ throw "`allowedServices` in details contains an invalid service";
89
+ }
90
+ } else if (details.allowedServices !== "all") {
91
+ throw "details object contains value for `allowedServices` which is not an array or `all`";
92
+ }
93
+ }
94
  });
95
  }
96
 
 
126
  if (data.userAgents) {
127
  formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);
128
  }
129
+
130
+ if (data.allowedServices) {
131
+ if (Array.isArray(data.allowedServices)) {
132
+ formatted[key].allowedServices = new Set(data.allowedServices);
133
+ } else {
134
+ formatted[key].allowedServices = data.allowedServices;
135
+ }
136
+ }
137
  }
138
 
139
  return formatted;
140
  }
141
 
142
  const updateKeys = (newKeys) => {
143
+ validateKeys(newKeys);
 
144
 
145
+ cluster.broadcast({ api_keys: newKeys });
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
+ keys = formatKeys(newKeys);
148
+ }
149
 
150
+ const loadRemoteKeys = async (source) => {
151
+ updateKeys(
152
+ await fetch(source).then(a => a.json())
153
+ );
154
+ }
155
 
156
+ const loadLocalKeys = async () => {
157
+ updateKeys(
158
+ JSON.parse(await reader.read())
159
+ );
160
  }
161
 
162
  const wrapLoad = (url, initial = false) => {
163
+ let load = loadRemoteKeys.bind(null, url);
164
+
165
+ if (url.protocol === 'file:') {
166
  if (initial) {
167
+ reader = FileWatcher.fromFileProtocol(url);
168
+ reader.on('file-updated', () => wrapLoad(url));
169
+ }
170
+
171
+ load = loadLocalKeys;
172
+ }
173
+
174
+ load().then(() => {
175
+ if (initial || reader) {
176
  console.log(`${Green('[✓]')} api keys loaded successfully!`)
177
  }
178
  })
 
241
  export const setup = (url) => {
242
  if (cluster.isPrimary) {
243
  wrapLoad(url, true);
244
+ if (env.keyReloadInterval > 0 && url.protocol !== 'file:') {
245
  setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000);
246
  }
247
  } else if (cluster.isWorker) {
 
252
  });
253
  }
254
  }
255
+
256
+ export const getAllowedServices = (key) => {
257
+ if (typeof key !== "string") return;
258
+
259
+ const allowedServices = keys[key.toLowerCase()]?.allowedServices;
260
+ if (!allowedServices) return;
261
+
262
+ if (allowedServices === "all") {
263
+ return env.allServices;
264
+ }
265
+ return allowedServices;
266
+ }
api/src/stream/ffmpeg.js ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ffmpeg from "ffmpeg-static";
2
+ import { spawn } from "child_process";
3
+ import { create as contentDisposition } from "content-disposition-header";
4
+
5
+ import { env } from "../config.js";
6
+ import { destroyInternalStream } from "./manage.js";
7
+ import { hlsExceptions } from "../processing/service-config.js";
8
+ import { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
9
+
10
+ const metadataTags = new Set([
11
+ "album",
12
+ "composer",
13
+ "genre",
14
+ "copyright",
15
+ "title",
16
+ "artist",
17
+ "album_artist",
18
+ "track",
19
+ "date",
20
+ "sublanguage"
21
+ ]);
22
+
23
+ const convertMetadataToFFmpeg = (metadata) => {
24
+ const args = [];
25
+
26
+ for (const [ name, value ] of Object.entries(metadata)) {
27
+ if (metadataTags.has(name)) {
28
+ if (name === "sublanguage") {
29
+ args.push('-metadata:s:s:0', `language=${value}`);
30
+ continue;
31
+ }
32
+ args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, '')}`); // skipcq: JS-0004
33
+ } else {
34
+ throw `${name} metadata tag is not supported.`;
35
+ }
36
+ }
37
+
38
+ return args;
39
+ }
40
+
41
+ const killProcess = (p) => {
42
+ p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
43
+
44
+ setTimeout(() => {
45
+ if (p?.exitCode === null)
46
+ p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
47
+ }, 5000);
48
+ }
49
+
50
+ const getCommand = (args) => {
51
+ if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
52
+ return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
53
+ }
54
+ return [ffmpeg, args]
55
+ }
56
+
57
+ const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
58
+ let process;
59
+ const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
60
+ const shutdown = () => (
61
+ killProcess(process),
62
+ closeResponse(res),
63
+ urls.map(destroyInternalStream)
64
+ );
65
+
66
+ try {
67
+ const args = [
68
+ '-loglevel', '-8',
69
+ ...ffargs,
70
+ ];
71
+
72
+ process = spawn(...getCommand(args), {
73
+ windowsHide: true,
74
+ stdio: [
75
+ 'inherit', 'inherit', 'inherit',
76
+ 'pipe'
77
+ ],
78
+ });
79
+
80
+ const [,,, muxOutput] = process.stdio;
81
+
82
+ res.setHeader('Connection', 'keep-alive');
83
+ res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
84
+
85
+ res.setHeader(
86
+ 'Estimated-Content-Length',
87
+ await estimateTunnelLength(streamInfo, estimateMultiplier)
88
+ );
89
+
90
+ pipe(muxOutput, res, shutdown);
91
+
92
+ process.on('close', shutdown);
93
+ res.on('finish', shutdown);
94
+ } catch {
95
+ shutdown();
96
+ }
97
+ }
98
+
99
+ const remux = async (streamInfo, res) => {
100
+ const format = streamInfo.filename.split('.').pop();
101
+ const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
102
+ const args = urls.flatMap(url => ['-i', url]);
103
+
104
+ // if the stream type is merge, we expect two URLs
105
+ if (streamInfo.type === 'merge' && urls.length !== 2) {
106
+ return closeResponse(res);
107
+ }
108
+
109
+ if (streamInfo.subtitles) {
110
+ args.push(
111
+ '-i', streamInfo.subtitles,
112
+ '-map', `${urls.length}:s`,
113
+ '-c:s', format === 'mp4' ? 'mov_text' : 'webvtt',
114
+ );
115
+ }
116
+
117
+ if (urls.length === 2) {
118
+ args.push(
119
+ '-map', '0:v',
120
+ '-map', '1:a',
121
+ );
122
+ } else {
123
+ args.push(
124
+ '-map', '0:v:0',
125
+ '-map', '0:a:0'
126
+ );
127
+ }
128
+
129
+ args.push(
130
+ '-c:v', 'copy',
131
+ ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
132
+ );
133
+
134
+ if (format === 'mp4') {
135
+ args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
136
+ }
137
+
138
+ if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.has(streamInfo.service)) {
139
+ if (streamInfo.service === 'youtube' && format === 'webm') {
140
+ args.push('-c:a', 'libopus');
141
+ } else {
142
+ args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');
143
+ }
144
+ }
145
+
146
+ if (streamInfo.metadata) {
147
+ args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
148
+ }
149
+
150
+ args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
151
+
152
+ await render(res, streamInfo, args);
153
+ }
154
+
155
+ const convertAudio = async (streamInfo, res) => {
156
+ const args = [
157
+ '-i', streamInfo.urls,
158
+ '-vn',
159
+ ...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
160
+ ];
161
+
162
+ if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
163
+ args.push('-ar', '12000');
164
+ }
165
+
166
+ if (streamInfo.audioFormat === 'opus') {
167
+ args.push('-vbr', 'off');
168
+ }
169
+
170
+ if (streamInfo.audioFormat === 'mp4a') {
171
+ args.push('-movflags', 'frag_keyframe+empty_moov');
172
+ }
173
+
174
+ if (streamInfo.metadata) {
175
+ args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
176
+ }
177
+
178
+ args.push(
179
+ '-f',
180
+ streamInfo.audioFormat === 'm4a' ? 'ipod' : streamInfo.audioFormat,
181
+ 'pipe:3',
182
+ );
183
+
184
+ await render(
185
+ res,
186
+ streamInfo,
187
+ args,
188
+ estimateAudioMultiplier(streamInfo) * 1.1,
189
+ );
190
+ }
191
+
192
+ const convertGif = async (streamInfo, res) => {
193
+ const args = [
194
+ '-i', streamInfo.urls,
195
+
196
+ '-vf',
197
+ 'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
198
+ '-loop', '0',
199
+
200
+ '-f', 'gif', 'pipe:3',
201
+ ];
202
+
203
+ await render(
204
+ res,
205
+ streamInfo,
206
+ args,
207
+ 60,
208
+ );
209
+ }
210
+
211
+ export default {
212
+ remux,
213
+ convertAudio,
214
+ convertGif,
215
+ }
api/src/stream/internal-hls.js CHANGED
@@ -1,5 +1,6 @@
1
  import HLS from "hls-parser";
2
  import { createInternalStream } from "./manage.js";
 
3
 
4
  function getURL(url) {
5
  try {
@@ -55,8 +56,11 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
55
 
56
  const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
57
 
58
- export function isHlsResponse (req) {
59
- return HLS_MIME_TYPES.includes(req.headers['content-type']);
 
 
 
60
  }
61
 
62
  export async function handleHlsPlaylist(streamInfo, req, res) {
@@ -71,3 +75,67 @@ export async function handleHlsPlaylist(streamInfo, req, res) {
71
 
72
  res.send(hlsPlaylist);
73
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import HLS from "hls-parser";
2
  import { createInternalStream } from "./manage.js";
3
+ import { request } from "undici";
4
 
5
  function getURL(url) {
6
  try {
 
56
 
57
  const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
58
 
59
+ export function isHlsResponse(req, streamInfo) {
60
+ return HLS_MIME_TYPES.includes(req.headers['content-type'])
61
+ // bluesky's cdn responds with wrong content-type for the hls playlist,
62
+ // so we enforce it here until they fix it
63
+ || (streamInfo.service === 'bsky' && streamInfo.url.endsWith('.m3u8'));
64
  }
65
 
66
  export async function handleHlsPlaylist(streamInfo, req, res) {
 
75
 
76
  res.send(hlsPlaylist);
77
  }
78
+
79
+ async function getSegmentSize(url, config) {
80
+ const segmentResponse = await request(url, {
81
+ ...config,
82
+ throwOnError: true
83
+ });
84
+
85
+ if (segmentResponse.headers['content-length']) {
86
+ segmentResponse.body.dump();
87
+ return +segmentResponse.headers['content-length'];
88
+ }
89
+
90
+ // if the response does not have a content-length
91
+ // header, we have to compute it ourselves
92
+ let size = 0;
93
+
94
+ for await (const data of segmentResponse.body) {
95
+ size += data.length;
96
+ }
97
+
98
+ return size;
99
+ }
100
+
101
+ export async function probeInternalHLSTunnel(streamInfo) {
102
+ const { url, headers, dispatcher, signal } = streamInfo;
103
+
104
+ // remove all falsy headers
105
+ Object.keys(headers).forEach(key => {
106
+ if (!headers[key]) delete headers[key];
107
+ });
108
+
109
+ const config = { headers, dispatcher, signal, maxRedirections: 16 };
110
+
111
+ const manifestResponse = await fetch(url, config);
112
+
113
+ const manifest = HLS.parse(await manifestResponse.text());
114
+ if (manifest.segments.length === 0)
115
+ return -1;
116
+
117
+ const segmentSamples = await Promise.all(
118
+ Array(5).fill().map(async () => {
119
+ const manifestIdx = Math.floor(Math.random() * manifest.segments.length);
120
+ const randomSegment = manifest.segments[manifestIdx];
121
+ if (!randomSegment.uri)
122
+ throw "segment is missing URI";
123
+
124
+ let segmentUrl;
125
+
126
+ if (getURL(randomSegment.uri)) {
127
+ segmentUrl = new URL(randomSegment.uri);
128
+ } else {
129
+ segmentUrl = new URL(randomSegment.uri, streamInfo.url);
130
+ }
131
+
132
+ const segmentSize = await getSegmentSize(segmentUrl, config) / randomSegment.duration;
133
+ return segmentSize;
134
+ })
135
+ );
136
+
137
+ const averageBitrate = segmentSamples.reduce((a, b) => a + b) / segmentSamples.length;
138
+ const totalDuration = manifest.segments.reduce((acc, segment) => acc + segment.duration, 0);
139
+
140
+ return averageBitrate * totalDuration;
141
+ }
api/src/stream/internal.js CHANGED
@@ -1,11 +1,13 @@
1
  import { request } from "undici";
2
  import { Readable } from "node:stream";
3
  import { closeRequest, getHeaders, pipe } from "./shared.js";
4
- import { handleHlsPlaylist, isHlsResponse } from "./internal-hls.js";
5
 
6
  const CHUNK_SIZE = BigInt(8e6); // 8 MB
7
  const min = (a, b) => a < b ? a : b;
8
 
 
 
9
  async function* readChunks(streamInfo, size) {
10
  let read = 0n, chunksSinceTransplant = 0;
11
  while (read < size) {
@@ -15,7 +17,7 @@ async function* readChunks(streamInfo, size) {
15
 
16
  const chunk = await request(streamInfo.url, {
17
  headers: {
18
- ...getHeaders('youtube'),
19
  Range: `bytes=${read}-${read + CHUNK_SIZE}`
20
  },
21
  dispatcher: streamInfo.dispatcher,
@@ -48,7 +50,7 @@ async function* readChunks(streamInfo, size) {
48
  }
49
  }
50
 
51
- async function handleYoutubeStream(streamInfo, res) {
52
  const { signal } = streamInfo.controller;
53
  const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
54
 
@@ -56,7 +58,7 @@ async function handleYoutubeStream(streamInfo, res) {
56
  let req, attempts = 3;
57
  while (attempts--) {
58
  req = await fetch(streamInfo.url, {
59
- headers: getHeaders('youtube'),
60
  method: 'HEAD',
61
  dispatcher: streamInfo.dispatcher,
62
  signal
@@ -118,10 +120,7 @@ async function handleGenericStream(streamInfo, res) {
118
  res.status(fileResponse.statusCode);
119
  fileResponse.body.on('error', () => {});
120
 
121
- // bluesky's cdn responds with wrong content-type for the hls playlist,
122
- // so we enforce it here until they fix it
123
- const isHls = isHlsResponse(fileResponse)
124
- || (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8'));
125
 
126
  for (const [ name, value ] of Object.entries(fileResponse.headers)) {
127
  if (!isHls || name.toLowerCase() !== 'content-length') {
@@ -149,9 +148,46 @@ export function internalStream(streamInfo, res) {
149
  streamInfo.headers.delete('icy-metadata');
150
  }
151
 
152
- if (streamInfo.service === 'youtube' && !streamInfo.isHLS) {
153
- return handleYoutubeStream(streamInfo, res);
154
  }
155
 
156
  return handleGenericStream(streamInfo, res);
157
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { request } from "undici";
2
  import { Readable } from "node:stream";
3
  import { closeRequest, getHeaders, pipe } from "./shared.js";
4
+ import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./internal-hls.js";
5
 
6
  const CHUNK_SIZE = BigInt(8e6); // 8 MB
7
  const min = (a, b) => a < b ? a : b;
8
 
9
+ const serviceNeedsChunks = new Set(["youtube", "vk"]);
10
+
11
  async function* readChunks(streamInfo, size) {
12
  let read = 0n, chunksSinceTransplant = 0;
13
  while (read < size) {
 
17
 
18
  const chunk = await request(streamInfo.url, {
19
  headers: {
20
+ ...getHeaders(streamInfo.service),
21
  Range: `bytes=${read}-${read + CHUNK_SIZE}`
22
  },
23
  dispatcher: streamInfo.dispatcher,
 
50
  }
51
  }
52
 
53
+ async function handleChunkedStream(streamInfo, res) {
54
  const { signal } = streamInfo.controller;
55
  const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
56
 
 
58
  let req, attempts = 3;
59
  while (attempts--) {
60
  req = await fetch(streamInfo.url, {
61
+ headers: getHeaders(streamInfo.service),
62
  method: 'HEAD',
63
  dispatcher: streamInfo.dispatcher,
64
  signal
 
120
  res.status(fileResponse.statusCode);
121
  fileResponse.body.on('error', () => {});
122
 
123
+ const isHls = isHlsResponse(fileResponse, streamInfo);
 
 
 
124
 
125
  for (const [ name, value ] of Object.entries(fileResponse.headers)) {
126
  if (!isHls || name.toLowerCase() !== 'content-length') {
 
148
  streamInfo.headers.delete('icy-metadata');
149
  }
150
 
151
+ if (serviceNeedsChunks.has(streamInfo.service) && !streamInfo.isHLS) {
152
+ return handleChunkedStream(streamInfo, res);
153
  }
154
 
155
  return handleGenericStream(streamInfo, res);
156
  }
157
+
158
+ export async function probeInternalTunnel(streamInfo) {
159
+ try {
160
+ const signal = AbortSignal.timeout(3000);
161
+ const headers = {
162
+ ...Object.fromEntries(streamInfo.headers || []),
163
+ ...getHeaders(streamInfo.service),
164
+ host: undefined,
165
+ range: undefined
166
+ };
167
+
168
+ if (streamInfo.isHLS) {
169
+ return probeInternalHLSTunnel({
170
+ ...streamInfo,
171
+ signal,
172
+ headers
173
+ });
174
+ }
175
+
176
+ const response = await request(streamInfo.url, {
177
+ method: 'HEAD',
178
+ headers,
179
+ dispatcher: streamInfo.dispatcher,
180
+ signal,
181
+ maxRedirections: 16
182
+ });
183
+
184
+ if (response.statusCode !== 200)
185
+ throw "status is not 200 OK";
186
+
187
+ const size = +response.headers['content-length'];
188
+ if (isNaN(size))
189
+ throw "content-length is not a number";
190
+
191
+ return size;
192
+ } catch {}
193
+ }
api/src/stream/manage.js CHANGED
@@ -41,7 +41,10 @@ export function createStream(obj) {
41
  audioFormat: obj.audioFormat,
42
 
43
  isHLS: obj.isHLS || false,
44
- originalRequest: obj.originalRequest
 
 
 
45
  };
46
 
47
  // FIXME: this is now a Promise, but it is not awaited
@@ -70,11 +73,70 @@ export function createStream(obj) {
70
  return streamLink.toString();
71
  }
72
 
73
- export function getInternalStream(id) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  return internalStreamCache.get(id);
75
  }
76
 
77
- export function createInternalStream(url, obj = {}) {
 
 
 
 
 
 
 
 
 
 
78
  assert(typeof url === 'string');
79
 
80
  let dispatcher = obj.dispatcher;
@@ -95,9 +157,12 @@ export function createInternalStream(url, obj = {}) {
95
  headers = new Map(Object.entries(obj.headers));
96
  }
97
 
 
 
 
98
  internalStreamCache.set(streamID, {
99
  url,
100
- service: obj.service,
101
  headers,
102
  controller,
103
  dispatcher,
@@ -131,7 +196,7 @@ export function destroyInternalStream(url) {
131
  const id = getInternalTunnelId(url);
132
 
133
  if (internalStreamCache.has(id)) {
134
- closeRequest(getInternalStream(id)?.controller);
135
  internalStreamCache.delete(id);
136
  }
137
  }
@@ -143,7 +208,7 @@ const transplantInternalTunnels = function(tunnelUrls, transplantUrls) {
143
 
144
  for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) {
145
  const id = getInternalTunnelId(tun);
146
- const itunnel = getInternalStream(id);
147
 
148
  if (!itunnel) continue;
149
  itunnel.url = url;
@@ -208,6 +273,14 @@ function wrapStream(streamInfo) {
208
  }
209
  } else throw 'invalid urls';
210
 
 
 
 
 
 
 
 
 
211
  return streamInfo;
212
  }
213
 
 
41
  audioFormat: obj.audioFormat,
42
 
43
  isHLS: obj.isHLS || false,
44
+ originalRequest: obj.originalRequest,
45
+
46
+ // url to a subtitle file
47
+ subtitles: obj.subtitles,
48
  };
49
 
50
  // FIXME: this is now a Promise, but it is not awaited
 
73
  return streamLink.toString();
74
  }
75
 
76
+ export function createProxyTunnels(info) {
77
+ const proxyTunnels = [];
78
+
79
+ let urls = info.url;
80
+
81
+ if (typeof urls === "string") {
82
+ urls = [urls];
83
+ }
84
+
85
+ const tunnelTemplate = {
86
+ type: "proxy",
87
+ headers: info?.headers,
88
+ requestIP: info?.requestIP,
89
+ }
90
+
91
+ for (const url of urls) {
92
+ proxyTunnels.push(
93
+ createStream({
94
+ ...tunnelTemplate,
95
+ url,
96
+ service: info?.service,
97
+ originalRequest: info?.originalRequest,
98
+ })
99
+ );
100
+ }
101
+
102
+ if (info.subtitles) {
103
+ proxyTunnels.push(
104
+ createStream({
105
+ ...tunnelTemplate,
106
+ url: info.subtitles,
107
+ service: `${info?.service}-subtitles`,
108
+ })
109
+ );
110
+ }
111
+
112
+ if (info.cover) {
113
+ proxyTunnels.push(
114
+ createStream({
115
+ ...tunnelTemplate,
116
+ url: info.cover,
117
+ service: `${info?.service}-cover`,
118
+ })
119
+ );
120
+ }
121
+
122
+ return proxyTunnels;
123
+ }
124
+
125
+ export function getInternalTunnel(id) {
126
  return internalStreamCache.get(id);
127
  }
128
 
129
+ export function getInternalTunnelFromURL(url) {
130
+ url = new URL(url);
131
+ if (url.hostname !== '127.0.0.1') {
132
+ return;
133
+ }
134
+
135
+ const id = url.searchParams.get('id');
136
+ return getInternalTunnel(id);
137
+ }
138
+
139
+ export function createInternalStream(url, obj = {}, isSubtitles) {
140
  assert(typeof url === 'string');
141
 
142
  let dispatcher = obj.dispatcher;
 
157
  headers = new Map(Object.entries(obj.headers));
158
  }
159
 
160
+ // subtitles don't need special treatment unlike big media files
161
+ const service = isSubtitles ? `${obj.service}-subtitles` : obj.service;
162
+
163
  internalStreamCache.set(streamID, {
164
  url,
165
+ service,
166
  headers,
167
  controller,
168
  dispatcher,
 
196
  const id = getInternalTunnelId(url);
197
 
198
  if (internalStreamCache.has(id)) {
199
+ closeRequest(getInternalTunnel(id)?.controller);
200
  internalStreamCache.delete(id);
201
  }
202
  }
 
208
 
209
  for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) {
210
  const id = getInternalTunnelId(tun);
211
+ const itunnel = getInternalTunnel(id);
212
 
213
  if (!itunnel) continue;
214
  itunnel.url = url;
 
273
  }
274
  } else throw 'invalid urls';
275
 
276
+ if (streamInfo.subtitles) {
277
+ streamInfo.subtitles = createInternalStream(
278
+ streamInfo.subtitles,
279
+ streamInfo,
280
+ /*isSubtitles=*/true
281
+ );
282
+ }
283
+
284
  return streamInfo;
285
  }
286
 
api/src/stream/proxy.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Agent, request } from "undici";
2
+ import { create as contentDisposition } from "content-disposition-header";
3
+
4
+ import { destroyInternalStream } from "./manage.js";
5
+ import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
6
+
7
+ const defaultAgent = new Agent();
8
+
9
+ export default async function (streamInfo, res) {
10
+ const abortController = new AbortController();
11
+ const shutdown = () => (
12
+ closeRequest(abortController),
13
+ closeResponse(res),
14
+ destroyInternalStream(streamInfo.urls)
15
+ );
16
+
17
+ try {
18
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
19
+ res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
20
+
21
+ const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
22
+ headers: {
23
+ ...getHeaders(streamInfo.service),
24
+ Range: streamInfo.range
25
+ },
26
+ signal: abortController.signal,
27
+ maxRedirections: 16,
28
+ dispatcher: defaultAgent,
29
+ });
30
+
31
+ res.status(statusCode);
32
+
33
+ for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {
34
+ if (headers[headerName]) {
35
+ res.setHeader(headerName, headers[headerName]);
36
+ }
37
+ }
38
+
39
+ pipe(stream, res, shutdown);
40
+ } catch {
41
+ shutdown();
42
+ }
43
+ }
api/src/stream/shared.js CHANGED
@@ -1,5 +1,7 @@
1
  import { genericUserAgent } from "../config.js";
2
  import { vkClientAgent } from "../processing/services/vk.js";
 
 
3
 
4
  const defaultHeaders = {
5
  'user-agent': genericUserAgent
@@ -17,6 +19,9 @@ const serviceHeaders = {
17
  },
18
  vk: {
19
  'user-agent': vkClientAgent
 
 
 
20
  }
21
  }
22
 
@@ -47,3 +52,40 @@ export function pipe(from, to, done) {
47
 
48
  from.pipe(to);
49
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { genericUserAgent } from "../config.js";
2
  import { vkClientAgent } from "../processing/services/vk.js";
3
+ import { getInternalTunnelFromURL } from "./manage.js";
4
+ import { probeInternalTunnel } from "./internal.js";
5
 
6
  const defaultHeaders = {
7
  'user-agent': genericUserAgent
 
19
  },
20
  vk: {
21
  'user-agent': vkClientAgent
22
+ },
23
+ tiktok: {
24
+ referer: 'https://www.tiktok.com/',
25
  }
26
  }
27
 
 
52
 
53
  from.pipe(to);
54
  }
55
+
56
+ export async function estimateTunnelLength(streamInfo, multiplier = 1.1) {
57
+ let urls = streamInfo.urls;
58
+ if (!Array.isArray(urls)) {
59
+ urls = [ urls ];
60
+ }
61
+
62
+ const internalTunnels = urls.map(getInternalTunnelFromURL);
63
+ if (internalTunnels.some(t => !t))
64
+ return -1;
65
+
66
+ const sizes = await Promise.all(internalTunnels.map(probeInternalTunnel));
67
+ const estimatedSize = sizes.reduce(
68
+ // if one of the sizes is missing, let's just make a very
69
+ // bold guess that it's the same size as the existing one
70
+ (acc, cur) => cur <= 0 ? acc * 2 : acc + cur,
71
+ 0
72
+ );
73
+
74
+ if (isNaN(estimatedSize) || estimatedSize <= 0) {
75
+ return -1;
76
+ }
77
+
78
+ return Math.floor(estimatedSize * multiplier);
79
+ }
80
+
81
+ export function estimateAudioMultiplier(streamInfo) {
82
+ if (streamInfo.audioFormat === 'wav') {
83
+ return 1411 / 128;
84
+ }
85
+
86
+ if (streamInfo.audioCopy) {
87
+ return 1;
88
+ }
89
+
90
+ return streamInfo.audioBitrate / 128;
91
+ }
api/src/stream/stream.js CHANGED
@@ -1,4 +1,5 @@
1
- import stream from "./types.js";
 
2
 
3
  import { closeResponse } from "./shared.js";
4
  import { internalStream } from "./internal.js";
@@ -7,23 +8,21 @@ export default async function(res, streamInfo) {
7
  try {
8
  switch (streamInfo.type) {
9
  case "proxy":
10
- return await stream.proxy(streamInfo, res);
11
 
12
  case "internal":
13
- return internalStream(streamInfo.data, res);
14
 
15
  case "merge":
16
- return stream.merge(streamInfo, res);
17
-
18
  case "remux":
19
  case "mute":
20
- return stream.remux(streamInfo, res);
21
 
22
  case "audio":
23
- return stream.convertAudio(streamInfo, res);
24
 
25
  case "gif":
26
- return stream.convertGif(streamInfo, res);
27
  }
28
 
29
  closeResponse(res);
 
1
+ import proxy from "./proxy.js";
2
+ import ffmpeg from "./ffmpeg.js";
3
 
4
  import { closeResponse } from "./shared.js";
5
  import { internalStream } from "./internal.js";
 
8
  try {
9
  switch (streamInfo.type) {
10
  case "proxy":
11
+ return await proxy(streamInfo, res);
12
 
13
  case "internal":
14
+ return await internalStream(streamInfo.data, res);
15
 
16
  case "merge":
 
 
17
  case "remux":
18
  case "mute":
19
+ return await ffmpeg.remux(streamInfo, res);
20
 
21
  case "audio":
22
+ return await ffmpeg.convertAudio(streamInfo, res);
23
 
24
  case "gif":
25
+ return await ffmpeg.convertGif(streamInfo, res);
26
  }
27
 
28
  closeResponse(res);
api/src/stream/types.js DELETED
@@ -1,340 +0,0 @@
1
- import { Agent, request } from "undici";
2
- import ffmpeg from "ffmpeg-static";
3
- import { spawn } from "child_process";
4
- import { create as contentDisposition } from "content-disposition-header";
5
-
6
- import { env } from "../config.js";
7
- import { destroyInternalStream } from "./manage.js";
8
- import { hlsExceptions } from "../processing/service-config.js";
9
- import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
10
-
11
- const ffmpegArgs = {
12
- webm: ["-c:v", "copy", "-c:a", "copy"],
13
- mp4: ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"],
14
- m4a: ["-movflags", "frag_keyframe+empty_moov"],
15
- gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"]
16
- }
17
-
18
- const metadataTags = [
19
- "album",
20
- "copyright",
21
- "title",
22
- "artist",
23
- "track",
24
- "date",
25
- ];
26
-
27
- const convertMetadataToFFmpeg = (metadata) => {
28
- let args = [];
29
-
30
- for (const [ name, value ] of Object.entries(metadata)) {
31
- if (metadataTags.includes(name)) {
32
- args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`);
33
- } else {
34
- throw `${name} metadata tag is not supported.`;
35
- }
36
- }
37
-
38
- return args;
39
- }
40
-
41
- const toRawHeaders = (headers) => {
42
- return Object.entries(headers)
43
- .map(([key, value]) => `${key}: ${value}\r\n`)
44
- .join('');
45
- }
46
-
47
- const killProcess = (p) => {
48
- p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
49
-
50
- setTimeout(() => {
51
- if (p?.exitCode === null)
52
- p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
53
- }, 5000);
54
- }
55
-
56
- const getCommand = (args) => {
57
- if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
58
- return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
59
- }
60
- return [ffmpeg, args]
61
- }
62
-
63
- const defaultAgent = new Agent();
64
-
65
- const proxy = async (streamInfo, res) => {
66
- const abortController = new AbortController();
67
- const shutdown = () => (
68
- closeRequest(abortController),
69
- closeResponse(res),
70
- destroyInternalStream(streamInfo.urls)
71
- );
72
-
73
- try {
74
- res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
75
- res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
76
-
77
- const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
78
- headers: {
79
- ...getHeaders(streamInfo.service),
80
- Range: streamInfo.range
81
- },
82
- signal: abortController.signal,
83
- maxRedirections: 16,
84
- dispatcher: defaultAgent,
85
- });
86
-
87
- res.status(statusCode);
88
-
89
- for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {
90
- if (headers[headerName]) {
91
- res.setHeader(headerName, headers[headerName]);
92
- }
93
- }
94
-
95
- pipe(stream, res, shutdown);
96
- } catch {
97
- shutdown();
98
- }
99
- }
100
-
101
- const merge = (streamInfo, res) => {
102
- let process;
103
- const shutdown = () => (
104
- killProcess(process),
105
- closeResponse(res),
106
- streamInfo.urls.map(destroyInternalStream)
107
- );
108
-
109
- const headers = getHeaders(streamInfo.service);
110
- const rawHeaders = toRawHeaders(headers);
111
-
112
- try {
113
- if (streamInfo.urls.length !== 2) return shutdown();
114
-
115
- const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
116
-
117
- let args = [
118
- '-loglevel', '-8',
119
- '-headers', rawHeaders,
120
- '-i', streamInfo.urls[0],
121
- '-headers', rawHeaders,
122
- '-i', streamInfo.urls[1],
123
- '-map', '0:v',
124
- '-map', '1:a',
125
- ]
126
-
127
- args = args.concat(ffmpegArgs[format]);
128
-
129
- if (hlsExceptions.includes(streamInfo.service) && streamInfo.isHLS) {
130
- if (streamInfo.service === "youtube" && format === "webm") {
131
- args.push('-c:a', 'libopus');
132
- } else {
133
- args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');
134
- }
135
- }
136
-
137
- if (streamInfo.metadata) {
138
- args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata))
139
- }
140
-
141
- args.push('-f', format, 'pipe:3');
142
-
143
- process = spawn(...getCommand(args), {
144
- windowsHide: true,
145
- stdio: [
146
- 'inherit', 'inherit', 'inherit',
147
- 'pipe'
148
- ],
149
- });
150
-
151
- const [,,, muxOutput] = process.stdio;
152
-
153
- res.setHeader('Connection', 'keep-alive');
154
- res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
155
-
156
- pipe(muxOutput, res, shutdown);
157
-
158
- process.on('close', shutdown);
159
- res.on('finish', shutdown);
160
- } catch {
161
- shutdown();
162
- }
163
- }
164
-
165
- const remux = (streamInfo, res) => {
166
- let process;
167
- const shutdown = () => (
168
- killProcess(process),
169
- closeResponse(res),
170
- destroyInternalStream(streamInfo.urls)
171
- );
172
-
173
- try {
174
- let args = [
175
- '-loglevel', '-8',
176
- '-headers', toRawHeaders(getHeaders(streamInfo.service)),
177
- ]
178
-
179
- if (streamInfo.service === "twitter") {
180
- args.push('-seekable', '0')
181
- }
182
-
183
- args.push(
184
- '-i', streamInfo.urls,
185
- '-c:v', 'copy',
186
- )
187
-
188
- if (streamInfo.type === "mute") {
189
- args.push('-an');
190
- }
191
-
192
- if (hlsExceptions.includes(streamInfo.service)) {
193
- if (streamInfo.type !== "mute") {
194
- args.push('-c:a', 'aac')
195
- }
196
- args.push('-bsf:a', 'aac_adtstoasc');
197
- }
198
-
199
- let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
200
- if (format === "mp4") {
201
- args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
202
- }
203
-
204
- args.push('-f', format, 'pipe:3');
205
-
206
- process = spawn(...getCommand(args), {
207
- windowsHide: true,
208
- stdio: [
209
- 'inherit', 'inherit', 'inherit',
210
- 'pipe'
211
- ],
212
- });
213
-
214
- const [,,, muxOutput] = process.stdio;
215
-
216
- res.setHeader('Connection', 'keep-alive');
217
- res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
218
-
219
- pipe(muxOutput, res, shutdown);
220
-
221
- process.on('close', shutdown);
222
- res.on('finish', shutdown);
223
- } catch {
224
- shutdown();
225
- }
226
- }
227
-
228
- const convertAudio = (streamInfo, res) => {
229
- let process;
230
- const shutdown = () => (
231
- killProcess(process),
232
- closeResponse(res),
233
- destroyInternalStream(streamInfo.urls)
234
- );
235
-
236
- try {
237
- let args = [
238
- '-loglevel', '-8',
239
- '-headers', toRawHeaders(getHeaders(streamInfo.service)),
240
- ]
241
-
242
- if (streamInfo.service === "twitter") {
243
- args.push('-seekable', '0');
244
- }
245
-
246
- args.push(
247
- '-i', streamInfo.urls,
248
- '-vn'
249
- )
250
-
251
- if (streamInfo.audioCopy) {
252
- args.push("-c:a", "copy")
253
- } else {
254
- args.push("-b:a", `${streamInfo.audioBitrate}k`)
255
- }
256
-
257
- if (streamInfo.audioFormat === "mp3" && streamInfo.audioBitrate === "8") {
258
- args.push("-ar", "12000");
259
- }
260
-
261
- if (streamInfo.audioFormat === "opus") {
262
- args.push("-vbr", "off")
263
- }
264
-
265
- if (ffmpegArgs[streamInfo.audioFormat]) {
266
- args = args.concat(ffmpegArgs[streamInfo.audioFormat])
267
- }
268
-
269
- if (streamInfo.metadata) {
270
- args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata))
271
- }
272
-
273
- args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3');
274
-
275
- process = spawn(...getCommand(args), {
276
- windowsHide: true,
277
- stdio: [
278
- 'inherit', 'inherit', 'inherit',
279
- 'pipe'
280
- ],
281
- });
282
-
283
- const [,,, muxOutput] = process.stdio;
284
-
285
- res.setHeader('Connection', 'keep-alive');
286
- res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
287
-
288
- pipe(muxOutput, res, shutdown);
289
- res.on('finish', shutdown);
290
- } catch {
291
- shutdown();
292
- }
293
- }
294
-
295
- const convertGif = (streamInfo, res) => {
296
- let process;
297
- const shutdown = () => (killProcess(process), closeResponse(res));
298
-
299
- try {
300
- let args = [
301
- '-loglevel', '-8'
302
- ]
303
-
304
- if (streamInfo.service === "twitter") {
305
- args.push('-seekable', '0')
306
- }
307
-
308
- args.push('-i', streamInfo.urls);
309
- args = args.concat(ffmpegArgs.gif);
310
- args.push('-f', "gif", 'pipe:3');
311
-
312
- process = spawn(...getCommand(args), {
313
- windowsHide: true,
314
- stdio: [
315
- 'inherit', 'inherit', 'inherit',
316
- 'pipe'
317
- ],
318
- });
319
-
320
- const [,,, muxOutput] = process.stdio;
321
-
322
- res.setHeader('Connection', 'keep-alive');
323
- res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
324
-
325
- pipe(muxOutput, res, shutdown);
326
-
327
- process.on('close', shutdown);
328
- res.on('finish', shutdown);
329
- } catch {
330
- shutdown();
331
- }
332
- }
333
-
334
- export default {
335
- proxy,
336
- merge,
337
- remux,
338
- convertAudio,
339
- convertGif,
340
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/src/util/test.js CHANGED
@@ -4,7 +4,7 @@ import { env } from "../config.js";
4
  import { runTest } from "../misc/run-test.js";
5
  import { loadJSON } from "../misc/load-from-fs.js";
6
  import { Red, Bright } from "../misc/console-text.js";
7
- import { setGlobalDispatcher, ProxyAgent } from "undici";
8
  import { randomizeCiphers } from "../misc/randomize-ciphers.js";
9
 
10
  import { services } from "../processing/service-config.js";
@@ -69,9 +69,10 @@ const printHeader = (service, padLen) => {
69
  console.log(service + '='.repeat(50));
70
  }
71
 
72
- if (env.externalProxy) {
73
- setGlobalDispatcher(new ProxyAgent(env.externalProxy));
74
- }
 
75
 
76
  env.streamLifespan = 10000;
77
  env.apiURL = 'http://x/';
 
4
  import { runTest } from "../misc/run-test.js";
5
  import { loadJSON } from "../misc/load-from-fs.js";
6
  import { Red, Bright } from "../misc/console-text.js";
7
+ import { setGlobalDispatcher, EnvHttpProxyAgent, ProxyAgent } from "undici";
8
  import { randomizeCiphers } from "../misc/randomize-ciphers.js";
9
 
10
  import { services } from "../processing/service-config.js";
 
69
  console.log(service + '='.repeat(50));
70
  }
71
 
72
+ // TODO: remove env.externalProxy in a future version
73
+ setGlobalDispatcher(
74
+ new EnvHttpProxyAgent({ httpProxy: env.externalProxy || undefined })
75
+ );
76
 
77
  env.streamLifespan = 10000;
78
  env.apiURL = 'http://x/';
api/src/util/tests/bilibili.json CHANGED
@@ -56,5 +56,14 @@
56
  "code": 200,
57
  "status": "tunnel"
58
  }
 
 
 
 
 
 
 
 
 
59
  }
60
  ]
 
56
  "code": 200,
57
  "status": "tunnel"
58
  }
59
+ },
60
+ {
61
+ "name": "bilibili.com link with part id",
62
+ "url": "https://www.bilibili.com/video/BV1uo4y1K72s?spm_id_from=333.788.videopod.episodes&p=6",
63
+ "params": {},
64
+ "expected": {
65
+ "code": 200,
66
+ "status": "tunnel"
67
+ }
68
  }
69
  ]
api/src/util/tests/facebook.json CHANGED
@@ -1,7 +1,7 @@
1
  [
2
  {
3
  "name": "direct video with username and id",
4
- "url": "https://web.facebook.com/100048111287134/videos/1157798148685638/",
5
  "params": {},
6
  "expected": {
7
  "code": 200,
 
1
  [
2
  {
3
  "name": "direct video with username and id",
4
+ "url": "https://web.facebook.com/100071784061914/videos/588631943886661/",
5
  "params": {},
6
  "expected": {
7
  "code": 200,
api/src/util/tests/loom.json CHANGED
@@ -29,5 +29,32 @@
29
  "code": 400,
30
  "status": "error"
31
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
- ]
 
29
  "code": 400,
30
  "status": "error"
31
  }
32
+ },
33
+ {
34
+ "name": "video with no transcodedUrl",
35
+ "url": "https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9",
36
+ "params": {},
37
+ "expected": {
38
+ "code": 200,
39
+ "status": "redirect"
40
+ }
41
+ },
42
+ {
43
+ "name": "video with title in url",
44
+ "url": "https://www.loom.com/share/Meet-AI-workflows-aa3d8b08bee74d05af5b42989e9f33e9",
45
+ "params": {},
46
+ "expected": {
47
+ "code": 200,
48
+ "status": "redirect"
49
+ }
50
+ },
51
+ {
52
+ "name": "video with title in url (2)",
53
+ "url": "https://www.loom.com/share/Unlocking-Incredible-Organizational-Velocity-with-Async-Video-4a2a8baf124c4390954dcbb46a58cfd7",
54
+ "params": {},
55
+ "expected": {
56
+ "code": 200,
57
+ "status": "redirect"
58
+ }
59
  }
60
+ ]
api/src/util/tests/newgrounds.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "name": "regular video",
4
+ "url": "https://www.newgrounds.com/portal/view/938050",
5
+ "params": {},
6
+ "expected": {
7
+ "code": 200,
8
+ "status": "tunnel"
9
+ }
10
+ },
11
+ {
12
+ "name": "regular video (audio only)",
13
+ "url": "https://www.newgrounds.com/portal/view/938050",
14
+ "params": {
15
+ "downloadMode": "audio"
16
+ },
17
+ "expected": {
18
+ "code": 200,
19
+ "status": "tunnel"
20
+ }
21
+ },
22
+ {
23
+ "name": "regular video (muted)",
24
+ "url": "https://www.newgrounds.com/portal/view/938050",
25
+ "params": {
26
+ "downloadMode": "mute"
27
+ },
28
+ "expected": {
29
+ "code": 200,
30
+ "status": "tunnel"
31
+ }
32
+ },
33
+ {
34
+ "name": "regular music",
35
+ "url": "https://www.newgrounds.com/audio/listen/500476",
36
+ "params": {},
37
+ "expected": {
38
+ "code": 200,
39
+ "status": "tunnel"
40
+ }
41
+ }
42
+ ]