Closure-RI commited on
Commit
b665708
·
1 Parent(s): ad38873
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +17 -0
  2. Dockerfile +31 -23
  3. package.json +52 -0
  4. pnpm-lock.yaml +0 -0
  5. src/cobalt.js +32 -0
  6. src/config.js +81 -0
  7. src/core/api.js +394 -0
  8. src/misc/cluster.js +71 -0
  9. src/misc/console-text.js +36 -0
  10. src/misc/crypto.js +19 -0
  11. src/misc/load-from-fs.js +20 -0
  12. src/misc/randomize-ciphers.js +28 -0
  13. src/misc/run-test.js +44 -0
  14. src/misc/utils.js +31 -0
  15. src/processing/cookie/cookie.js +48 -0
  16. src/processing/cookie/manager.js +156 -0
  17. src/processing/create-filename.js +70 -0
  18. src/processing/match-action.js +215 -0
  19. src/processing/match.js +306 -0
  20. src/processing/request.js +97 -0
  21. src/processing/schema.js +51 -0
  22. src/processing/service-alias.js +10 -0
  23. src/processing/service-config.js +185 -0
  24. src/processing/service-patterns.js +74 -0
  25. src/processing/services/bilibili.js +112 -0
  26. src/processing/services/bluesky.js +124 -0
  27. src/processing/services/dailymotion.js +107 -0
  28. src/processing/services/facebook.js +57 -0
  29. src/processing/services/instagram.js +373 -0
  30. src/processing/services/loom.js +39 -0
  31. src/processing/services/ok.js +64 -0
  32. src/processing/services/pinterest.js +46 -0
  33. src/processing/services/reddit.js +127 -0
  34. src/processing/services/rutube.js +82 -0
  35. src/processing/services/snapchat.js +126 -0
  36. src/processing/services/soundcloud.js +122 -0
  37. src/processing/services/streamable.js +22 -0
  38. src/processing/services/tiktok.js +153 -0
  39. src/processing/services/tumblr.js +71 -0
  40. src/processing/services/twitch.js +89 -0
  41. src/processing/services/twitter.js +235 -0
  42. src/processing/services/vimeo.js +170 -0
  43. src/processing/services/vk.js +140 -0
  44. src/processing/services/youtube.js +488 -0
  45. src/processing/url.js +213 -0
  46. src/security/api-keys.js +227 -0
  47. src/security/jwt.js +59 -0
  48. src/security/secrets.js +62 -0
  49. src/security/turnstile.js +19 -0
  50. src/store/base-store.js +48 -0
.env ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Port dan URL utama untuk API
2
+ API_PORT=7860
3
+ API_URL=https://closure-ri-test.hf.space/
4
+
5
+ # Konfigurasi JWT (Authentication)
6
+ JWT_SECRET=9f4b3a2c1d8e7f6a5c4b3a2e1f8e7d6c5b4a3c2e1f7d8c9b0a1e2f3c4d5b6a7
7
+ JWT_EXPIRY=120
8
+
9
+ # Konfigurasi Rate Limit
10
+ RATELIMIT_WINDOW=60
11
+ RATELIMIT_MAX=20
12
+
13
+ # Durasi maksimum video
14
+ DURATION_LIMIT=10800
15
+
16
+ # Redis Cache untuk kinerja
17
+ API_REDIS_URL=redis://localhost:6379
Dockerfile CHANGED
@@ -1,23 +1,31 @@
1
- # Gunakan image Docker resmi
2
- FROM docker:latest
3
-
4
- # Install curl dan docker-compose
5
- RUN apk add --no-cache curl docker-compose
6
-
7
- # Buat user non-root
8
- RUN adduser -D -u 1000 user
9
-
10
- # Buat direktori kerja dan berikan izin
11
- WORKDIR /home/user/app
12
- RUN mkdir -p /home/user/app && chown user:user /home/user/app
13
-
14
- # Switch ke user non-root
15
- USER user
16
-
17
- # Ambil docker-compose.yml dari raw URL
18
- RUN curl -L -o docker-compose.yml https://raw.githubusercontent.com/Lingz-ui/data-myBot/refs/heads/main/docker-compose.yml
19
-
20
- EXPOSE 7860
21
-
22
- # Jalankan Docker Compose saat container dimulai
23
- CMD ["docker-compose", "up", "-d"]
 
 
 
 
 
 
 
 
 
1
+ # Gunakan base image Node.js
2
+ FROM node:20
3
+
4
+ # Install ffmpeg dan dependency tambahan
5
+ RUN apt-get update && apt-get install -y \
6
+ ffmpeg \
7
+ curl \
8
+ wget \
9
+ ca-certificates \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Install pnpm secara global
13
+ RUN corepack enable && corepack prepare pnpm@latest --activate
14
+
15
+ # Atur direktori kerja di dalam container
16
+ WORKDIR /app
17
+
18
+ # Salin file konfigurasi proyek (package.json, pnpm-lock.yaml, dll.)
19
+ COPY package.json pnpm-lock.yaml ./
20
+
21
+ # Install dependencies menggunakan pnpm
22
+ RUN pnpm install --frozen-lockfile
23
+
24
+ # Salin semua file proyek ke dalam container
25
+ COPY . .
26
+
27
+ # Ekspose port 7860
28
+ EXPOSE 7860
29
+
30
+ # Jalankan aplikasi
31
+ CMD ["pnpm", "start"]
package.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@imput/cobalt-api",
3
+ "description": "save what you love",
4
+ "version": "10.4.4",
5
+ "author": "imput",
6
+ "exports": "./src/cobalt.js",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "scripts": {
12
+ "start": "node src/cobalt",
13
+ "test": "node src/util/test",
14
+ "token:youtube": "node src/util/generate-youtube-tokens",
15
+ "token:jwt": "node src/util/generate-jwt-secret"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/imputnet/cobalt.git"
20
+ },
21
+ "license": "AGPL-3.0",
22
+ "bugs": {
23
+ "url": "https://github.com/imputnet/cobalt/issues"
24
+ },
25
+ "homepage": "https://github.com/imputnet/cobalt#readme",
26
+ "dependencies": {
27
+ "@datastructures-js/priority-queue": "^6.3.1",
28
+ "@imput/psl": "^2.0.4",
29
+ "@imput/version-info": "workspace:^",
30
+ "content-disposition-header": "0.6.0",
31
+ "cors": "^2.8.5",
32
+ "dotenv": "^16.0.1",
33
+ "esbuild": "^0.14.51",
34
+ "express": "^4.21.2",
35
+ "express-rate-limit": "^7.4.1",
36
+ "ffmpeg-static": "^5.1.0",
37
+ "hls-parser": "^0.10.7",
38
+ "ipaddr.js": "2.2.0",
39
+ "nanoid": "^5.0.9",
40
+ "node-cache": "^5.1.2",
41
+ "set-cookie-parser": "2.6.0",
42
+ "undici": "^5.19.1",
43
+ "url-pattern": "1.0.3",
44
+ "youtubei.js": "^12.2.0",
45
+ "zod": "^3.23.8"
46
+ },
47
+ "optionalDependencies": {
48
+ "freebind": "^0.2.2",
49
+ "rate-limit-redis": "^4.2.0",
50
+ "redis": "^4.7.0"
51
+ }
52
+ }
pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
src/cobalt.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "dotenv/config";
2
+
3
+ import express from "express";
4
+ import cluster from "node:cluster";
5
+
6
+ import path from "path";
7
+ import { fileURLToPath } from "url";
8
+
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
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename).slice(0, -4);
17
+
18
+ app.disable("x-powered-by");
19
+
20
+ if (env.apiURL) {
21
+ const { runAPI } = await import("./core/api.js");
22
+
23
+ if (isCluster) {
24
+ await initCluster();
25
+ }
26
+
27
+ runAPI(express, app, __dirname, cluster.isPrimary);
28
+ } else {
29
+ console.log(
30
+ Red("API_URL env variable is missing, cobalt api can't start.")
31
+ )
32
+ }
src/config.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getVersion } from "@imput/version-info";
2
+ import { services } from "./processing/service-config.js";
3
+ import { supportsReusePort } from "./misc/cluster.js";
4
+
5
+ const version = await getVersion();
6
+
7
+ const disabledServices = process.env.DISABLED_SERVICES?.split(',') || [];
8
+ const enabledServices = new Set(Object.keys(services).filter(e => {
9
+ if (!disabledServices.includes(e)) {
10
+ return e;
11
+ }
12
+ }));
13
+
14
+ const env = {
15
+ apiURL: process.env.API_URL || '',
16
+ apiPort: process.env.API_PORT || 9000,
17
+ tunnelPort: process.env.API_PORT || 9000,
18
+
19
+ listenAddress: process.env.API_LISTEN_ADDRESS,
20
+ freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
21
+
22
+ corsWildcard: process.env.CORS_WILDCARD !== '0',
23
+ corsURL: process.env.CORS_URL,
24
+
25
+ cookiePath: process.env.COOKIE_PATH,
26
+
27
+ rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
28
+ rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
29
+
30
+ durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
31
+ streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90,
32
+
33
+ processingPriority: process.platform !== 'win32'
34
+ && process.env.PROCESSING_PRIORITY
35
+ && parseInt(process.env.PROCESSING_PRIORITY),
36
+
37
+ externalProxy: process.env.API_EXTERNAL_PROXY,
38
+
39
+ turnstileSitekey: process.env.TURNSTILE_SITEKEY,
40
+ turnstileSecret: process.env.TURNSTILE_SECRET,
41
+ jwtSecret: process.env.JWT_SECRET,
42
+ jwtLifetime: process.env.JWT_EXPIRY || 120,
43
+
44
+ sessionEnabled: process.env.TURNSTILE_SITEKEY
45
+ && process.env.TURNSTILE_SECRET
46
+ && process.env.JWT_SECRET,
47
+
48
+ apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL),
49
+ authRequired: process.env.API_AUTH_REQUIRED === '1',
50
+ redisURL: process.env.API_REDIS_URL,
51
+ instanceCount: (process.env.API_INSTANCE_COUNT && parseInt(process.env.API_INSTANCE_COUNT)) || 1,
52
+ keyReloadInterval: 900,
53
+
54
+ enabledServices,
55
+ }
56
+
57
+ 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";
58
+ const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
59
+
60
+ export const setTunnelPort = (port) => env.tunnelPort = port;
61
+ export const isCluster = env.instanceCount > 1;
62
+
63
+ if (env.sessionEnabled && env.jwtSecret.length < 16) {
64
+ throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
65
+ }
66
+
67
+ if (env.instanceCount > 1 && !env.redisURL) {
68
+ throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
69
+ } else if (env.instanceCount > 1 && !await supportsReusePort()) {
70
+ console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
71
+ console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
72
+ console.error('(or other OS that supports it). for more info, see `reusePort` option on');
73
+ console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
74
+ throw new Error('SO_REUSEPORT is not supported');
75
+ }
76
+
77
+ export {
78
+ env,
79
+ genericUserAgent,
80
+ cobaltUserAgent,
81
+ }
src/core/api.js ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import * as APIKeys from "../security/api-keys.js";
22
+ import * as Cookies from "../processing/cookie/manager.js";
23
+
24
+ const git = {
25
+ branch: await getBranch(),
26
+ commit: await getCommit(),
27
+ remote: await getRemote(),
28
+ }
29
+
30
+ const version = await getVersion();
31
+
32
+ const acceptRegex = /^application\/json(; charset=utf-8)?$/;
33
+
34
+ const corsConfig = env.corsWildcard ? {} : {
35
+ origin: env.corsURL,
36
+ optionsSuccessStatus: 200
37
+ }
38
+
39
+ const fail = (res, code, context) => {
40
+ const { status, body } = createResponse("error", { code, context });
41
+ res.status(status).json(body);
42
+ }
43
+
44
+ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
45
+ const startTime = new Date();
46
+ const startTimestamp = startTime.getTime();
47
+
48
+ const serverInfo = JSON.stringify({
49
+ cobalt: {
50
+ version: version,
51
+ url: env.apiURL,
52
+ startTime: `${startTimestamp}`,
53
+ durationLimit: env.durationLimit,
54
+ turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
55
+ services: [...env.enabledServices].map(e => {
56
+ return friendlyServiceName(e);
57
+ }),
58
+ },
59
+ git,
60
+ })
61
+
62
+ const handleRateExceeded = (_, res) => {
63
+ const { status, body } = createResponse("error", {
64
+ code: "error.api.rate_exceeded",
65
+ context: {
66
+ limit: env.rateLimitWindow
67
+ }
68
+ });
69
+ return res.status(status).json(body);
70
+ };
71
+
72
+ const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
73
+
74
+ const sessionLimiter = rateLimit({
75
+ windowMs: 60000,
76
+ limit: 10,
77
+ standardHeaders: 'draft-6',
78
+ legacyHeaders: false,
79
+ keyGenerator,
80
+ store: await createStore('session'),
81
+ handler: handleRateExceeded
82
+ });
83
+
84
+ const apiLimiter = rateLimit({
85
+ windowMs: env.rateLimitWindow * 1000,
86
+ limit: (req) => req.rateLimitMax || env.rateLimitMax,
87
+ standardHeaders: 'draft-6',
88
+ legacyHeaders: false,
89
+ keyGenerator: req => req.rateLimitKey || keyGenerator(req),
90
+ store: await createStore('api'),
91
+ handler: handleRateExceeded
92
+ })
93
+
94
+ const apiTunnelLimiter = rateLimit({
95
+ windowMs: env.rateLimitWindow * 1000,
96
+ limit: (req) => req.rateLimitMax || env.rateLimitMax,
97
+ standardHeaders: 'draft-6',
98
+ legacyHeaders: false,
99
+ keyGenerator: req => req.rateLimitKey || keyGenerator(req),
100
+ store: await createStore('tunnel'),
101
+ handler: (_, res) => {
102
+ return res.sendStatus(429)
103
+ }
104
+ })
105
+
106
+ app.set('trust proxy', ['loopback', 'uniquelocal']);
107
+
108
+ app.use('/', cors({
109
+ methods: ['GET', 'POST'],
110
+ exposedHeaders: [
111
+ 'Ratelimit-Limit',
112
+ 'Ratelimit-Policy',
113
+ 'Ratelimit-Remaining',
114
+ 'Ratelimit-Reset'
115
+ ],
116
+ ...corsConfig,
117
+ }));
118
+
119
+ app.post('/', (req, res, next) => {
120
+ if (!acceptRegex.test(req.header('Accept'))) {
121
+ return fail(res, "error.api.header.accept");
122
+ }
123
+ if (!acceptRegex.test(req.header('Content-Type'))) {
124
+ return fail(res, "error.api.header.content_type");
125
+ }
126
+ next();
127
+ });
128
+
129
+ app.post('/', (req, res, next) => {
130
+ if (!env.apiKeyURL) {
131
+ return next();
132
+ }
133
+
134
+ const { success, error } = APIKeys.validateAuthorization(req);
135
+ if (!success) {
136
+ // We call next() here if either if:
137
+ // a) we have user sessions enabled, meaning the request
138
+ // will still need a Bearer token to not be rejected, or
139
+ // b) we do not require the user to be authenticated, and
140
+ // so they can just make the request with the regular
141
+ // rate limit configuration;
142
+ // otherwise, we reject the request.
143
+ if (
144
+ (env.sessionEnabled || !env.authRequired)
145
+ && ['missing', 'not_api_key'].includes(error)
146
+ ) {
147
+ return next();
148
+ }
149
+
150
+ return fail(res, `error.api.auth.key.${error}`);
151
+ }
152
+
153
+ return next();
154
+ });
155
+
156
+ app.post('/', (req, res, next) => {
157
+ if (!env.sessionEnabled || req.rateLimitKey) {
158
+ return next();
159
+ }
160
+
161
+ try {
162
+ const authorization = req.header("Authorization");
163
+ if (!authorization) {
164
+ return fail(res, "error.api.auth.jwt.missing");
165
+ }
166
+
167
+ if (authorization.length >= 256) {
168
+ return fail(res, "error.api.auth.jwt.invalid");
169
+ }
170
+
171
+ const [ type, token, ...rest ] = authorization.split(" ");
172
+ if (!token || type.toLowerCase() !== 'bearer' || rest.length) {
173
+ return fail(res, "error.api.auth.jwt.invalid");
174
+ }
175
+
176
+ if (!jwt.verify(token)) {
177
+ return fail(res, "error.api.auth.jwt.invalid");
178
+ }
179
+
180
+ req.rateLimitKey = hashHmac(token, 'rate');
181
+ } catch {
182
+ return fail(res, "error.api.generic");
183
+ }
184
+ next();
185
+ });
186
+
187
+ app.post('/', apiLimiter);
188
+ app.use('/', express.json({ limit: 1024 }));
189
+
190
+ app.use('/', (err, _, res, next) => {
191
+ if (err) {
192
+ const { status, body } = createResponse("error", {
193
+ code: "error.api.invalid_body",
194
+ });
195
+ return res.status(status).json(body);
196
+ }
197
+
198
+ next();
199
+ });
200
+
201
+ app.post("/session", sessionLimiter, async (req, res) => {
202
+ if (!env.sessionEnabled) {
203
+ return fail(res, "error.api.auth.not_configured")
204
+ }
205
+
206
+ const turnstileResponse = req.header("cf-turnstile-response");
207
+
208
+ if (!turnstileResponse) {
209
+ return fail(res, "error.api.auth.turnstile.missing");
210
+ }
211
+
212
+ const turnstileResult = await verifyTurnstileToken(
213
+ turnstileResponse,
214
+ req.ip
215
+ );
216
+
217
+ if (!turnstileResult) {
218
+ return fail(res, "error.api.auth.turnstile.invalid");
219
+ }
220
+
221
+ try {
222
+ res.json(jwt.generate());
223
+ } catch {
224
+ return fail(res, "error.api.generic");
225
+ }
226
+ });
227
+
228
+ app.post('/', async (req, res) => {
229
+ const request = req.body;
230
+
231
+ if (!request.url) {
232
+ return fail(res, "error.api.link.missing");
233
+ }
234
+
235
+ const { success, data: normalizedRequest } = await normalizeRequest(request);
236
+ if (!success) {
237
+ return fail(res, "error.api.invalid_body");
238
+ }
239
+
240
+ const parsed = extract(normalizedRequest.url);
241
+
242
+ if (!parsed) {
243
+ return fail(res, "error.api.link.invalid");
244
+ }
245
+ if ("error" in parsed) {
246
+ let context;
247
+ if (parsed?.context) {
248
+ context = parsed.context;
249
+ }
250
+ return fail(res, `error.api.${parsed.error}`, context);
251
+ }
252
+
253
+ try {
254
+ const result = await match({
255
+ host: parsed.host,
256
+ patternMatch: parsed.patternMatch,
257
+ params: normalizedRequest,
258
+ });
259
+
260
+ res.status(result.status).json(result.body);
261
+ } catch {
262
+ fail(res, "error.api.generic");
263
+ }
264
+ })
265
+
266
+ app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
267
+ const id = String(req.query.id);
268
+ const exp = String(req.query.exp);
269
+ const sig = String(req.query.sig);
270
+ const sec = String(req.query.sec);
271
+ const iv = String(req.query.iv);
272
+
273
+ const checkQueries = id && exp && sig && sec && iv;
274
+ const checkBaseLength = id.length === 21 && exp.length === 13;
275
+ const checkSafeLength = sig.length === 43 && sec.length === 43 && iv.length === 22;
276
+
277
+ if (!checkQueries || !checkBaseLength || !checkSafeLength) {
278
+ return res.status(400).end();
279
+ }
280
+
281
+ if (req.query.p) {
282
+ return res.status(200).end();
283
+ }
284
+
285
+ const streamInfo = await verifyStream(id, sig, exp, sec, iv);
286
+ if (!streamInfo?.service) {
287
+ return res.status(streamInfo.status).end();
288
+ }
289
+
290
+ if (streamInfo.type === 'proxy') {
291
+ streamInfo.range = req.headers['range'];
292
+ }
293
+
294
+ return stream(res, streamInfo);
295
+ })
296
+
297
+ const itunnelHandler = (req, res) => {
298
+ if (!req.ip.endsWith('127.0.0.1')) {
299
+ return res.sendStatus(403);
300
+ }
301
+
302
+ if (String(req.query.id).length !== 21) {
303
+ return res.sendStatus(400);
304
+ }
305
+
306
+ const streamInfo = getInternalStream(req.query.id);
307
+ if (!streamInfo) {
308
+ return res.sendStatus(404);
309
+ }
310
+
311
+ streamInfo.headers = new Map([
312
+ ...(streamInfo.headers || []),
313
+ ...Object.entries(req.headers)
314
+ ]);
315
+
316
+ return stream(res, { type: 'internal', ...streamInfo });
317
+ };
318
+
319
+ app.get('/itunnel', itunnelHandler);
320
+
321
+ app.get('/', (_, res) => {
322
+ res.type('json');
323
+ res.status(200).send(serverInfo);
324
+ })
325
+
326
+ app.get('/favicon.ico', (req, res) => {
327
+ res.status(404).end();
328
+ })
329
+
330
+ app.get('/*', (req, res) => {
331
+ res.redirect('/');
332
+ })
333
+
334
+ // handle all express errors
335
+ app.use((_, __, res, ___) => {
336
+ return fail(res, "error.api.generic");
337
+ })
338
+
339
+ randomizeCiphers();
340
+ setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
341
+
342
+ if (env.externalProxy) {
343
+ if (env.freebindCIDR) {
344
+ throw new Error('Freebind is not available when external proxy is enabled')
345
+ }
346
+
347
+ setGlobalDispatcher(new ProxyAgent(env.externalProxy))
348
+ }
349
+
350
+ http.createServer(app).listen({
351
+ port: env.apiPort,
352
+ host: env.listenAddress,
353
+ reusePort: env.instanceCount > 1 || undefined
354
+ }, () => {
355
+ if (isPrimary) {
356
+ console.log(`\n` +
357
+ Bright(Cyan("cobalt ")) + Bright("API ^ω⁠^") + "\n" +
358
+
359
+ "~~~~~~\n" +
360
+ Bright("version: ") + version + "\n" +
361
+ Bright("commit: ") + git.commit + "\n" +
362
+ Bright("branch: ") + git.branch + "\n" +
363
+ Bright("remote: ") + git.remote + "\n" +
364
+ Bright("start time: ") + startTime.toUTCString() + "\n" +
365
+ "~~~~~~\n" +
366
+
367
+ Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
368
+ Bright("port: ") + env.apiPort + "\n"
369
+ );
370
+ }
371
+
372
+ if (env.apiKeyURL) {
373
+ APIKeys.setup(env.apiKeyURL);
374
+ }
375
+
376
+ if (env.cookiePath) {
377
+ Cookies.setup(env.cookiePath);
378
+ }
379
+ });
380
+
381
+ if (isCluster) {
382
+ const istreamer = express();
383
+ istreamer.get('/itunnel', itunnelHandler);
384
+ const server = istreamer.listen({
385
+ port: 0,
386
+ host: '127.0.0.1',
387
+ exclusive: true
388
+ }, () => {
389
+ const { port } = server.address();
390
+ console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`);
391
+ setTunnelPort(port);
392
+ });
393
+ }
394
+ }
src/misc/cluster.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cluster from "node:cluster";
2
+ import net from "node:net";
3
+ import { syncSecrets } from "../security/secrets.js";
4
+ import { env, isCluster } from "../config.js";
5
+
6
+ export { isPrimary, isWorker } from "node:cluster";
7
+
8
+ export const supportsReusePort = async () => {
9
+ try {
10
+ await new Promise((resolve, reject) => {
11
+ const server = net.createServer().listen({ port: 0, reusePort: true });
12
+ server.on('listening', () => server.close(resolve));
13
+ server.on('error', (err) => (server.close(), reject(err)));
14
+ });
15
+
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export const initCluster = async () => {
23
+ if (cluster.isPrimary) {
24
+ for (let i = 1; i < env.instanceCount; ++i) {
25
+ cluster.fork();
26
+ }
27
+ }
28
+
29
+ await syncSecrets();
30
+ }
31
+
32
+ export const broadcast = (message) => {
33
+ if (!isCluster || !cluster.isPrimary || !cluster.workers) {
34
+ return;
35
+ }
36
+
37
+ for (const worker of Object.values(cluster.workers)) {
38
+ worker.send(message);
39
+ }
40
+ }
41
+
42
+ export const send = (message) => {
43
+ if (!isCluster) {
44
+ return;
45
+ }
46
+
47
+ if (cluster.isPrimary) {
48
+ return broadcast(message);
49
+ } else {
50
+ return process.send(message);
51
+ }
52
+ }
53
+
54
+ export const waitFor = (key) => {
55
+ return new Promise(resolve => {
56
+ const listener = (message) => {
57
+ if (key in message) {
58
+ process.off('message', listener);
59
+ return resolve(message);
60
+ }
61
+ }
62
+
63
+ process.on('message', listener);
64
+ });
65
+ }
66
+
67
+ export const mainOnMessage = (cb) => {
68
+ for (const worker of Object.values(cluster.workers)) {
69
+ worker.on('message', cb);
70
+ }
71
+ }
src/misc/console-text.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const ANSI = {
2
+ RESET: "\x1b[0m",
3
+ BRIGHT: "\x1b[1m",
4
+ RED: "\x1b[31m",
5
+ GREEN: "\x1b[32m",
6
+ CYAN: "\x1b[36m",
7
+ YELLOW: "\x1b[93m"
8
+ }
9
+
10
+ function wrap(color, text) {
11
+ if (!ANSI[color.toUpperCase()]) {
12
+ throw "invalid color";
13
+ }
14
+
15
+ return ANSI[color.toUpperCase()] + text + ANSI.RESET;
16
+ }
17
+
18
+ export function Bright(text) {
19
+ return wrap('bright', text);
20
+ }
21
+
22
+ export function Red(text) {
23
+ return wrap('red', text);
24
+ }
25
+
26
+ export function Green(text) {
27
+ return wrap('green', text);
28
+ }
29
+
30
+ export function Cyan(text) {
31
+ return wrap('cyan', text);
32
+ }
33
+
34
+ export function Yellow(text) {
35
+ return wrap('yellow', text);
36
+ }
src/misc/crypto.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createCipheriv, createDecipheriv } from "crypto";
2
+
3
+ const algorithm = "aes256";
4
+
5
+ export function encryptStream(plaintext, iv, secret) {
6
+ const buff = Buffer.from(JSON.stringify(plaintext));
7
+ const key = Buffer.from(secret, "base64url");
8
+ const cipher = createCipheriv(algorithm, key, Buffer.from(iv, "base64url"));
9
+
10
+ return Buffer.concat([ cipher.update(buff), cipher.final() ])
11
+ }
12
+
13
+ export function decryptStream(ciphertext, iv, secret) {
14
+ const buff = Buffer.from(ciphertext);
15
+ const key = Buffer.from(secret, "base64url");
16
+ const decipher = createDecipheriv(algorithm, key, Buffer.from(iv, "base64url"));
17
+
18
+ return Buffer.concat([ decipher.update(buff), decipher.final() ])
19
+ }
src/misc/load-from-fs.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from "fs";
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const root = join(
6
+ dirname(fileURLToPath(import.meta.url)),
7
+ '../../'
8
+ );
9
+
10
+ export function loadFile(path) {
11
+ return fs.readFileSync(join(root, path), 'utf-8')
12
+ }
13
+
14
+ export function loadJSON(path) {
15
+ try {
16
+ return JSON.parse(loadFile(path))
17
+ } catch {
18
+ return false
19
+ }
20
+ }
src/misc/randomize-ciphers.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tls from 'node:tls';
2
+ import { randomBytes } from 'node:crypto';
3
+
4
+ const ORIGINAL_CIPHERS = tls.DEFAULT_CIPHERS;
5
+
6
+ // How many ciphers from the top of the list to shuffle.
7
+ // The remaining ciphers are left in the original order.
8
+ const TOP_N_SHUFFLE = 8;
9
+
10
+ // Modified variation of https://stackoverflow.com/a/12646864
11
+ const shuffleArray = (array) => {
12
+ for (let i = array.length - 1; i > 0; i--) {
13
+ const j = randomBytes(4).readUint32LE() % array.length;
14
+ [array[i], array[j]] = [array[j], array[i]];
15
+ }
16
+
17
+ return array;
18
+ }
19
+
20
+ export const randomizeCiphers = () => {
21
+ do {
22
+ const cipherList = ORIGINAL_CIPHERS.split(':');
23
+ const shuffled = shuffleArray(cipherList.slice(0, TOP_N_SHUFFLE));
24
+ const retained = cipherList.slice(TOP_N_SHUFFLE);
25
+
26
+ tls.DEFAULT_CIPHERS = [ ...shuffled, ...retained ].join(':');
27
+ } while (tls.DEFAULT_CIPHERS === ORIGINAL_CIPHERS);
28
+ }
src/misc/run-test.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { normalizeRequest } from "../processing/request.js";
2
+ import match from "../processing/match.js";
3
+ import { extract } from "../processing/url.js";
4
+
5
+ export async function runTest(url, params, expect) {
6
+ const { success, data: normalized } = await normalizeRequest({ url, ...params });
7
+ if (!success) {
8
+ throw "invalid request";
9
+ }
10
+
11
+ const parsed = extract(normalized.url);
12
+ if (parsed === null) {
13
+ throw `invalid url: ${normalized.url}`;
14
+ }
15
+
16
+ const result = await match({
17
+ host: parsed.host,
18
+ patternMatch: parsed.patternMatch,
19
+ params: normalized,
20
+ });
21
+
22
+ let error = [];
23
+ if (expect.status !== result.body.status) {
24
+ const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
25
+ error.push(`status mismatch: ${detail}`);
26
+ }
27
+
28
+ if (expect.code !== result.status) {
29
+ const detail = `${expect.code} (expected) != ${result.status} (actual)`;
30
+ error.push(`status code mismatch: ${detail}`);
31
+ }
32
+
33
+ if (error.length) {
34
+ if (result.body.text) {
35
+ error.push(`error message: ${result.body.text}`);
36
+ }
37
+
38
+ throw error.join('\n');
39
+ }
40
+
41
+ if (result.body.status === 'tunnel') {
42
+ // TODO: stream testing
43
+ }
44
+ }
src/misc/utils.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function getRedirectingURL(url) {
2
+ return fetch(url, { redirect: 'manual' }).then((r) => {
3
+ if ([301, 302, 303].includes(r.status) && r.headers.has('location'))
4
+ return r.headers.get('location');
5
+ }).catch(() => null);
6
+ }
7
+
8
+ export function merge(a, b) {
9
+ for (const k of Object.keys(b)) {
10
+ if (Array.isArray(b[k])) {
11
+ a[k] = [...(a[k] ?? []), ...b[k]];
12
+ } else if (typeof b[k] === 'object') {
13
+ a[k] = merge(a[k], b[k]);
14
+ } else {
15
+ a[k] = b[k];
16
+ }
17
+ }
18
+
19
+ return a;
20
+ }
21
+
22
+ export function splitFilenameExtension(filename) {
23
+ const parts = filename.split('.');
24
+ const ext = parts.pop();
25
+
26
+ if (!parts.length) {
27
+ return [ ext, "" ]
28
+ } else {
29
+ return [ parts.join('.'), ext ]
30
+ }
31
+ }
src/processing/cookie/cookie.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from 'node:assert';
2
+
3
+ export default class Cookie {
4
+ constructor(input) {
5
+ assert(typeof input === 'object');
6
+ this._values = {};
7
+
8
+ for (const [ k, v ] of Object.entries(input))
9
+ this.set(k, v);
10
+ }
11
+
12
+ set(key, value) {
13
+ const old = this._values[key];
14
+ if (old === value)
15
+ return false;
16
+
17
+ this._values[key] = value;
18
+ return true;
19
+ }
20
+
21
+ unset(keys) {
22
+ for (const key of keys) delete this._values[key]
23
+ }
24
+
25
+ static fromString(str) {
26
+ const obj = {};
27
+
28
+ str.split('; ').forEach(cookie => {
29
+ const key = cookie.split('=')[0];
30
+ const value = cookie.split('=').splice(1).join('=');
31
+ obj[key] = value
32
+ })
33
+
34
+ return new Cookie(obj)
35
+ }
36
+
37
+ toString() {
38
+ return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ')
39
+ }
40
+
41
+ toJSON() {
42
+ return this.toString()
43
+ }
44
+
45
+ values() {
46
+ return Object.freeze({ ...this._values })
47
+ }
48
+ }
src/processing/cookie/manager.js ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Cookie from './cookie.js';
2
+
3
+ import { readFile, writeFile } from 'fs/promises';
4
+ import { Red, Green, Yellow } from '../../misc/console-text.js';
5
+ import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
6
+ import * as cluster from '../../misc/cluster.js';
7
+ import { isCluster } from '../../config.js';
8
+
9
+ const WRITE_INTERVAL = 60000;
10
+ const VALID_SERVICES = new Set([
11
+ 'instagram',
12
+ 'instagram_bearer',
13
+ 'reddit',
14
+ 'twitter',
15
+ 'youtube_oauth'
16
+ ]);
17
+
18
+ const invalidCookies = {};
19
+ let cookies = {}, dirty = false, intervalId;
20
+
21
+ function writeChanges(cookiePath) {
22
+ if (!dirty) return;
23
+ dirty = false;
24
+
25
+ const cookieData = JSON.stringify({ ...cookies, ...invalidCookies }, null, 4);
26
+ writeFile(cookiePath, cookieData).catch((e) => {
27
+ console.warn(`${Yellow('[!]')} failed writing updated cookies to storage`);
28
+ console.warn(e);
29
+ clearInterval(intervalId);
30
+ intervalId = null;
31
+ })
32
+ }
33
+
34
+ const setupMain = async (cookiePath) => {
35
+ try {
36
+ cookies = await readFile(cookiePath, 'utf8');
37
+ cookies = JSON.parse(cookies);
38
+ for (const serviceName in cookies) {
39
+ if (!VALID_SERVICES.has(serviceName)) {
40
+ console.warn(`${Yellow('[!]')} ignoring unknown service in cookie file: ${serviceName}`);
41
+ } else if (!Array.isArray(cookies[serviceName])) {
42
+ console.warn(`${Yellow('[!]')} ${serviceName} in cookies file is not an array, ignoring it`);
43
+ } else if (cookies[serviceName].some(c => typeof c !== 'string')) {
44
+ console.warn(`${Yellow('[!]')} some cookie for ${serviceName} contains non-string value in cookies file`);
45
+ } else continue;
46
+
47
+ invalidCookies[serviceName] = cookies[serviceName];
48
+ delete cookies[serviceName];
49
+ }
50
+
51
+ if (!intervalId) {
52
+ intervalId = setInterval(() => writeChanges(cookiePath), WRITE_INTERVAL);
53
+ }
54
+
55
+ cluster.broadcast({ cookies });
56
+
57
+ console.log(`${Green('[✓]')} cookies loaded successfully!`);
58
+ } catch (e) {
59
+ console.error(`${Yellow('[!]')} failed to load cookies.`);
60
+ console.error('error:', e);
61
+ }
62
+ }
63
+
64
+ const setupWorker = async () => {
65
+ cookies = (await cluster.waitFor('cookies')).cookies;
66
+ }
67
+
68
+ export const loadFromFile = async (path) => {
69
+ if (cluster.isPrimary) {
70
+ await setupMain(path);
71
+ } else if (cluster.isWorker) {
72
+ await setupWorker();
73
+ }
74
+
75
+ dirty = false;
76
+ }
77
+
78
+ export const setup = async (path) => {
79
+ await loadFromFile(path);
80
+
81
+ if (isCluster) {
82
+ const messageHandler = (message) => {
83
+ if ('cookieUpdate' in message) {
84
+ const { cookieUpdate } = message;
85
+
86
+ if (cluster.isPrimary) {
87
+ dirty = true;
88
+ cluster.broadcast({ cookieUpdate });
89
+ }
90
+
91
+ const { service, idx, cookie } = cookieUpdate;
92
+ cookies[service][idx] = cookie;
93
+ }
94
+ }
95
+
96
+ if (cluster.isPrimary) {
97
+ cluster.mainOnMessage(messageHandler);
98
+ } else {
99
+ process.on('message', messageHandler);
100
+ }
101
+ }
102
+ }
103
+
104
+ export function getCookie(service) {
105
+ if (!VALID_SERVICES.has(service)) {
106
+ console.error(
107
+ `${Red('[!]')} ${service} not in allowed services list for cookies.`
108
+ + ' if adding a new cookie type, include it there.'
109
+ );
110
+ return;
111
+ }
112
+
113
+ if (!cookies[service] || !cookies[service].length) return;
114
+
115
+ const idx = Math.floor(Math.random() * cookies[service].length);
116
+
117
+ const cookie = cookies[service][idx];
118
+ if (typeof cookie === 'string') {
119
+ cookies[service][idx] = Cookie.fromString(cookie);
120
+ }
121
+
122
+ cookies[service][idx].meta = { service, idx };
123
+ return cookies[service][idx];
124
+ }
125
+
126
+ export function updateCookieValues(cookie, values) {
127
+ let changed = false;
128
+
129
+ for (const [ key, value ] of Object.entries(values)) {
130
+ changed = cookie.set(key, value) || changed;
131
+ }
132
+
133
+ if (changed && cookie.meta) {
134
+ dirty = true;
135
+ if (isCluster) {
136
+ const message = { cookieUpdate: { ...cookie.meta, cookie } };
137
+ cluster.send(message);
138
+ }
139
+ }
140
+
141
+ return changed;
142
+ }
143
+
144
+ export function updateCookie(cookie, headers) {
145
+ if (!cookie) return;
146
+
147
+ const parsed = parseSetCookie(
148
+ splitCookiesString(headers.get('set-cookie')),
149
+ { decodeValues: false }
150
+ ), values = {}
151
+
152
+ cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name));
153
+ parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value);
154
+
155
+ updateCookieValues(cookie, values);
156
+ }
src/processing/create-filename.js ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
11
+ export default (f, style, isAudioOnly, isAudioMuted) => {
12
+ let filename = '';
13
+
14
+ let infoBase = [f.service, f.id];
15
+ let classicTags = [...infoBase];
16
+ let basicTags = [];
17
+
18
+ let title = sanitizeString(f.title);
19
+
20
+ if (f.author) {
21
+ title += ` - ${sanitizeString(f.author)}`;
22
+ }
23
+
24
+ if (f.resolution) {
25
+ classicTags.push(f.resolution);
26
+ }
27
+
28
+ if (f.qualityLabel) {
29
+ basicTags.push(f.qualityLabel);
30
+ }
31
+
32
+ if (f.youtubeFormat) {
33
+ classicTags.push(f.youtubeFormat);
34
+ basicTags.push(f.youtubeFormat);
35
+ }
36
+
37
+ if (isAudioMuted) {
38
+ classicTags.push("mute");
39
+ basicTags.push("mute");
40
+ } else if (f.youtubeDubName) {
41
+ classicTags.push(f.youtubeDubName);
42
+ basicTags.push(f.youtubeDubName);
43
+ }
44
+
45
+ switch (style) {
46
+ default:
47
+ case "classic":
48
+ if (isAudioOnly) {
49
+ if (f.youtubeDubName) {
50
+ infoBase.push(f.youtubeDubName);
51
+ }
52
+ return `${infoBase.join("_")}_audio`;
53
+ }
54
+ filename = classicTags.join("_");
55
+ break;
56
+ case "basic":
57
+ if (isAudioOnly) return title;
58
+ filename = `${title} (${basicTags.join(", ")})`;
59
+ break;
60
+ case "pretty":
61
+ if (isAudioOnly) return `${title} (${infoBase[0]})`;
62
+ filename = `${title} (${[...basicTags, infoBase[0]].join(", ")})`;
63
+ break;
64
+ case "nerdy":
65
+ if (isAudioOnly) return `${title} (${infoBase.join(", ")})`;
66
+ filename = `${title} (${basicTags.concat(infoBase).join(", ")})`;
67
+ break;
68
+ }
69
+ return `${filename}.${f.extension}`;
70
+ }
src/processing/match-action.js ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import createFilename from "./create-filename.js";
2
+
3
+ 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 = {
12
+ url: r.urls,
13
+ headers: r.headers,
14
+ service: host,
15
+ filename: r.filenameAttributes ?
16
+ createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
17
+ fileMetadata: !disableMetadata ? r.fileMetadata : false,
18
+ requestIP
19
+ },
20
+ params = {};
21
+
22
+ if (r.isPhoto) action = "photo";
23
+ else if (r.picker) action = "picker"
24
+ else if (r.isGif && twitterGif) action = "gif";
25
+ else if (isAudioOnly) action = "audio";
26
+ else if (isAudioMuted) action = "muteVideo";
27
+ else if (r.isHLS) action = "hls";
28
+ else action = "video";
29
+
30
+ if (action === "picker" || action === "audio") {
31
+ if (!r.filenameAttributes) defaultParams.filename = r.audioFilename;
32
+ defaultParams.audioFormat = audioFormat;
33
+ }
34
+
35
+ if (action === "muteVideo" && isAudioMuted && !r.filenameAttributes) {
36
+ const [ name, ext ] = splitFilenameExtension(r.filename);
37
+ defaultParams.filename = `${name}_mute.${ext}`;
38
+ } else if (action === "gif") {
39
+ const [ name ] = splitFilenameExtension(r.filename);
40
+ defaultParams.filename = `${name}.gif`;
41
+ }
42
+
43
+ switch (action) {
44
+ default:
45
+ return createResponse("error", {
46
+ code: "error.api.fetch.empty"
47
+ });
48
+
49
+ case "photo":
50
+ responseType = "redirect";
51
+ break;
52
+
53
+ case "gif":
54
+ params = { type: "gif" };
55
+ break;
56
+
57
+ case "hls":
58
+ params = {
59
+ type: Array.isArray(r.urls) ? "merge" : "remux",
60
+ isHLS: true,
61
+ }
62
+ break;
63
+
64
+ case "muteVideo":
65
+ let muteType = "mute";
66
+ if (Array.isArray(r.urls) && !r.isHLS) {
67
+ muteType = "proxy";
68
+ }
69
+ params = {
70
+ type: muteType,
71
+ url: Array.isArray(r.urls) ? r.urls[0] : r.urls
72
+ }
73
+ if (host === "reddit" && r.typeId === "redirect") {
74
+ responseType = "redirect";
75
+ }
76
+ break;
77
+
78
+ case "picker":
79
+ responseType = "picker";
80
+ switch (host) {
81
+ case "instagram":
82
+ case "twitter":
83
+ case "snapchat":
84
+ case "bsky":
85
+ params = { picker: r.picker };
86
+ break;
87
+
88
+ case "tiktok":
89
+ let audioStreamType = "audio";
90
+ if (r.bestAudio === "mp3" && audioFormat === "best") {
91
+ audioFormat = "mp3";
92
+ audioStreamType = "proxy"
93
+ }
94
+ params = {
95
+ picker: r.picker,
96
+ url: createStream({
97
+ service: "tiktok",
98
+ type: audioStreamType,
99
+ url: r.urls,
100
+ headers: r.headers,
101
+ filename: `${r.audioFilename}.${audioFormat}`,
102
+ isAudioOnly: true,
103
+ audioFormat,
104
+ })
105
+ }
106
+ break;
107
+ }
108
+ break;
109
+
110
+ case "video":
111
+ switch (host) {
112
+ case "bilibili":
113
+ params = { type: "merge" };
114
+ break;
115
+
116
+ case "youtube":
117
+ params = { type: r.type };
118
+ break;
119
+
120
+ case "reddit":
121
+ responseType = r.typeId;
122
+ params = { type: r.type };
123
+ break;
124
+
125
+ case "vimeo":
126
+ if (Array.isArray(r.urls)) {
127
+ params = { type: "merge" }
128
+ } else {
129
+ responseType = "redirect";
130
+ }
131
+ break;
132
+
133
+ case "twitter":
134
+ if (r.type === "remux") {
135
+ params = { type: r.type };
136
+ } else {
137
+ responseType = "redirect";
138
+ }
139
+ break;
140
+
141
+ case "ok":
142
+ case "vk":
143
+ case "tiktok":
144
+ params = { type: "proxy" };
145
+ break;
146
+
147
+ case "facebook":
148
+ case "instagram":
149
+ case "tumblr":
150
+ case "pinterest":
151
+ case "streamable":
152
+ case "snapchat":
153
+ case "loom":
154
+ case "twitch":
155
+ responseType = "redirect";
156
+ break;
157
+ }
158
+ break;
159
+
160
+ case "audio":
161
+ if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
162
+ return createResponse("error", {
163
+ code: "error.api.service.audio_not_supported"
164
+ })
165
+ }
166
+
167
+ let processType = "audio";
168
+ let copy = false;
169
+
170
+ if (audioFormat === "best") {
171
+ const serviceBestAudio = r.bestAudio;
172
+
173
+ if (serviceBestAudio) {
174
+ audioFormat = serviceBestAudio;
175
+ processType = "proxy";
176
+
177
+ if (host === "soundcloud") {
178
+ processType = "audio";
179
+ copy = true;
180
+ }
181
+ } else {
182
+ audioFormat = "m4a";
183
+ copy = true;
184
+ }
185
+ }
186
+
187
+ if (r.isHLS || host === "vimeo") {
188
+ copy = false;
189
+ processType = "audio";
190
+ }
191
+
192
+ params = {
193
+ type: processType,
194
+ url: Array.isArray(r.urls) ? r.urls[1] : r.urls,
195
+
196
+ audioBitrate,
197
+ audioCopy: copy,
198
+ audioFormat,
199
+
200
+ isHLS: r.isHLS,
201
+ }
202
+ break;
203
+ }
204
+
205
+ if (defaultParams.filename && (action === "picker" || action === "audio")) {
206
+ defaultParams.filename += `.${audioFormat}`;
207
+ }
208
+
209
+ if (alwaysProxy && responseType === "redirect") {
210
+ responseType = "tunnel";
211
+ params.type = "proxy";
212
+ }
213
+
214
+ return createResponse(responseType, {...defaultParams, ...params})
215
+ }
src/processing/match.js ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+
3
+ import { env } from "../config.js";
4
+ import { createResponse } from "../processing/request.js";
5
+
6
+ import { testers } from "./service-patterns.js";
7
+ import matchAction from "./match-action.js";
8
+
9
+ import { friendlyServiceName } from "./service-alias.js";
10
+
11
+ import bilibili from "./services/bilibili.js";
12
+ import reddit from "./services/reddit.js";
13
+ import twitter from "./services/twitter.js";
14
+ import youtube from "./services/youtube.js";
15
+ import vk from "./services/vk.js";
16
+ import ok from "./services/ok.js";
17
+ import tiktok from "./services/tiktok.js";
18
+ import tumblr from "./services/tumblr.js";
19
+ import vimeo from "./services/vimeo.js";
20
+ import soundcloud from "./services/soundcloud.js";
21
+ import instagram from "./services/instagram.js";
22
+ import pinterest from "./services/pinterest.js";
23
+ import streamable from "./services/streamable.js";
24
+ import twitch from "./services/twitch.js";
25
+ import rutube from "./services/rutube.js";
26
+ import dailymotion from "./services/dailymotion.js";
27
+ import snapchat from "./services/snapchat.js";
28
+ import loom from "./services/loom.js";
29
+ import facebook from "./services/facebook.js";
30
+ import bluesky from "./services/bluesky.js";
31
+
32
+ let freebind;
33
+
34
+ export default async function({ host, patternMatch, params }) {
35
+ const { url } = params;
36
+ assert(url instanceof URL);
37
+ let dispatcher, requestIP;
38
+
39
+ if (env.freebindCIDR) {
40
+ if (!freebind) {
41
+ freebind = await import('freebind');
42
+ }
43
+
44
+ requestIP = freebind.ip.random(env.freebindCIDR);
45
+ dispatcher = freebind.dispatcherFromIP(requestIP, { strict: false });
46
+ }
47
+
48
+ try {
49
+ let r,
50
+ isAudioOnly = params.downloadMode === "audio",
51
+ isAudioMuted = params.downloadMode === "mute";
52
+
53
+ if (!testers[host]) {
54
+ return createResponse("error", {
55
+ code: "error.api.service.unsupported"
56
+ });
57
+ }
58
+ if (!(testers[host](patternMatch))) {
59
+ return createResponse("error", {
60
+ code: "error.api.link.unsupported",
61
+ context: {
62
+ service: friendlyServiceName(host),
63
+ }
64
+ });
65
+ }
66
+
67
+ switch (host) {
68
+ case "twitter":
69
+ r = await twitter({
70
+ id: patternMatch.id,
71
+ index: patternMatch.index - 1,
72
+ toGif: !!params.twitterGif,
73
+ alwaysProxy: params.alwaysProxy,
74
+ dispatcher
75
+ });
76
+ break;
77
+
78
+ case "vk":
79
+ r = await vk({
80
+ ownerId: patternMatch.ownerId,
81
+ videoId: patternMatch.videoId,
82
+ accessKey: patternMatch.accessKey,
83
+ quality: params.videoQuality
84
+ });
85
+ break;
86
+
87
+ case "ok":
88
+ r = await ok({
89
+ id: patternMatch.id,
90
+ quality: params.videoQuality
91
+ });
92
+ break;
93
+
94
+ case "bilibili":
95
+ r = await bilibili(patternMatch);
96
+ break;
97
+
98
+ case "youtube":
99
+ let fetchInfo = {
100
+ dispatcher,
101
+ id: patternMatch.id.slice(0, 11),
102
+ quality: params.videoQuality,
103
+ format: params.youtubeVideoCodec,
104
+ isAudioOnly,
105
+ isAudioMuted,
106
+ dubLang: params.youtubeDubLang,
107
+ youtubeHLS: params.youtubeHLS,
108
+ }
109
+
110
+ if (url.hostname === "music.youtube.com" || isAudioOnly) {
111
+ fetchInfo.quality = "max";
112
+ fetchInfo.format = "vp9";
113
+ fetchInfo.isAudioOnly = true;
114
+ fetchInfo.isAudioMuted = false;
115
+ }
116
+
117
+ r = await youtube(fetchInfo);
118
+ break;
119
+
120
+ case "reddit":
121
+ r = await reddit({
122
+ sub: patternMatch.sub,
123
+ id: patternMatch.id,
124
+ user: patternMatch.user
125
+ });
126
+ break;
127
+
128
+ case "tiktok":
129
+ r = await tiktok({
130
+ postId: patternMatch.postId,
131
+ shortLink: patternMatch.shortLink,
132
+ fullAudio: params.tiktokFullAudio,
133
+ isAudioOnly,
134
+ h265: params.tiktokH265,
135
+ alwaysProxy: params.alwaysProxy,
136
+ });
137
+ break;
138
+
139
+ case "tumblr":
140
+ r = await tumblr({
141
+ id: patternMatch.id,
142
+ user: patternMatch.user,
143
+ url
144
+ });
145
+ break;
146
+
147
+ case "vimeo":
148
+ r = await vimeo({
149
+ id: patternMatch.id.slice(0, 11),
150
+ password: patternMatch.password,
151
+ quality: params.videoQuality,
152
+ isAudioOnly,
153
+ });
154
+ break;
155
+
156
+ case "soundcloud":
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
+
169
+ case "instagram":
170
+ r = await instagram({
171
+ ...patternMatch,
172
+ quality: params.videoQuality,
173
+ alwaysProxy: params.alwaysProxy,
174
+ dispatcher
175
+ })
176
+ break;
177
+
178
+ case "pinterest":
179
+ r = await pinterest({
180
+ id: patternMatch.id,
181
+ shortLink: patternMatch.shortLink || false
182
+ });
183
+ break;
184
+
185
+ case "streamable":
186
+ r = await streamable({
187
+ id: patternMatch.id,
188
+ quality: params.videoQuality,
189
+ isAudioOnly,
190
+ });
191
+ break;
192
+
193
+ case "twitch":
194
+ r = await twitch({
195
+ clipId: patternMatch.clip || false,
196
+ quality: params.videoQuality,
197
+ isAudioOnly,
198
+ });
199
+ break;
200
+
201
+ case "rutube":
202
+ r = await rutube({
203
+ id: patternMatch.id,
204
+ yappyId: patternMatch.yappyId,
205
+ key: patternMatch.key,
206
+ quality: params.videoQuality,
207
+ isAudioOnly,
208
+ });
209
+ break;
210
+
211
+ case "dailymotion":
212
+ r = await dailymotion(patternMatch);
213
+ break;
214
+
215
+ case "snapchat":
216
+ r = await snapchat({
217
+ ...patternMatch,
218
+ alwaysProxy: params.alwaysProxy,
219
+ });
220
+ break;
221
+
222
+ case "loom":
223
+ r = await loom({
224
+ id: patternMatch.id
225
+ });
226
+ break;
227
+
228
+ case "facebook":
229
+ r = await facebook({
230
+ ...patternMatch
231
+ });
232
+ break;
233
+
234
+ case "bsky":
235
+ r = await bluesky({
236
+ ...patternMatch,
237
+ alwaysProxy: params.alwaysProxy,
238
+ dispatcher
239
+ });
240
+ break;
241
+
242
+ default:
243
+ return createResponse("error", {
244
+ code: "error.api.service.unsupported"
245
+ });
246
+ }
247
+
248
+ if (r.isAudioOnly) {
249
+ isAudioOnly = true;
250
+ isAudioMuted = false;
251
+ }
252
+
253
+ if (r.error && r.critical) {
254
+ return createResponse("critical", {
255
+ code: `error.api.${r.error}`,
256
+ })
257
+ }
258
+
259
+ if (r.error) {
260
+ let context;
261
+ switch(r.error) {
262
+ case "content.too_long":
263
+ context = {
264
+ limit: env.durationLimit / 60,
265
+ }
266
+ break;
267
+
268
+ case "fetch.fail":
269
+ case "fetch.rate":
270
+ case "fetch.critical":
271
+ case "link.unsupported":
272
+ case "content.video.unavailable":
273
+ context = {
274
+ service: friendlyServiceName(host),
275
+ }
276
+ break;
277
+ }
278
+
279
+ return createResponse("error", {
280
+ code: `error.api.${r.error}`,
281
+ context,
282
+ })
283
+ }
284
+
285
+ return matchAction({
286
+ r,
287
+ host,
288
+ audioFormat: params.audioFormat,
289
+ isAudioOnly,
290
+ isAudioMuted,
291
+ disableMetadata: params.disableMetadata,
292
+ filenameStyle: params.filenameStyle,
293
+ twitterGif: params.twitterGif,
294
+ requestIP,
295
+ audioBitrate: params.audioBitrate,
296
+ alwaysProxy: params.alwaysProxy,
297
+ })
298
+ } catch {
299
+ return createResponse("error", {
300
+ code: "error.api.fetch.critical",
301
+ context: {
302
+ service: friendlyServiceName(host),
303
+ }
304
+ })
305
+ }
306
+ }
src/processing/request.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) => {
8
+ return {
9
+ status: 500,
10
+ body: {
11
+ status: "error",
12
+ error: {
13
+ code: code || "error.api.fetch.critical",
14
+ },
15
+ critical: true
16
+ }
17
+ }
18
+ }
19
+
20
+ try {
21
+ let status = 200,
22
+ response = {};
23
+
24
+ if (responseType === "error") {
25
+ status = 400;
26
+ }
27
+
28
+ switch (responseType) {
29
+ case "error":
30
+ response = {
31
+ error: {
32
+ code: responseData?.code,
33
+ context: responseData?.context,
34
+ }
35
+ }
36
+ break;
37
+
38
+ case "redirect":
39
+ response = {
40
+ url: responseData?.url,
41
+ filename: responseData?.filename
42
+ }
43
+ break;
44
+
45
+ case "tunnel":
46
+ response = {
47
+ url: createStream(responseData),
48
+ filename: responseData?.filename
49
+ }
50
+ break;
51
+
52
+ case "picker":
53
+ response = {
54
+ picker: responseData?.picker,
55
+ audio: responseData?.url,
56
+ audioFilename: responseData?.filename
57
+ }
58
+ break;
59
+
60
+ case "critical":
61
+ return internalError(responseData?.code);
62
+
63
+ default:
64
+ throw "unreachable"
65
+ }
66
+
67
+ return {
68
+ status,
69
+ body: {
70
+ status: responseType,
71
+ ...response
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
+ ));
83
+ }
84
+
85
+ export function getIP(req) {
86
+ const strippedIP = req.ip.replace(/^::ffff:/, '');
87
+ const ip = ipaddr.parse(strippedIP);
88
+ if (ip.kind() === 'ipv4') {
89
+ return strippedIP;
90
+ }
91
+
92
+ const prefix = 56;
93
+ const v6Bytes = ip.toByteArray();
94
+ v6Bytes.fill(0, prefix / 8);
95
+
96
+ return ipaddr.fromByteArray(v6Bytes).toString();
97
+ }
src/processing/schema.js ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from "zod";
2
+ import { normalizeURL } from "./url.js";
3
+
4
+ export const apiSchema = z.object({
5
+ url: z.string()
6
+ .min(1)
7
+ .transform(url => normalizeURL(url)),
8
+
9
+ audioBitrate: z.enum(
10
+ ["320", "256", "128", "96", "64", "8"]
11
+ ).default("128"),
12
+
13
+ audioFormat: z.enum(
14
+ ["best", "mp3", "ogg", "wav", "opus"]
15
+ ).default("mp3"),
16
+
17
+ downloadMode: z.enum(
18
+ ["auto", "audio", "mute"]
19
+ ).default("auto"),
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();
src/processing/service-alias.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ const friendlyNames = {
2
+ bsky: "bluesky",
3
+ }
4
+
5
+ export const friendlyServiceName = (service) => {
6
+ if (service in friendlyNames) {
7
+ return friendlyNames[service];
8
+ }
9
+ return service;
10
+ }
src/processing/service-config.js ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"
13
+ ],
14
+ subdomains: ["m"],
15
+ },
16
+ bsky: {
17
+ patterns: [
18
+ "profile/:user/post/:post"
19
+ ],
20
+ tld: "app",
21
+ },
22
+ dailymotion: {
23
+ patterns: ["video/:id"],
24
+ },
25
+ facebook: {
26
+ patterns: [
27
+ "_shortLink/:shortLink",
28
+ ":username/videos/:caption/:id",
29
+ ":username/videos/:id",
30
+ "reel/:id",
31
+ "share/:shareType/:id"
32
+ ],
33
+ subdomains: ["web", "m"],
34
+ altDomains: ["fb.watch"],
35
+ },
36
+ instagram: {
37
+ patterns: [
38
+ "reels/:postId",
39
+ ":username/reel/:postId",
40
+ "reel/:postId",
41
+ "p/:postId",
42
+ ":username/p/:postId",
43
+ "tv/:postId",
44
+ "stories/:username/:storyId"
45
+ ],
46
+ altDomains: ["ddinstagram.com"],
47
+ },
48
+ loom: {
49
+ patterns: ["share/:id", "embed/:id"],
50
+ },
51
+ ok: {
52
+ patterns: [
53
+ "video/:id",
54
+ "videoembed/:id"
55
+ ],
56
+ tld: "ru",
57
+ },
58
+ pinterest: {
59
+ patterns: [
60
+ "pin/:id",
61
+ "pin/:id/:garbage",
62
+ "url_shortener/:shortLink"
63
+ ],
64
+ },
65
+ reddit: {
66
+ patterns: [
67
+ "r/:sub/comments/:id/:title",
68
+ "user/:user/comments/:id/:title"
69
+ ],
70
+ subdomains: "*",
71
+ },
72
+ rutube: {
73
+ patterns: [
74
+ "video/:id",
75
+ "play/embed/:id",
76
+ "shorts/:id",
77
+ "yappy/:yappyId",
78
+ "video/private/:id?p=:key",
79
+ "video/private/:id"
80
+ ],
81
+ tld: "ru",
82
+ },
83
+ snapchat: {
84
+ patterns: [
85
+ ":shortLink",
86
+ "spotlight/:spotlightId",
87
+ "add/:username/:storyId",
88
+ "u/:username/:storyId",
89
+ "add/:username",
90
+ "u/:username",
91
+ "t/:shortLink",
92
+ ],
93
+ subdomains: ["t", "story"],
94
+ },
95
+ soundcloud: {
96
+ patterns: [
97
+ ":author/:song/s-:accessKey",
98
+ ":author/:song",
99
+ ":shortLink"
100
+ ],
101
+ subdomains: ["on", "m"],
102
+ },
103
+ streamable: {
104
+ patterns: [
105
+ ":id",
106
+ "o/:id",
107
+ "e/:id",
108
+ "s/:id"
109
+ ],
110
+ },
111
+ tiktok: {
112
+ patterns: [
113
+ ":user/video/:postId",
114
+ ":shortLink",
115
+ "t/:shortLink",
116
+ ":user/photo/:postId",
117
+ "v/:postId.html"
118
+ ],
119
+ subdomains: ["vt", "vm", "m"],
120
+ },
121
+ tumblr: {
122
+ patterns: [
123
+ "post/:id",
124
+ "blog/view/:user/:id",
125
+ ":user/:id",
126
+ ":user/:id/:trackingId"
127
+ ],
128
+ subdomains: "*",
129
+ },
130
+ twitch: {
131
+ patterns: [":channel/clip/:clip"],
132
+ tld: "tv",
133
+ },
134
+ twitter: {
135
+ patterns: [
136
+ ":user/status/:id",
137
+ ":user/status/:id/video/:index",
138
+ ":user/status/:id/photo/:index",
139
+ ":user/status/:id/mediaviewer",
140
+ ":user/status/:id/mediaViewer",
141
+ "i/bookmarks?post_id=:id"
142
+ ],
143
+ subdomains: ["mobile"],
144
+ altDomains: ["x.com", "vxtwitter.com", "fixvx.com"],
145
+ },
146
+ vimeo: {
147
+ patterns: [
148
+ ":id",
149
+ "video/:id",
150
+ ":id/:password",
151
+ "/channels/:user/:id"
152
+ ],
153
+ subdomains: ["player"],
154
+ },
155
+ vk: {
156
+ patterns: [
157
+ "video:ownerId_:videoId",
158
+ "clip:ownerId_:videoId",
159
+ "clips:duplicate?z=clip:ownerId_:videoId",
160
+ "videos:duplicate?z=video:ownerId_:videoId",
161
+ "video:ownerId_:videoId_:accessKey",
162
+ "clip:ownerId_:videoId_:accessKey",
163
+ "clips:duplicate?z=clip:ownerId_:videoId_:accessKey",
164
+ "videos:duplicate?z=video:ownerId_:videoId_:accessKey"
165
+ ],
166
+ subdomains: ["m"],
167
+ altDomains: ["vkvideo.ru", "vk.ru"],
168
+ },
169
+ youtube: {
170
+ patterns: [
171
+ "watch?v=:id",
172
+ "embed/:id",
173
+ "watch/:id"
174
+ ],
175
+ subdomains: ["music", "m"],
176
+ }
177
+ }
178
+
179
+ Object.values(services).forEach(service => {
180
+ service.patterns = service.patterns.map(
181
+ pattern => new UrlPattern(pattern, {
182
+ segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.:'
183
+ })
184
+ )
185
+ })
src/processing/service-patterns.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 <= 12
10
+ || (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
11
+
12
+ "loom": pattern =>
13
+ pattern.id?.length <= 32,
14
+
15
+ "ok": pattern =>
16
+ pattern.id?.length <= 16,
17
+
18
+ "pinterest": pattern =>
19
+ pattern.id?.length <= 128 || pattern.shortLink?.length <= 32,
20
+
21
+ "reddit": pattern =>
22
+ (pattern.sub?.length <= 22 && pattern.id?.length <= 10)
23
+ || (pattern.user?.length <= 22 && pattern.id?.length <= 10),
24
+
25
+ "rutube": pattern =>
26
+ (pattern.id?.length === 32 && pattern.key?.length <= 32) ||
27
+ pattern.id?.length === 32 || pattern.yappyId?.length === 32,
28
+
29
+ "soundcloud": pattern =>
30
+ (pattern.author?.length <= 255 && pattern.song?.length <= 255)
31
+ || pattern.shortLink?.length <= 32,
32
+
33
+ "snapchat": pattern =>
34
+ (pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255))
35
+ || pattern.spotlightId?.length <= 255
36
+ || pattern.shortLink?.length <= 16,
37
+
38
+ "streamable": pattern =>
39
+ pattern.id?.length <= 6,
40
+
41
+ "tiktok": pattern =>
42
+ pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13,
43
+
44
+ "tumblr": pattern =>
45
+ pattern.id?.length < 21
46
+ || (pattern.id?.length < 21 && pattern.user?.length <= 32),
47
+
48
+ "twitch": pattern =>
49
+ pattern.channel && pattern.clip?.length <= 100,
50
+
51
+ "twitter": pattern =>
52
+ pattern.id?.length < 20,
53
+
54
+ "vimeo": pattern =>
55
+ pattern.id?.length <= 11
56
+ && (!pattern.password || pattern.password.length < 16),
57
+
58
+ "vk": pattern =>
59
+ (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||
60
+ (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),
61
+
62
+ "youtube": pattern =>
63
+ pattern.id?.length <= 11,
64
+
65
+ "facebook": pattern =>
66
+ pattern.shortLink?.length <= 11
67
+ || pattern.username?.length <= 30
68
+ || pattern.caption?.length <= 255
69
+ || pattern.id?.length <= 20 && !pattern.shareType
70
+ || pattern.id?.length <= 20 && pattern.shareType?.length === 1,
71
+
72
+ "bsky": pattern =>
73
+ pattern.user?.length <= 128 && pattern.post?.length <= 128,
74
+ }
src/processing/services/bilibili.js ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { genericUserAgent, env } from "../../config.js";
2
+
3
+ // TO-DO: higher quality downloads (currently requires an account)
4
+
5
+ function com_resolveShortlink(shortId) {
6
+ return fetch(`https://b23.tv/${shortId}`, { redirect: 'manual' })
7
+ .then(r => r.status > 300 && r.status < 400 && r.headers.get('location'))
8
+ .then(url => {
9
+ if (!url) return;
10
+ const path = new URL(url).pathname;
11
+ if (path.startsWith('/video/'))
12
+ return path.split('/')[2];
13
+ })
14
+ .catch(() => {})
15
+ }
16
+
17
+ function getBest(content) {
18
+ return content?.filter(v => v.baseUrl || v.url)
19
+ .map(v => (v.baseUrl = v.baseUrl || v.url, v))
20
+ .reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);
21
+ }
22
+
23
+ function extractBestQuality(dashData) {
24
+ const bestVideo = getBest(dashData.video),
25
+ bestAudio = getBest(dashData.audio);
26
+
27
+ if (!bestVideo || !bestAudio) return [];
28
+ return [ bestVideo, bestAudio ];
29
+ }
30
+
31
+ async function com_download(id) {
32
+ let html = await fetch(`https://bilibili.com/video/${id}`, {
33
+ headers: {
34
+ "user-agent": genericUserAgent
35
+ }
36
+ })
37
+ .then(r => r.text())
38
+ .catch(() => {});
39
+
40
+ if (!html) {
41
+ return { error: "fetch.fail" }
42
+ }
43
+
44
+ if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) {
45
+ return { error: "fetch.empty" };
46
+ }
47
+
48
+ let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
49
+ if (streamData.data.timelength > env.durationLimit * 1000) {
50
+ return { error: "content.too_long" };
51
+ }
52
+
53
+ const [ video, audio ] = extractBestQuality(streamData.data.dash);
54
+ if (!video || !audio) {
55
+ return { error: "fetch.empty" };
56
+ }
57
+
58
+ return {
59
+ urls: [video.baseUrl, audio.baseUrl],
60
+ audioFilename: `bilibili_${id}_audio`,
61
+ filename: `bilibili_${id}_${video.width}x${video.height}.mp4`
62
+ };
63
+ }
64
+
65
+ async function tv_download(id) {
66
+ const url = new URL(
67
+ 'https://api.bilibili.tv/intl/gateway/web/playurl'
68
+ + '?s_locale=en_US&platform=web&qn=64&type=0&device=wap'
69
+ + '&tf=0&spm_id=bstar-web.ugc-video-detail.0.0&from_spm_id='
70
+ );
71
+
72
+ url.searchParams.set('aid', id);
73
+
74
+ const { data } = await fetch(url).then(a => a.json());
75
+ if (!data?.playurl?.video) {
76
+ return { error: "fetch.empty" };
77
+ }
78
+
79
+ const [ video, audio ] = extractBestQuality({
80
+ video: data.playurl.video.map(s => s.video_resource)
81
+ .filter(s => s.codecs.includes('avc1')),
82
+ audio: data.playurl.audio_resource
83
+ });
84
+
85
+ if (!video || !audio) {
86
+ return { error: "fetch.empty" };
87
+ }
88
+
89
+ if (video.duration > env.durationLimit * 1000) {
90
+ return { error: "content.too_long" };
91
+ }
92
+
93
+ return {
94
+ urls: [video.url, audio.url],
95
+ audioFilename: `bilibili_tv_${id}_audio`,
96
+ filename: `bilibili_tv_${id}.mp4`
97
+ };
98
+ }
99
+
100
+ export default async function({ comId, tvId, comShortLink }) {
101
+ if (comShortLink) {
102
+ comId = await com_resolveShortlink(comShortLink);
103
+ }
104
+
105
+ if (comId) {
106
+ return com_download(comId);
107
+ } else if (tvId) {
108
+ return tv_download(tvId);
109
+ }
110
+
111
+ return { error: "fetch.fail" };
112
+ }
src/processing/services/bluesky.js ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import HLS from "hls-parser";
2
+ import { cobaltUserAgent } from "../../config.js";
3
+ import { createStream } from "../../stream/manage.js";
4
+
5
+ const extractVideo = async ({ media, filename, dispatcher }) => {
6
+ let urlMasterHLS = media?.playlist;
7
+
8
+ if (!urlMasterHLS || !urlMasterHLS.startsWith("https://video.bsky.app/")) {
9
+ return { error: "fetch.empty" };
10
+ }
11
+
12
+ urlMasterHLS = urlMasterHLS.replace(
13
+ "video.bsky.app/watch/",
14
+ "video.cdn.bsky.app/hls/"
15
+ );
16
+
17
+ const masterHLS = await fetch(urlMasterHLS, { dispatcher })
18
+ .then(r => {
19
+ if (r.status !== 200) return;
20
+ return r.text();
21
+ })
22
+ .catch(() => {});
23
+
24
+ if (!masterHLS) return { error: "fetch.empty" };
25
+
26
+ const video = HLS.parse(masterHLS)
27
+ ?.variants
28
+ ?.reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);
29
+
30
+ const videoURL = new URL(video.uri, urlMasterHLS).toString();
31
+
32
+ return {
33
+ urls: videoURL,
34
+ filename: `${filename}.mp4`,
35
+ audioFilename: `${filename}_audio`,
36
+ isHLS: true,
37
+ }
38
+ }
39
+
40
+ const extractImages = ({ getPost, filename, alwaysProxy }) => {
41
+ const images = getPost?.thread?.post?.embed?.images;
42
+
43
+ if (!images || images.length === 0) {
44
+ return { error: "fetch.empty" };
45
+ }
46
+
47
+ if (images.length === 1) return {
48
+ urls: images[0].fullsize,
49
+ isPhoto: true,
50
+ filename: `${filename}.jpg`,
51
+ }
52
+
53
+ const picker = images.map((image, i) => {
54
+ let url = image.fullsize;
55
+ let proxiedImage = createStream({
56
+ service: "bluesky",
57
+ type: "proxy",
58
+ url,
59
+ filename: `${filename}_${i + 1}.jpg`,
60
+ });
61
+
62
+ if (alwaysProxy) url = proxiedImage;
63
+
64
+ return {
65
+ type: "photo",
66
+ url,
67
+ thumb: proxiedImage,
68
+ }
69
+ });
70
+
71
+ return { picker };
72
+ }
73
+
74
+ export default async function ({ user, post, alwaysProxy, dispatcher }) {
75
+ const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
76
+ apiEndpoint.searchParams.set(
77
+ "uri",
78
+ `at://${user}/app.bsky.feed.post/${post}`
79
+ );
80
+
81
+ const getPost = await fetch(apiEndpoint, {
82
+ headers: {
83
+ "user-agent": cobaltUserAgent,
84
+ },
85
+ dispatcher
86
+ }).then(r => r.json()).catch(() => {});
87
+
88
+ if (!getPost) return { error: "fetch.empty" };
89
+
90
+ if (getPost.error) {
91
+ switch (getPost.error) {
92
+ case "NotFound":
93
+ case "InternalServerError":
94
+ return { error: "content.post.unavailable" };
95
+ case "InvalidRequest":
96
+ return { error: "link.unsupported" };
97
+ default:
98
+ return { error: "content.post.unavailable" };
99
+ }
100
+ }
101
+
102
+ const embedType = getPost?.thread?.post?.embed?.$type;
103
+ const filename = `bluesky_${user}_${post}`;
104
+
105
+ if (embedType === "app.bsky.embed.video#view") {
106
+ return extractVideo({
107
+ media: getPost.thread?.post?.embed,
108
+ filename,
109
+ })
110
+ }
111
+
112
+ if (embedType === "app.bsky.embed.recordWithMedia#view") {
113
+ return extractVideo({
114
+ media: getPost.thread?.post?.embed?.media,
115
+ filename,
116
+ })
117
+ }
118
+
119
+ if (embedType === "app.bsky.embed.images#view") {
120
+ return extractImages({ getPost, filename, alwaysProxy });
121
+ }
122
+
123
+ return { error: "fetch.empty" };
124
+ }
src/processing/services/dailymotion.js ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import HLSParser from "hls-parser";
2
+ import { env } from "../../config.js";
3
+
4
+ let _token;
5
+
6
+ function getExp(token) {
7
+ return JSON.parse(
8
+ Buffer.from(token.split('.')[1], 'base64')
9
+ ).exp * 1000;
10
+ }
11
+
12
+ const getToken = async () => {
13
+ if (_token && getExp(_token) > new Date().getTime()) {
14
+ return _token;
15
+ }
16
+
17
+ const req = await fetch('https://graphql.api.dailymotion.com/oauth/token', {
18
+ method: 'POST',
19
+ headers: {
20
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
21
+ 'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',
22
+ 'Authorization': 'Basic MGQyZDgyNjQwOWFmOWU3MmRiNWQ6ODcxNmJmYTVjYmEwMmUwMGJkYTVmYTg1NTliNDIwMzQ3NzIyYWMzYQ=='
23
+ },
24
+ body: 'traffic_segment=&grant_type=client_credentials'
25
+ }).then(r => r.json()).catch(() => {});
26
+
27
+ if (req.access_token) {
28
+ return _token = req.access_token;
29
+ }
30
+ }
31
+
32
+ export default async function({ id }) {
33
+ const token = await getToken();
34
+ if (!token) return { error: "fetch.fail" };
35
+
36
+ const req = await fetch('https://graphql.api.dailymotion.com/',
37
+ {
38
+ method: 'POST',
39
+ headers: {
40
+ 'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',
41
+ Authorization: `Bearer ${token}`,
42
+ 'Content-Type': 'application/json',
43
+ 'X-DM-AppInfo-Version': '7.16.0_240213162706',
44
+ 'X-DM-AppInfo-Type': 'iosapp',
45
+ 'X-DM-AppInfo-Id': 'com.dailymotion.dailymotion'
46
+ },
47
+ body: JSON.stringify({
48
+ operationName: "Media",
49
+ query: `
50
+ query Media($xid: String!, $password: String) {
51
+ media(xid: $xid, password: $password) {
52
+ __typename
53
+ ... on Video {
54
+ xid
55
+ hlsURL
56
+ duration
57
+ title
58
+ channel {
59
+ displayName
60
+ }
61
+ }
62
+ }
63
+ }
64
+ `,
65
+ variables: { xid: id }
66
+ })
67
+ }
68
+ ).then(r => r.status === 200 && r.json()).catch(() => {});
69
+
70
+ const media = req?.data?.media;
71
+
72
+ if (media?.__typename !== 'Video' || !media.hlsURL) {
73
+ return { error: "fetch.empty" }
74
+ }
75
+
76
+ if (media.duration > env.durationLimit) {
77
+ return { error: "content.too_long" };
78
+ }
79
+
80
+ const manifest = await fetch(media.hlsURL).then(r => r.text()).catch(() => {});
81
+ if (!manifest) return { error: "fetch.fail" };
82
+
83
+ const bestQuality = HLSParser.parse(manifest).variants
84
+ .filter(v => v.codecs.includes('avc1'))
85
+ .reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);
86
+ if (!bestQuality) return { error: "fetch.empty" }
87
+
88
+ const fileMetadata = {
89
+ title: media.title,
90
+ artist: media.channel.displayName
91
+ }
92
+
93
+ return {
94
+ urls: bestQuality.uri,
95
+ isHLS: true,
96
+ filenameAttributes: {
97
+ service: 'dailymotion',
98
+ id: media.xid,
99
+ title: fileMetadata.title,
100
+ author: fileMetadata.artist,
101
+ resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
102
+ qualityLabel: `${bestQuality.resolution.height}p`,
103
+ extension: 'mp4'
104
+ },
105
+ fileMetadata
106
+ }
107
+ }
src/processing/services/facebook.js ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { genericUserAgent } from "../../config.js";
2
+
3
+ const headers = {
4
+ 'User-Agent': genericUserAgent,
5
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
6
+ 'Accept-Language': 'en-US,en;q=0.5',
7
+ 'Sec-Fetch-Mode': 'navigate',
8
+ 'Sec-Fetch-Site': 'none',
9
+ }
10
+
11
+ const resolveUrl = (url) => {
12
+ return fetch(url, { headers })
13
+ .then(r => {
14
+ if (r.headers.get('location')) {
15
+ return decodeURIComponent(r.headers.get('location'));
16
+ }
17
+ if (r.headers.get('link')) {
18
+ const linkMatch = r.headers.get('link').match(/<(.*?)\/>/);
19
+ return decodeURIComponent(linkMatch[1]);
20
+ }
21
+ return false;
22
+ })
23
+ .catch(() => false);
24
+ }
25
+
26
+ export default async function({ id, shareType, shortLink }) {
27
+ let url = `https://web.facebook.com/i/videos/${id}`;
28
+
29
+ if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
30
+ if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`);
31
+
32
+ const html = await fetch(url, { headers })
33
+ .then(r => r.text())
34
+ .catch(() => false);
35
+
36
+ if (!html && shortLink) return { error: "fetch.short_link" }
37
+ if (!html) return { error: "fetch.fail" };
38
+
39
+ const urls = [];
40
+ const hd = html.match('"browser_native_hd_url":(".*?")');
41
+ const sd = html.match('"browser_native_sd_url":(".*?")');
42
+
43
+ if (hd?.[1]) urls.push(JSON.parse(hd[1]));
44
+ if (sd?.[1]) urls.push(JSON.parse(sd[1]));
45
+
46
+ if (!urls.length) {
47
+ return { error: "fetch.empty" };
48
+ }
49
+
50
+ const baseFilename = `facebook_${id || shortLink}`;
51
+
52
+ return {
53
+ urls: urls[0],
54
+ filename: `${baseFilename}.mp4`,
55
+ audioFilename: `${baseFilename}_audio`,
56
+ };
57
+ }
src/processing/services/instagram.js ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { genericUserAgent } from "../../config.js";
2
+ import { createStream } from "../../stream/manage.js";
3
+ import { getCookie, updateCookie } from "../cookie/manager.js";
4
+
5
+ const commonHeaders = {
6
+ "user-agent": genericUserAgent,
7
+ "sec-gpc": "1",
8
+ "sec-fetch-site": "same-origin",
9
+ "x-ig-app-id": "936619743392459"
10
+ }
11
+ const mobileHeaders = {
12
+ "x-ig-app-locale": "en_US",
13
+ "x-ig-device-locale": "en_US",
14
+ "x-ig-mapped-locale": "en_US",
15
+ "user-agent": "Instagram 275.0.0.27.98 Android (33/13; 280dpi; 720x1423; Xiaomi; Redmi 7; onclite; qcom; en_US; 458229237)",
16
+ "accept-language": "en-US",
17
+ "x-fb-http-engine": "Liger",
18
+ "x-fb-client-ip": "True",
19
+ "x-fb-server-cluster": "True",
20
+ "content-length": "0",
21
+ }
22
+ const embedHeaders = {
23
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
24
+ "Accept-Language": "en-GB,en;q=0.9",
25
+ "Cache-Control": "max-age=0",
26
+ "Dnt": "1",
27
+ "Priority": "u=0, i",
28
+ "Sec-Ch-Ua": 'Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99',
29
+ "Sec-Ch-Ua-Mobile": "?0",
30
+ "Sec-Ch-Ua-Platform": "macOS",
31
+ "Sec-Fetch-Dest": "document",
32
+ "Sec-Fetch-Mode": "navigate",
33
+ "Sec-Fetch-Site": "none",
34
+ "Sec-Fetch-User": "?1",
35
+ "Upgrade-Insecure-Requests": "1",
36
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
37
+ }
38
+
39
+ const cachedDtsg = {
40
+ value: '',
41
+ expiry: 0
42
+ }
43
+
44
+ export default function(obj) {
45
+ const dispatcher = obj.dispatcher;
46
+
47
+ async function findDtsgId(cookie) {
48
+ try {
49
+ if (cachedDtsg.expiry > Date.now()) return cachedDtsg.value;
50
+
51
+ const data = await fetch('https://www.instagram.com/', {
52
+ headers: {
53
+ ...commonHeaders,
54
+ cookie
55
+ },
56
+ dispatcher
57
+ }).then(r => r.text());
58
+
59
+ const token = data.match(/"dtsg":{"token":"(.*?)"/)[1];
60
+
61
+ cachedDtsg.value = token;
62
+ cachedDtsg.expiry = Date.now() + 86390000;
63
+
64
+ if (token) return token;
65
+ return false;
66
+ }
67
+ catch {}
68
+ }
69
+
70
+ async function request(url, cookie, method = 'GET', requestData) {
71
+ let headers = {
72
+ ...commonHeaders,
73
+ 'x-ig-www-claim': cookie?._wwwClaim || '0',
74
+ 'x-csrftoken': cookie?.values()?.csrftoken,
75
+ cookie
76
+ }
77
+ if (method === 'POST') {
78
+ headers['content-type'] = 'application/x-www-form-urlencoded';
79
+ }
80
+
81
+ const data = await fetch(url, {
82
+ method,
83
+ headers,
84
+ body: requestData && new URLSearchParams(requestData),
85
+ dispatcher
86
+ });
87
+
88
+ if (data.headers.get('X-Ig-Set-Www-Claim') && cookie)
89
+ cookie._wwwClaim = data.headers.get('X-Ig-Set-Www-Claim');
90
+
91
+ updateCookie(cookie, data.headers);
92
+ return data.json();
93
+ }
94
+ async function getMediaId(id, { cookie, token } = {}) {
95
+ const oembedURL = new URL('https://i.instagram.com/api/v1/oembed/');
96
+ oembedURL.searchParams.set('url', `https://www.instagram.com/p/${id}/`);
97
+
98
+ const oembed = await fetch(oembedURL, {
99
+ headers: {
100
+ ...mobileHeaders,
101
+ ...( token && { authorization: `Bearer ${token}` } ),
102
+ cookie
103
+ },
104
+ dispatcher
105
+ }).then(r => r.json()).catch(() => {});
106
+
107
+ return oembed?.media_id;
108
+ }
109
+
110
+ async function requestMobileApi(mediaId, { cookie, token } = {}) {
111
+ const mediaInfo = await fetch(`https://i.instagram.com/api/v1/media/${mediaId}/info/`, {
112
+ headers: {
113
+ ...mobileHeaders,
114
+ ...( token && { authorization: `Bearer ${token}` } ),
115
+ cookie
116
+ },
117
+ dispatcher
118
+ }).then(r => r.json()).catch(() => {});
119
+
120
+ return mediaInfo?.items?.[0];
121
+ }
122
+ async function requestHTML(id, cookie) {
123
+ const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, {
124
+ headers: {
125
+ ...embedHeaders,
126
+ cookie
127
+ },
128
+ dispatcher
129
+ }).then(r => r.text()).catch(() => {});
130
+
131
+ let embedData = JSON.parse(data?.match(/"init",\[\],\[(.*?)\]\],/)[1]);
132
+
133
+ if (!embedData || !embedData?.contextJSON) return false;
134
+
135
+ embedData = JSON.parse(embedData.contextJSON);
136
+
137
+ return embedData;
138
+ }
139
+ async function requestGQL(id, cookie) {
140
+ let dtsgId;
141
+
142
+ if (cookie) {
143
+ dtsgId = await findDtsgId(cookie);
144
+ }
145
+ const url = new URL('https://www.instagram.com/api/graphql/');
146
+
147
+ const requestData = {
148
+ jazoest: '26406',
149
+ variables: JSON.stringify({
150
+ shortcode: id,
151
+ __relay_internal__pv__PolarisShareMenurelayprovider: false
152
+ }),
153
+ doc_id: '7153618348081770'
154
+ };
155
+ if (dtsgId) {
156
+ requestData.fb_dtsg = dtsgId;
157
+ }
158
+
159
+ return (await request(url, cookie, 'POST', requestData))
160
+ .data
161
+ ?.xdt_api__v1__media__shortcode__web_info
162
+ ?.items
163
+ ?.[0];
164
+ }
165
+
166
+ function extractOldPost(data, id, alwaysProxy) {
167
+ const sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children;
168
+ if (sidecar) {
169
+ const picker = sidecar.edges.filter(e => e.node?.display_url)
170
+ .map((e, i) => {
171
+ const type = e.node?.is_video ? "video" : "photo";
172
+ const url = type === "video" ? e.node?.video_url : e.node?.display_url;
173
+
174
+ let itemExt = type === "video" ? "mp4" : "jpg";
175
+
176
+ let proxyFile;
177
+ if (alwaysProxy) proxyFile = createStream({
178
+ service: "instagram",
179
+ type: "proxy",
180
+ url,
181
+ filename: `instagram_${id}_${i + 1}.${itemExt}`
182
+ });
183
+
184
+ return {
185
+ type,
186
+ url: proxyFile || url,
187
+ /* thumbnails have `Cross-Origin-Resource-Policy`
188
+ ** set to `same-origin`, so we need to proxy them */
189
+ thumb: createStream({
190
+ service: "instagram",
191
+ type: "proxy",
192
+ url: e.node?.display_url,
193
+ filename: `instagram_${id}_${i + 1}.jpg`
194
+ })
195
+ }
196
+ });
197
+
198
+ if (picker.length) return { picker }
199
+ } else if (data?.gql_data?.shortcode_media?.video_url) {
200
+ return {
201
+ urls: data.gql_data.shortcode_media.video_url,
202
+ filename: `instagram_${id}.mp4`,
203
+ audioFilename: `instagram_${id}_audio`
204
+ }
205
+ } else if (data?.gql_data?.shortcode_media?.display_url) {
206
+ return {
207
+ urls: data.gql_data?.shortcode_media.display_url,
208
+ isPhoto: true
209
+ }
210
+ }
211
+ }
212
+
213
+ function extractNewPost(data, id, alwaysProxy) {
214
+ const carousel = data.carousel_media;
215
+ if (carousel) {
216
+ const picker = carousel.filter(e => e?.image_versions2)
217
+ .map((e, i) => {
218
+ const type = e.video_versions ? "video" : "photo";
219
+ const imageUrl = e.image_versions2.candidates[0].url;
220
+
221
+ let url = imageUrl;
222
+ let itemExt = type === "video" ? "mp4" : "jpg";
223
+
224
+ if (type === "video") {
225
+ const video = e.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a);
226
+ url = video.url;
227
+ }
228
+
229
+ let proxyFile;
230
+ if (alwaysProxy) proxyFile = createStream({
231
+ service: "instagram",
232
+ type: "proxy",
233
+ url,
234
+ filename: `instagram_${id}_${i + 1}.${itemExt}`
235
+ });
236
+
237
+ return {
238
+ type,
239
+ url: proxyFile || url,
240
+ /* thumbnails have `Cross-Origin-Resource-Policy`
241
+ ** set to `same-origin`, so we need to always proxy them */
242
+ thumb: createStream({
243
+ service: "instagram",
244
+ type: "proxy",
245
+ url: imageUrl,
246
+ filename: `instagram_${id}_${i + 1}.jpg`
247
+ })
248
+ }
249
+ });
250
+
251
+ if (picker.length) return { picker }
252
+ } else if (data.video_versions) {
253
+ const video = data.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)
254
+ return {
255
+ urls: video.url,
256
+ filename: `instagram_${id}.mp4`,
257
+ audioFilename: `instagram_${id}_audio`
258
+ }
259
+ } else if (data.image_versions2?.candidates) {
260
+ return {
261
+ urls: data.image_versions2.candidates[0].url,
262
+ isPhoto: true,
263
+ filename: `instagram_${id}.jpg`,
264
+ }
265
+ }
266
+ }
267
+
268
+ async function getPost(id, alwaysProxy) {
269
+ const hasData = (data) => data && data.gql_data !== null;
270
+ let data, result;
271
+ try {
272
+ const cookie = getCookie('instagram');
273
+
274
+ const bearer = getCookie('instagram_bearer');
275
+ const token = bearer?.values()?.token;
276
+
277
+ // get media_id for mobile api, three methods
278
+ let media_id = await getMediaId(id);
279
+ if (!media_id && token) media_id = await getMediaId(id, { token });
280
+ if (!media_id && cookie) media_id = await getMediaId(id, { cookie });
281
+
282
+ // mobile api (bearer)
283
+ if (media_id && token) data = await requestMobileApi(media_id, { token });
284
+
285
+ // mobile api (no cookie, cookie)
286
+ if (media_id && !hasData(data)) data = await requestMobileApi(media_id);
287
+ if (media_id && cookie && !hasData(data)) data = await requestMobileApi(media_id, { cookie });
288
+
289
+ // html embed (no cookie, cookie)
290
+ if (!hasData(data)) data = await requestHTML(id);
291
+ if (!hasData(data) && cookie) data = await requestHTML(id, cookie);
292
+
293
+ // web app graphql api (no cookie, cookie)
294
+ if (!hasData(data)) data = await requestGQL(id);
295
+ if (!hasData(data) && cookie) data = await requestGQL(id, cookie);
296
+ } catch {}
297
+
298
+ if (!data) return { error: "fetch.fail" };
299
+
300
+ if (data?.gql_data) {
301
+ result = extractOldPost(data, id, alwaysProxy)
302
+ } else {
303
+ result = extractNewPost(data, id, alwaysProxy)
304
+ }
305
+
306
+ if (result) return result;
307
+ return { error: "fetch.empty" }
308
+ }
309
+
310
+ async function usernameToId(username, cookie) {
311
+ const url = new URL('https://www.instagram.com/api/v1/users/web_profile_info/');
312
+ url.searchParams.set('username', username);
313
+
314
+ try {
315
+ const data = await request(url, cookie);
316
+ return data?.data?.user?.id;
317
+ } catch {}
318
+ }
319
+
320
+ async function getStory(username, id) {
321
+ const cookie = getCookie('instagram');
322
+ if (!cookie) return { error: "link.unsupported" };
323
+
324
+ const userId = await usernameToId(username, cookie);
325
+ if (!userId) return { error: "fetch.empty" };
326
+
327
+ const dtsgId = await findDtsgId(cookie);
328
+
329
+ const url = new URL('https://www.instagram.com/api/graphql/');
330
+ const requestData = {
331
+ fb_dtsg: dtsgId,
332
+ jazoest: '26438',
333
+ variables: JSON.stringify({
334
+ reel_ids_arr : [ userId ],
335
+ }),
336
+ server_timestamps: true,
337
+ doc_id: '25317500907894419'
338
+ };
339
+
340
+ let media;
341
+ try {
342
+ const data = (await request(url, cookie, 'POST', requestData));
343
+ media = data?.data?.xdt_api__v1__feed__reels_media?.reels_media?.find(m => m.id === userId);
344
+ } catch {}
345
+
346
+ const item = media.items.find(m => m.pk === id);
347
+ if (!item) return { error: "fetch.empty" };
348
+
349
+ if (item.video_versions) {
350
+ const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a)
351
+ return {
352
+ urls: video.url,
353
+ filename: `instagram_${id}.mp4`,
354
+ audioFilename: `instagram_${id}_audio`
355
+ }
356
+ }
357
+
358
+ if (item.image_versions2?.candidates) {
359
+ return {
360
+ urls: item.image_versions2.candidates[0].url,
361
+ isPhoto: true
362
+ }
363
+ }
364
+
365
+ return { error: "link.unsupported" };
366
+ }
367
+
368
+ const { postId, storyId, username, alwaysProxy } = obj;
369
+ if (postId) return getPost(postId, alwaysProxy);
370
+ if (username && storyId) return getStory(username, storyId);
371
+
372
+ return { error: "fetch.empty" }
373
+ }
src/processing/services/loom.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
19
+ anonID: null,
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
+ }
src/processing/services/ok.js ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { genericUserAgent, env } from "../../config.js";
2
+
3
+ const resolutions = {
4
+ "ultra": "2160",
5
+ "quad": "1440",
6
+ "full": "1080",
7
+ "hd": "720",
8
+ "sd": "480",
9
+ "low": "360",
10
+ "lowest": "240",
11
+ "mobile": "144"
12
+ }
13
+
14
+ export default async function(o) {
15
+ let quality = o.quality === "max" ? "2160" : o.quality;
16
+
17
+ let html = await fetch(`https://ok.ru/video/${o.id}`, {
18
+ headers: { "user-agent": genericUserAgent }
19
+ }).then(r => r.text()).catch(() => {});
20
+
21
+ if (!html) return { error: "fetch.fail" };
22
+
23
+ let videoData = html.match(/<div data-module="OKVideo" .*? data-options="({.*?})"( .*?)>/)
24
+ ?.[1]
25
+ ?.replaceAll("&quot;", '"');
26
+
27
+ if (!videoData) {
28
+ return { error: "fetch.empty" };
29
+ }
30
+
31
+ videoData = JSON.parse(JSON.parse(videoData).flashvars.metadata);
32
+
33
+ if (videoData.provider !== "UPLOADED_ODKL")
34
+ return { error: "link.unsupported" };
35
+
36
+ if (videoData.movie.is_live)
37
+ return { error: "content.video.live" };
38
+
39
+ if (videoData.movie.duration > env.durationLimit)
40
+ return { error: "content.too_long" };
41
+
42
+ let videos = videoData.videos.filter(v => !v.disallowed);
43
+ let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
44
+
45
+ let fileMetadata = {
46
+ title: videoData.movie.title.trim(),
47
+ author: (videoData.author?.name || videoData.compilationTitle).trim(),
48
+ }
49
+
50
+ if (bestVideo) return {
51
+ urls: bestVideo.url,
52
+ filenameAttributes: {
53
+ service: "ok",
54
+ id: o.id,
55
+ title: fileMetadata.title,
56
+ author: fileMetadata.author,
57
+ resolution: `${resolutions[bestVideo.name]}p`,
58
+ qualityLabel: `${resolutions[bestVideo.name]}p`,
59
+ extension: "mp4"
60
+ }
61
+ }
62
+
63
+ return { error: "fetch.empty" }
64
+ }
src/processing/services/pinterest.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { genericUserAgent } from "../../config.js";
2
+
3
+ const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g;
4
+ const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
5
+
6
+ export default async function(o) {
7
+ let id = o.id;
8
+
9
+ if (!o.id && o.shortLink) {
10
+ id = await fetch(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`, { redirect: "manual" })
11
+ .then(r => r.headers.get("location").split('pin/')[1].split('/')[0])
12
+ .catch(() => {});
13
+ }
14
+ if (id.includes("--")) id = id.split("--")[1];
15
+ if (!id) return { error: "fetch.fail" };
16
+
17
+ const html = await fetch(`https://www.pinterest.com/pin/${id}/`, {
18
+ headers: { "user-agent": genericUserAgent }
19
+ }).then(r => r.text()).catch(() => {});
20
+
21
+ if (!html) return { error: "fetch.fail" };
22
+
23
+ const videoLink = [...html.matchAll(videoRegex)]
24
+ .map(([, link]) => link)
25
+ .find(a => a.endsWith('.mp4') && a.includes('720p'));
26
+
27
+ if (videoLink) return {
28
+ urls: videoLink,
29
+ filename: `pinterest_${o.id}.mp4`,
30
+ audioFilename: `pinterest_${o.id}_audio`
31
+ }
32
+
33
+ const imageLink = [...html.matchAll(imageRegex)]
34
+ .map(([, link]) => link)
35
+ .find(a => a.endsWith('.jpg') || a.endsWith('.gif'));
36
+
37
+ const imageType = imageLink.endsWith(".gif") ? "gif" : "jpg"
38
+
39
+ if (imageLink) return {
40
+ urls: imageLink,
41
+ isPhoto: true,
42
+ filename: `pinterest_${o.id}.${imageType}`
43
+ }
44
+
45
+ return { error: "fetch.empty" };
46
+ }
src/processing/services/reddit.js ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { genericUserAgent, env } from "../../config.js";
2
+ import { getCookie, updateCookieValues } from "../cookie/manager.js";
3
+
4
+ async function getAccessToken() {
5
+ /* "cookie" in cookiefile needs to contain:
6
+ * client_id, client_secret, refresh_token
7
+ * e.g. client_id=bla; client_secret=bla; refresh_token=bla
8
+ *
9
+ * you can get these by making a reddit app and
10
+ * authenticating an account against reddit's oauth2 api
11
+ * see: https://github.com/reddit-archive/reddit/wiki/OAuth2
12
+ *
13
+ * any additional cookie fields are managed by this code and you
14
+ * should not touch them unless you know what you're doing. **/
15
+ const cookie = await getCookie('reddit');
16
+ if (!cookie) return;
17
+
18
+ const values = cookie.values(),
19
+ needRefresh = !values.access_token
20
+ || !values.expiry
21
+ || Number(values.expiry) < new Date().getTime();
22
+ if (!needRefresh) return values.access_token;
23
+
24
+ const data = await fetch('https://www.reddit.com/api/v1/access_token', {
25
+ method: 'POST',
26
+ headers: {
27
+ 'authorization': `Basic ${Buffer.from(
28
+ [values.client_id, values.client_secret].join(':')
29
+ ).toString('base64')}`,
30
+ 'content-type': 'application/x-www-form-urlencoded',
31
+ 'user-agent': genericUserAgent,
32
+ 'accept': 'application/json'
33
+ },
34
+ body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(values.refresh_token)}`
35
+ }).then(r => r.json()).catch(() => {});
36
+ if (!data) return;
37
+
38
+ const { access_token, refresh_token, expires_in } = data;
39
+ if (!access_token) return;
40
+
41
+ updateCookieValues(cookie, {
42
+ ...cookie.values(),
43
+ access_token, refresh_token,
44
+ expiry: new Date().getTime() + (expires_in * 1000),
45
+ });
46
+
47
+ return access_token;
48
+ }
49
+
50
+ export default async function(obj) {
51
+ let url = new URL(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}.json`);
52
+
53
+ if (obj.user) {
54
+ url.pathname = `/user/${obj.user}/comments/${obj.id}.json`;
55
+ }
56
+
57
+ const accessToken = await getAccessToken();
58
+ if (accessToken) url.hostname = 'oauth.reddit.com';
59
+
60
+ let data = await fetch(
61
+ url, {
62
+ headers: {
63
+ 'User-Agent': genericUserAgent,
64
+ accept: 'application/json',
65
+ authorization: accessToken && `Bearer ${accessToken}`
66
+ }
67
+ }
68
+ ).then(r => r.json()).catch(() => {});
69
+
70
+ if (!data || !Array.isArray(data)) {
71
+ return { error: "fetch.fail" }
72
+ }
73
+
74
+ data = data[0]?.data?.children[0]?.data;
75
+
76
+ const id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
77
+
78
+ if (data?.url?.endsWith('.gif')) return {
79
+ typeId: "redirect",
80
+ urls: data.url,
81
+ filename: `reddit_${id}.gif`,
82
+ }
83
+
84
+ if (!data.secure_media?.reddit_video)
85
+ return { error: "fetch.empty" };
86
+
87
+ if (data.secure_media?.reddit_video?.duration > env.durationLimit)
88
+ return { error: "content.too_long" };
89
+
90
+ let audio = false,
91
+ video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
92
+ audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`;
93
+
94
+ if (video.match('.mp4')) {
95
+ audioFileLink = `${video.split('_')[0]}_audio.mp4`
96
+ }
97
+
98
+ // test the existence of audio
99
+ await fetch(audioFileLink, { method: "HEAD" }).then(r => {
100
+ if (Number(r.status) === 200) {
101
+ audio = true
102
+ }
103
+ }).catch(() => {})
104
+
105
+ // fallback for videos with variable audio quality
106
+ if (!audio) {
107
+ audioFileLink = `${video.split('_')[0]}_AUDIO_128.mp4`
108
+ await fetch(audioFileLink, { method: "HEAD" }).then(r => {
109
+ if (Number(r.status) === 200) {
110
+ audio = true
111
+ }
112
+ }).catch(() => {})
113
+ }
114
+
115
+ if (!audio) return {
116
+ typeId: "redirect",
117
+ urls: video
118
+ }
119
+
120
+ return {
121
+ typeId: "tunnel",
122
+ type: "merge",
123
+ urls: [video, audioFileLink],
124
+ audioFilename: `reddit_${id}_audio`,
125
+ filename: `reddit_${id}.mp4`
126
+ }
127
+ }
src/processing/services/rutube.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import HLS from "hls-parser";
2
+ import { env } from "../../config.js";
3
+
4
+ async function requestJSON(url) {
5
+ try {
6
+ const r = await fetch(url);
7
+ return await r.json();
8
+ } catch {}
9
+ }
10
+
11
+ const delta = (a, b) => Math.abs(a - b);
12
+
13
+ export default async function(obj) {
14
+ if (obj.yappyId) {
15
+ const yappy = await requestJSON(
16
+ `https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`
17
+ )
18
+ const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;
19
+ if (!yappyURL) return { error: "fetch.empty" };
20
+
21
+ return {
22
+ urls: yappyURL,
23
+ filename: `rutube_yappy_${obj.yappyId}.mp4`,
24
+ audioFilename: `rutube_yappy_${obj.yappyId}_audio`
25
+ }
26
+ }
27
+
28
+ const quality = Number(obj.quality) || 9000;
29
+
30
+ const requestURL = new URL(`https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2`);
31
+ if (obj.key) requestURL.searchParams.set('p', obj.key);
32
+
33
+ const play = await requestJSON(requestURL);
34
+ if (!play) return { error: "fetch.fail" };
35
+
36
+ if (play.detail?.type === "blocking_rule") {
37
+ return { error: "content.video.region" };
38
+ }
39
+
40
+ if (play.detail || !play.video_balancer) return { error: "fetch.empty" };
41
+ if (play.live_streams?.hls) return { error: "content.video.live" };
42
+
43
+ if (play.duration > env.durationLimit * 1000)
44
+ return { error: "content.too_long" };
45
+
46
+ let m3u8 = await fetch(play.video_balancer.m3u8)
47
+ .then(r => r.text())
48
+ .catch(() => {});
49
+
50
+ if (!m3u8) return { error: "fetch.fail" };
51
+
52
+ m3u8 = HLS.parse(m3u8).variants;
53
+
54
+ const matchingQuality = m3u8.reduce((prev, next) => {
55
+ const diff = {
56
+ prev: delta(quality, prev.resolution.height),
57
+ next: delta(quality, next.resolution.height)
58
+ };
59
+
60
+ return diff.prev < diff.next ? prev : next;
61
+ });
62
+
63
+ const fileMetadata = {
64
+ title: play.title.trim(),
65
+ artist: play.author.name.trim(),
66
+ }
67
+
68
+ return {
69
+ urls: matchingQuality.uri,
70
+ isHLS: true,
71
+ filenameAttributes: {
72
+ service: "rutube",
73
+ id: obj.id,
74
+ title: fileMetadata.title,
75
+ author: fileMetadata.artist,
76
+ resolution: `${matchingQuality.resolution.width}x${matchingQuality.resolution.height}`,
77
+ qualityLabel: `${matchingQuality.resolution.height}p`,
78
+ extension: "mp4"
79
+ },
80
+ fileMetadata: fileMetadata
81
+ }
82
+ }
src/processing/services/snapchat.js ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { extract, normalizeURL } from "../url.js";
2
+ import { genericUserAgent } from "../../config.js";
3
+ import { createStream } from "../../stream/manage.js";
4
+ import { getRedirectingURL } from "../../misc/utils.js";
5
+
6
+ const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
7
+ const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
8
+
9
+ async function getSpotlight(id) {
10
+ const html = await fetch(`https://www.snapchat.com/spotlight/${id}`, {
11
+ headers: { 'user-agent': genericUserAgent }
12
+ }).then((r) => r.text()).catch(() => null);
13
+
14
+ if (!html) {
15
+ return { error: "fetch.fail" };
16
+ }
17
+
18
+ const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
19
+
20
+ if (videoURL && new URL(videoURL).hostname.endsWith(".sc-cdn.net")) {
21
+ return {
22
+ urls: videoURL,
23
+ filename: `snapchat_${id}.mp4`,
24
+ audioFilename: `snapchat_${id}_audio`
25
+ }
26
+ }
27
+ }
28
+
29
+ async function getStory(username, storyId, alwaysProxy) {
30
+ const html = await fetch(
31
+ `https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`,
32
+ { headers: { 'user-agent': genericUserAgent } }
33
+ )
34
+ .then((r) => r.text())
35
+ .catch(() => null);
36
+
37
+ if (!html) {
38
+ return { error: "fetch.fail" };
39
+ }
40
+
41
+ const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
42
+ if (nextDataString) {
43
+ const data = JSON.parse(nextDataString);
44
+ const storyIdParam = data.query.profileParams[1];
45
+
46
+ if (storyIdParam && data.props.pageProps.story) {
47
+ const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);
48
+ if (story) {
49
+ if (story.snapMediaType === 0) {
50
+ return {
51
+ urls: story.snapUrls.mediaUrl,
52
+ filename: `snapchat_${storyId}.jpg`,
53
+ isPhoto: true
54
+ }
55
+ }
56
+
57
+ return {
58
+ urls: story.snapUrls.mediaUrl,
59
+ filename: `snapchat_${storyId}.mp4`,
60
+ audioFilename: `snapchat_${storyId}_audio`
61
+ }
62
+ }
63
+ }
64
+
65
+ const defaultStory = data.props.pageProps.curatedHighlights[0];
66
+ if (defaultStory) {
67
+ return {
68
+ picker: defaultStory.snapList.map(snap => {
69
+ const snapType = snap.snapMediaType === 0 ? "photo" : "video";
70
+ const snapExt = snapType === "video" ? "mp4" : "jpg";
71
+ let snapUrl = snap.snapUrls.mediaUrl;
72
+
73
+ const proxy = createStream({
74
+ service: "snapchat",
75
+ type: "proxy",
76
+ url: snapUrl,
77
+ filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`,
78
+ });
79
+
80
+ let thumbProxy;
81
+ if (snapType === "video") thumbProxy = createStream({
82
+ service: "snapchat",
83
+ type: "proxy",
84
+ url: snap.snapUrls.mediaPreviewUrl.value,
85
+ });
86
+
87
+ if (alwaysProxy) snapUrl = proxy;
88
+
89
+ return {
90
+ type: snapType,
91
+ url: snapUrl,
92
+ thumb: thumbProxy || proxy,
93
+ }
94
+ })
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ export default async function (obj) {
101
+ let params = obj;
102
+ if (obj.shortLink) {
103
+ const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
104
+
105
+ if (!link?.startsWith('https://www.snapchat.com/')) {
106
+ return { error: "fetch.short_link" };
107
+ }
108
+
109
+ const extractResult = extract(normalizeURL(link));
110
+ if (extractResult?.host !== 'snapchat') {
111
+ return { error: "fetch.short_link" };
112
+ }
113
+
114
+ params = extractResult.patternMatch;
115
+ }
116
+
117
+ if (params.spotlightId) {
118
+ const result = await getSpotlight(params.spotlightId);
119
+ if (result) return result;
120
+ } else if (params.username) {
121
+ const result = await getStory(params.username, params.storyId, obj.alwaysProxy);
122
+ if (result) return result;
123
+ }
124
+
125
+ return { error: "fetch.fail" };
126
+ }
src/processing/services/soundcloud.js ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { env } from "../../config.js";
2
+
3
+ const cachedID = {
4
+ version: '',
5
+ id: ''
6
+ }
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];
29
+ break;
30
+ }
31
+ }
32
+ cachedID.version = scVersion;
33
+ cachedID.id = clientid;
34
+
35
+ return clientid;
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)) {
83
+ selectedStream = mp3Media;
84
+ bestAudio = "mp3"
85
+ }
86
+
87
+ if (!selectedStream) {
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
+ }
src/processing/services/streamable.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default async function(obj) {
2
+ let video = await fetch(`https://api.streamable.com/videos/${obj.id}`)
3
+ .then(r => r.status === 200 ? r.json() : false)
4
+ .catch(() => {});
5
+
6
+ if (!video) return { error: "fetch.empty" };
7
+
8
+ let best = video.files["mp4-mobile"];
9
+ if (video.files.mp4 && (obj.isAudioOnly || obj.quality === "max" || obj.quality >= 720)) {
10
+ best = video.files.mp4;
11
+ }
12
+
13
+ if (best) return {
14
+ urls: best.url,
15
+ filename: `streamable_${obj.id}_${best.width}x${best.height}.mp4`,
16
+ audioFilename: `streamable_${obj.id}_audio`,
17
+ fileMetadata: {
18
+ title: video.title
19
+ }
20
+ }
21
+ return { error: "fetch.fail" }
22
+ }
src/processing/services/tiktok.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Cookie from "../cookie/cookie.js";
2
+
3
+ import { extract } 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
+
10
+ export default async function(obj) {
11
+ const cookie = new Cookie({});
12
+ let postId = obj.postId;
13
+
14
+ if (!postId) {
15
+ let html = await fetch(`${shortDomain}${obj.shortLink}`, {
16
+ redirect: "manual",
17
+ headers: {
18
+ "user-agent": genericUserAgent.split(' Chrome/1')[0]
19
+ }
20
+ }).then(r => r.text()).catch(() => {});
21
+
22
+ if (!html) return { error: "fetch.fail" };
23
+
24
+ if (html.startsWith('<a href="https://')) {
25
+ const extractedURL = html.split('<a href="')[1].split('?')[0];
26
+ const { patternMatch } = extract(extractedURL);
27
+ postId = patternMatch.postId;
28
+ }
29
+ }
30
+ if (!postId) return { error: "fetch.short_link" };
31
+
32
+ // should always be /video/, even for photos
33
+ const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
34
+ headers: {
35
+ "user-agent": genericUserAgent,
36
+ cookie,
37
+ }
38
+ })
39
+ updateCookie(cookie, res.headers);
40
+
41
+ const html = await res.text();
42
+
43
+ let detail;
44
+ try {
45
+ const json = html
46
+ .split('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">')[1]
47
+ .split('</script>')[0];
48
+
49
+ const data = JSON.parse(json);
50
+ const videoDetail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"];
51
+
52
+ if (!videoDetail) throw "no video detail found";
53
+
54
+ // status_deleted or etc
55
+ if (videoDetail.statusMsg) {
56
+ return { error: "content.post.unavailable"};
57
+ }
58
+
59
+ detail = videoDetail?.itemInfo?.itemStruct;
60
+ } catch {
61
+ return { error: "fetch.fail" };
62
+ }
63
+
64
+ if (detail.isContentClassified) {
65
+ return { error: "content.post.age" };
66
+ }
67
+
68
+ if (!detail.author) {
69
+ return { error: "fetch.empty" };
70
+ }
71
+
72
+ let video, videoFilename, audioFilename, audio, images,
73
+ filenameBase = `tiktok_${detail.author?.uniqueId}_${postId}`,
74
+ bestAudio; // will get defaulted to m4a later on in match-action
75
+
76
+ images = detail.imagePost?.images;
77
+
78
+ let playAddr = detail.video?.playAddr;
79
+
80
+ if (obj.h265) {
81
+ const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes("h265"))?.PlayAddr.UrlList[0]
82
+ playAddr = h265PlayAddr || playAddr
83
+ }
84
+
85
+ if (!obj.isAudioOnly && !images) {
86
+ video = playAddr;
87
+ videoFilename = `${filenameBase}.mp4`;
88
+ } else {
89
+ audio = playAddr;
90
+ audioFilename = `${filenameBase}_audio`;
91
+
92
+ if (obj.fullAudio || !audio) {
93
+ audio = detail.music.playUrl;
94
+ audioFilename += `_original`
95
+ }
96
+ if (audio.includes("mime_type=audio_mpeg")) bestAudio = 'mp3';
97
+ }
98
+
99
+ if (video) {
100
+ return {
101
+ urls: video,
102
+ filename: videoFilename,
103
+ headers: { cookie }
104
+ }
105
+ }
106
+
107
+ if (images && obj.isAudioOnly) {
108
+ return {
109
+ urls: audio,
110
+ audioFilename: audioFilename,
111
+ isAudioOnly: true,
112
+ bestAudio,
113
+ headers: { cookie }
114
+ }
115
+ }
116
+
117
+ if (images) {
118
+ let imageLinks = images
119
+ .map(i => i.imageURL.urlList.find(p => p.includes(".jpeg?")))
120
+ .map((url, i) => {
121
+ if (obj.alwaysProxy) url = createStream({
122
+ service: "tiktok",
123
+ type: "proxy",
124
+ url,
125
+ filename: `${filenameBase}_photo_${i + 1}.jpg`
126
+ })
127
+
128
+ return {
129
+ type: "photo",
130
+ url
131
+ }
132
+ });
133
+
134
+ return {
135
+ picker: imageLinks,
136
+ urls: audio,
137
+ audioFilename: audioFilename,
138
+ isAudioOnly: true,
139
+ bestAudio,
140
+ headers: { cookie }
141
+ }
142
+ }
143
+
144
+ if (audio) {
145
+ return {
146
+ urls: audio,
147
+ audioFilename: audioFilename,
148
+ isAudioOnly: true,
149
+ bestAudio,
150
+ headers: { cookie }
151
+ }
152
+ }
153
+ }
src/processing/services/tumblr.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import psl from "@imput/psl";
2
+
3
+ const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';
4
+ const API_BASE = 'https://api-http2.tumblr.com';
5
+
6
+ function request(domain, id) {
7
+ const url = new URL(`/v2/blog/${domain}/posts/${id}/permalink`, API_BASE);
8
+ url.searchParams.set('api_key', API_KEY);
9
+ url.searchParams.set('fields[blogs]', 'uuid,name,avatar,?description,?can_message,?can_be_followed,?is_adult,?reply_conditions,'
10
+ + '?theme,?title,?url,?is_blocked_from_primary,?placement_id,?primary,?updated,?followed,'
11
+ + '?ask,?can_subscribe,?paywall_access,?subscription_plan,?is_blogless_advertiser,?tumblrmart_accessories');
12
+
13
+ return fetch(url, {
14
+ headers: {
15
+ 'User-Agent': 'Tumblr/iPhone/33.3/333010/17.3.1/tumblr',
16
+ 'X-Version': 'iPhone/33.3/333010/17.3.1/tumblr'
17
+ }
18
+ }).then(a => a.json()).catch(() => {});
19
+ }
20
+
21
+ export default async function(input) {
22
+ let { subdomain } = psl.parse(input.url.hostname);
23
+
24
+ if (subdomain?.includes('.')) {
25
+ return { error: "link.unsupported" };
26
+ } else if (subdomain === 'www' || subdomain === 'at') {
27
+ subdomain = undefined
28
+ }
29
+
30
+ const domain = `${subdomain ?? input.user}.tumblr.com`;
31
+ const data = await request(domain, input.id);
32
+
33
+ const element = data?.response?.timeline?.elements?.[0];
34
+ if (!element) return { error: "fetch.empty" };
35
+
36
+ const contents = [
37
+ ...element.content,
38
+ ...element?.trail?.map(t => t.content).flat()
39
+ ]
40
+
41
+ const audio = contents.find(c => c.type === 'audio');
42
+ if (audio && audio.provider === 'tumblr') {
43
+ const fileMetadata = {
44
+ title: audio?.title,
45
+ artist: audio?.artist
46
+ };
47
+
48
+ return {
49
+ urls: audio.media.url,
50
+ filenameAttributes: {
51
+ service: 'tumblr',
52
+ id: input.id,
53
+ title: fileMetadata.title,
54
+ author: fileMetadata.artist
55
+ },
56
+ isAudioOnly: true,
57
+ bestAudio: "mp3",
58
+ }
59
+ }
60
+
61
+ const video = contents.find(c => c.type === 'video');
62
+ if (video && video.provider === 'tumblr') {
63
+ return {
64
+ urls: video.media.url,
65
+ filename: `tumblr_${input.id}.mp4`,
66
+ audioFilename: `tumblr_${input.id}_audio`
67
+ }
68
+ }
69
+
70
+ return { error: "link.unsupported" }
71
+ }
src/processing/services/twitch.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { env } from "../../config.js";
2
+
3
+ const gqlURL = "https://gql.twitch.tv/gql";
4
+ const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
5
+
6
+ export default async function (obj) {
7
+ const req_metadata = await fetch(gqlURL, {
8
+ method: "POST",
9
+ headers: clientIdHead,
10
+ body: JSON.stringify({
11
+ query: `{
12
+ clip(slug: "${obj.clipId}") {
13
+ broadcaster {
14
+ login
15
+ }
16
+ createdAt
17
+ curator {
18
+ login
19
+ }
20
+ durationSeconds
21
+ id
22
+ medium: thumbnailURL(width: 480, height: 272)
23
+ title
24
+ videoQualities {
25
+ quality
26
+ sourceURL
27
+ }
28
+ }
29
+ }`
30
+ })
31
+ }).then(r => r.status === 200 ? r.json() : false).catch(() => {});
32
+
33
+ if (!req_metadata) return { error: "fetch.fail" };
34
+
35
+ const clipMetadata = req_metadata.data.clip;
36
+
37
+ if (clipMetadata.durationSeconds > env.durationLimit) {
38
+ return { error: "content.too_long" };
39
+ }
40
+ if (!clipMetadata.videoQualities || !clipMetadata.broadcaster) {
41
+ return { error: "fetch.empty" };
42
+ }
43
+
44
+ const req_token = await fetch(gqlURL, {
45
+ method: "POST",
46
+ headers: clientIdHead,
47
+ body: JSON.stringify([
48
+ {
49
+ "operationName": "VideoAccessToken_Clip",
50
+ "variables": {
51
+ "slug": obj.clipId
52
+ },
53
+ "extensions": {
54
+ "persistedQuery": {
55
+ "version": 1,
56
+ "sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
57
+ }
58
+ }
59
+ }
60
+ ])
61
+ }).then(r => r.status === 200 ? r.json() : false).catch(() => {});
62
+
63
+ if (!req_token) return { error: "fetch.fail" };
64
+
65
+ const formats = clipMetadata.videoQualities;
66
+ const format = formats.find(f => f.quality === obj.quality) || formats[0];
67
+
68
+ return {
69
+ type: "proxy",
70
+ urls: `${format.sourceURL}?${new URLSearchParams({
71
+ sig: req_token[0].data.clip.playbackAccessToken.signature,
72
+ token: req_token[0].data.clip.playbackAccessToken.value
73
+ })}`,
74
+ fileMetadata: {
75
+ title: clipMetadata.title.trim(),
76
+ artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
77
+ },
78
+ filenameAttributes: {
79
+ service: "twitch",
80
+ id: clipMetadata.id,
81
+ title: clipMetadata.title.trim(),
82
+ author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`,
83
+ qualityLabel: `${format.quality}p`,
84
+ extension: 'mp4'
85
+ },
86
+ filename: `twitchclip_${clipMetadata.id}_${format.quality}p.mp4`,
87
+ audioFilename: `twitchclip_${clipMetadata.id}_audio`
88
+ }
89
+ }
src/processing/services/twitter.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
14
+ "authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
15
+ "x-twitter-client-language": "en",
16
+ "x-twitter-active-user": "yes",
17
+ "accept-language": "en"
18
+ }
19
+
20
+ // fix all videos affected by the container bug in twitter muxer (took them over two weeks to fix it????)
21
+ const TWITTER_EPOCH = 1288834974657n;
22
+ const badContainerStart = new Date(1701446400000);
23
+ const badContainerEnd = new Date(1702605600000);
24
+
25
+ function needsFixing(media) {
26
+ const representativeId = media.source_status_id_str ?? media.id_str;
27
+ const mediaTimestamp = new Date(
28
+ Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
29
+ );
30
+ return mediaTimestamp > badContainerStart && mediaTimestamp < badContainerEnd
31
+ }
32
+
33
+ function bestQuality(arr) {
34
+ return arr.filter(v => v.content_type === "video/mp4")
35
+ .reduce((a, b) => Number(a?.bitrate) > Number(b?.bitrate) ? a : b)
36
+ .url
37
+ }
38
+
39
+ let _cachedToken;
40
+ const getGuestToken = async (dispatcher, forceReload = false) => {
41
+ if (_cachedToken && !forceReload) {
42
+ return _cachedToken;
43
+ }
44
+
45
+ const tokenResponse = await fetch(tokenURL, {
46
+ method: 'POST',
47
+ headers: commonHeaders,
48
+ dispatcher
49
+ }).then(r => r.status === 200 && r.json()).catch(() => {})
50
+
51
+ if (tokenResponse?.guest_token) {
52
+ return _cachedToken = tokenResponse.guest_token
53
+ }
54
+ }
55
+
56
+ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
57
+ const graphqlTweetURL = new URL(graphqlURL);
58
+
59
+ let headers = {
60
+ ...commonHeaders,
61
+ 'content-type': 'application/json',
62
+ 'x-guest-token': token,
63
+ cookie: `guest_id=${encodeURIComponent(`v1:${token}`)}`
64
+ }
65
+
66
+ if (cookie) {
67
+ headers = {
68
+ ...commonHeaders,
69
+ 'content-type': 'application/json',
70
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
71
+ 'x-csrf-token': cookie.values().ct0,
72
+ cookie
73
+ }
74
+ }
75
+
76
+ graphqlTweetURL.searchParams.set('variables',
77
+ JSON.stringify({
78
+ tweetId,
79
+ withCommunity: false,
80
+ includePromotedContent: false,
81
+ withVoice: false
82
+ })
83
+ );
84
+ graphqlTweetURL.searchParams.set('features', tweetFeatures);
85
+ graphqlTweetURL.searchParams.set('fieldToggles', tweetFieldToggles);
86
+
87
+ let result = await fetch(graphqlTweetURL, { headers, dispatcher });
88
+ updateCookie(cookie, result.headers);
89
+
90
+ // we might have been missing the `ct0` cookie, retry
91
+ if (result.status === 403 && result.headers.get('set-cookie')) {
92
+ result = await fetch(graphqlTweetURL, {
93
+ headers: {
94
+ ...headers,
95
+ 'x-csrf-token': cookie.values().ct0
96
+ },
97
+ dispatcher
98
+ });
99
+ }
100
+
101
+ return result
102
+ }
103
+
104
+ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
105
+ const cookie = await getCookie('twitter');
106
+
107
+ let guestToken = await getGuestToken(dispatcher);
108
+ if (!guestToken) return { error: "fetch.fail" };
109
+
110
+ let tweet = await requestTweet(dispatcher, id, guestToken);
111
+
112
+ // get new token & retry if old one expired
113
+ if ([403, 429].includes(tweet.status)) {
114
+ guestToken = await getGuestToken(dispatcher, true);
115
+ tweet = await requestTweet(dispatcher, id, guestToken)
116
+ }
117
+
118
+ tweet = await tweet.json();
119
+
120
+ let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
121
+
122
+ if (!tweetTypename) {
123
+ return { error: "fetch.empty" }
124
+ }
125
+
126
+ if (tweetTypename === "TweetUnavailable") {
127
+ const reason = tweet?.data?.tweetResult?.result?.reason;
128
+ switch(reason) {
129
+ case "Protected":
130
+ return { error: "content.post.private" }
131
+ case "NsfwLoggedOut":
132
+ if (cookie) {
133
+ tweet = await requestTweet(dispatcher, id, guestToken, cookie);
134
+ tweet = await tweet.json();
135
+ tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
136
+ } else return { error: "content.post.age" }
137
+ }
138
+ }
139
+
140
+ if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
141
+ return { error: "content.post.unavailable" }
142
+ }
143
+
144
+ let tweetResult = tweet.data.tweetResult.result,
145
+ baseTweet = tweetResult.legacy,
146
+ repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities;
147
+
148
+ if (tweetTypename === "TweetWithVisibilityResults") {
149
+ baseTweet = tweetResult.tweet.legacy;
150
+ repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
151
+ }
152
+
153
+ let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
154
+
155
+ // check if there's a video at given index (/video/<index>)
156
+ if (index >= 0 && index < media?.length) {
157
+ media = [media[index]]
158
+ }
159
+
160
+ const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
161
+
162
+ const proxyMedia = (url, filename) => createStream({
163
+ service: "twitter",
164
+ type: "proxy",
165
+ url, filename,
166
+ })
167
+
168
+ switch (media?.length) {
169
+ case undefined:
170
+ case 0:
171
+ return {
172
+ error: "fetch.empty"
173
+ }
174
+ case 1:
175
+ if (media[0].type === "photo") {
176
+ return {
177
+ type: "proxy",
178
+ isPhoto: true,
179
+ filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`,
180
+ urls: `${media[0].media_url_https}?name=4096x4096`
181
+ }
182
+ }
183
+
184
+ return {
185
+ type: needsFixing(media[0]) ? "remux" : "proxy",
186
+ urls: bestQuality(media[0].video_info.variants),
187
+ filename: `twitter_${id}.mp4`,
188
+ audioFilename: `twitter_${id}_audio`,
189
+ isGif: media[0].type === "animated_gif"
190
+ }
191
+ default:
192
+ const proxyThumb = (url, i) =>
193
+ proxyMedia(url, `twitter_${id}_${i + 1}.${getFileExt(url)}`);
194
+
195
+ const picker = media.map((content, i) => {
196
+ if (content.type === "photo") {
197
+ let url = `${content.media_url_https}?name=4096x4096`;
198
+ let proxiedImage = proxyThumb(url, i);
199
+
200
+ if (alwaysProxy) url = proxiedImage;
201
+
202
+ return {
203
+ type: "photo",
204
+ url,
205
+ thumb: proxiedImage,
206
+ }
207
+ }
208
+
209
+ let url = bestQuality(content.video_info.variants);
210
+ const shouldRenderGif = content.type === "animated_gif" && toGif;
211
+ const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`;
212
+
213
+ let type = "video";
214
+ if (shouldRenderGif) type = "gif";
215
+
216
+ if (needsFixing(content) || shouldRenderGif) {
217
+ url = createStream({
218
+ service: "twitter",
219
+ type: shouldRenderGif ? "gif" : "remux",
220
+ url,
221
+ filename: videoFilename,
222
+ })
223
+ } else if (alwaysProxy) {
224
+ url = proxyMedia(url, videoFilename);
225
+ }
226
+
227
+ return {
228
+ type,
229
+ url,
230
+ thumb: proxyThumb(content.media_url_https, i),
231
+ }
232
+ });
233
+ return { picker };
234
+ }
235
+ }
src/processing/services/vimeo.js ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
7
+ "2732": 1440,
8
+ "2560": 1440,
9
+ "2048": 1080,
10
+ "1920": 1080,
11
+ "1366": 720,
12
+ "1280": 720,
13
+ "960": 480,
14
+ "640": 360,
15
+ "426": 240
16
+ }
17
+
18
+ const requestApiInfo = (videoId, password) => {
19
+ if (password) {
20
+ videoId += `:${password}`
21
+ }
22
+
23
+ return fetch(
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
+ )
34
+ .then(a => a.json())
35
+ .catch(() => {});
36
+ }
37
+
38
+ const compareQuality = (rendition, requestedQuality) => {
39
+ const quality = parseInt(rendition);
40
+ return Math.abs(quality - requestedQuality);
41
+ }
42
+
43
+ const getDirectLink = (data, quality) => {
44
+ if (!data.files) return;
45
+
46
+ const match = data.files
47
+ .filter(f => f.rendition?.endsWith('p'))
48
+ .reduce((prev, next) => {
49
+ const delta = {
50
+ prev: compareQuality(prev.rendition, quality),
51
+ next: compareQuality(next.rendition, quality)
52
+ };
53
+
54
+ return delta.prev < delta.next ? prev : next;
55
+ });
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,
64
+ extension: "mp4"
65
+ },
66
+ bestAudio: "mp3",
67
+ }
68
+ }
69
+
70
+ const getHLS = async (configURL, obj) => {
71
+ if (!configURL) return;
72
+
73
+ const api = await fetch(configURL)
74
+ .then(r => r.json())
75
+ .catch(() => {});
76
+ if (!api) return { error: "fetch.fail" };
77
+
78
+ if (api.video?.duration > env.durationLimit) {
79
+ return { error: "content.too_long" };
80
+ }
81
+
82
+ const urlMasterHLS = api.request?.files?.hls?.cdns?.akfire_interconnect_quic?.url;
83
+ if (!urlMasterHLS) return { error: "fetch.fail" };
84
+
85
+ const masterHLS = await fetch(urlMasterHLS)
86
+ .then(r => r.text())
87
+ .catch(() => {});
88
+
89
+ if (!masterHLS) return { error: "fetch.fail" };
90
+
91
+ const variants = HLS.parse(masterHLS)?.variants?.sort(
92
+ (a, b) => Number(b.bandwidth) - Number(a.bandwidth)
93
+ );
94
+ if (!variants || variants.length === 0) return { error: "fetch.empty" };
95
+
96
+ let bestQuality;
97
+
98
+ if (obj.quality < resolutionMatch[variants[0]?.resolution?.width]) {
99
+ bestQuality = variants.find(v =>
100
+ (obj.quality === resolutionMatch[v.resolution.width])
101
+ );
102
+ }
103
+
104
+ if (!bestQuality) bestQuality = variants[0];
105
+
106
+ const expandLink = (path) => {
107
+ return new URL(path, urlMasterHLS).toString();
108
+ };
109
+
110
+ let urls = expandLink(bestQuality.uri);
111
+
112
+ const audioPath = bestQuality?.audio[0]?.uri;
113
+ if (audioPath) {
114
+ urls = [
115
+ urls,
116
+ expandLink(audioPath)
117
+ ]
118
+ } else if (obj.isAudioOnly) {
119
+ return { error: "fetch.empty" };
120
+ }
121
+
122
+ return {
123
+ urls,
124
+ isHLS: true,
125
+ filenameAttributes: {
126
+ resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
127
+ qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
128
+ extension: "mp4"
129
+ },
130
+ bestAudio: "mp3",
131
+ }
132
+ }
133
+
134
+ export default async function(obj) {
135
+ let quality = obj.quality === "max" ? 9000 : Number(obj.quality);
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) {
150
+ return response;
151
+ }
152
+
153
+ const fileMetadata = {
154
+ title: info.name,
155
+ artist: info.user.name,
156
+ };
157
+
158
+ return merge(
159
+ {
160
+ fileMetadata,
161
+ filenameAttributes: {
162
+ service: "vimeo",
163
+ id: obj.id,
164
+ title: fileMetadata.title,
165
+ author: fileMetadata.artist,
166
+ }
167
+ },
168
+ response
169
+ );
170
+ }
src/processing/services/vk.js ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { env } from "../../config.js";
2
+
3
+ const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
4
+
5
+ const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token";
6
+ const apiUrl = "https://api.vk.com/method";
7
+
8
+ const clientId = "51552953";
9
+ const clientSecret = "qgr0yWwXCrsxA1jnRtRX";
10
+
11
+ // used in stream/shared.js for accessing media files
12
+ export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119";
13
+
14
+ const cachedToken = {
15
+ token: "",
16
+ expiry: 0,
17
+ device_id: "",
18
+ };
19
+
20
+ const getToken = async () => {
21
+ if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) {
22
+ return cachedToken.token;
23
+ }
24
+
25
+ const randomDeviceId = crypto.randomUUID().toUpperCase();
26
+
27
+ const anonymOauth = new URL(oauthUrl);
28
+ anonymOauth.searchParams.set("client_id", clientId);
29
+ anonymOauth.searchParams.set("client_secret", clientSecret);
30
+ anonymOauth.searchParams.set("device_id", randomDeviceId);
31
+
32
+ const oauthResponse = await fetch(anonymOauth.toString(), {
33
+ headers: {
34
+ "user-agent": vkClientAgent,
35
+ }
36
+ }).then(r => {
37
+ if (r.status === 200) {
38
+ return r.json();
39
+ }
40
+ });
41
+
42
+ if (!oauthResponse) return;
43
+
44
+ if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") {
45
+ cachedToken.token = oauthResponse.token;
46
+ cachedToken.expiry = oauthResponse.expired_at;
47
+ cachedToken.device_id = randomDeviceId;
48
+ }
49
+
50
+ if (!cachedToken.token) return;
51
+
52
+ return cachedToken.token;
53
+ }
54
+
55
+ const getVideo = async (ownerId, videoId, accessKey) => {
56
+ const video = await fetch(`${apiUrl}/video.get`, {
57
+ method: "POST",
58
+ headers: {
59
+ "content-type": "application/x-www-form-urlencoded; charset=utf-8",
60
+ "user-agent": vkClientAgent,
61
+ },
62
+ body: new URLSearchParams({
63
+ anonymous_token: cachedToken.token,
64
+ device_id: cachedToken.device_id,
65
+ lang: "en",
66
+ v: "5.244",
67
+ videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`
68
+ }).toString()
69
+ })
70
+ .then(r => {
71
+ if (r.status === 200) {
72
+ return r.json();
73
+ }
74
+ });
75
+
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
+
83
+ const videoGet = await getVideo(ownerId, videoId, accessKey);
84
+
85
+ if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) {
86
+ return { error: "fetch.empty" };
87
+ }
88
+
89
+ const video = videoGet.response.items[0];
90
+
91
+ if (video.restriction) {
92
+ const title = video.restriction.title;
93
+ if (title.endsWith("country") || title.endsWith("region.")) {
94
+ return { error: "content.video.region" };
95
+ }
96
+ if (title === "Processing video") {
97
+ return { error: "fetch.empty" };
98
+ }
99
+ return { error: "content.video.unavailable" };
100
+ }
101
+
102
+ if (!video.files || !video.duration) {
103
+ return { error: "fetch.fail" };
104
+ }
105
+
106
+ if (video.duration > env.durationLimit) {
107
+ return { error: "content.too_long" };
108
+ }
109
+
110
+ const userQuality = quality === "max" ? resolutions[0] : quality;
111
+ let pickedQuality;
112
+
113
+ for (const resolution of resolutions) {
114
+ if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) {
115
+ pickedQuality = resolution;
116
+ break
117
+ }
118
+ }
119
+
120
+ const url = video.files[`mp4_${pickedQuality}`];
121
+
122
+ if (!url) return { error: "fetch.fail" };
123
+
124
+ const fileMetadata = {
125
+ title: video.title.trim(),
126
+ }
127
+
128
+ return {
129
+ urls: url,
130
+ fileMetadata,
131
+ filenameAttributes: {
132
+ service: "vk",
133
+ id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`,
134
+ title: fileMetadata.title,
135
+ resolution: `${pickedQuality}p`,
136
+ qualityLabel: `${pickedQuality}p`,
137
+ extension: "mp4"
138
+ }
139
+ }
140
+ }
src/processing/services/youtube.js ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import HLS from "hls-parser";
2
+
3
+ import { fetch } from "undici";
4
+ import { Innertube, Session } from "youtubei.js";
5
+
6
+ import { env } from "../../config.js";
7
+ import { getCookie, updateCookieValues } from "../cookie/manager.js";
8
+
9
+ const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
10
+
11
+ let innertube, lastRefreshedAt;
12
+
13
+ const codecList = {
14
+ h264: {
15
+ videoCodec: "avc1",
16
+ audioCodec: "mp4a",
17
+ container: "mp4"
18
+ },
19
+ av1: {
20
+ videoCodec: "av01",
21
+ audioCodec: "opus",
22
+ container: "webm"
23
+ },
24
+ vp9: {
25
+ videoCodec: "vp9",
26
+ audioCodec: "opus",
27
+ container: "webm"
28
+ }
29
+ }
30
+
31
+ const hlsCodecList = {
32
+ h264: {
33
+ videoCodec: "avc1",
34
+ audioCodec: "mp4a",
35
+ container: "mp4"
36
+ },
37
+ vp9: {
38
+ videoCodec: "vp09",
39
+ audioCodec: "mp4a",
40
+ container: "webm"
41
+ }
42
+ }
43
+
44
+ const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
45
+
46
+ const transformSessionData = (cookie) => {
47
+ if (!cookie)
48
+ return;
49
+
50
+ const values = { ...cookie.values() };
51
+ const REQUIRED_VALUES = [ 'access_token', 'refresh_token' ];
52
+
53
+ if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
54
+ return;
55
+ }
56
+
57
+ if (values.expires) {
58
+ values.expiry_date = values.expires;
59
+ delete values.expires;
60
+ } else if (!values.expiry_date) {
61
+ return;
62
+ }
63
+
64
+ return values;
65
+ }
66
+
67
+ const cloneInnertube = async (customFetch) => {
68
+ const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
69
+ if (!innertube || shouldRefreshPlayer) {
70
+ innertube = await Innertube.create({
71
+ fetch: customFetch,
72
+ retrieve_player: false,
73
+ });
74
+ lastRefreshedAt = +new Date();
75
+ }
76
+
77
+ const session = new Session(
78
+ innertube.session.context,
79
+ innertube.session.key,
80
+ innertube.session.api_version,
81
+ innertube.session.account_index,
82
+ innertube.session.player,
83
+ undefined,
84
+ customFetch ?? innertube.session.http.fetch,
85
+ innertube.session.cache
86
+ );
87
+
88
+ const cookie = getCookie('youtube_oauth');
89
+ const oauthData = transformSessionData(cookie);
90
+
91
+ if (!session.logged_in && oauthData) {
92
+ await session.oauth.init(oauthData);
93
+ session.logged_in = true;
94
+ }
95
+
96
+ if (session.logged_in) {
97
+ if (session.oauth.shouldRefreshToken()) {
98
+ await session.oauth.refreshAccessToken();
99
+ }
100
+
101
+ const cookieValues = cookie.values();
102
+ const oldExpiry = new Date(cookieValues.expiry_date);
103
+ const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date);
104
+
105
+ if (oldExpiry.getTime() !== newExpiry.getTime()) {
106
+ updateCookieValues(cookie, {
107
+ ...session.oauth.client_id,
108
+ ...session.oauth.oauth2_tokens,
109
+ expiry_date: newExpiry.toISOString()
110
+ });
111
+ }
112
+ }
113
+
114
+ const yt = new Innertube(session);
115
+ return yt;
116
+ }
117
+
118
+ export default async function(o) {
119
+ let yt;
120
+ try {
121
+ yt = await cloneInnertube(
122
+ (input, init) => fetch(input, {
123
+ ...init,
124
+ dispatcher: o.dispatcher
125
+ })
126
+ );
127
+ } catch (e) {
128
+ if (e.message?.endsWith("decipher algorithm")) {
129
+ return { error: "youtube.decipher" }
130
+ } else if (e.message?.includes("refresh access token")) {
131
+ return { error: "youtube.token_expired" }
132
+ } else throw e;
133
+ }
134
+
135
+ let useHLS = o.youtubeHLS;
136
+
137
+ // HLS playlists don't contain the av1 video format, at least with the iOS client
138
+ if (useHLS && o.format === "av1") {
139
+ useHLS = false;
140
+ }
141
+
142
+ let info;
143
+ try {
144
+ info = await yt.getBasicInfo(o.id, useHLS ? 'IOS' : 'ANDROID');
145
+ } catch (e) {
146
+ if (e?.info) {
147
+ const errorInfo = JSON.parse(e?.info);
148
+
149
+ if (errorInfo?.reason === "This video is private") {
150
+ return { error: "content.video.private" };
151
+ }
152
+ if (["INVALID_ARGUMENT", "UNAUTHENTICATED"].includes(errorInfo?.error?.status)) {
153
+ return { error: "youtube.api_error" };
154
+ }
155
+ }
156
+
157
+ if (e?.message === "This video is unavailable") {
158
+ return { error: "content.video.unavailable" };
159
+ }
160
+
161
+ return { error: "fetch.fail" };
162
+ }
163
+
164
+ if (!info) return { error: "fetch.fail" };
165
+
166
+ const playability = info.playability_status;
167
+ const basicInfo = info.basic_info;
168
+
169
+ switch(playability.status) {
170
+ case "LOGIN_REQUIRED":
171
+ if (playability.reason.endsWith("bot")) {
172
+ return { error: "youtube.login" }
173
+ }
174
+ if (playability.reason.endsWith("age")) {
175
+ return { error: "content.video.age" }
176
+ }
177
+ if (playability?.error_screen?.reason?.text === "Private video") {
178
+ return { error: "content.video.private" }
179
+ }
180
+ break;
181
+
182
+ case "UNPLAYABLE":
183
+ if (playability?.reason?.endsWith("request limit.")) {
184
+ return { error: "fetch.rate" }
185
+ }
186
+ if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
187
+ return { error: "content.video.region" }
188
+ }
189
+ if (playability?.error_screen?.reason?.text === "Private video") {
190
+ return { error: "content.video.private" }
191
+ }
192
+ break;
193
+
194
+ case "AGE_VERIFICATION_REQUIRED":
195
+ return { error: "content.video.age" };
196
+ }
197
+
198
+ if (playability.status !== "OK") {
199
+ return { error: "content.video.unavailable" };
200
+ }
201
+
202
+ if (basicInfo.is_live) {
203
+ return { error: "content.video.live" };
204
+ }
205
+
206
+ if (basicInfo.duration > env.durationLimit) {
207
+ return { error: "content.too_long" };
208
+ }
209
+
210
+ // return a critical error if returned video is "Video Not Available"
211
+ // or a similar stub by youtube
212
+ if (basicInfo.id !== o.id) {
213
+ return {
214
+ error: "fetch.fail",
215
+ critical: true
216
+ }
217
+ }
218
+
219
+ const quality = o.quality === "max" ? 9000 : Number(o.quality);
220
+
221
+ const normalizeQuality = res => {
222
+ const shortestSide = res.height > res.width ? res.width : res.height;
223
+ return videoQualities.find(qual => qual >= shortestSide);
224
+ }
225
+
226
+ let video, audio, dubbedLanguage,
227
+ codec = o.format || "h264";
228
+
229
+ if (useHLS) {
230
+ const hlsManifest = info.streaming_data.hls_manifest_url;
231
+
232
+ if (!hlsManifest) {
233
+ return { error: "youtube.no_hls_streams" };
234
+ }
235
+
236
+ const fetchedHlsManifest = await fetch(hlsManifest, {
237
+ dispatcher: o.dispatcher,
238
+ }).then(r => {
239
+ if (r.status === 200) {
240
+ return r.text();
241
+ } else {
242
+ throw new Error("couldn't fetch the HLS playlist");
243
+ }
244
+ }).catch(() => {});
245
+
246
+ if (!fetchedHlsManifest) {
247
+ return { error: "youtube.no_hls_streams" };
248
+ }
249
+
250
+ const variants = HLS.parse(fetchedHlsManifest).variants.sort(
251
+ (a, b) => Number(b.bandwidth) - Number(a.bandwidth)
252
+ );
253
+
254
+ if (!variants || variants.length === 0) {
255
+ return { error: "youtube.no_hls_streams" };
256
+ }
257
+
258
+ const matchHlsCodec = codecs => (
259
+ codecs.includes(hlsCodecList[codec].videoCodec)
260
+ );
261
+
262
+ const best = variants.find(i => matchHlsCodec(i.codecs));
263
+
264
+ const preferred = variants.find(i =>
265
+ matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality
266
+ );
267
+
268
+ let selected = preferred || best;
269
+
270
+ if (!selected) {
271
+ codec = "h264";
272
+ selected = variants.find(i => matchHlsCodec(i.codecs));
273
+ }
274
+
275
+ if (!selected) {
276
+ return { error: "youtube.no_matching_format" };
277
+ }
278
+
279
+ audio = selected.audio.find(i => i.isDefault);
280
+
281
+ // some videos (mainly those with AI dubs) don't have any tracks marked as default
282
+ // why? god knows, but we assume that a default track is marked as such in the title
283
+ if (!audio) {
284
+ audio = selected.audio.find(i => i.name.endsWith("- original"));
285
+ }
286
+
287
+ if (o.dubLang) {
288
+ const dubbedAudio = selected.audio.find(i =>
289
+ i.language?.startsWith(o.dubLang)
290
+ );
291
+
292
+ if (dubbedAudio && !dubbedAudio.isDefault) {
293
+ dubbedLanguage = dubbedAudio.language;
294
+ audio = dubbedAudio;
295
+ }
296
+ }
297
+
298
+ selected.audio = [];
299
+ selected.subtitles = [];
300
+ video = selected;
301
+ } else {
302
+ // i miss typescript so bad
303
+ const sorted_formats = {
304
+ h264: {
305
+ video: [],
306
+ audio: [],
307
+ bestVideo: undefined,
308
+ bestAudio: undefined,
309
+ },
310
+ vp9: {
311
+ video: [],
312
+ audio: [],
313
+ bestVideo: undefined,
314
+ bestAudio: undefined,
315
+ },
316
+ av1: {
317
+ video: [],
318
+ audio: [],
319
+ bestVideo: undefined,
320
+ bestAudio: undefined,
321
+ },
322
+ }
323
+
324
+ const checkFormat = (format, pCodec) => format.content_length &&
325
+ (format.mime_type.includes(codecList[pCodec].videoCodec)
326
+ || format.mime_type.includes(codecList[pCodec].audioCodec));
327
+
328
+ // sort formats & weed out bad ones
329
+ info.streaming_data.adaptive_formats.sort((a, b) =>
330
+ Number(b.bitrate) - Number(a.bitrate)
331
+ ).forEach(format => {
332
+ Object.keys(codecList).forEach(yCodec => {
333
+ const sorted = sorted_formats[yCodec];
334
+ const goodFormat = checkFormat(format, yCodec);
335
+ if (!goodFormat) return;
336
+
337
+ if (format.has_video) {
338
+ sorted.video.push(format);
339
+ if (!sorted.bestVideo) sorted.bestVideo = format;
340
+ }
341
+ if (format.has_audio) {
342
+ sorted.audio.push(format);
343
+ if (!sorted.bestAudio) sorted.bestAudio = format;
344
+ }
345
+ })
346
+ });
347
+
348
+ const noBestMedia = () => {
349
+ const vid = sorted_formats[codec]?.bestVideo;
350
+ const aud = sorted_formats[codec]?.bestAudio;
351
+ return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly)
352
+ };
353
+
354
+ if (noBestMedia()) {
355
+ if (codec === "av1") codec = "vp9";
356
+ else if (codec === "vp9") codec = "av1";
357
+
358
+ // if there's no higher quality fallback, then use h264
359
+ if (noBestMedia()) codec = "h264";
360
+ }
361
+
362
+ // if there's no proper combo of av1, vp9, or h264, then give up
363
+ if (noBestMedia()) {
364
+ return { error: "youtube.no_matching_format" };
365
+ }
366
+
367
+ audio = sorted_formats[codec].bestAudio;
368
+
369
+ if (audio?.audio_track && !audio?.audio_track?.audio_is_default) {
370
+ audio = sorted_formats[codec].audio.find(i =>
371
+ i?.audio_track?.audio_is_default
372
+ );
373
+ }
374
+
375
+ if (o.dubLang) {
376
+ const dubbedAudio = sorted_formats[codec].audio.find(i =>
377
+ i.language?.startsWith(o.dubLang) && i.audio_track
378
+ );
379
+
380
+ if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
381
+ audio = dubbedAudio;
382
+ dubbedLanguage = dubbedAudio.language;
383
+ }
384
+ }
385
+
386
+ if (!o.isAudioOnly) {
387
+ const qual = (i) => {
388
+ return normalizeQuality({
389
+ width: i.width,
390
+ height: i.height,
391
+ })
392
+ }
393
+
394
+ const bestQuality = qual(sorted_formats[codec].bestVideo);
395
+ const useBestQuality = quality >= bestQuality;
396
+
397
+ video = useBestQuality
398
+ ? sorted_formats[codec].bestVideo
399
+ : sorted_formats[codec].video.find(i => qual(i) === quality);
400
+
401
+ if (!video) video = sorted_formats[codec].bestVideo;
402
+ }
403
+ }
404
+
405
+ const fileMetadata = {
406
+ title: basicInfo.title.trim(),
407
+ artist: basicInfo.author.replace("- Topic", "").trim()
408
+ }
409
+
410
+ if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
411
+ const descItems = basicInfo.short_description.split("\n\n", 5);
412
+
413
+ if (descItems.length === 5) {
414
+ fileMetadata.album = descItems[2];
415
+ fileMetadata.copyright = descItems[3];
416
+ if (descItems[4].startsWith("Released on:")) {
417
+ fileMetadata.date = descItems[4].replace("Released on: ", '').trim();
418
+ }
419
+ }
420
+ }
421
+
422
+ const filenameAttributes = {
423
+ service: "youtube",
424
+ id: o.id,
425
+ title: fileMetadata.title,
426
+ author: fileMetadata.artist,
427
+ youtubeDubName: dubbedLanguage || false,
428
+ }
429
+
430
+ if (audio && o.isAudioOnly) {
431
+ let bestAudio = codec === "h264" ? "m4a" : "opus";
432
+ let urls = audio.url;
433
+
434
+ if (useHLS) {
435
+ bestAudio = "mp3";
436
+ urls = audio.uri;
437
+ }
438
+
439
+ return {
440
+ type: "audio",
441
+ isAudioOnly: true,
442
+ urls,
443
+ filenameAttributes,
444
+ fileMetadata,
445
+ bestAudio,
446
+ isHLS: useHLS,
447
+ }
448
+ }
449
+
450
+ if (video && audio) {
451
+ let resolution;
452
+
453
+ if (useHLS) {
454
+ resolution = normalizeQuality(video.resolution);
455
+ filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
456
+ filenameAttributes.extension = hlsCodecList[codec].container;
457
+
458
+ video = video.uri;
459
+ audio = audio.uri;
460
+ } else {
461
+ resolution = normalizeQuality({
462
+ width: video.width,
463
+ height: video.height,
464
+ });
465
+ filenameAttributes.resolution = `${video.width}x${video.height}`;
466
+ filenameAttributes.extension = codecList[codec].container;
467
+
468
+ video = video.url;
469
+ audio = audio.url;
470
+ }
471
+
472
+ filenameAttributes.qualityLabel = `${resolution}p`;
473
+ filenameAttributes.youtubeFormat = codec;
474
+
475
+ return {
476
+ type: "merge",
477
+ urls: [
478
+ video,
479
+ audio,
480
+ ],
481
+ filenameAttributes,
482
+ fileMetadata,
483
+ isHLS: useHLS,
484
+ }
485
+ }
486
+
487
+ return { error: "youtube.no_matching_format" };
488
+ }
src/processing/url.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import psl from "@imput/psl";
2
+ import { strict as assert } from "node:assert";
3
+
4
+ import { env } from "../config.js";
5
+ import { services } from "./service-config.js";
6
+ import { friendlyServiceName } from "./service-alias.js";
7
+
8
+ function aliasURL(url) {
9
+ assert(url instanceof URL);
10
+
11
+ const host = psl.parse(url.hostname);
12
+ const parts = url.pathname.split('/');
13
+
14
+ switch (host.sld) {
15
+ case "youtube":
16
+ if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) {
17
+ url.pathname = '/watch';
18
+ // parts := ['', 'live' || 'shorts', id, ...rest]
19
+ url.search = `?v=${encodeURIComponent(parts[2])}`
20
+ }
21
+ break;
22
+
23
+ case "youtu":
24
+ if (url.hostname === 'youtu.be' && parts.length >= 2) {
25
+ /* youtu.be urls can be weird, e.g. https://youtu.be/<id>//asdasd// still works
26
+ ** but we only care about the 1st segment of the path */
27
+ url = new URL(`https://youtube.com/watch?v=${
28
+ encodeURIComponent(parts[1])
29
+ }`)
30
+ }
31
+ break;
32
+
33
+ case "pin":
34
+ if (url.hostname === 'pin.it' && parts.length === 2) {
35
+ url = new URL(`https://pinterest.com/url_shortener/${
36
+ encodeURIComponent(parts[1])
37
+ }`)
38
+ }
39
+ break;
40
+
41
+ case "vxtwitter":
42
+ case "fixvx":
43
+ case "x":
44
+ if (services.twitter.altDomains.includes(url.hostname)) {
45
+ url.hostname = 'twitter.com';
46
+ }
47
+ break;
48
+
49
+ case "twitch":
50
+ if (url.hostname === 'clips.twitch.tv' && parts.length >= 2) {
51
+ url = new URL(`https://twitch.tv/_/clip/${parts[1]}`);
52
+ }
53
+ break;
54
+
55
+ case "bilibili":
56
+ if (host.tld === 'tv') {
57
+ url = new URL(`https://bilibili.com/_tv${url.pathname}`);
58
+ }
59
+ break;
60
+
61
+ case "b23":
62
+ if (url.hostname === 'b23.tv' && parts.length === 2) {
63
+ url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
64
+ }
65
+ break;
66
+
67
+ case "dai":
68
+ if (url.hostname === 'dai.ly' && parts.length === 2) {
69
+ url = new URL(`https://dailymotion.com/video/${parts[1]}`)
70
+ }
71
+ break;
72
+
73
+ case "facebook":
74
+ case "fb":
75
+ if (url.searchParams.get('v')) {
76
+ url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`)
77
+ }
78
+ if (url.hostname === 'fb.watch') {
79
+ url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`)
80
+ }
81
+ break;
82
+
83
+ case "ddinstagram":
84
+ if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) {
85
+ url.hostname = 'instagram.com';
86
+ }
87
+ break;
88
+
89
+ case "vk":
90
+ case "vkvideo":
91
+ if (services.vk.altDomains.includes(url.hostname)) {
92
+ url.hostname = 'vk.com';
93
+ }
94
+ break;
95
+ }
96
+
97
+ return url
98
+ }
99
+
100
+ function cleanURL(url) {
101
+ assert(url instanceof URL);
102
+ const host = psl.parse(url.hostname).sld;
103
+
104
+ let stripQuery = true;
105
+
106
+ const limitQuery = (param) => {
107
+ url.search = `?${param}=` + encodeURIComponent(url.searchParams.get(param));
108
+ stripQuery = false;
109
+ }
110
+
111
+ switch (host) {
112
+ case "pinterest":
113
+ url.hostname = 'pinterest.com';
114
+ break;
115
+ case "vk":
116
+ if (url.pathname.includes('/clip') && url.searchParams.get('z')) {
117
+ limitQuery('z')
118
+ }
119
+ break;
120
+ case "youtube":
121
+ if (url.searchParams.get('v')) {
122
+ limitQuery('v')
123
+ }
124
+ break;
125
+ case "rutube":
126
+ if (url.searchParams.get('p')) {
127
+ limitQuery('p')
128
+ }
129
+ break;
130
+ case "twitter":
131
+ if (url.searchParams.get('post_id')) {
132
+ limitQuery('post_id')
133
+ }
134
+ break;
135
+ }
136
+
137
+ if (stripQuery) {
138
+ url.search = ''
139
+ }
140
+
141
+ url.username = url.password = url.port = url.hash = ''
142
+
143
+ if (url.pathname.endsWith('/'))
144
+ url.pathname = url.pathname.slice(0, -1);
145
+
146
+ return url
147
+ }
148
+
149
+ function getHostIfValid(url) {
150
+ const host = psl.parse(url.hostname);
151
+ if (host.error) return;
152
+
153
+ const service = services[host.sld];
154
+ if (!service) return;
155
+ if ((service.tld ?? 'com') !== host.tld) return;
156
+
157
+ const anySubdomainAllowed = service.subdomains === '*';
158
+ const validSubdomain = [null, 'www', ...(service.subdomains ?? [])].includes(host.subdomain);
159
+ if (!validSubdomain && !anySubdomainAllowed) return;
160
+
161
+ return host.sld;
162
+ }
163
+
164
+ export function normalizeURL(url) {
165
+ return cleanURL(
166
+ aliasURL(
167
+ new URL(url.replace(/^https\/\//, 'https://'))
168
+ )
169
+ );
170
+ }
171
+
172
+ export function extract(url) {
173
+ if (!(url instanceof URL)) {
174
+ url = new URL(url);
175
+ }
176
+
177
+ const host = getHostIfValid(url);
178
+
179
+ if (!host) {
180
+ return { error: "link.invalid" };
181
+ }
182
+
183
+ if (!env.enabledServices.has(host)) {
184
+ // show a different message when youtube is disabled on official instances
185
+ // as it only happens when shit hits the fan
186
+ if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
187
+ return { error: "youtube.temporary_disabled" };
188
+ }
189
+ return { error: "service.disabled" };
190
+ }
191
+
192
+ let patternMatch;
193
+ for (const pattern of services[host].patterns) {
194
+ patternMatch = pattern.match(
195
+ url.pathname.substring(1) + url.search
196
+ );
197
+
198
+ if (patternMatch) {
199
+ break;
200
+ }
201
+ }
202
+
203
+ if (!patternMatch) {
204
+ return {
205
+ error: "link.unsupported",
206
+ context: {
207
+ service: friendlyServiceName(host),
208
+ }
209
+ };
210
+ }
211
+
212
+ return { host, patternMatch };
213
+ }
src/security/api-keys.js ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
9
+ const generateWildcardRegex = rule => {
10
+ var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
11
+ return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$");
12
+ }
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<
22
+ ** UUIDv4String,
23
+ ** {
24
+ ** name?: string,
25
+ ** limit?: number | "unlimited",
26
+ ** ips?: CIDRString[],
27
+ ** userAgents?: string[]
28
+ ** }
29
+ ** >;
30
+ */
31
+
32
+ const validateKeys = (input) => {
33
+ if (typeof input !== 'object' || input === null) {
34
+ throw "input is not an object";
35
+ }
36
+
37
+ if (Object.keys(input).some(x => !UUID_REGEX.test(x))) {
38
+ throw "key file contains invalid key(s)";
39
+ }
40
+
41
+ Object.values(input).forEach(details => {
42
+ if (typeof details !== 'object' || details === null) {
43
+ throw "some key(s) are incorrectly configured";
44
+ }
45
+
46
+ const unexpected_key = Object.keys(details).find(k => !ALLOWED_KEYS.has(k));
47
+ if (unexpected_key) {
48
+ throw "detail object contains unexpected key: " + unexpected_key;
49
+ }
50
+
51
+ if (details.limit && details.limit !== 'unlimited') {
52
+ if (typeof details.limit !== 'number')
53
+ throw "detail object contains invalid limit (not a number)";
54
+ else if (details.limit < 1)
55
+ throw "detail object contains invalid limit (not a positive number)";
56
+ }
57
+
58
+ if (details.ips) {
59
+ if (!Array.isArray(details.ips))
60
+ throw "details object contains value for `ips` which is not an array";
61
+
62
+ const invalid_ip = details.ips.find(
63
+ addr => typeof addr !== 'string' || (!ip.isValidCIDR(addr) && !ip.isValid(addr))
64
+ );
65
+
66
+ if (invalid_ip) {
67
+ throw "`ips` in details contains an invalid IP or CIDR range: " + invalid_ip;
68
+ }
69
+ }
70
+
71
+ if (details.userAgents) {
72
+ if (!Array.isArray(details.userAgents))
73
+ throw "details object contains value for `userAgents` which is not an array";
74
+
75
+ const invalid_ua = details.userAgents.find(ua => typeof ua !== 'string');
76
+ if (invalid_ua) {
77
+ throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
78
+ }
79
+ }
80
+ });
81
+ }
82
+
83
+ const formatKeys = (keyData) => {
84
+ const formatted = {};
85
+
86
+ for (let key in keyData) {
87
+ const data = keyData[key];
88
+ key = key.toLowerCase();
89
+
90
+ formatted[key] = {};
91
+
92
+ if (data.limit) {
93
+ if (data.limit === "unlimited") {
94
+ data.limit = Infinity;
95
+ }
96
+
97
+ formatted[key].limit = data.limit;
98
+ }
99
+
100
+ if (data.ips) {
101
+ formatted[key].ips = data.ips.map(addr => {
102
+ if (ip.isValid(addr)) {
103
+ const parsed = ip.parse(addr);
104
+ const range = parsed.kind() === 'ipv6' ? 128 : 32;
105
+ return [ parsed, range ];
106
+ }
107
+
108
+ return ip.parseCIDR(addr);
109
+ });
110
+ }
111
+
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
+ })
152
+ .catch((e) => {
153
+ console.error(`${Yellow('[!]')} Failed loading API keys at ${new Date().toISOString()}.`);
154
+ console.error('Error:', e);
155
+ })
156
+ }
157
+
158
+ const err = (reason) => ({ success: false, error: reason });
159
+
160
+ export const validateAuthorization = (req) => {
161
+ const authHeader = req.get('Authorization');
162
+
163
+ if (typeof authHeader !== 'string') {
164
+ return err("missing");
165
+ }
166
+
167
+ const [ authType, keyString ] = authHeader.split(' ', 2);
168
+ if (authType.toLowerCase() !== 'api-key') {
169
+ return err("not_api_key");
170
+ }
171
+
172
+ if (!UUID_REGEX.test(keyString) || `${authType} ${keyString}` !== authHeader) {
173
+ return err("invalid");
174
+ }
175
+
176
+ const matchingKey = keys[keyString.toLowerCase()];
177
+ if (!matchingKey) {
178
+ return err("not_found");
179
+ }
180
+
181
+ if (matchingKey.ips) {
182
+ let addr;
183
+ try {
184
+ addr = ip.parse(req.ip);
185
+ } catch {
186
+ return err("invalid_ip");
187
+ }
188
+
189
+ const ip_allowed = matchingKey.ips.some(
190
+ ([ allowed, size ]) => {
191
+ return addr.kind() === allowed.kind()
192
+ && addr.match(allowed, size);
193
+ }
194
+ );
195
+
196
+ if (!ip_allowed) {
197
+ return err("ip_not_allowed");
198
+ }
199
+ }
200
+
201
+ if (matchingKey.userAgents) {
202
+ const userAgent = req.get('User-Agent');
203
+ if (!matchingKey.userAgents.some(regex => regex.test(userAgent))) {
204
+ return err("ua_not_allowed");
205
+ }
206
+ }
207
+
208
+ req.rateLimitKey = keyString.toLowerCase();
209
+ req.rateLimitMax = matchingKey.limit;
210
+
211
+ return { success: true };
212
+ }
213
+
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) {
221
+ process.on('message', (message) => {
222
+ if ('api_keys' in message) {
223
+ updateKeys(message.api_keys);
224
+ }
225
+ });
226
+ }
227
+ }
src/security/jwt.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { nanoid } from "nanoid";
2
+ import { createHmac } from "crypto";
3
+
4
+ import { env } from "../config.js";
5
+
6
+ const toBase64URL = (b) => Buffer.from(b).toString("base64url");
7
+ const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
8
+
9
+ const makeHmac = (header, payload) =>
10
+ createHmac("sha256", env.jwtSecret)
11
+ .update(`${header}.${payload}`)
12
+ .digest("base64url");
13
+
14
+ const generate = () => {
15
+ const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
16
+
17
+ const header = toBase64URL(JSON.stringify({
18
+ alg: "HS256",
19
+ typ: "JWT"
20
+ }));
21
+
22
+ const payload = toBase64URL(JSON.stringify({
23
+ jti: nanoid(8),
24
+ exp,
25
+ }));
26
+
27
+ const signature = makeHmac(header, payload);
28
+
29
+ return {
30
+ token: `${header}.${payload}.${signature}`,
31
+ exp: env.jwtLifetime - 2,
32
+ };
33
+ }
34
+
35
+ const verify = (jwt) => {
36
+ const [header, payload, signature] = jwt.split(".", 3);
37
+ const timestamp = Math.floor(new Date().getTime() / 1000);
38
+
39
+ if ([header, payload, signature].join('.') !== jwt) {
40
+ return false;
41
+ }
42
+
43
+ const verifySignature = makeHmac(header, payload);
44
+
45
+ if (verifySignature !== signature) {
46
+ return false;
47
+ }
48
+
49
+ if (timestamp >= JSON.parse(fromBase64URL(payload)).exp) {
50
+ return false;
51
+ }
52
+
53
+ return true;
54
+ }
55
+
56
+ export default {
57
+ generate,
58
+ verify,
59
+ }
src/security/secrets.js ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cluster from "node:cluster";
2
+ import { createHmac, randomBytes } from "node:crypto";
3
+
4
+ const generateSalt = () => {
5
+ if (cluster.isPrimary)
6
+ return randomBytes(64);
7
+
8
+ return null;
9
+ }
10
+
11
+ let rateSalt = generateSalt();
12
+ let streamSalt = generateSalt();
13
+
14
+ export const syncSecrets = () => {
15
+ return new Promise((resolve, reject) => {
16
+ if (cluster.isPrimary) {
17
+ let remaining = Object.values(cluster.workers).length;
18
+ const handleReady = (worker, m) => {
19
+ if (m.ready)
20
+ worker.send({ rateSalt, streamSalt });
21
+
22
+ if (!--remaining)
23
+ resolve();
24
+ }
25
+
26
+ for (const worker of Object.values(cluster.workers)) {
27
+ worker.once(
28
+ 'message',
29
+ (m) => handleReady(worker, m)
30
+ );
31
+ }
32
+ } else if (cluster.isWorker) {
33
+ if (rateSalt || streamSalt)
34
+ return reject();
35
+
36
+ process.send({ ready: true });
37
+ process.once('message', (message) => {
38
+ if (rateSalt || streamSalt)
39
+ return reject();
40
+
41
+ if (message.rateSalt && message.streamSalt) {
42
+ streamSalt = Buffer.from(message.streamSalt);
43
+ rateSalt = Buffer.from(message.rateSalt);
44
+ resolve();
45
+ }
46
+ });
47
+ } else reject();
48
+ });
49
+ }
50
+
51
+
52
+ export const hashHmac = (value, type) => {
53
+ let salt;
54
+ if (type === 'rate')
55
+ salt = rateSalt;
56
+ else if (type === 'stream')
57
+ salt = streamSalt;
58
+ else
59
+ throw "unknown salt";
60
+
61
+ return createHmac("sha256", salt).update(value).digest();
62
+ }
src/security/turnstile.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { env } from "../config.js";
2
+
3
+ export const verifyTurnstileToken = async (turnstileResponse, ip) => {
4
+ const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
5
+ method: "POST",
6
+ headers: {
7
+ "Content-Type": "application/json",
8
+ },
9
+ body: JSON.stringify({
10
+ secret: env.turnstileSecret,
11
+ response: turnstileResponse,
12
+ remoteip: ip,
13
+ }),
14
+ })
15
+ .then(r => r.json())
16
+ .catch(() => {});
17
+
18
+ return !!result?.success;
19
+ }
src/store/base-store.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const _stores = new Set();
2
+
3
+ export class Store {
4
+ id;
5
+
6
+ constructor(name) {
7
+ name = name.toUpperCase();
8
+
9
+ if (_stores.has(name))
10
+ throw `${name} store already exists`;
11
+ _stores.add(name);
12
+
13
+ this.id = name;
14
+ }
15
+
16
+ async _has(_key) { await Promise.reject("needs implementation"); }
17
+ has(key) {
18
+ if (typeof key !== 'string') {
19
+ key = key.toString();
20
+ }
21
+
22
+ return this._has(key);
23
+ }
24
+
25
+ async _get(_key) { await Promise.reject("needs implementation"); }
26
+ async get(key) {
27
+ if (typeof key !== 'string') {
28
+ key = key.toString();
29
+ }
30
+
31
+ const val = await this._get(key);
32
+ if (val === null)
33
+ return null;
34
+
35
+ return val;
36
+ }
37
+
38
+ async _set(_key, _val, _exp_sec = -1) { await Promise.reject("needs implementation") }
39
+ set(key, val, exp_sec = -1) {
40
+ if (typeof key !== 'string') {
41
+ key = key.toString();
42
+ }
43
+
44
+ exp_sec = Math.round(exp_sec);
45
+
46
+ return this._set(key, val, exp_sec);
47
+ }
48
+ };