Spaces:
Building
Building
Closure-RI
commited on
Commit
·
b665708
1
Parent(s):
ad38873
ggg
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env +17 -0
- Dockerfile +31 -23
- package.json +52 -0
- pnpm-lock.yaml +0 -0
- src/cobalt.js +32 -0
- src/config.js +81 -0
- src/core/api.js +394 -0
- src/misc/cluster.js +71 -0
- src/misc/console-text.js +36 -0
- src/misc/crypto.js +19 -0
- src/misc/load-from-fs.js +20 -0
- src/misc/randomize-ciphers.js +28 -0
- src/misc/run-test.js +44 -0
- src/misc/utils.js +31 -0
- src/processing/cookie/cookie.js +48 -0
- src/processing/cookie/manager.js +156 -0
- src/processing/create-filename.js +70 -0
- src/processing/match-action.js +215 -0
- src/processing/match.js +306 -0
- src/processing/request.js +97 -0
- src/processing/schema.js +51 -0
- src/processing/service-alias.js +10 -0
- src/processing/service-config.js +185 -0
- src/processing/service-patterns.js +74 -0
- src/processing/services/bilibili.js +112 -0
- src/processing/services/bluesky.js +124 -0
- src/processing/services/dailymotion.js +107 -0
- src/processing/services/facebook.js +57 -0
- src/processing/services/instagram.js +373 -0
- src/processing/services/loom.js +39 -0
- src/processing/services/ok.js +64 -0
- src/processing/services/pinterest.js +46 -0
- src/processing/services/reddit.js +127 -0
- src/processing/services/rutube.js +82 -0
- src/processing/services/snapchat.js +126 -0
- src/processing/services/soundcloud.js +122 -0
- src/processing/services/streamable.js +22 -0
- src/processing/services/tiktok.js +153 -0
- src/processing/services/tumblr.js +71 -0
- src/processing/services/twitch.js +89 -0
- src/processing/services/twitter.js +235 -0
- src/processing/services/vimeo.js +170 -0
- src/processing/services/vk.js +140 -0
- src/processing/services/youtube.js +488 -0
- src/processing/url.js +213 -0
- src/security/api-keys.js +227 -0
- src/security/jwt.js +59 -0
- src/security/secrets.js +62 -0
- src/security/turnstile.js +19 -0
- 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
|
2 |
-
FROM
|
3 |
-
|
4 |
-
# Install
|
5 |
-
RUN
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(""", '"');
|
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 |
+
};
|