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