Spaces:
Sleeping
Sleeping
Upload 376 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +15 -0
- api/README.md +90 -9
- api/package.json +12 -12
- api/src/cobalt.js +15 -10
- api/src/config.js +41 -1
- api/src/core/api.js +139 -68
- api/src/misc/cluster.js +71 -0
- api/src/misc/console-text.js +30 -10
- api/src/misc/crypto.js +1 -9
- api/src/misc/run-test.js +10 -1
- api/src/misc/utils.js +24 -48
- api/src/processing/cookie/cookie.js +16 -5
- api/src/processing/cookie/manager.js +119 -29
- api/src/processing/create-filename.js +15 -1
- api/src/processing/helpers/youtube-session.js +81 -0
- api/src/processing/match-action.js +23 -15
- api/src/processing/match.js +22 -16
- api/src/processing/request.js +3 -4
- api/src/processing/schema.js +9 -5
- api/src/processing/service-config.js +60 -21
- api/src/processing/service-patterns.js +15 -9
- api/src/processing/services/bilibili.js +3 -13
- api/src/processing/services/bluesky.js +68 -27
- api/src/processing/services/dailymotion.js +1 -1
- api/src/processing/services/facebook.js +5 -5
- api/src/processing/services/instagram.js +211 -42
- api/src/processing/services/ok.js +2 -3
- api/src/processing/services/pinterest.js +8 -7
- api/src/processing/services/reddit.js +36 -16
- api/src/processing/services/rutube.js +7 -5
- api/src/processing/services/snapchat.js +7 -19
- api/src/processing/services/soundcloud.js +17 -4
- api/src/processing/services/tiktok.js +30 -11
- api/src/processing/services/tumblr.js +1 -1
- api/src/processing/services/twitch.js +2 -3
- api/src/processing/services/twitter.js +106 -32
- api/src/processing/services/vimeo.js +4 -5
- api/src/processing/services/vk.js +109 -32
- api/src/processing/services/xiaohongshu.js +109 -0
- api/src/processing/services/youtube.js +373 -169
- api/src/processing/url.js +67 -9
- api/src/security/api-keys.js +227 -0
- api/src/security/jwt.js +19 -12
- api/src/security/secrets.js +62 -0
- api/src/store/base-store.js +48 -0
- api/src/store/memory-store.js +77 -0
- api/src/store/redis-ratelimit.js +19 -0
- api/src/store/redis-store.js +64 -0
- api/src/store/store.js +10 -0
- api/src/stream/internal-hls.js +7 -5
.gitattributes
CHANGED
@@ -33,3 +33,18 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
web/static/update-banners/bettertogether.webp filter=lfs diff=lfs merge=lfs -text
|
37 |
+
web/static/update-banners/catmakeup.webp filter=lfs diff=lfs merge=lfs -text
|
38 |
+
web/static/update-banners/catphonestand.webp filter=lfs diff=lfs merge=lfs -text
|
39 |
+
web/static/update-banners/catsleep.webp filter=lfs diff=lfs merge=lfs -text
|
40 |
+
web/static/update-banners/catswitchboxes.webp filter=lfs diff=lfs merge=lfs -text
|
41 |
+
web/static/update-banners/cattired.webp filter=lfs diff=lfs merge=lfs -text
|
42 |
+
web/static/update-banners/developers.webp filter=lfs diff=lfs merge=lfs -text
|
43 |
+
web/static/update-banners/meowthball.webp filter=lfs diff=lfs merge=lfs -text
|
44 |
+
web/static/update-banners/meowthcooking.webp filter=lfs diff=lfs merge=lfs -text
|
45 |
+
web/static/update-banners/meowthpolishegg.webp filter=lfs diff=lfs merge=lfs -text
|
46 |
+
web/static/update-banners/meowthproductions.webp filter=lfs diff=lfs merge=lfs -text
|
47 |
+
web/static/update-banners/meowthsnap.webp filter=lfs diff=lfs merge=lfs -text
|
48 |
+
web/static/update-banners/meowthstrong.webp filter=lfs diff=lfs merge=lfs -text
|
49 |
+
web/static/update-banners/millionusers.webp filter=lfs diff=lfs merge=lfs -text
|
50 |
+
web/static/update-banners/valentines.webp filter=lfs diff=lfs merge=lfs -text
|
api/README.md
CHANGED
@@ -1,4 +1,64 @@
|
|
1 |
# cobalt api
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
## license
|
4 |
cobalt api code is licensed under [AGPL-3.0](LICENSE).
|
@@ -9,14 +69,35 @@ as long as you:
|
|
9 |
- provide a link to the license and indicate if changes to the code were made, and
|
10 |
- release the code under the **same license**
|
11 |
|
12 |
-
##
|
13 |
-
|
14 |
-
|
15 |
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
|
21 |
-
|
22 |
-
it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md)
|
|
|
1 |
# cobalt api
|
2 |
+
this directory includes the source code for cobalt api. it's made with [express.js](https://www.npmjs.com/package/express) and love!
|
3 |
+
|
4 |
+
## running your own instance
|
5 |
+
if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
|
6 |
+
we recommend to use docker compose unless you intend to run cobalt for developing/debugging purposes.
|
7 |
+
|
8 |
+
## accessing the api
|
9 |
+
there is currently no publicly available pre-hosted api.
|
10 |
+
we recommend [deploying your own instance](/docs/run-an-instance.md) if you wish to use the cobalt api.
|
11 |
+
|
12 |
+
you can read [the api documentation here](/docs/api.md).
|
13 |
+
|
14 |
+
## supported services
|
15 |
+
this list is not final and keeps expanding over time!
|
16 |
+
if the desired service isn't supported yet, feel free to create an appropriate issue (or a pull request 👀).
|
17 |
+
|
18 |
+
| service | video + audio | only audio | only video | metadata | rich file names |
|
19 |
+
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
|
20 |
+
| bilibili | ✅ | ✅ | ✅ | ➖ | ➖ |
|
21 |
+
| bluesky | ✅ | ✅ | ✅ | ➖ | ➖ |
|
22 |
+
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
|
23 |
+
| instagram | ✅ | ✅ | ✅ | ➖ | ➖ |
|
24 |
+
| facebook | ✅ | ❌ | ✅ | ➖ | ➖ |
|
25 |
+
| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
|
26 |
+
| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
|
27 |
+
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
28 |
+
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
29 |
+
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
30 |
+
| snapchat | ✅ | ✅ | ✅ | ➖ | ➖ |
|
31 |
+
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
|
32 |
+
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
|
33 |
+
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
|
34 |
+
| tumblr | ✅ | ✅ | ✅ | ➖ | ➖ |
|
35 |
+
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
|
36 |
+
| twitter/x | ✅ | ✅ | ✅ | ➖ | ➖ |
|
37 |
+
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
|
38 |
+
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
|
39 |
+
| xiaohongshu | ✅ | ✅ | ✅ | ➖ | ➖ |
|
40 |
+
| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
41 |
+
|
42 |
+
| emoji | meaning |
|
43 |
+
| :-----: | :---------------------- |
|
44 |
+
| ✅ | supported |
|
45 |
+
| ➖ | unreasonable/impossible |
|
46 |
+
| ❌ | not supported |
|
47 |
+
|
48 |
+
### additional notes or features (per service)
|
49 |
+
| service | notes or features |
|
50 |
+
| :-------- | :----- |
|
51 |
+
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
|
52 |
+
| facebook | supports public accessible videos content only. |
|
53 |
+
| pinterest | supports photos, gifs, videos and stories. |
|
54 |
+
| reddit | supports gifs and videos. |
|
55 |
+
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
|
56 |
+
| rutube | supports yappy & private links. |
|
57 |
+
| soundcloud | supports private links. |
|
58 |
+
| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
|
59 |
+
| twitter/x | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management. |
|
60 |
+
| vimeo | audio downloads are only available for dash. |
|
61 |
+
| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |
|
62 |
|
63 |
## license
|
64 |
cobalt api code is licensed under [AGPL-3.0](LICENSE).
|
|
|
69 |
- provide a link to the license and indicate if changes to the code were made, and
|
70 |
- release the code under the **same license**
|
71 |
|
72 |
+
## open source acknowledgements
|
73 |
+
### ffmpeg
|
74 |
+
cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have an ability to use it for free, just like anyone else. we believe it should be way more recognized.
|
75 |
|
76 |
+
you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
|
77 |
+
|
78 |
+
### youtube.js
|
79 |
+
cobalt relies on **[youtube.js](https://github.com/LuanRT/YouTube.js)** for interacting with youtube's innertube api, it wouldn't have been possible without this package.
|
80 |
+
|
81 |
+
you can support the developer via various methods listed on their github page!
|
82 |
+
(linked above)
|
83 |
+
|
84 |
+
### many others
|
85 |
+
cobalt-api also depends on:
|
86 |
+
|
87 |
+
- **[content-disposition-header](https://www.npmjs.com/package/content-disposition-header)** to simplify the provision of `content-disposition` headers.
|
88 |
+
- **[cors](https://www.npmjs.com/package/cors)** to manage cross-origin resource sharing within expressjs.
|
89 |
+
- **[dotenv](https://www.npmjs.com/package/dotenv)** to load environment variables from the `.env` file.
|
90 |
+
- **[express](https://www.npmjs.com/package/express)** as the backbone of cobalt servers.
|
91 |
+
- **[express-rate-limit](https://www.npmjs.com/package/express-rate-limit)** to rate limit api endpoints.
|
92 |
+
- **[ffmpeg-static](https://www.npmjs.com/package/ffmpeg-static)** to get binaries for ffmpeg depending on the platform.
|
93 |
+
- **[hls-parser](https://www.npmjs.com/package/hls-parser)** to parse HLS playlists according to spec (very impressive stuff).
|
94 |
+
- **[ipaddr.js](https://www.npmjs.com/package/ipaddr.js)** to parse ip addresses (used for rate limiting).
|
95 |
+
- **[nanoid](https://www.npmjs.com/package/nanoid)** to generate unique identifiers for each requested tunnel.
|
96 |
+
- **[set-cookie-parser](https://www.npmjs.com/package/set-cookie-parser)** to parse cookies that cobalt receives from certain services.
|
97 |
+
- **[undici](https://www.npmjs.com/package/undici)** for making http requests.
|
98 |
+
- **[url-pattern](https://www.npmjs.com/package/url-pattern)** to match provided links with supported patterns.
|
99 |
+
- **[zod](https://www.npmjs.com/package/zod)** to lock down the api request schema.
|
100 |
+
- **[@datastructures-js/priority-queue](https://www.npmjs.com/package/@datastructures-js/priority-queue)** for sorting stream caches for future clean up (without redis).
|
101 |
+
- **[@imput/psl](https://www.npmjs.com/package/@imput/psl)** as the domain name parser, our fork of [psl](https://www.npmjs.com/package/psl).
|
102 |
|
103 |
+
...and many other packages that these packages rely on.
|
|
api/package.json
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
{
|
2 |
"name": "@imput/cobalt-api",
|
3 |
"description": "save what you love",
|
4 |
-
"version": "10.1
|
5 |
"author": "imput",
|
6 |
"exports": "./src/cobalt.js",
|
7 |
"type": "module",
|
@@ -10,9 +10,8 @@
|
|
10 |
},
|
11 |
"scripts": {
|
12 |
"start": "node src/cobalt",
|
13 |
-
"setup": "node src/util/setup",
|
14 |
"test": "node src/util/test",
|
15 |
-
"token:
|
16 |
},
|
17 |
"repository": {
|
18 |
"type": "git",
|
@@ -24,26 +23,27 @@
|
|
24 |
},
|
25 |
"homepage": "https://github.com/imputnet/cobalt#readme",
|
26 |
"dependencies": {
|
|
|
|
|
27 |
"@imput/version-info": "workspace:^",
|
28 |
"content-disposition-header": "0.6.0",
|
29 |
"cors": "^2.8.5",
|
30 |
"dotenv": "^16.0.1",
|
31 |
-
"
|
32 |
-
"express": "^4.
|
33 |
-
"express-rate-limit": "^6.3.0",
|
34 |
"ffmpeg-static": "^5.1.0",
|
35 |
"hls-parser": "^0.10.7",
|
36 |
-
"ipaddr.js": "2.
|
37 |
-
"nanoid": "^
|
38 |
-
"node-cache": "^5.1.2",
|
39 |
-
"psl": "1.9.0",
|
40 |
"set-cookie-parser": "2.6.0",
|
41 |
"undici": "^5.19.1",
|
42 |
"url-pattern": "1.0.3",
|
43 |
-
"youtubei.js": "^
|
44 |
"zod": "^3.23.8"
|
45 |
},
|
46 |
"optionalDependencies": {
|
47 |
-
"freebind": "^0.2.2"
|
|
|
|
|
48 |
}
|
49 |
}
|
|
|
1 |
{
|
2 |
"name": "@imput/cobalt-api",
|
3 |
"description": "save what you love",
|
4 |
+
"version": "10.9.1",
|
5 |
"author": "imput",
|
6 |
"exports": "./src/cobalt.js",
|
7 |
"type": "module",
|
|
|
10 |
},
|
11 |
"scripts": {
|
12 |
"start": "node src/cobalt",
|
|
|
13 |
"test": "node src/util/test",
|
14 |
+
"token:jwt": "node src/util/generate-jwt-secret"
|
15 |
},
|
16 |
"repository": {
|
17 |
"type": "git",
|
|
|
23 |
},
|
24 |
"homepage": "https://github.com/imputnet/cobalt#readme",
|
25 |
"dependencies": {
|
26 |
+
"@datastructures-js/priority-queue": "^6.3.1",
|
27 |
+
"@imput/psl": "^2.0.4",
|
28 |
"@imput/version-info": "workspace:^",
|
29 |
"content-disposition-header": "0.6.0",
|
30 |
"cors": "^2.8.5",
|
31 |
"dotenv": "^16.0.1",
|
32 |
+
"express": "^4.21.2",
|
33 |
+
"express-rate-limit": "^7.4.1",
|
|
|
34 |
"ffmpeg-static": "^5.1.0",
|
35 |
"hls-parser": "^0.10.7",
|
36 |
+
"ipaddr.js": "2.2.0",
|
37 |
+
"nanoid": "^5.0.9",
|
|
|
|
|
38 |
"set-cookie-parser": "2.6.0",
|
39 |
"undici": "^5.19.1",
|
40 |
"url-pattern": "1.0.3",
|
41 |
+
"youtubei.js": "^13.3.0",
|
42 |
"zod": "^3.23.8"
|
43 |
},
|
44 |
"optionalDependencies": {
|
45 |
+
"freebind": "^0.2.2",
|
46 |
+
"rate-limit-redis": "^4.2.0",
|
47 |
+
"redis": "^4.7.0"
|
48 |
}
|
49 |
}
|
api/src/cobalt.js
CHANGED
@@ -1,27 +1,32 @@
|
|
1 |
import "dotenv/config";
|
2 |
|
3 |
import express from "express";
|
|
|
4 |
|
5 |
-
import path from
|
6 |
-
import { fileURLToPath } from
|
7 |
|
8 |
-
import { env } from "./config.js"
|
9 |
-
import {
|
|
|
10 |
|
11 |
const app = express();
|
12 |
|
13 |
const __filename = fileURLToPath(import.meta.url);
|
14 |
const __dirname = path.dirname(__filename).slice(0, -4);
|
15 |
|
16 |
-
app.disable(
|
17 |
|
18 |
if (env.apiURL) {
|
19 |
-
const { runAPI } = await import(
|
20 |
-
|
|
|
|
|
|
|
|
|
|
|
21 |
} else {
|
22 |
console.log(
|
23 |
-
Red(
|
24 |
-
+ Bright(`please run the setup script to fix this: `)
|
25 |
-
+ Green(`npm run setup`)
|
26 |
)
|
27 |
}
|
|
|
1 |
import "dotenv/config";
|
2 |
|
3 |
import express from "express";
|
4 |
+
import cluster from "node:cluster";
|
5 |
|
6 |
+
import path from "path";
|
7 |
+
import { fileURLToPath } from "url";
|
8 |
|
9 |
+
import { env, isCluster } from "./config.js"
|
10 |
+
import { Red } from "./misc/console-text.js";
|
11 |
+
import { initCluster } from "./misc/cluster.js";
|
12 |
|
13 |
const app = express();
|
14 |
|
15 |
const __filename = fileURLToPath(import.meta.url);
|
16 |
const __dirname = path.dirname(__filename).slice(0, -4);
|
17 |
|
18 |
+
app.disable("x-powered-by");
|
19 |
|
20 |
if (env.apiURL) {
|
21 |
+
const { runAPI } = await import("./core/api.js");
|
22 |
+
|
23 |
+
if (isCluster) {
|
24 |
+
await initCluster();
|
25 |
+
}
|
26 |
+
|
27 |
+
runAPI(express, app, __dirname, cluster.isPrimary);
|
28 |
} else {
|
29 |
console.log(
|
30 |
+
Red("API_URL env variable is missing, cobalt api can't start.")
|
|
|
|
|
31 |
)
|
32 |
}
|
api/src/config.js
CHANGED
@@ -1,5 +1,7 @@
|
|
|
|
1 |
import { getVersion } from "@imput/version-info";
|
2 |
import { services } from "./processing/service-config.js";
|
|
|
3 |
|
4 |
const version = await getVersion();
|
5 |
|
@@ -13,6 +15,7 @@ const enabledServices = new Set(Object.keys(services).filter(e => {
|
|
13 |
const env = {
|
14 |
apiURL: process.env.API_URL || '',
|
15 |
apiPort: process.env.API_PORT || 9000,
|
|
|
16 |
|
17 |
listenAddress: process.env.API_LISTEN_ADDRESS,
|
18 |
freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
|
@@ -25,8 +28,11 @@ const env = {
|
|
25 |
rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
|
26 |
rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
|
27 |
|
|
|
|
|
|
|
28 |
durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
|
29 |
-
streamLifespan: 90,
|
30 |
|
31 |
processingPriority: process.platform !== 'win32'
|
32 |
&& process.env.PROCESSING_PRIORITY
|
@@ -43,12 +49,46 @@ const env = {
|
|
43 |
&& process.env.TURNSTILE_SECRET
|
44 |
&& process.env.JWT_SECRET,
|
45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
enabledServices,
|
|
|
|
|
|
|
|
|
|
|
47 |
}
|
48 |
|
49 |
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";
|
50 |
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
|
51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
export {
|
53 |
env,
|
54 |
genericUserAgent,
|
|
|
1 |
+
import { Constants } from "youtubei.js";
|
2 |
import { getVersion } from "@imput/version-info";
|
3 |
import { services } from "./processing/service-config.js";
|
4 |
+
import { supportsReusePort } from "./misc/cluster.js";
|
5 |
|
6 |
const version = await getVersion();
|
7 |
|
|
|
15 |
const env = {
|
16 |
apiURL: process.env.API_URL || '',
|
17 |
apiPort: process.env.API_PORT || 9000,
|
18 |
+
tunnelPort: process.env.API_PORT || 9000,
|
19 |
|
20 |
listenAddress: process.env.API_LISTEN_ADDRESS,
|
21 |
freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
|
|
|
28 |
rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
|
29 |
rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
|
30 |
|
31 |
+
sessionRateLimitWindow: (process.env.SESSION_RATELIMIT_WINDOW && parseInt(process.env.SESSION_RATELIMIT_WINDOW)) || 60,
|
32 |
+
sessionRateLimit: (process.env.SESSION_RATELIMIT && parseInt(process.env.SESSION_RATELIMIT)) || 10,
|
33 |
+
|
34 |
durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
|
35 |
+
streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90,
|
36 |
|
37 |
processingPriority: process.platform !== 'win32'
|
38 |
&& process.env.PROCESSING_PRIORITY
|
|
|
49 |
&& process.env.TURNSTILE_SECRET
|
50 |
&& process.env.JWT_SECRET,
|
51 |
|
52 |
+
apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL),
|
53 |
+
authRequired: process.env.API_AUTH_REQUIRED === '1',
|
54 |
+
redisURL: process.env.API_REDIS_URL,
|
55 |
+
instanceCount: (process.env.API_INSTANCE_COUNT && parseInt(process.env.API_INSTANCE_COUNT)) || 1,
|
56 |
+
keyReloadInterval: 900,
|
57 |
+
|
58 |
enabledServices,
|
59 |
+
|
60 |
+
customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
|
61 |
+
ytSessionServer: process.env.YOUTUBE_SESSION_SERVER,
|
62 |
+
ytSessionReloadInterval: 300,
|
63 |
+
ytSessionInnertubeClient: process.env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
|
64 |
}
|
65 |
|
66 |
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";
|
67 |
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
|
68 |
|
69 |
+
export const setTunnelPort = (port) => env.tunnelPort = port;
|
70 |
+
export const isCluster = env.instanceCount > 1;
|
71 |
+
|
72 |
+
if (env.sessionEnabled && env.jwtSecret.length < 16) {
|
73 |
+
throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
|
74 |
+
}
|
75 |
+
|
76 |
+
if (env.instanceCount > 1 && !env.redisURL) {
|
77 |
+
throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
|
78 |
+
} else if (env.instanceCount > 1 && !await supportsReusePort()) {
|
79 |
+
console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
|
80 |
+
console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
|
81 |
+
console.error('(or other OS that supports it). for more info, see `reusePort` option on');
|
82 |
+
console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
|
83 |
+
throw new Error('SO_REUSEPORT is not supported');
|
84 |
+
}
|
85 |
+
|
86 |
+
if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
|
87 |
+
console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
|
88 |
+
console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
|
89 |
+
throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
|
90 |
+
}
|
91 |
+
|
92 |
export {
|
93 |
env,
|
94 |
genericUserAgent,
|
api/src/core/api.js
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
import cors from "cors";
|
|
|
2 |
import rateLimit from "express-rate-limit";
|
3 |
import { setGlobalDispatcher, ProxyAgent } from "undici";
|
4 |
import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
|
@@ -7,17 +8,21 @@ import jwt from "../security/jwt.js";
|
|
7 |
import stream from "../stream/stream.js";
|
8 |
import match from "../processing/match.js";
|
9 |
|
10 |
-
import { env } from "../config.js";
|
11 |
import { extract } from "../processing/url.js";
|
12 |
-
import {
|
13 |
-
import {
|
14 |
-
import {
|
15 |
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
16 |
import { verifyTurnstileToken } from "../security/turnstile.js";
|
17 |
import { friendlyServiceName } from "../processing/service-alias.js";
|
18 |
import { verifyStream, getInternalStream } from "../stream/manage.js";
|
19 |
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
|
20 |
|
|
|
|
|
|
|
|
|
21 |
const git = {
|
22 |
branch: await getBranch(),
|
23 |
commit: await getCommit(),
|
@@ -28,7 +33,6 @@ const version = await getVersion();
|
|
28 |
|
29 |
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
|
30 |
|
31 |
-
const ipSalt = generateSalt();
|
32 |
const corsConfig = env.corsWildcard ? {} : {
|
33 |
origin: env.corsURL,
|
34 |
optionsSuccessStatus: 200
|
@@ -39,7 +43,7 @@ const fail = (res, code, context) => {
|
|
39 |
res.status(status).json(body);
|
40 |
}
|
41 |
|
42 |
-
export const runAPI = (express, app, __dirname) => {
|
43 |
const startTime = new Date();
|
44 |
const startTimestamp = startTime.getTime();
|
45 |
|
@@ -57,38 +61,49 @@ export const runAPI = (express, app, __dirname) => {
|
|
57 |
git,
|
58 |
})
|
59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
const apiLimiter = rateLimit({
|
61 |
windowMs: env.rateLimitWindow * 1000,
|
62 |
-
|
63 |
-
standardHeaders:
|
64 |
legacyHeaders: false,
|
65 |
-
keyGenerator: req =>
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
return generateHmac(getIP(req), ipSalt);
|
70 |
-
},
|
71 |
-
handler: (req, res) => {
|
72 |
-
const { status, body } = createResponse("error", {
|
73 |
-
code: "error.api.rate_exceeded",
|
74 |
-
context: {
|
75 |
-
limit: env.rateLimitWindow
|
76 |
-
}
|
77 |
-
});
|
78 |
-
return res.status(status).json(body);
|
79 |
-
}
|
80 |
-
})
|
81 |
|
82 |
-
const
|
83 |
windowMs: env.rateLimitWindow * 1000,
|
84 |
-
|
85 |
-
standardHeaders:
|
86 |
legacyHeaders: false,
|
87 |
-
keyGenerator: req =>
|
88 |
-
|
|
|
89 |
return res.sendStatus(429)
|
90 |
}
|
91 |
-
})
|
92 |
|
93 |
app.set('trust proxy', ['loopback', 'uniquelocal']);
|
94 |
|
@@ -103,9 +118,6 @@ export const runAPI = (express, app, __dirname) => {
|
|
103 |
...corsConfig,
|
104 |
}));
|
105 |
|
106 |
-
app.post('/', apiLimiter);
|
107 |
-
app.use('/tunnel', apiLimiterStream);
|
108 |
-
|
109 |
app.post('/', (req, res, next) => {
|
110 |
if (!acceptRegex.test(req.header('Accept'))) {
|
111 |
return fail(res, "error.api.header.accept");
|
@@ -117,7 +129,34 @@ export const runAPI = (express, app, __dirname) => {
|
|
117 |
});
|
118 |
|
119 |
app.post('/', (req, res, next) => {
|
120 |
-
if (!env.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
return next();
|
122 |
}
|
123 |
|
@@ -127,26 +166,29 @@ export const runAPI = (express, app, __dirname) => {
|
|
127 |
return fail(res, "error.api.auth.jwt.missing");
|
128 |
}
|
129 |
|
130 |
-
if (
|
131 |
return fail(res, "error.api.auth.jwt.invalid");
|
132 |
}
|
133 |
|
134 |
-
const
|
135 |
-
|
136 |
-
|
|
|
137 |
|
138 |
-
if (!
|
139 |
return fail(res, "error.api.auth.jwt.invalid");
|
140 |
}
|
141 |
|
142 |
-
req.
|
143 |
} catch {
|
144 |
return fail(res, "error.api.generic");
|
145 |
}
|
146 |
next();
|
147 |
});
|
148 |
|
|
|
149 |
app.use('/', express.json({ limit: 1024 }));
|
|
|
150 |
app.use('/', (err, _, res, next) => {
|
151 |
if (err) {
|
152 |
const { status, body } = createResponse("error", {
|
@@ -158,7 +200,7 @@ export const runAPI = (express, app, __dirname) => {
|
|
158 |
next();
|
159 |
});
|
160 |
|
161 |
-
app.post("/session", async (req, res) => {
|
162 |
if (!env.sessionEnabled) {
|
163 |
return fail(res, "error.api.auth.not_configured")
|
164 |
}
|
@@ -179,7 +221,7 @@ export const runAPI = (express, app, __dirname) => {
|
|
179 |
}
|
180 |
|
181 |
try {
|
182 |
-
res.json(jwt.generate());
|
183 |
} catch {
|
184 |
return fail(res, "error.api.generic");
|
185 |
}
|
@@ -187,16 +229,11 @@ export const runAPI = (express, app, __dirname) => {
|
|
187 |
|
188 |
app.post('/', async (req, res) => {
|
189 |
const request = req.body;
|
190 |
-
const lang = languageCode(req);
|
191 |
|
192 |
if (!request.url) {
|
193 |
return fail(res, "error.api.link.missing");
|
194 |
}
|
195 |
|
196 |
-
if (request.youtubeDubBrowserLang) {
|
197 |
-
request.youtubeDubLang = lang;
|
198 |
-
}
|
199 |
-
|
200 |
const { success, data: normalizedRequest } = await normalizeRequest(request);
|
201 |
if (!success) {
|
202 |
return fail(res, "error.api.invalid_body");
|
@@ -228,7 +265,7 @@ export const runAPI = (express, app, __dirname) => {
|
|
228 |
}
|
229 |
})
|
230 |
|
231 |
-
app.get('/tunnel', (req, res) => {
|
232 |
const id = String(req.query.id);
|
233 |
const exp = String(req.query.exp);
|
234 |
const sig = String(req.query.sig);
|
@@ -247,7 +284,7 @@ export const runAPI = (express, app, __dirname) => {
|
|
247 |
return res.status(200).end();
|
248 |
}
|
249 |
|
250 |
-
const streamInfo = verifyStream(id, sig, exp, sec, iv);
|
251 |
if (!streamInfo?.service) {
|
252 |
return res.status(streamInfo.status).end();
|
253 |
}
|
@@ -259,7 +296,7 @@ export const runAPI = (express, app, __dirname) => {
|
|
259 |
return stream(res, streamInfo);
|
260 |
})
|
261 |
|
262 |
-
|
263 |
if (!req.ip.endsWith('127.0.0.1')) {
|
264 |
return res.sendStatus(403);
|
265 |
}
|
@@ -278,8 +315,10 @@ export const runAPI = (express, app, __dirname) => {
|
|
278 |
...Object.entries(req.headers)
|
279 |
]);
|
280 |
|
281 |
-
return stream(res, { type: 'internal',
|
282 |
-
}
|
|
|
|
|
283 |
|
284 |
app.get('/', (_, res) => {
|
285 |
res.type('json');
|
@@ -310,20 +349,52 @@ export const runAPI = (express, app, __dirname) => {
|
|
310 |
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
|
311 |
}
|
312 |
|
313 |
-
app.listen(
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
329 |
}
|
|
|
1 |
import cors from "cors";
|
2 |
+
import http from "node:http";
|
3 |
import rateLimit from "express-rate-limit";
|
4 |
import { setGlobalDispatcher, ProxyAgent } from "undici";
|
5 |
import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
|
|
|
8 |
import stream from "../stream/stream.js";
|
9 |
import match from "../processing/match.js";
|
10 |
|
11 |
+
import { env, isCluster, setTunnelPort } from "../config.js";
|
12 |
import { extract } from "../processing/url.js";
|
13 |
+
import { Green, Bright, Cyan } from "../misc/console-text.js";
|
14 |
+
import { hashHmac } from "../security/secrets.js";
|
15 |
+
import { createStore } from "../store/redis-ratelimit.js";
|
16 |
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
17 |
import { verifyTurnstileToken } from "../security/turnstile.js";
|
18 |
import { friendlyServiceName } from "../processing/service-alias.js";
|
19 |
import { verifyStream, getInternalStream } from "../stream/manage.js";
|
20 |
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
|
21 |
|
22 |
+
import * as APIKeys from "../security/api-keys.js";
|
23 |
+
import * as Cookies from "../processing/cookie/manager.js";
|
24 |
+
import * as YouTubeSession from "../processing/helpers/youtube-session.js";
|
25 |
+
|
26 |
const git = {
|
27 |
branch: await getBranch(),
|
28 |
commit: await getCommit(),
|
|
|
33 |
|
34 |
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
|
35 |
|
|
|
36 |
const corsConfig = env.corsWildcard ? {} : {
|
37 |
origin: env.corsURL,
|
38 |
optionsSuccessStatus: 200
|
|
|
43 |
res.status(status).json(body);
|
44 |
}
|
45 |
|
46 |
+
export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
47 |
const startTime = new Date();
|
48 |
const startTimestamp = startTime.getTime();
|
49 |
|
|
|
61 |
git,
|
62 |
})
|
63 |
|
64 |
+
const handleRateExceeded = (_, res) => {
|
65 |
+
const { status, body } = createResponse("error", {
|
66 |
+
code: "error.api.rate_exceeded",
|
67 |
+
context: {
|
68 |
+
limit: env.rateLimitWindow
|
69 |
+
}
|
70 |
+
});
|
71 |
+
return res.status(status).json(body);
|
72 |
+
};
|
73 |
+
|
74 |
+
const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
|
75 |
+
|
76 |
+
const sessionLimiter = rateLimit({
|
77 |
+
windowMs: env.sessionRateLimitWindow * 1000,
|
78 |
+
limit: env.sessionRateLimit,
|
79 |
+
standardHeaders: 'draft-6',
|
80 |
+
legacyHeaders: false,
|
81 |
+
keyGenerator,
|
82 |
+
store: await createStore('session'),
|
83 |
+
handler: handleRateExceeded
|
84 |
+
});
|
85 |
+
|
86 |
const apiLimiter = rateLimit({
|
87 |
windowMs: env.rateLimitWindow * 1000,
|
88 |
+
limit: (req) => req.rateLimitMax || env.rateLimitMax,
|
89 |
+
standardHeaders: 'draft-6',
|
90 |
legacyHeaders: false,
|
91 |
+
keyGenerator: req => req.rateLimitKey || keyGenerator(req),
|
92 |
+
store: await createStore('api'),
|
93 |
+
handler: handleRateExceeded
|
94 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
|
96 |
+
const apiTunnelLimiter = rateLimit({
|
97 |
windowMs: env.rateLimitWindow * 1000,
|
98 |
+
limit: (req) => req.rateLimitMax || env.rateLimitMax,
|
99 |
+
standardHeaders: 'draft-6',
|
100 |
legacyHeaders: false,
|
101 |
+
keyGenerator: req => req.rateLimitKey || keyGenerator(req),
|
102 |
+
store: await createStore('tunnel'),
|
103 |
+
handler: (_, res) => {
|
104 |
return res.sendStatus(429)
|
105 |
}
|
106 |
+
});
|
107 |
|
108 |
app.set('trust proxy', ['loopback', 'uniquelocal']);
|
109 |
|
|
|
118 |
...corsConfig,
|
119 |
}));
|
120 |
|
|
|
|
|
|
|
121 |
app.post('/', (req, res, next) => {
|
122 |
if (!acceptRegex.test(req.header('Accept'))) {
|
123 |
return fail(res, "error.api.header.accept");
|
|
|
129 |
});
|
130 |
|
131 |
app.post('/', (req, res, next) => {
|
132 |
+
if (!env.apiKeyURL) {
|
133 |
+
return next();
|
134 |
+
}
|
135 |
+
|
136 |
+
const { success, error } = APIKeys.validateAuthorization(req);
|
137 |
+
if (!success) {
|
138 |
+
// We call next() here if either if:
|
139 |
+
// a) we have user sessions enabled, meaning the request
|
140 |
+
// will still need a Bearer token to not be rejected, or
|
141 |
+
// b) we do not require the user to be authenticated, and
|
142 |
+
// so they can just make the request with the regular
|
143 |
+
// rate limit configuration;
|
144 |
+
// otherwise, we reject the request.
|
145 |
+
if (
|
146 |
+
(env.sessionEnabled || !env.authRequired)
|
147 |
+
&& ['missing', 'not_api_key'].includes(error)
|
148 |
+
) {
|
149 |
+
return next();
|
150 |
+
}
|
151 |
+
|
152 |
+
return fail(res, `error.api.auth.key.${error}`);
|
153 |
+
}
|
154 |
+
|
155 |
+
return next();
|
156 |
+
});
|
157 |
+
|
158 |
+
app.post('/', (req, res, next) => {
|
159 |
+
if (!env.sessionEnabled || req.rateLimitKey) {
|
160 |
return next();
|
161 |
}
|
162 |
|
|
|
166 |
return fail(res, "error.api.auth.jwt.missing");
|
167 |
}
|
168 |
|
169 |
+
if (authorization.length >= 256) {
|
170 |
return fail(res, "error.api.auth.jwt.invalid");
|
171 |
}
|
172 |
|
173 |
+
const [ type, token, ...rest ] = authorization.split(" ");
|
174 |
+
if (!token || type.toLowerCase() !== 'bearer' || rest.length) {
|
175 |
+
return fail(res, "error.api.auth.jwt.invalid");
|
176 |
+
}
|
177 |
|
178 |
+
if (!jwt.verify(token, getIP(req, 32))) {
|
179 |
return fail(res, "error.api.auth.jwt.invalid");
|
180 |
}
|
181 |
|
182 |
+
req.rateLimitKey = hashHmac(token, 'rate');
|
183 |
} catch {
|
184 |
return fail(res, "error.api.generic");
|
185 |
}
|
186 |
next();
|
187 |
});
|
188 |
|
189 |
+
app.post('/', apiLimiter);
|
190 |
app.use('/', express.json({ limit: 1024 }));
|
191 |
+
|
192 |
app.use('/', (err, _, res, next) => {
|
193 |
if (err) {
|
194 |
const { status, body } = createResponse("error", {
|
|
|
200 |
next();
|
201 |
});
|
202 |
|
203 |
+
app.post("/session", sessionLimiter, async (req, res) => {
|
204 |
if (!env.sessionEnabled) {
|
205 |
return fail(res, "error.api.auth.not_configured")
|
206 |
}
|
|
|
221 |
}
|
222 |
|
223 |
try {
|
224 |
+
res.json(jwt.generate(getIP(req, 32)));
|
225 |
} catch {
|
226 |
return fail(res, "error.api.generic");
|
227 |
}
|
|
|
229 |
|
230 |
app.post('/', async (req, res) => {
|
231 |
const request = req.body;
|
|
|
232 |
|
233 |
if (!request.url) {
|
234 |
return fail(res, "error.api.link.missing");
|
235 |
}
|
236 |
|
|
|
|
|
|
|
|
|
237 |
const { success, data: normalizedRequest } = await normalizeRequest(request);
|
238 |
if (!success) {
|
239 |
return fail(res, "error.api.invalid_body");
|
|
|
265 |
}
|
266 |
})
|
267 |
|
268 |
+
app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
|
269 |
const id = String(req.query.id);
|
270 |
const exp = String(req.query.exp);
|
271 |
const sig = String(req.query.sig);
|
|
|
284 |
return res.status(200).end();
|
285 |
}
|
286 |
|
287 |
+
const streamInfo = await verifyStream(id, sig, exp, sec, iv);
|
288 |
if (!streamInfo?.service) {
|
289 |
return res.status(streamInfo.status).end();
|
290 |
}
|
|
|
296 |
return stream(res, streamInfo);
|
297 |
})
|
298 |
|
299 |
+
const itunnelHandler = (req, res) => {
|
300 |
if (!req.ip.endsWith('127.0.0.1')) {
|
301 |
return res.sendStatus(403);
|
302 |
}
|
|
|
315 |
...Object.entries(req.headers)
|
316 |
]);
|
317 |
|
318 |
+
return stream(res, { type: 'internal', data: streamInfo });
|
319 |
+
};
|
320 |
+
|
321 |
+
app.get('/itunnel', itunnelHandler);
|
322 |
|
323 |
app.get('/', (_, res) => {
|
324 |
res.type('json');
|
|
|
349 |
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
|
350 |
}
|
351 |
|
352 |
+
http.createServer(app).listen({
|
353 |
+
port: env.apiPort,
|
354 |
+
host: env.listenAddress,
|
355 |
+
reusePort: env.instanceCount > 1 || undefined
|
356 |
+
}, () => {
|
357 |
+
if (isPrimary) {
|
358 |
+
console.log(`\n` +
|
359 |
+
Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
|
360 |
+
|
361 |
+
"~~~~~~\n" +
|
362 |
+
Bright("version: ") + version + "\n" +
|
363 |
+
Bright("commit: ") + git.commit + "\n" +
|
364 |
+
Bright("branch: ") + git.branch + "\n" +
|
365 |
+
Bright("remote: ") + git.remote + "\n" +
|
366 |
+
Bright("start time: ") + startTime.toUTCString() + "\n" +
|
367 |
+
"~~~~~~\n" +
|
368 |
+
|
369 |
+
Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
|
370 |
+
Bright("port: ") + env.apiPort + "\n"
|
371 |
+
);
|
372 |
+
}
|
373 |
+
|
374 |
+
if (env.apiKeyURL) {
|
375 |
+
APIKeys.setup(env.apiKeyURL);
|
376 |
+
}
|
377 |
+
|
378 |
+
if (env.cookiePath) {
|
379 |
+
Cookies.setup(env.cookiePath);
|
380 |
+
}
|
381 |
+
|
382 |
+
if (env.ytSessionServer) {
|
383 |
+
YouTubeSession.setup();
|
384 |
+
}
|
385 |
+
});
|
386 |
+
|
387 |
+
if (isCluster) {
|
388 |
+
const istreamer = express();
|
389 |
+
istreamer.get('/itunnel', itunnelHandler);
|
390 |
+
const server = istreamer.listen({
|
391 |
+
port: 0,
|
392 |
+
host: '127.0.0.1',
|
393 |
+
exclusive: true
|
394 |
+
}, () => {
|
395 |
+
const { port } = server.address();
|
396 |
+
console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`);
|
397 |
+
setTunnelPort(port);
|
398 |
+
});
|
399 |
+
}
|
400 |
}
|
api/src/misc/cluster.js
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import cluster from "node:cluster";
|
2 |
+
import net from "node:net";
|
3 |
+
import { syncSecrets } from "../security/secrets.js";
|
4 |
+
import { env, isCluster } from "../config.js";
|
5 |
+
|
6 |
+
export { isPrimary, isWorker } from "node:cluster";
|
7 |
+
|
8 |
+
export const supportsReusePort = async () => {
|
9 |
+
try {
|
10 |
+
await new Promise((resolve, reject) => {
|
11 |
+
const server = net.createServer().listen({ port: 0, reusePort: true });
|
12 |
+
server.on('listening', () => server.close(resolve));
|
13 |
+
server.on('error', (err) => (server.close(), reject(err)));
|
14 |
+
});
|
15 |
+
|
16 |
+
return true;
|
17 |
+
} catch {
|
18 |
+
return false;
|
19 |
+
}
|
20 |
+
}
|
21 |
+
|
22 |
+
export const initCluster = async () => {
|
23 |
+
if (cluster.isPrimary) {
|
24 |
+
for (let i = 1; i < env.instanceCount; ++i) {
|
25 |
+
cluster.fork();
|
26 |
+
}
|
27 |
+
}
|
28 |
+
|
29 |
+
await syncSecrets();
|
30 |
+
}
|
31 |
+
|
32 |
+
export const broadcast = (message) => {
|
33 |
+
if (!isCluster || !cluster.isPrimary || !cluster.workers) {
|
34 |
+
return;
|
35 |
+
}
|
36 |
+
|
37 |
+
for (const worker of Object.values(cluster.workers)) {
|
38 |
+
worker.send(message);
|
39 |
+
}
|
40 |
+
}
|
41 |
+
|
42 |
+
export const send = (message) => {
|
43 |
+
if (!isCluster) {
|
44 |
+
return;
|
45 |
+
}
|
46 |
+
|
47 |
+
if (cluster.isPrimary) {
|
48 |
+
return broadcast(message);
|
49 |
+
} else {
|
50 |
+
return process.send(message);
|
51 |
+
}
|
52 |
+
}
|
53 |
+
|
54 |
+
export const waitFor = (key) => {
|
55 |
+
return new Promise(resolve => {
|
56 |
+
const listener = (message) => {
|
57 |
+
if (key in message) {
|
58 |
+
process.off('message', listener);
|
59 |
+
return resolve(message);
|
60 |
+
}
|
61 |
+
}
|
62 |
+
|
63 |
+
process.on('message', listener);
|
64 |
+
});
|
65 |
+
}
|
66 |
+
|
67 |
+
export const mainOnMessage = (cb) => {
|
68 |
+
for (const worker of Object.values(cluster.workers)) {
|
69 |
+
worker.on('message', cb);
|
70 |
+
}
|
71 |
+
}
|
api/src/misc/console-text.js
CHANGED
@@ -1,16 +1,36 @@
|
|
1 |
-
|
2 |
-
|
|
|
|
|
|
|
|
|
|
|
3 |
}
|
4 |
|
5 |
-
|
6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
}
|
8 |
-
|
9 |
-
|
|
|
10 |
}
|
11 |
-
|
12 |
-
|
|
|
13 |
}
|
14 |
-
|
15 |
-
|
|
|
16 |
}
|
|
|
1 |
+
const ANSI = {
|
2 |
+
RESET: "\x1b[0m",
|
3 |
+
BRIGHT: "\x1b[1m",
|
4 |
+
RED: "\x1b[31m",
|
5 |
+
GREEN: "\x1b[32m",
|
6 |
+
CYAN: "\x1b[36m",
|
7 |
+
YELLOW: "\x1b[93m"
|
8 |
}
|
9 |
|
10 |
+
function wrap(color, text) {
|
11 |
+
if (!ANSI[color.toUpperCase()]) {
|
12 |
+
throw "invalid color";
|
13 |
+
}
|
14 |
+
|
15 |
+
return ANSI[color.toUpperCase()] + text + ANSI.RESET;
|
16 |
+
}
|
17 |
+
|
18 |
+
export function Bright(text) {
|
19 |
+
return wrap('bright', text);
|
20 |
+
}
|
21 |
+
|
22 |
+
export function Red(text) {
|
23 |
+
return wrap('red', text);
|
24 |
}
|
25 |
+
|
26 |
+
export function Green(text) {
|
27 |
+
return wrap('green', text);
|
28 |
}
|
29 |
+
|
30 |
+
export function Cyan(text) {
|
31 |
+
return wrap('cyan', text);
|
32 |
}
|
33 |
+
|
34 |
+
export function Yellow(text) {
|
35 |
+
return wrap('yellow', text);
|
36 |
}
|
api/src/misc/crypto.js
CHANGED
@@ -1,15 +1,7 @@
|
|
1 |
-
import {
|
2 |
|
3 |
const algorithm = "aes256";
|
4 |
|
5 |
-
export function generateSalt() {
|
6 |
-
return randomBytes(64).toString('hex');
|
7 |
-
}
|
8 |
-
|
9 |
-
export function generateHmac(str, salt) {
|
10 |
-
return createHmac("sha256", salt).update(str).digest("base64url");
|
11 |
-
}
|
12 |
-
|
13 |
export function encryptStream(plaintext, iv, secret) {
|
14 |
const buff = Buffer.from(JSON.stringify(plaintext));
|
15 |
const key = Buffer.from(secret, "base64url");
|
|
|
1 |
+
import { createCipheriv, createDecipheriv } from "crypto";
|
2 |
|
3 |
const algorithm = "aes256";
|
4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
export function encryptStream(plaintext, iv, secret) {
|
6 |
const buff = Buffer.from(JSON.stringify(plaintext));
|
7 |
const key = Buffer.from(secret, "base64url");
|
api/src/misc/run-test.js
CHANGED
@@ -23,6 +23,15 @@ export async function runTest(url, params, expect) {
|
|
23 |
if (expect.status !== result.body.status) {
|
24 |
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
|
25 |
error.push(`status mismatch: ${detail}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
}
|
27 |
|
28 |
if (expect.code !== result.status) {
|
@@ -41,4 +50,4 @@ export async function runTest(url, params, expect) {
|
|
41 |
if (result.body.status === 'tunnel') {
|
42 |
// TODO: stream testing
|
43 |
}
|
44 |
-
}
|
|
|
23 |
if (expect.status !== result.body.status) {
|
24 |
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
|
25 |
error.push(`status mismatch: ${detail}`);
|
26 |
+
|
27 |
+
if (result.body.status === 'error') {
|
28 |
+
error.push(`error code: ${result.body?.error?.code}`);
|
29 |
+
}
|
30 |
+
}
|
31 |
+
|
32 |
+
if (expect.errorCode && expect.errorCode !== result.body?.error?.code) {
|
33 |
+
const detail = `${expect.errorCode} (expected) != ${result.body.error.code} (actual)`
|
34 |
+
error.push(`error mismatch: ${detail}`);
|
35 |
}
|
36 |
|
37 |
if (expect.code !== result.status) {
|
|
|
50 |
if (result.body.status === 'tunnel') {
|
51 |
// TODO: stream testing
|
52 |
}
|
53 |
+
}
|
api/src/misc/utils.js
CHANGED
@@ -1,55 +1,27 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
const
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
for (const i in keys) {
|
16 |
-
if (tags.includes(keys[i]))
|
17 |
-
commands.push('-metadata', `${keys[i]}=${obj[keys[i]]}`)
|
18 |
}
|
19 |
-
|
20 |
-
}
|
21 |
-
|
22 |
-
export function cleanString(string) {
|
23 |
-
for (const i in forbiddenCharsString) {
|
24 |
-
string = string.replaceAll("/", "_")
|
25 |
-
.replaceAll(forbiddenCharsString[i], '')
|
26 |
-
}
|
27 |
-
return string;
|
28 |
-
}
|
29 |
-
export function verifyLanguageCode(code) {
|
30 |
-
const langCode = String(code.slice(0, 2).toLowerCase());
|
31 |
-
if (RegExp(/[a-z]{2}/).test(code)) {
|
32 |
-
return langCode
|
33 |
-
}
|
34 |
-
return "en"
|
35 |
-
}
|
36 |
-
export function languageCode(req) {
|
37 |
-
if (req.header('Accept-Language')) {
|
38 |
-
return verifyLanguageCode(req.header('Accept-Language'))
|
39 |
-
}
|
40 |
-
return "en"
|
41 |
-
}
|
42 |
-
export function cleanHTML(html) {
|
43 |
-
let clean = html.replace(/ {4}/g, '');
|
44 |
-
clean = clean.replace(/\n/g, '');
|
45 |
-
return clean
|
46 |
-
}
|
47 |
|
48 |
-
|
49 |
-
|
50 |
-
if ([301, 302, 303].includes(r.status) && r.headers.has('location'))
|
51 |
return r.headers.get('location');
|
|
|
52 |
}).catch(() => null);
|
|
|
|
|
53 |
}
|
54 |
|
55 |
export function merge(a, b) {
|
@@ -76,3 +48,7 @@ export function splitFilenameExtension(filename) {
|
|
76 |
return [ parts.join('.'), ext ]
|
77 |
}
|
78 |
}
|
|
|
|
|
|
|
|
|
|
1 |
+
import { request } from 'undici';
|
2 |
+
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
|
3 |
+
|
4 |
+
export async function getRedirectingURL(url, dispatcher, headers) {
|
5 |
+
const params = {
|
6 |
+
dispatcher,
|
7 |
+
method: 'HEAD',
|
8 |
+
headers,
|
9 |
+
redirect: 'manual'
|
10 |
+
};
|
11 |
+
|
12 |
+
let location = await request(url, params).then(r => {
|
13 |
+
if (redirectStatuses.has(r.statusCode) && r.headers['location']) {
|
14 |
+
return r.headers['location'];
|
|
|
|
|
|
|
15 |
}
|
16 |
+
}).catch(() => null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
|
18 |
+
location ??= await fetch(url, params).then(r => {
|
19 |
+
if (redirectStatuses.has(r.status) && r.headers.has('location')) {
|
|
|
20 |
return r.headers.get('location');
|
21 |
+
}
|
22 |
}).catch(() => null);
|
23 |
+
|
24 |
+
return location;
|
25 |
}
|
26 |
|
27 |
export function merge(a, b) {
|
|
|
48 |
return [ parts.join('.'), ext ]
|
49 |
}
|
50 |
}
|
51 |
+
|
52 |
+
export function zip(a, b) {
|
53 |
+
return a.map((value, i) => [ value, b[i] ]);
|
54 |
+
}
|
api/src/processing/cookie/cookie.js
CHANGED
@@ -4,16 +4,24 @@ export default class Cookie {
|
|
4 |
constructor(input) {
|
5 |
assert(typeof input === 'object');
|
6 |
this._values = {};
|
7 |
-
|
|
|
|
|
8 |
}
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
)
|
|
|
|
|
|
|
|
|
13 |
}
|
|
|
14 |
unset(keys) {
|
15 |
for (const key of keys) delete this._values[key]
|
16 |
}
|
|
|
17 |
static fromString(str) {
|
18 |
const obj = {};
|
19 |
|
@@ -25,12 +33,15 @@ export default class Cookie {
|
|
25 |
|
26 |
return new Cookie(obj)
|
27 |
}
|
|
|
28 |
toString() {
|
29 |
return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ')
|
30 |
}
|
|
|
31 |
toJSON() {
|
32 |
return this.toString()
|
33 |
}
|
|
|
34 |
values() {
|
35 |
return Object.freeze({ ...this._values })
|
36 |
}
|
|
|
4 |
constructor(input) {
|
5 |
assert(typeof input === 'object');
|
6 |
this._values = {};
|
7 |
+
|
8 |
+
for (const [ k, v ] of Object.entries(input))
|
9 |
+
this.set(k, v);
|
10 |
}
|
11 |
+
|
12 |
+
set(key, value) {
|
13 |
+
const old = this._values[key];
|
14 |
+
if (old === value)
|
15 |
+
return false;
|
16 |
+
|
17 |
+
this._values[key] = value;
|
18 |
+
return true;
|
19 |
}
|
20 |
+
|
21 |
unset(keys) {
|
22 |
for (const key of keys) delete this._values[key]
|
23 |
}
|
24 |
+
|
25 |
static fromString(str) {
|
26 |
const obj = {};
|
27 |
|
|
|
33 |
|
34 |
return new Cookie(obj)
|
35 |
}
|
36 |
+
|
37 |
toString() {
|
38 |
return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ')
|
39 |
}
|
40 |
+
|
41 |
toJSON() {
|
42 |
return this.toString()
|
43 |
}
|
44 |
+
|
45 |
values() {
|
46 |
return Object.freeze({ ...this._values })
|
47 |
}
|
api/src/processing/cookie/manager.js
CHANGED
@@ -1,50 +1,144 @@
|
|
1 |
import Cookie from './cookie.js';
|
|
|
2 |
import { readFile, writeFile } from 'fs/promises';
|
|
|
3 |
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
|
4 |
-
import
|
|
|
5 |
|
6 |
-
const WRITE_INTERVAL = 60000
|
7 |
-
|
8 |
-
|
|
|
|
|
|
|
|
|
|
|
9 |
|
|
|
10 |
let cookies = {}, dirty = false, intervalId;
|
11 |
|
12 |
-
|
13 |
-
|
14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
|
|
|
|
|
16 |
cookies = await readFile(cookiePath, 'utf8');
|
17 |
cookies = JSON.parse(cookies);
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
}
|
21 |
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
|
24 |
-
function writeChanges() {
|
25 |
-
if (!dirty) return;
|
26 |
dirty = false;
|
|
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
}
|
32 |
|
33 |
export function getCookie(service) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
if (!cookies[service] || !cookies[service].length) return;
|
35 |
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
n = (cookies[service][COUNTER] %= cookies[service].length)
|
42 |
}
|
43 |
|
44 |
-
|
45 |
-
|
|
|
46 |
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
}
|
49 |
|
50 |
export function updateCookie(cookie, headers) {
|
@@ -57,10 +151,6 @@ export function updateCookie(cookie, headers) {
|
|
57 |
|
58 |
cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name));
|
59 |
parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value);
|
60 |
-
updateCookieValues(cookie, values);
|
61 |
-
}
|
62 |
|
63 |
-
|
64 |
-
cookie.set(values);
|
65 |
-
if (Object.keys(values).length) dirty = true
|
66 |
}
|
|
|
1 |
import Cookie from './cookie.js';
|
2 |
+
|
3 |
import { readFile, writeFile } from 'fs/promises';
|
4 |
+
import { Red, Green, Yellow } from '../../misc/console-text.js';
|
5 |
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
|
6 |
+
import * as cluster from '../../misc/cluster.js';
|
7 |
+
import { isCluster } from '../../config.js';
|
8 |
|
9 |
+
const WRITE_INTERVAL = 60000;
|
10 |
+
const VALID_SERVICES = new Set([
|
11 |
+
'instagram',
|
12 |
+
'instagram_bearer',
|
13 |
+
'reddit',
|
14 |
+
'twitter',
|
15 |
+
'youtube',
|
16 |
+
]);
|
17 |
|
18 |
+
const invalidCookies = {};
|
19 |
let cookies = {}, dirty = false, intervalId;
|
20 |
|
21 |
+
function writeChanges(cookiePath) {
|
22 |
+
if (!dirty) return;
|
23 |
+
dirty = false;
|
24 |
+
|
25 |
+
const cookieData = JSON.stringify({ ...cookies, ...invalidCookies }, null, 4);
|
26 |
+
writeFile(cookiePath, cookieData).catch((e) => {
|
27 |
+
console.warn(`${Yellow('[!]')} failed writing updated cookies to storage`);
|
28 |
+
console.warn(e);
|
29 |
+
clearInterval(intervalId);
|
30 |
+
intervalId = null;
|
31 |
+
})
|
32 |
+
}
|
33 |
|
34 |
+
const setupMain = async (cookiePath) => {
|
35 |
+
try {
|
36 |
cookies = await readFile(cookiePath, 'utf8');
|
37 |
cookies = JSON.parse(cookies);
|
38 |
+
for (const serviceName in cookies) {
|
39 |
+
if (!VALID_SERVICES.has(serviceName)) {
|
40 |
+
console.warn(`${Yellow('[!]')} ignoring unknown service in cookie file: ${serviceName}`);
|
41 |
+
} else if (!Array.isArray(cookies[serviceName])) {
|
42 |
+
console.warn(`${Yellow('[!]')} ${serviceName} in cookies file is not an array, ignoring it`);
|
43 |
+
} else if (cookies[serviceName].some(c => typeof c !== 'string')) {
|
44 |
+
console.warn(`${Yellow('[!]')} some cookie for ${serviceName} contains non-string value in cookies file`);
|
45 |
+
} else continue;
|
46 |
+
|
47 |
+
invalidCookies[serviceName] = cookies[serviceName];
|
48 |
+
delete cookies[serviceName];
|
49 |
+
}
|
50 |
+
|
51 |
+
if (!intervalId) {
|
52 |
+
intervalId = setInterval(() => writeChanges(cookiePath), WRITE_INTERVAL);
|
53 |
+
}
|
54 |
+
|
55 |
+
cluster.broadcast({ cookies });
|
56 |
+
|
57 |
+
console.log(`${Green('[✓]')} cookies loaded successfully!`);
|
58 |
+
} catch (e) {
|
59 |
+
console.error(`${Yellow('[!]')} failed to load cookies.`);
|
60 |
+
console.error('error:', e);
|
61 |
+
}
|
62 |
}
|
63 |
|
64 |
+
const setupWorker = async () => {
|
65 |
+
cookies = (await cluster.waitFor('cookies')).cookies;
|
66 |
+
}
|
67 |
+
|
68 |
+
export const loadFromFile = async (path) => {
|
69 |
+
if (cluster.isPrimary) {
|
70 |
+
await setupMain(path);
|
71 |
+
} else if (cluster.isWorker) {
|
72 |
+
await setupWorker();
|
73 |
+
}
|
74 |
|
|
|
|
|
75 |
dirty = false;
|
76 |
+
}
|
77 |
|
78 |
+
export const setup = async (path) => {
|
79 |
+
await loadFromFile(path);
|
80 |
+
|
81 |
+
if (isCluster) {
|
82 |
+
const messageHandler = (message) => {
|
83 |
+
if ('cookieUpdate' in message) {
|
84 |
+
const { cookieUpdate } = message;
|
85 |
+
|
86 |
+
if (cluster.isPrimary) {
|
87 |
+
dirty = true;
|
88 |
+
cluster.broadcast({ cookieUpdate });
|
89 |
+
}
|
90 |
+
|
91 |
+
const { service, idx, cookie } = cookieUpdate;
|
92 |
+
cookies[service][idx] = cookie;
|
93 |
+
}
|
94 |
+
}
|
95 |
+
|
96 |
+
if (cluster.isPrimary) {
|
97 |
+
cluster.mainOnMessage(messageHandler);
|
98 |
+
} else {
|
99 |
+
process.on('message', messageHandler);
|
100 |
+
}
|
101 |
+
}
|
102 |
}
|
103 |
|
104 |
export function getCookie(service) {
|
105 |
+
if (!VALID_SERVICES.has(service)) {
|
106 |
+
console.error(
|
107 |
+
`${Red('[!]')} ${service} not in allowed services list for cookies.`
|
108 |
+
+ ' if adding a new cookie type, include it there.'
|
109 |
+
);
|
110 |
+
return;
|
111 |
+
}
|
112 |
+
|
113 |
if (!cookies[service] || !cookies[service].length) return;
|
114 |
|
115 |
+
const idx = Math.floor(Math.random() * cookies[service].length);
|
116 |
+
|
117 |
+
const cookie = cookies[service][idx];
|
118 |
+
if (typeof cookie === 'string') {
|
119 |
+
cookies[service][idx] = Cookie.fromString(cookie);
|
|
|
120 |
}
|
121 |
|
122 |
+
cookies[service][idx].meta = { service, idx };
|
123 |
+
return cookies[service][idx];
|
124 |
+
}
|
125 |
|
126 |
+
export function updateCookieValues(cookie, values) {
|
127 |
+
let changed = false;
|
128 |
+
|
129 |
+
for (const [ key, value ] of Object.entries(values)) {
|
130 |
+
changed = cookie.set(key, value) || changed;
|
131 |
+
}
|
132 |
+
|
133 |
+
if (changed && cookie.meta) {
|
134 |
+
dirty = true;
|
135 |
+
if (isCluster) {
|
136 |
+
const message = { cookieUpdate: { ...cookie.meta, cookie } };
|
137 |
+
cluster.send(message);
|
138 |
+
}
|
139 |
+
}
|
140 |
+
|
141 |
+
return changed;
|
142 |
}
|
143 |
|
144 |
export function updateCookie(cookie, headers) {
|
|
|
151 |
|
152 |
cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name));
|
153 |
parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value);
|
|
|
|
|
154 |
|
155 |
+
updateCookieValues(cookie, values);
|
|
|
|
|
156 |
}
|
api/src/processing/create-filename.js
CHANGED
@@ -1,3 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
export default (f, style, isAudioOnly, isAudioMuted) => {
|
2 |
let filename = '';
|
3 |
|
@@ -5,7 +15,11 @@ export default (f, style, isAudioOnly, isAudioMuted) => {
|
|
5 |
let classicTags = [...infoBase];
|
6 |
let basicTags = [];
|
7 |
|
8 |
-
|
|
|
|
|
|
|
|
|
9 |
|
10 |
if (f.resolution) {
|
11 |
classicTags.push(f.resolution);
|
|
|
1 |
+
const illegalCharacters = ['}', '{', '%', '>', '<', '^', ';', ':', '`', '$', '"', "@", '=', '?', '|', '*'];
|
2 |
+
|
3 |
+
const sanitizeString = (string) => {
|
4 |
+
for (const i in illegalCharacters) {
|
5 |
+
string = string.replaceAll("/", "_").replaceAll("\\", "_")
|
6 |
+
.replaceAll(illegalCharacters[i], '')
|
7 |
+
}
|
8 |
+
return string;
|
9 |
+
}
|
10 |
+
|
11 |
export default (f, style, isAudioOnly, isAudioMuted) => {
|
12 |
let filename = '';
|
13 |
|
|
|
15 |
let classicTags = [...infoBase];
|
16 |
let basicTags = [];
|
17 |
|
18 |
+
let title = sanitizeString(f.title);
|
19 |
+
|
20 |
+
if (f.author) {
|
21 |
+
title += ` - ${sanitizeString(f.author)}`;
|
22 |
+
}
|
23 |
|
24 |
if (f.resolution) {
|
25 |
classicTags.push(f.resolution);
|
api/src/processing/helpers/youtube-session.js
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as cluster from "../../misc/cluster.js";
|
2 |
+
|
3 |
+
import { Agent } from "undici";
|
4 |
+
import { env } from "../../config.js";
|
5 |
+
import { Green, Yellow } from "../../misc/console-text.js";
|
6 |
+
|
7 |
+
const defaultAgent = new Agent();
|
8 |
+
|
9 |
+
let session;
|
10 |
+
|
11 |
+
const validateSession = (sessionResponse) => {
|
12 |
+
if (!sessionResponse.potoken) {
|
13 |
+
throw "no poToken in session response";
|
14 |
+
}
|
15 |
+
|
16 |
+
if (!sessionResponse.visitor_data) {
|
17 |
+
throw "no visitor_data in session response";
|
18 |
+
}
|
19 |
+
|
20 |
+
if (!sessionResponse.updated) {
|
21 |
+
throw "no last update timestamp in session response";
|
22 |
+
}
|
23 |
+
|
24 |
+
// https://github.com/iv-org/youtube-trusted-session-generator/blob/c2dfe3f/potoken_generator/main.py#L25
|
25 |
+
if (sessionResponse.potoken.length < 160) {
|
26 |
+
console.error(`${Yellow('[!]')} poToken is too short and might not work (${new Date().toISOString()})`);
|
27 |
+
}
|
28 |
+
}
|
29 |
+
|
30 |
+
const updateSession = (newSession) => {
|
31 |
+
session = newSession;
|
32 |
+
}
|
33 |
+
|
34 |
+
const loadSession = async () => {
|
35 |
+
const sessionServerUrl = new URL(env.ytSessionServer);
|
36 |
+
sessionServerUrl.pathname = "/token";
|
37 |
+
|
38 |
+
const newSession = await fetch(
|
39 |
+
sessionServerUrl,
|
40 |
+
{ dispatcher: defaultAgent }
|
41 |
+
).then(a => a.json());
|
42 |
+
|
43 |
+
validateSession(newSession);
|
44 |
+
|
45 |
+
if (!session || session.updated < newSession?.updated) {
|
46 |
+
cluster.broadcast({ youtube_session: newSession });
|
47 |
+
updateSession(newSession);
|
48 |
+
}
|
49 |
+
}
|
50 |
+
|
51 |
+
const wrapLoad = (initial = false) => {
|
52 |
+
loadSession()
|
53 |
+
.then(() => {
|
54 |
+
if (initial) {
|
55 |
+
console.log(`${Green('[✓]')} poToken & visitor_data loaded successfully!`);
|
56 |
+
}
|
57 |
+
})
|
58 |
+
.catch((e) => {
|
59 |
+
console.error(`${Yellow('[!]')} Failed loading poToken & visitor_data at ${new Date().toISOString()}.`);
|
60 |
+
console.error('Error:', e);
|
61 |
+
})
|
62 |
+
}
|
63 |
+
|
64 |
+
export const getYouTubeSession = () => {
|
65 |
+
return session;
|
66 |
+
}
|
67 |
+
|
68 |
+
export const setup = () => {
|
69 |
+
if (cluster.isPrimary) {
|
70 |
+
wrapLoad(true);
|
71 |
+
if (env.ytSessionReloadInterval > 0) {
|
72 |
+
setInterval(wrapLoad, env.ytSessionReloadInterval * 1000);
|
73 |
+
}
|
74 |
+
} else if (cluster.isWorker) {
|
75 |
+
process.on('message', (message) => {
|
76 |
+
if ('youtube_session' in message) {
|
77 |
+
updateSession(message.youtube_session);
|
78 |
+
}
|
79 |
+
});
|
80 |
+
}
|
81 |
+
}
|
api/src/processing/match-action.js
CHANGED
@@ -9,13 +9,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
9 |
let action,
|
10 |
responseType = "tunnel",
|
11 |
defaultParams = {
|
12 |
-
|
13 |
headers: r.headers,
|
14 |
service: host,
|
15 |
filename: r.filenameAttributes ?
|
16 |
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
|
17 |
fileMetadata: !disableMetadata ? r.fileMetadata : false,
|
18 |
-
requestIP
|
|
|
19 |
},
|
20 |
params = {};
|
21 |
|
@@ -24,7 +25,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
24 |
else if (r.isGif && twitterGif) action = "gif";
|
25 |
else if (isAudioOnly) action = "audio";
|
26 |
else if (isAudioMuted) action = "muteVideo";
|
27 |
-
else if (r.
|
28 |
else action = "video";
|
29 |
|
30 |
if (action === "picker" || action === "audio") {
|
@@ -47,27 +48,29 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
47 |
});
|
48 |
|
49 |
case "photo":
|
50 |
-
|
51 |
break;
|
52 |
|
53 |
case "gif":
|
54 |
params = { type: "gif" };
|
55 |
break;
|
56 |
|
57 |
-
case "
|
58 |
params = {
|
59 |
-
type: Array.isArray(r.urls) ? "merge" : "remux"
|
|
|
60 |
}
|
61 |
break;
|
62 |
|
63 |
case "muteVideo":
|
64 |
let muteType = "mute";
|
65 |
-
if (Array.isArray(r.urls) && !r.
|
66 |
muteType = "proxy";
|
67 |
}
|
68 |
params = {
|
69 |
type: muteType,
|
70 |
-
|
|
|
71 |
}
|
72 |
if (host === "reddit" && r.typeId === "redirect") {
|
73 |
responseType = "redirect";
|
@@ -81,6 +84,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
81 |
case "twitter":
|
82 |
case "snapchat":
|
83 |
case "bsky":
|
|
|
84 |
params = { picker: r.picker };
|
85 |
break;
|
86 |
|
@@ -92,14 +96,15 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
92 |
}
|
93 |
params = {
|
94 |
picker: r.picker,
|
95 |
-
|
96 |
service: "tiktok",
|
97 |
type: audioStreamType,
|
98 |
-
|
99 |
headers: r.headers,
|
100 |
-
filename: r.audioFilename
|
101 |
isAudioOnly: true,
|
102 |
audioFormat,
|
|
|
103 |
})
|
104 |
}
|
105 |
break;
|
@@ -137,13 +142,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
137 |
}
|
138 |
break;
|
139 |
|
|
|
140 |
case "vk":
|
141 |
case "tiktok":
|
|
|
142 |
params = { type: "proxy" };
|
143 |
break;
|
144 |
|
145 |
case "facebook":
|
146 |
-
case "vine":
|
147 |
case "instagram":
|
148 |
case "tumblr":
|
149 |
case "pinterest":
|
@@ -159,7 +165,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
159 |
case "audio":
|
160 |
if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
|
161 |
return createResponse("error", {
|
162 |
-
code: "error.api.
|
163 |
})
|
164 |
}
|
165 |
|
@@ -183,18 +189,20 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
|
|
183 |
}
|
184 |
}
|
185 |
|
186 |
-
if (r.
|
187 |
copy = false;
|
188 |
processType = "audio";
|
189 |
}
|
190 |
|
191 |
params = {
|
192 |
type: processType,
|
193 |
-
|
194 |
|
195 |
audioBitrate,
|
196 |
audioCopy: copy,
|
197 |
audioFormat,
|
|
|
|
|
198 |
}
|
199 |
break;
|
200 |
}
|
|
|
9 |
let action,
|
10 |
responseType = "tunnel",
|
11 |
defaultParams = {
|
12 |
+
url: r.urls,
|
13 |
headers: r.headers,
|
14 |
service: host,
|
15 |
filename: r.filenameAttributes ?
|
16 |
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
|
17 |
fileMetadata: !disableMetadata ? r.fileMetadata : false,
|
18 |
+
requestIP,
|
19 |
+
originalRequest: r.originalRequest
|
20 |
},
|
21 |
params = {};
|
22 |
|
|
|
25 |
else if (r.isGif && twitterGif) action = "gif";
|
26 |
else if (isAudioOnly) action = "audio";
|
27 |
else if (isAudioMuted) action = "muteVideo";
|
28 |
+
else if (r.isHLS) action = "hls";
|
29 |
else action = "video";
|
30 |
|
31 |
if (action === "picker" || action === "audio") {
|
|
|
48 |
});
|
49 |
|
50 |
case "photo":
|
51 |
+
params = { type: "proxy" };
|
52 |
break;
|
53 |
|
54 |
case "gif":
|
55 |
params = { type: "gif" };
|
56 |
break;
|
57 |
|
58 |
+
case "hls":
|
59 |
params = {
|
60 |
+
type: Array.isArray(r.urls) ? "merge" : "remux",
|
61 |
+
isHLS: true,
|
62 |
}
|
63 |
break;
|
64 |
|
65 |
case "muteVideo":
|
66 |
let muteType = "mute";
|
67 |
+
if (Array.isArray(r.urls) && !r.isHLS) {
|
68 |
muteType = "proxy";
|
69 |
}
|
70 |
params = {
|
71 |
type: muteType,
|
72 |
+
url: Array.isArray(r.urls) ? r.urls[0] : r.urls,
|
73 |
+
isHLS: r.isHLS
|
74 |
}
|
75 |
if (host === "reddit" && r.typeId === "redirect") {
|
76 |
responseType = "redirect";
|
|
|
84 |
case "twitter":
|
85 |
case "snapchat":
|
86 |
case "bsky":
|
87 |
+
case "xiaohongshu":
|
88 |
params = { picker: r.picker };
|
89 |
break;
|
90 |
|
|
|
96 |
}
|
97 |
params = {
|
98 |
picker: r.picker,
|
99 |
+
url: createStream({
|
100 |
service: "tiktok",
|
101 |
type: audioStreamType,
|
102 |
+
url: r.urls,
|
103 |
headers: r.headers,
|
104 |
+
filename: `${r.audioFilename}.${audioFormat}`,
|
105 |
isAudioOnly: true,
|
106 |
audioFormat,
|
107 |
+
audioBitrate
|
108 |
})
|
109 |
}
|
110 |
break;
|
|
|
142 |
}
|
143 |
break;
|
144 |
|
145 |
+
case "ok":
|
146 |
case "vk":
|
147 |
case "tiktok":
|
148 |
+
case "xiaohongshu":
|
149 |
params = { type: "proxy" };
|
150 |
break;
|
151 |
|
152 |
case "facebook":
|
|
|
153 |
case "instagram":
|
154 |
case "tumblr":
|
155 |
case "pinterest":
|
|
|
165 |
case "audio":
|
166 |
if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
|
167 |
return createResponse("error", {
|
168 |
+
code: "error.api.service.audio_not_supported"
|
169 |
})
|
170 |
}
|
171 |
|
|
|
189 |
}
|
190 |
}
|
191 |
|
192 |
+
if (r.isHLS || host === "vimeo") {
|
193 |
copy = false;
|
194 |
processType = "audio";
|
195 |
}
|
196 |
|
197 |
params = {
|
198 |
type: processType,
|
199 |
+
url: Array.isArray(r.urls) ? r.urls[1] : r.urls,
|
200 |
|
201 |
audioBitrate,
|
202 |
audioCopy: copy,
|
203 |
audioFormat,
|
204 |
+
|
205 |
+
isHLS: r.isHLS,
|
206 |
}
|
207 |
break;
|
208 |
}
|
api/src/processing/match.js
CHANGED
@@ -19,7 +19,6 @@ import tumblr from "./services/tumblr.js";
|
|
19 |
import vimeo from "./services/vimeo.js";
|
20 |
import soundcloud from "./services/soundcloud.js";
|
21 |
import instagram from "./services/instagram.js";
|
22 |
-
import vine from "./services/vine.js";
|
23 |
import pinterest from "./services/pinterest.js";
|
24 |
import streamable from "./services/streamable.js";
|
25 |
import twitch from "./services/twitch.js";
|
@@ -29,6 +28,7 @@ import snapchat from "./services/snapchat.js";
|
|
29 |
import loom from "./services/loom.js";
|
30 |
import facebook from "./services/facebook.js";
|
31 |
import bluesky from "./services/bluesky.js";
|
|
|
32 |
|
33 |
let freebind;
|
34 |
|
@@ -78,8 +78,9 @@ export default async function({ host, patternMatch, params }) {
|
|
78 |
|
79 |
case "vk":
|
80 |
r = await vk({
|
81 |
-
|
82 |
videoId: patternMatch.videoId,
|
|
|
83 |
quality: params.videoQuality
|
84 |
});
|
85 |
break;
|
@@ -97,17 +98,18 @@ export default async function({ host, patternMatch, params }) {
|
|
97 |
|
98 |
case "youtube":
|
99 |
let fetchInfo = {
|
|
|
100 |
id: patternMatch.id.slice(0, 11),
|
101 |
quality: params.videoQuality,
|
102 |
format: params.youtubeVideoCodec,
|
103 |
isAudioOnly,
|
104 |
isAudioMuted,
|
105 |
dubLang: params.youtubeDubLang,
|
106 |
-
|
107 |
}
|
108 |
|
109 |
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
110 |
-
fetchInfo.quality = "
|
111 |
fetchInfo.format = "vp9";
|
112 |
fetchInfo.isAudioOnly = true;
|
113 |
fetchInfo.isAudioMuted = false;
|
@@ -118,16 +120,15 @@ export default async function({ host, patternMatch, params }) {
|
|
118 |
|
119 |
case "reddit":
|
120 |
r = await reddit({
|
121 |
-
|
122 |
-
|
123 |
-
user: patternMatch.user
|
124 |
});
|
125 |
break;
|
126 |
|
127 |
case "tiktok":
|
128 |
r = await tiktok({
|
129 |
postId: patternMatch.postId,
|
130 |
-
|
131 |
fullAudio: params.tiktokFullAudio,
|
132 |
isAudioOnly,
|
133 |
h265: params.tiktokH265,
|
@@ -174,12 +175,6 @@ export default async function({ host, patternMatch, params }) {
|
|
174 |
})
|
175 |
break;
|
176 |
|
177 |
-
case "vine":
|
178 |
-
r = await vine({
|
179 |
-
id: patternMatch.id
|
180 |
-
});
|
181 |
-
break;
|
182 |
-
|
183 |
case "pinterest":
|
184 |
r = await pinterest({
|
185 |
id: patternMatch.id,
|
@@ -232,14 +227,25 @@ export default async function({ host, patternMatch, params }) {
|
|
232 |
|
233 |
case "facebook":
|
234 |
r = await facebook({
|
235 |
-
...patternMatch
|
|
|
236 |
});
|
237 |
break;
|
238 |
|
239 |
case "bsky":
|
240 |
r = await bluesky({
|
241 |
...patternMatch,
|
242 |
-
alwaysProxy: params.alwaysProxy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
243 |
});
|
244 |
break;
|
245 |
|
|
|
19 |
import vimeo from "./services/vimeo.js";
|
20 |
import soundcloud from "./services/soundcloud.js";
|
21 |
import instagram from "./services/instagram.js";
|
|
|
22 |
import pinterest from "./services/pinterest.js";
|
23 |
import streamable from "./services/streamable.js";
|
24 |
import twitch from "./services/twitch.js";
|
|
|
28 |
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 |
|
|
|
78 |
|
79 |
case "vk":
|
80 |
r = await vk({
|
81 |
+
ownerId: patternMatch.ownerId,
|
82 |
videoId: patternMatch.videoId,
|
83 |
+
accessKey: patternMatch.accessKey,
|
84 |
quality: params.videoQuality
|
85 |
});
|
86 |
break;
|
|
|
98 |
|
99 |
case "youtube":
|
100 |
let fetchInfo = {
|
101 |
+
dispatcher,
|
102 |
id: patternMatch.id.slice(0, 11),
|
103 |
quality: params.videoQuality,
|
104 |
format: params.youtubeVideoCodec,
|
105 |
isAudioOnly,
|
106 |
isAudioMuted,
|
107 |
dubLang: params.youtubeDubLang,
|
108 |
+
youtubeHLS: params.youtubeHLS,
|
109 |
}
|
110 |
|
111 |
if (url.hostname === "music.youtube.com" || isAudioOnly) {
|
112 |
+
fetchInfo.quality = "1080";
|
113 |
fetchInfo.format = "vp9";
|
114 |
fetchInfo.isAudioOnly = true;
|
115 |
fetchInfo.isAudioMuted = false;
|
|
|
120 |
|
121 |
case "reddit":
|
122 |
r = await reddit({
|
123 |
+
...patternMatch,
|
124 |
+
dispatcher,
|
|
|
125 |
});
|
126 |
break;
|
127 |
|
128 |
case "tiktok":
|
129 |
r = await tiktok({
|
130 |
postId: patternMatch.postId,
|
131 |
+
shortLink: patternMatch.shortLink,
|
132 |
fullAudio: params.tiktokFullAudio,
|
133 |
isAudioOnly,
|
134 |
h265: params.tiktokH265,
|
|
|
175 |
})
|
176 |
break;
|
177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
178 |
case "pinterest":
|
179 |
r = await pinterest({
|
180 |
id: patternMatch.id,
|
|
|
227 |
|
228 |
case "facebook":
|
229 |
r = await facebook({
|
230 |
+
...patternMatch,
|
231 |
+
dispatcher
|
232 |
});
|
233 |
break;
|
234 |
|
235 |
case "bsky":
|
236 |
r = await bluesky({
|
237 |
...patternMatch,
|
238 |
+
alwaysProxy: params.alwaysProxy,
|
239 |
+
dispatcher
|
240 |
+
});
|
241 |
+
break;
|
242 |
+
|
243 |
+
case "xiaohongshu":
|
244 |
+
r = await xiaohongshu({
|
245 |
+
...patternMatch,
|
246 |
+
h265: params.tiktokH265,
|
247 |
+
isAudioOnly,
|
248 |
+
dispatcher,
|
249 |
});
|
250 |
break;
|
251 |
|
api/src/processing/request.js
CHANGED
@@ -37,7 +37,7 @@ export function createResponse(responseType, responseData) {
|
|
37 |
|
38 |
case "redirect":
|
39 |
response = {
|
40 |
-
url: responseData?.
|
41 |
filename: responseData?.filename
|
42 |
}
|
43 |
break;
|
@@ -52,7 +52,7 @@ export function createResponse(responseType, responseData) {
|
|
52 |
case "picker":
|
53 |
response = {
|
54 |
picker: responseData?.picker,
|
55 |
-
audio: responseData?.
|
56 |
audioFilename: responseData?.filename
|
57 |
}
|
58 |
break;
|
@@ -82,14 +82,13 @@ export function normalizeRequest(request) {
|
|
82 |
));
|
83 |
}
|
84 |
|
85 |
-
export function getIP(req) {
|
86 |
const strippedIP = req.ip.replace(/^::ffff:/, '');
|
87 |
const ip = ipaddr.parse(strippedIP);
|
88 |
if (ip.kind() === 'ipv4') {
|
89 |
return strippedIP;
|
90 |
}
|
91 |
|
92 |
-
const prefix = 56;
|
93 |
const v6Bytes = ip.toByteArray();
|
94 |
v6Bytes.fill(0, prefix / 8);
|
95 |
|
|
|
37 |
|
38 |
case "redirect":
|
39 |
response = {
|
40 |
+
url: responseData?.url,
|
41 |
filename: responseData?.filename
|
42 |
}
|
43 |
break;
|
|
|
52 |
case "picker":
|
53 |
response = {
|
54 |
picker: responseData?.picker,
|
55 |
+
audio: responseData?.url,
|
56 |
audioFilename: responseData?.filename
|
57 |
}
|
58 |
break;
|
|
|
82 |
));
|
83 |
}
|
84 |
|
85 |
+
export function getIP(req, prefix = 56) {
|
86 |
const strippedIP = req.ip.replace(/^::ffff:/, '');
|
87 |
const ip = ipaddr.parse(strippedIP);
|
88 |
if (ip.kind() === 'ipv4') {
|
89 |
return strippedIP;
|
90 |
}
|
91 |
|
|
|
92 |
const v6Bytes = ip.toByteArray();
|
93 |
v6Bytes.fill(0, prefix / 8);
|
94 |
|
api/src/processing/schema.js
CHANGED
@@ -1,7 +1,5 @@
|
|
1 |
import { z } from "zod";
|
2 |
-
|
3 |
import { normalizeURL } from "./url.js";
|
4 |
-
import { verifyLanguageCode } from "../misc/utils.js";
|
5 |
|
6 |
export const apiSchema = z.object({
|
7 |
url: z.string()
|
@@ -33,15 +31,21 @@ export const apiSchema = z.object({
|
|
33 |
).default("1080"),
|
34 |
|
35 |
youtubeDubLang: z.string()
|
36 |
-
.
|
37 |
-
.
|
|
|
38 |
.optional(),
|
39 |
|
|
|
|
|
|
|
|
|
40 |
alwaysProxy: z.boolean().default(false),
|
41 |
disableMetadata: z.boolean().default(false),
|
42 |
tiktokFullAudio: z.boolean().default(false),
|
43 |
tiktokH265: z.boolean().default(false),
|
44 |
twitterGif: z.boolean().default(true),
|
45 |
-
|
|
|
46 |
})
|
47 |
.strict();
|
|
|
1 |
import { z } from "zod";
|
|
|
2 |
import { normalizeURL } from "./url.js";
|
|
|
3 |
|
4 |
export const apiSchema = z.object({
|
5 |
url: z.string()
|
|
|
31 |
).default("1080"),
|
32 |
|
33 |
youtubeDubLang: z.string()
|
34 |
+
.min(2)
|
35 |
+
.max(8)
|
36 |
+
.regex(/^[0-9a-zA-Z\-]+$/)
|
37 |
.optional(),
|
38 |
|
39 |
+
// TODO: remove this variable as it's no longer used
|
40 |
+
// and is kept for schema compatibility reasons
|
41 |
+
youtubeDubBrowserLang: z.boolean().default(false),
|
42 |
+
|
43 |
alwaysProxy: z.boolean().default(false),
|
44 |
disableMetadata: z.boolean().default(false),
|
45 |
tiktokFullAudio: z.boolean().default(false),
|
46 |
tiktokH265: z.boolean().default(false),
|
47 |
twitterGif: z.boolean().default(true),
|
48 |
+
|
49 |
+
youtubeHLS: z.boolean().default(false),
|
50 |
})
|
51 |
.strict();
|
api/src/processing/service-config.js
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
import UrlPattern from "url-pattern";
|
2 |
|
3 |
export const audioIgnore = ["vk", "ok", "loom"];
|
4 |
-
export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky"];
|
5 |
|
6 |
export const services = {
|
7 |
bilibili: {
|
@@ -30,23 +30,35 @@ export const services = {
|
|
30 |
"reel/:id",
|
31 |
"share/:shareType/:id"
|
32 |
],
|
33 |
-
subdomains: ["web"],
|
34 |
altDomains: ["fb.watch"],
|
35 |
},
|
36 |
instagram: {
|
37 |
patterns: [
|
38 |
-
"reels/:postId",
|
39 |
-
":username/reel/:postId",
|
40 |
-
"reel/:postId",
|
41 |
"p/:postId",
|
42 |
-
":username/p/:postId",
|
43 |
"tv/:postId",
|
44 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
],
|
46 |
altDomains: ["ddinstagram.com"],
|
47 |
},
|
48 |
loom: {
|
49 |
-
patterns: ["share/:id"],
|
50 |
},
|
51 |
ok: {
|
52 |
patterns: [
|
@@ -64,8 +76,23 @@ export const services = {
|
|
64 |
},
|
65 |
reddit: {
|
66 |
patterns: [
|
|
|
|
|
|
|
67 |
"r/:sub/comments/:id/:title",
|
68 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
],
|
70 |
subdomains: "*",
|
71 |
},
|
@@ -111,12 +138,13 @@ export const services = {
|
|
111 |
tiktok: {
|
112 |
patterns: [
|
113 |
":user/video/:postId",
|
114 |
-
"
|
115 |
-
"
|
|
|
116 |
":user/photo/:postId",
|
117 |
-
"v/:
|
118 |
],
|
119 |
-
subdomains: ["vt", "vm", "m"],
|
120 |
},
|
121 |
tumblr: {
|
122 |
patterns: [
|
@@ -137,15 +165,12 @@ export const services = {
|
|
137 |
":user/status/:id/video/:index",
|
138 |
":user/status/:id/photo/:index",
|
139 |
":user/status/:id/mediaviewer",
|
140 |
-
":user/status/:id/mediaViewer"
|
|
|
141 |
],
|
142 |
subdomains: ["mobile"],
|
143 |
altDomains: ["x.com", "vxtwitter.com", "fixvx.com"],
|
144 |
},
|
145 |
-
vine: {
|
146 |
-
patterns: ["v/:id"],
|
147 |
-
tld: "co",
|
148 |
-
},
|
149 |
vimeo: {
|
150 |
patterns: [
|
151 |
":id",
|
@@ -157,11 +182,25 @@ export const services = {
|
|
157 |
},
|
158 |
vk: {
|
159 |
patterns: [
|
160 |
-
"video:
|
161 |
-
"clip:
|
162 |
-
"clips:duplicate?z=clip:
|
|
|
|
|
|
|
|
|
|
|
163 |
],
|
164 |
subdomains: ["m"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
},
|
166 |
youtube: {
|
167 |
patterns: [
|
|
|
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: {
|
|
|
30 |
"reel/:id",
|
31 |
"share/:shareType/:id"
|
32 |
],
|
33 |
+
subdomains: ["web", "m"],
|
34 |
altDomains: ["fb.watch"],
|
35 |
},
|
36 |
instagram: {
|
37 |
patterns: [
|
|
|
|
|
|
|
38 |
"p/:postId",
|
|
|
39 |
"tv/:postId",
|
40 |
+
"reel/:postId",
|
41 |
+
"reels/:postId",
|
42 |
+
"stories/:username/:storyId",
|
43 |
+
|
44 |
+
/*
|
45 |
+
share & username links use the same url pattern,
|
46 |
+
so we test the share pattern first, cuz id type is different.
|
47 |
+
however, if someone has the "share" username and the user
|
48 |
+
somehow gets a link of this ancient style, it's joever.
|
49 |
+
*/
|
50 |
+
|
51 |
+
"share/:shareId",
|
52 |
+
"share/p/:shareId",
|
53 |
+
"share/reel/:shareId",
|
54 |
+
|
55 |
+
":username/p/:postId",
|
56 |
+
":username/reel/:postId",
|
57 |
],
|
58 |
altDomains: ["ddinstagram.com"],
|
59 |
},
|
60 |
loom: {
|
61 |
+
patterns: ["share/:id", "embed/:id"],
|
62 |
},
|
63 |
ok: {
|
64 |
patterns: [
|
|
|
76 |
},
|
77 |
reddit: {
|
78 |
patterns: [
|
79 |
+
"comments/:id",
|
80 |
+
|
81 |
+
"r/:sub/comments/:id",
|
82 |
"r/:sub/comments/:id/:title",
|
83 |
+
"r/:sub/comments/:id/comment/:commentId",
|
84 |
+
|
85 |
+
"user/:user/comments/:id",
|
86 |
+
"user/:user/comments/:id/:title",
|
87 |
+
"user/:user/comments/:id/comment/:commentId",
|
88 |
+
|
89 |
+
"r/u_:user/comments/:id",
|
90 |
+
"r/u_:user/comments/:id/:title",
|
91 |
+
"r/u_:user/comments/:id/comment/:commentId",
|
92 |
+
|
93 |
+
"r/:sub/s/:shareId",
|
94 |
+
|
95 |
+
"video/:shortId",
|
96 |
],
|
97 |
subdomains: "*",
|
98 |
},
|
|
|
138 |
tiktok: {
|
139 |
patterns: [
|
140 |
":user/video/:postId",
|
141 |
+
"i18n/share/video/:postId",
|
142 |
+
":shortLink",
|
143 |
+
"t/:shortLink",
|
144 |
":user/photo/:postId",
|
145 |
+
"v/:postId.html"
|
146 |
],
|
147 |
+
subdomains: ["vt", "vm", "m", "t"],
|
148 |
},
|
149 |
tumblr: {
|
150 |
patterns: [
|
|
|
165 |
":user/status/:id/video/:index",
|
166 |
":user/status/:id/photo/:index",
|
167 |
":user/status/:id/mediaviewer",
|
168 |
+
":user/status/:id/mediaViewer",
|
169 |
+
"i/bookmarks?post_id=:id"
|
170 |
],
|
171 |
subdomains: ["mobile"],
|
172 |
altDomains: ["x.com", "vxtwitter.com", "fixvx.com"],
|
173 |
},
|
|
|
|
|
|
|
|
|
174 |
vimeo: {
|
175 |
patterns: [
|
176 |
":id",
|
|
|
182 |
},
|
183 |
vk: {
|
184 |
patterns: [
|
185 |
+
"video:ownerId_:videoId",
|
186 |
+
"clip:ownerId_:videoId",
|
187 |
+
"clips:duplicate?z=clip:ownerId_:videoId",
|
188 |
+
"videos:duplicate?z=video:ownerId_:videoId",
|
189 |
+
"video:ownerId_:videoId_:accessKey",
|
190 |
+
"clip:ownerId_:videoId_:accessKey",
|
191 |
+
"clips:duplicate?z=clip:ownerId_:videoId_:accessKey",
|
192 |
+
"videos:duplicate?z=video:ownerId_:videoId_:accessKey"
|
193 |
],
|
194 |
subdomains: ["m"],
|
195 |
+
altDomains: ["vkvideo.ru", "vk.ru"],
|
196 |
+
},
|
197 |
+
xiaohongshu: {
|
198 |
+
patterns: [
|
199 |
+
"explore/:id?xsec_token=:token",
|
200 |
+
"discovery/item/:id?xsec_token=:token",
|
201 |
+
"a/:shareId"
|
202 |
+
],
|
203 |
+
altDomains: ["xhslink.com"],
|
204 |
},
|
205 |
youtube: {
|
206 |
patterns: [
|
api/src/processing/service-patterns.js
CHANGED
@@ -6,7 +6,8 @@ export const testers = {
|
|
6 |
"dailymotion": pattern => pattern.id?.length <= 32,
|
7 |
|
8 |
"instagram": pattern =>
|
9 |
-
pattern.postId?.length <=
|
|
|
10 |
|| (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
|
11 |
|
12 |
"loom": pattern =>
|
@@ -19,8 +20,11 @@ export const testers = {
|
|
19 |
pattern.id?.length <= 128 || pattern.shortLink?.length <= 32,
|
20 |
|
21 |
"reddit": pattern =>
|
22 |
-
|
23 |
-
|| (pattern.
|
|
|
|
|
|
|
24 |
|
25 |
"rutube": pattern =>
|
26 |
(pattern.id?.length === 32 && pattern.key?.length <= 32) ||
|
@@ -36,10 +40,10 @@ export const testers = {
|
|
36 |
|| pattern.shortLink?.length <= 16,
|
37 |
|
38 |
"streamable": pattern =>
|
39 |
-
pattern.id?.length
|
40 |
|
41 |
"tiktok": pattern =>
|
42 |
-
pattern.postId?.length <= 21 || pattern.
|
43 |
|
44 |
"tumblr": pattern =>
|
45 |
pattern.id?.length < 21
|
@@ -55,11 +59,9 @@ export const testers = {
|
|
55 |
pattern.id?.length <= 11
|
56 |
&& (!pattern.password || pattern.password.length < 16),
|
57 |
|
58 |
-
"vine": pattern =>
|
59 |
-
pattern.id?.length <= 12,
|
60 |
-
|
61 |
"vk": pattern =>
|
62 |
-
pattern.
|
|
|
63 |
|
64 |
"youtube": pattern =>
|
65 |
pattern.id?.length <= 11,
|
@@ -73,4 +75,8 @@ export const testers = {
|
|
73 |
|
74 |
"bsky": pattern =>
|
75 |
pattern.user?.length <= 128 && pattern.post?.length <= 128,
|
|
|
|
|
|
|
|
|
76 |
}
|
|
|
6 |
"dailymotion": pattern => pattern.id?.length <= 32,
|
7 |
|
8 |
"instagram": pattern =>
|
9 |
+
pattern.postId?.length <= 48
|
10 |
+
|| pattern.shareId?.length <= 16
|
11 |
|| (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
|
12 |
|
13 |
"loom": pattern =>
|
|
|
20 |
pattern.id?.length <= 128 || pattern.shortLink?.length <= 32,
|
21 |
|
22 |
"reddit": pattern =>
|
23 |
+
pattern.id?.length <= 16 && !pattern.sub && !pattern.user
|
24 |
+
|| (pattern.sub?.length <= 22 && pattern.id?.length <= 16)
|
25 |
+
|| (pattern.user?.length <= 22 && pattern.id?.length <= 16)
|
26 |
+
|| (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16)
|
27 |
+
|| (pattern.shortId?.length <= 16),
|
28 |
|
29 |
"rutube": pattern =>
|
30 |
(pattern.id?.length === 32 && pattern.key?.length <= 32) ||
|
|
|
40 |
|| pattern.shortLink?.length <= 16,
|
41 |
|
42 |
"streamable": pattern =>
|
43 |
+
pattern.id?.length <= 6,
|
44 |
|
45 |
"tiktok": pattern =>
|
46 |
+
pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13,
|
47 |
|
48 |
"tumblr": pattern =>
|
49 |
pattern.id?.length < 21
|
|
|
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,
|
|
|
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 |
}
|
api/src/processing/services/bilibili.js
CHANGED
@@ -1,19 +1,8 @@
|
|
1 |
import { genericUserAgent, env } from "../../config.js";
|
|
|
2 |
|
3 |
// TO-DO: higher quality downloads (currently requires an account)
|
4 |
|
5 |
-
function com_resolveShortlink(shortId) {
|
6 |
-
return fetch(`https://b23.tv/${shortId}`, { redirect: 'manual' })
|
7 |
-
.then(r => r.status > 300 && r.status < 400 && r.headers.get('location'))
|
8 |
-
.then(url => {
|
9 |
-
if (!url) return;
|
10 |
-
const path = new URL(url).pathname;
|
11 |
-
if (path.startsWith('/video/'))
|
12 |
-
return path.split('/')[2];
|
13 |
-
})
|
14 |
-
.catch(() => {})
|
15 |
-
}
|
16 |
-
|
17 |
function getBest(content) {
|
18 |
return content?.filter(v => v.baseUrl || v.url)
|
19 |
.map(v => (v.baseUrl = v.baseUrl || v.url, v))
|
@@ -99,7 +88,8 @@ async function tv_download(id) {
|
|
99 |
|
100 |
export default async function({ comId, tvId, comShortLink }) {
|
101 |
if (comShortLink) {
|
102 |
-
|
|
|
103 |
}
|
104 |
|
105 |
if (comId) {
|
|
|
1 |
import { genericUserAgent, env } from "../../config.js";
|
2 |
+
import { resolveRedirectingURL } from "../url.js";
|
3 |
|
4 |
// TO-DO: higher quality downloads (currently requires an account)
|
5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
function getBest(content) {
|
7 |
return content?.filter(v => v.baseUrl || v.url)
|
8 |
.map(v => (v.baseUrl = v.baseUrl || v.url, v))
|
|
|
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) {
|
api/src/processing/services/bluesky.js
CHANGED
@@ -2,12 +2,19 @@ import HLS from "hls-parser";
|
|
2 |
import { cobaltUserAgent } from "../../config.js";
|
3 |
import { createStream } from "../../stream/manage.js";
|
4 |
|
5 |
-
const extractVideo = async ({ media, filename }) => {
|
6 |
-
|
7 |
-
if (!urlMasterHLS) return { error: "fetch.empty" };
|
8 |
-
if (!urlMasterHLS.startsWith("https://video.bsky.app/")) return { error: "fetch.empty" };
|
9 |
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
.then(r => {
|
12 |
if (r.status !== 200) return;
|
13 |
return r.text();
|
@@ -26,7 +33,7 @@ const extractVideo = async ({ media, filename }) => {
|
|
26 |
urls: videoURL,
|
27 |
filename: `${filename}.mp4`,
|
28 |
audioFilename: `${filename}_audio`,
|
29 |
-
|
30 |
}
|
31 |
}
|
32 |
|
@@ -48,7 +55,7 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
|
|
48 |
let proxiedImage = createStream({
|
49 |
service: "bluesky",
|
50 |
type: "proxy",
|
51 |
-
|
52 |
filename: `${filename}_${i + 1}.jpg`,
|
53 |
});
|
54 |
|
@@ -64,7 +71,25 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
|
|
64 |
return { picker };
|
65 |
}
|
66 |
|
67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
68 |
const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
|
69 |
apiEndpoint.searchParams.set(
|
70 |
"uri",
|
@@ -73,8 +98,9 @@ export default async function ({ user, post, alwaysProxy }) {
|
|
73 |
|
74 |
const getPost = await fetch(apiEndpoint, {
|
75 |
headers: {
|
76 |
-
"user-agent": cobaltUserAgent
|
77 |
-
}
|
|
|
78 |
}).then(r => r.json()).catch(() => {});
|
79 |
|
80 |
if (!getPost) return { error: "fetch.empty" };
|
@@ -87,29 +113,44 @@ export default async function ({ user, post, alwaysProxy }) {
|
|
87 |
case "InvalidRequest":
|
88 |
return { error: "link.unsupported" };
|
89 |
default:
|
90 |
-
return { error: "
|
91 |
}
|
92 |
}
|
93 |
|
94 |
const embedType = getPost?.thread?.post?.embed?.$type;
|
95 |
const filename = `bluesky_${user}_${post}`;
|
96 |
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
113 |
}
|
114 |
|
115 |
return { error: "fetch.empty" };
|
|
|
2 |
import { cobaltUserAgent } from "../../config.js";
|
3 |
import { createStream } from "../../stream/manage.js";
|
4 |
|
5 |
+
const extractVideo = async ({ media, filename, dispatcher }) => {
|
6 |
+
let urlMasterHLS = media?.playlist;
|
|
|
|
|
7 |
|
8 |
+
if (!urlMasterHLS || !urlMasterHLS.startsWith("https://video.bsky.app/")) {
|
9 |
+
return { error: "fetch.empty" };
|
10 |
+
}
|
11 |
+
|
12 |
+
urlMasterHLS = urlMasterHLS.replace(
|
13 |
+
"video.bsky.app/watch/",
|
14 |
+
"video.cdn.bsky.app/hls/"
|
15 |
+
);
|
16 |
+
|
17 |
+
const masterHLS = await fetch(urlMasterHLS, { dispatcher })
|
18 |
.then(r => {
|
19 |
if (r.status !== 200) return;
|
20 |
return r.text();
|
|
|
33 |
urls: videoURL,
|
34 |
filename: `${filename}.mp4`,
|
35 |
audioFilename: `${filename}_audio`,
|
36 |
+
isHLS: true,
|
37 |
}
|
38 |
}
|
39 |
|
|
|
55 |
let proxiedImage = createStream({
|
56 |
service: "bluesky",
|
57 |
type: "proxy",
|
58 |
+
url,
|
59 |
filename: `${filename}_${i + 1}.jpg`,
|
60 |
});
|
61 |
|
|
|
71 |
return { picker };
|
72 |
}
|
73 |
|
74 |
+
const extractGif = ({ url, filename }) => {
|
75 |
+
const gifUrl = new URL(url);
|
76 |
+
|
77 |
+
if (!gifUrl || gifUrl.hostname !== "media.tenor.com") {
|
78 |
+
return { error: "fetch.empty" };
|
79 |
+
}
|
80 |
+
|
81 |
+
// remove downscaling params from gif url
|
82 |
+
// such as "?hh=498&ww=498"
|
83 |
+
gifUrl.search = "";
|
84 |
+
|
85 |
+
return {
|
86 |
+
urls: gifUrl,
|
87 |
+
isPhoto: true,
|
88 |
+
filename: `${filename}.gif`,
|
89 |
+
}
|
90 |
+
}
|
91 |
+
|
92 |
+
export default async function ({ user, post, alwaysProxy, dispatcher }) {
|
93 |
const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
|
94 |
apiEndpoint.searchParams.set(
|
95 |
"uri",
|
|
|
98 |
|
99 |
const getPost = await fetch(apiEndpoint, {
|
100 |
headers: {
|
101 |
+
"user-agent": cobaltUserAgent,
|
102 |
+
},
|
103 |
+
dispatcher
|
104 |
}).then(r => r.json()).catch(() => {});
|
105 |
|
106 |
if (!getPost) return { error: "fetch.empty" };
|
|
|
113 |
case "InvalidRequest":
|
114 |
return { error: "link.unsupported" };
|
115 |
default:
|
116 |
+
return { error: "content.post.unavailable" };
|
117 |
}
|
118 |
}
|
119 |
|
120 |
const embedType = getPost?.thread?.post?.embed?.$type;
|
121 |
const filename = `bluesky_${user}_${post}`;
|
122 |
|
123 |
+
switch (embedType) {
|
124 |
+
case "app.bsky.embed.video#view":
|
125 |
+
return extractVideo({
|
126 |
+
media: getPost.thread?.post?.embed,
|
127 |
+
filename,
|
128 |
+
});
|
129 |
+
|
130 |
+
case "app.bsky.embed.images#view":
|
131 |
+
return extractImages({
|
132 |
+
getPost,
|
133 |
+
filename,
|
134 |
+
alwaysProxy
|
135 |
+
});
|
136 |
+
|
137 |
+
case "app.bsky.embed.external#view":
|
138 |
+
return extractGif({
|
139 |
+
url: getPost?.thread?.post?.embed?.external?.uri,
|
140 |
+
filename,
|
141 |
+
});
|
142 |
+
|
143 |
+
case "app.bsky.embed.recordWithMedia#view":
|
144 |
+
if (getPost?.thread?.post?.embed?.media?.$type === "app.bsky.embed.external#view") {
|
145 |
+
return extractGif({
|
146 |
+
url: getPost?.thread?.post?.embed?.media?.external?.uri,
|
147 |
+
filename,
|
148 |
+
});
|
149 |
+
}
|
150 |
+
return extractVideo({
|
151 |
+
media: getPost.thread?.post?.embed?.media,
|
152 |
+
filename,
|
153 |
+
});
|
154 |
}
|
155 |
|
156 |
return { error: "fetch.empty" };
|
api/src/processing/services/dailymotion.js
CHANGED
@@ -92,7 +92,7 @@ export default async function({ id }) {
|
|
92 |
|
93 |
return {
|
94 |
urls: bestQuality.uri,
|
95 |
-
|
96 |
filenameAttributes: {
|
97 |
service: 'dailymotion',
|
98 |
id: media.xid,
|
|
|
92 |
|
93 |
return {
|
94 |
urls: bestQuality.uri,
|
95 |
+
isHLS: true,
|
96 |
filenameAttributes: {
|
97 |
service: 'dailymotion',
|
98 |
id: media.xid,
|
api/src/processing/services/facebook.js
CHANGED
@@ -8,8 +8,8 @@ const headers = {
|
|
8 |
'Sec-Fetch-Site': 'none',
|
9 |
}
|
10 |
|
11 |
-
const resolveUrl = (url) => {
|
12 |
-
return fetch(url, { headers })
|
13 |
.then(r => {
|
14 |
if (r.headers.get('location')) {
|
15 |
return decodeURIComponent(r.headers.get('location'));
|
@@ -23,13 +23,13 @@ const resolveUrl = (url) => {
|
|
23 |
.catch(() => false);
|
24 |
}
|
25 |
|
26 |
-
export default async function({ id, shareType, shortLink }) {
|
27 |
let url = `https://web.facebook.com/i/videos/${id}`;
|
28 |
|
29 |
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
|
30 |
-
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}
|
31 |
|
32 |
-
const html = await fetch(url, { headers })
|
33 |
.then(r => r.text())
|
34 |
.catch(() => false);
|
35 |
|
|
|
8 |
'Sec-Fetch-Site': 'none',
|
9 |
}
|
10 |
|
11 |
+
const resolveUrl = (url, dispatcher) => {
|
12 |
+
return fetch(url, { headers, dispatcher })
|
13 |
.then(r => {
|
14 |
if (r.headers.get('location')) {
|
15 |
return decodeURIComponent(r.headers.get('location'));
|
|
|
23 |
.catch(() => false);
|
24 |
}
|
25 |
|
26 |
+
export default async function({ id, shareType, shortLink, dispatcher }) {
|
27 |
let url = `https://web.facebook.com/i/videos/${id}`;
|
28 |
|
29 |
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
|
30 |
+
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`, dispatcher);
|
31 |
|
32 |
+
const html = await fetch(url, { headers, dispatcher })
|
33 |
.then(r => r.text())
|
34 |
.catch(() => false);
|
35 |
|
api/src/processing/services/instagram.js
CHANGED
@@ -1,3 +1,5 @@
|
|
|
|
|
|
1 |
import { genericUserAgent } from "../../config.js";
|
2 |
import { createStream } from "../../stream/manage.js";
|
3 |
import { getCookie, updateCookie } from "../cookie/manager.js";
|
@@ -8,6 +10,7 @@ const commonHeaders = {
|
|
8 |
"sec-fetch-site": "same-origin",
|
9 |
"x-ig-app-id": "936619743392459"
|
10 |
}
|
|
|
11 |
const mobileHeaders = {
|
12 |
"x-ig-app-locale": "en_US",
|
13 |
"x-ig-device-locale": "en_US",
|
@@ -19,6 +22,7 @@ const mobileHeaders = {
|
|
19 |
"x-fb-server-cluster": "True",
|
20 |
"content-length": "0",
|
21 |
}
|
|
|
22 |
const embedHeaders = {
|
23 |
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
24 |
"Accept-Language": "en-GB,en;q=0.9",
|
@@ -33,7 +37,7 @@ const embedHeaders = {
|
|
33 |
"Sec-Fetch-Site": "none",
|
34 |
"Sec-Fetch-User": "?1",
|
35 |
"Upgrade-Insecure-Requests": "1",
|
36 |
-
"User-Agent":
|
37 |
}
|
38 |
|
39 |
const cachedDtsg = {
|
@@ -41,7 +45,17 @@ const cachedDtsg = {
|
|
41 |
expiry: 0
|
42 |
}
|
43 |
|
44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
const dispatcher = obj.dispatcher;
|
46 |
|
47 |
async function findDtsgId(cookie) {
|
@@ -91,6 +105,7 @@ export default function(obj) {
|
|
91 |
updateCookie(cookie, data.headers);
|
92 |
return data.json();
|
93 |
}
|
|
|
94 |
async function getMediaId(id, { cookie, token } = {}) {
|
95 |
const oembedURL = new URL('https://i.instagram.com/api/v1/oembed/');
|
96 |
oembedURL.searchParams.set('url', `https://www.instagram.com/p/${id}/`);
|
@@ -119,6 +134,7 @@ export default function(obj) {
|
|
119 |
|
120 |
return mediaInfo?.items?.[0];
|
121 |
}
|
|
|
122 |
async function requestHTML(id, cookie) {
|
123 |
const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, {
|
124 |
headers: {
|
@@ -136,40 +152,167 @@ export default function(obj) {
|
|
136 |
|
137 |
return embedData;
|
138 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
139 |
async function requestGQL(id, cookie) {
|
140 |
-
|
141 |
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
146 |
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
__relay_internal__pv__PolarisShareMenurelayprovider: false
|
152 |
-
}),
|
153 |
-
doc_id: '7153618348081770'
|
154 |
};
|
155 |
-
|
156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
157 |
}
|
158 |
|
159 |
-
return
|
160 |
-
.data
|
161 |
-
?.xdt_api__v1__media__shortcode__web_info
|
162 |
-
?.items
|
163 |
-
?.[0];
|
164 |
}
|
165 |
|
166 |
function extractOldPost(data, id, alwaysProxy) {
|
167 |
-
const
|
|
|
|
|
168 |
if (sidecar) {
|
169 |
const picker = sidecar.edges.filter(e => e.node?.display_url)
|
170 |
.map((e, i) => {
|
171 |
-
const type = e.node?.is_video ? "video" : "photo";
|
172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
173 |
|
174 |
let itemExt = type === "video" ? "mp4" : "jpg";
|
175 |
|
@@ -177,7 +320,7 @@ export default function(obj) {
|
|
177 |
if (alwaysProxy) proxyFile = createStream({
|
178 |
service: "instagram",
|
179 |
type: "proxy",
|
180 |
-
|
181 |
filename: `instagram_${id}_${i + 1}.${itemExt}`
|
182 |
});
|
183 |
|
@@ -189,23 +332,28 @@ export default function(obj) {
|
|
189 |
thumb: createStream({
|
190 |
service: "instagram",
|
191 |
type: "proxy",
|
192 |
-
|
193 |
filename: `instagram_${id}_${i + 1}.jpg`
|
194 |
})
|
195 |
}
|
196 |
});
|
197 |
|
198 |
if (picker.length) return { picker }
|
199 |
-
}
|
|
|
|
|
200 |
return {
|
201 |
-
urls:
|
202 |
filename: `instagram_${id}.mp4`,
|
203 |
audioFilename: `instagram_${id}_audio`
|
204 |
}
|
205 |
-
}
|
|
|
|
|
206 |
return {
|
207 |
-
urls:
|
208 |
-
isPhoto: true
|
|
|
209 |
}
|
210 |
}
|
211 |
}
|
@@ -230,7 +378,7 @@ export default function(obj) {
|
|
230 |
if (alwaysProxy) proxyFile = createStream({
|
231 |
service: "instagram",
|
232 |
type: "proxy",
|
233 |
-
|
234 |
filename: `instagram_${id}_${i + 1}.${itemExt}`
|
235 |
});
|
236 |
|
@@ -242,7 +390,7 @@ export default function(obj) {
|
|
242 |
thumb: createStream({
|
243 |
service: "instagram",
|
244 |
type: "proxy",
|
245 |
-
|
246 |
filename: `instagram_${id}_${i + 1}.jpg`
|
247 |
})
|
248 |
}
|
@@ -266,6 +414,9 @@ export default function(obj) {
|
|
266 |
}
|
267 |
|
268 |
async function getPost(id, alwaysProxy) {
|
|
|
|
|
|
|
269 |
let data, result;
|
270 |
try {
|
271 |
const cookie = getCookie('instagram');
|
@@ -282,19 +433,21 @@ export default function(obj) {
|
|
282 |
if (media_id && token) data = await requestMobileApi(media_id, { token });
|
283 |
|
284 |
// mobile api (no cookie, cookie)
|
285 |
-
if (media_id && !data) data = await requestMobileApi(media_id);
|
286 |
-
if (media_id && cookie && !data) data = await requestMobileApi(media_id, { cookie });
|
287 |
|
288 |
// html embed (no cookie, cookie)
|
289 |
-
if (!data) data = await requestHTML(id);
|
290 |
-
if (!data && cookie) data = await requestHTML(id, cookie);
|
291 |
|
292 |
// web app graphql api (no cookie, cookie)
|
293 |
-
if (!data) data = await requestGQL(id);
|
294 |
-
if (!data && cookie) data = await requestGQL(id, cookie);
|
295 |
} catch {}
|
296 |
|
297 |
-
if (!data)
|
|
|
|
|
298 |
|
299 |
if (data?.gql_data) {
|
300 |
result = extractOldPost(data, id, alwaysProxy)
|
@@ -357,14 +510,30 @@ export default function(obj) {
|
|
357 |
if (item.image_versions2?.candidates) {
|
358 |
return {
|
359 |
urls: item.image_versions2.candidates[0].url,
|
360 |
-
isPhoto: true
|
|
|
361 |
}
|
362 |
}
|
363 |
|
364 |
return { error: "link.unsupported" };
|
365 |
}
|
366 |
|
367 |
-
const { postId, storyId, username, alwaysProxy } = obj;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
368 |
if (postId) return getPost(postId, alwaysProxy);
|
369 |
if (username && storyId) return getStory(username, storyId);
|
370 |
|
|
|
1 |
+
import { randomBytes } from "node:crypto";
|
2 |
+
import { resolveRedirectingURL } from "../url.js";
|
3 |
import { genericUserAgent } from "../../config.js";
|
4 |
import { createStream } from "../../stream/manage.js";
|
5 |
import { getCookie, updateCookie } from "../cookie/manager.js";
|
|
|
10 |
"sec-fetch-site": "same-origin",
|
11 |
"x-ig-app-id": "936619743392459"
|
12 |
}
|
13 |
+
|
14 |
const mobileHeaders = {
|
15 |
"x-ig-app-locale": "en_US",
|
16 |
"x-ig-device-locale": "en_US",
|
|
|
22 |
"x-fb-server-cluster": "True",
|
23 |
"content-length": "0",
|
24 |
}
|
25 |
+
|
26 |
const embedHeaders = {
|
27 |
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
28 |
"Accept-Language": "en-GB,en;q=0.9",
|
|
|
37 |
"Sec-Fetch-Site": "none",
|
38 |
"Sec-Fetch-User": "?1",
|
39 |
"Upgrade-Insecure-Requests": "1",
|
40 |
+
"User-Agent": genericUserAgent,
|
41 |
}
|
42 |
|
43 |
const cachedDtsg = {
|
|
|
45 |
expiry: 0
|
46 |
}
|
47 |
|
48 |
+
const getNumberFromQuery = (name, data) => {
|
49 |
+
const s = data?.match(new RegExp(name + '=(\\d+)'))?.[1];
|
50 |
+
if (+s) return +s;
|
51 |
+
}
|
52 |
+
|
53 |
+
const getObjectFromEntries = (name, data) => {
|
54 |
+
const obj = data?.match(new RegExp('\\["' + name + '",.*?,({.*?}),\\d+\\]'))?.[1];
|
55 |
+
return obj && JSON.parse(obj);
|
56 |
+
}
|
57 |
+
|
58 |
+
export default function instagram(obj) {
|
59 |
const dispatcher = obj.dispatcher;
|
60 |
|
61 |
async function findDtsgId(cookie) {
|
|
|
105 |
updateCookie(cookie, data.headers);
|
106 |
return data.json();
|
107 |
}
|
108 |
+
|
109 |
async function getMediaId(id, { cookie, token } = {}) {
|
110 |
const oembedURL = new URL('https://i.instagram.com/api/v1/oembed/');
|
111 |
oembedURL.searchParams.set('url', `https://www.instagram.com/p/${id}/`);
|
|
|
134 |
|
135 |
return mediaInfo?.items?.[0];
|
136 |
}
|
137 |
+
|
138 |
async function requestHTML(id, cookie) {
|
139 |
const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, {
|
140 |
headers: {
|
|
|
152 |
|
153 |
return embedData;
|
154 |
}
|
155 |
+
|
156 |
+
async function getGQLParams(id, cookie) {
|
157 |
+
const req = await fetch(`https://www.instagram.com/p/${id}/`, {
|
158 |
+
headers: {
|
159 |
+
...embedHeaders,
|
160 |
+
cookie
|
161 |
+
},
|
162 |
+
dispatcher
|
163 |
+
});
|
164 |
+
|
165 |
+
const html = await req.text();
|
166 |
+
const siteData = getObjectFromEntries('SiteData', html);
|
167 |
+
const polarisSiteData = getObjectFromEntries('PolarisSiteData', html);
|
168 |
+
const webConfig = getObjectFromEntries('DGWWebConfig', html);
|
169 |
+
const pushInfo = getObjectFromEntries('InstagramWebPushInfo', html);
|
170 |
+
const lsd = getObjectFromEntries('LSD', html)?.token || randomBytes(8).toString('base64url');
|
171 |
+
const csrf = getObjectFromEntries('InstagramSecurityConfig', html)?.csrf_token;
|
172 |
+
|
173 |
+
const anon_cookie = [
|
174 |
+
csrf && "csrftoken=" + csrf,
|
175 |
+
polarisSiteData?.device_id && "ig_did=" + polarisSiteData?.device_id,
|
176 |
+
"wd=1280x720",
|
177 |
+
"dpr=2",
|
178 |
+
polarisSiteData?.machine_id && "mid=" + polarisSiteData.machine_id,
|
179 |
+
"ig_nrcb=1"
|
180 |
+
].filter(a => a).join('; ');
|
181 |
+
|
182 |
+
return {
|
183 |
+
headers: {
|
184 |
+
'x-ig-app-id': webConfig?.appId || '936619743392459',
|
185 |
+
'X-FB-LSD': lsd,
|
186 |
+
'X-CSRFToken': csrf,
|
187 |
+
'X-Bloks-Version-Id': getObjectFromEntries('WebBloksVersioningID', html)?.versioningID,
|
188 |
+
'x-asbd-id': 129477,
|
189 |
+
cookie: anon_cookie
|
190 |
+
},
|
191 |
+
body: {
|
192 |
+
__d: 'www',
|
193 |
+
__a: '1',
|
194 |
+
__s: '::' + Math.random().toString(36).substring(2).replace(/\d/g, '').slice(0, 6),
|
195 |
+
__hs: siteData?.haste_session || '20126.HYP:instagram_web_pkg.2.1...0',
|
196 |
+
__req: 'b',
|
197 |
+
__ccg: 'EXCELLENT',
|
198 |
+
__rev: pushInfo?.rollout_hash || '1019933358',
|
199 |
+
__hsi: siteData?.hsi || '7436540909012459023',
|
200 |
+
__dyn: randomBytes(154).toString('base64url'),
|
201 |
+
__csr: randomBytes(154).toString('base64url'),
|
202 |
+
__user: '0',
|
203 |
+
__comet_req: getNumberFromQuery('__comet_req', html) || '7',
|
204 |
+
av: '0',
|
205 |
+
dpr: '2',
|
206 |
+
lsd,
|
207 |
+
jazoest: getNumberFromQuery('jazoest', html) || Math.floor(Math.random() * 10000),
|
208 |
+
__spin_r: siteData?.__spin_r || '1019933358',
|
209 |
+
__spin_b: siteData?.__spin_b || 'trunk',
|
210 |
+
__spin_t: siteData?.__spin_t || Math.floor(new Date().getTime() / 1000),
|
211 |
+
}
|
212 |
+
};
|
213 |
+
}
|
214 |
+
|
215 |
async function requestGQL(id, cookie) {
|
216 |
+
const { headers, body } = await getGQLParams(id, cookie);
|
217 |
|
218 |
+
const req = await fetch('https://www.instagram.com/graphql/query', {
|
219 |
+
method: 'POST',
|
220 |
+
dispatcher,
|
221 |
+
headers: {
|
222 |
+
...embedHeaders,
|
223 |
+
...headers,
|
224 |
+
cookie,
|
225 |
+
'content-type': 'application/x-www-form-urlencoded',
|
226 |
+
'X-FB-Friendly-Name': 'PolarisPostActionLoadPostQueryQuery',
|
227 |
+
},
|
228 |
+
body: new URLSearchParams({
|
229 |
+
...body,
|
230 |
+
fb_api_caller_class: 'RelayModern',
|
231 |
+
fb_api_req_friendly_name: 'PolarisPostActionLoadPostQueryQuery',
|
232 |
+
variables: JSON.stringify({
|
233 |
+
shortcode: id,
|
234 |
+
fetch_tagged_user_count: null,
|
235 |
+
hoisted_comment_id: null,
|
236 |
+
hoisted_reply_id: null
|
237 |
+
}),
|
238 |
+
server_timestamps: true,
|
239 |
+
doc_id: '8845758582119845'
|
240 |
+
}).toString()
|
241 |
+
});
|
242 |
|
243 |
+
return {
|
244 |
+
gql_data: await req.json()
|
245 |
+
.then(r => r.data)
|
246 |
+
.catch(() => null)
|
|
|
|
|
|
|
247 |
};
|
248 |
+
}
|
249 |
+
|
250 |
+
async function getErrorContext(id) {
|
251 |
+
try {
|
252 |
+
const { headers, body } = await getGQLParams(id);
|
253 |
+
|
254 |
+
const req = await fetch('https://www.instagram.com/ajax/bulk-route-definitions/', {
|
255 |
+
method: 'POST',
|
256 |
+
dispatcher,
|
257 |
+
headers: {
|
258 |
+
...embedHeaders,
|
259 |
+
...headers,
|
260 |
+
'content-type': 'application/x-www-form-urlencoded',
|
261 |
+
'X-Ig-D': 'www',
|
262 |
+
},
|
263 |
+
body: new URLSearchParams({
|
264 |
+
'route_urls[0]': `/p/${id}/`,
|
265 |
+
routing_namespace: 'igx_www',
|
266 |
+
...body
|
267 |
+
}).toString()
|
268 |
+
});
|
269 |
+
|
270 |
+
const response = await req.text();
|
271 |
+
if (response.includes('"tracePolicy":"polaris.privatePostPage"'))
|
272 |
+
return { error: 'content.post.private' };
|
273 |
+
|
274 |
+
const [, mediaId, mediaOwnerId] = response.match(
|
275 |
+
/"media_id":\s*?"(\d+)","media_owner_id":\s*?"(\d+)"/
|
276 |
+
) || [];
|
277 |
+
|
278 |
+
if (mediaId && mediaOwnerId) {
|
279 |
+
const rulingURL = new URL('https://www.instagram.com/api/v1/web/get_ruling_for_media_content_logged_out');
|
280 |
+
rulingURL.searchParams.set('media_id', mediaId);
|
281 |
+
rulingURL.searchParams.set('owner_id', mediaOwnerId);
|
282 |
+
|
283 |
+
const rulingResponse = await fetch(rulingURL, {
|
284 |
+
headers: {
|
285 |
+
...headers,
|
286 |
+
...commonHeaders
|
287 |
+
},
|
288 |
+
dispatcher,
|
289 |
+
}).then(a => a.json()).catch(() => ({}));
|
290 |
+
|
291 |
+
if (rulingResponse?.title?.includes('Restricted'))
|
292 |
+
return { error: "content.post.age" };
|
293 |
+
}
|
294 |
+
} catch {
|
295 |
+
return { error: "fetch.fail" };
|
296 |
}
|
297 |
|
298 |
+
return { error: "fetch.empty" };
|
|
|
|
|
|
|
|
|
299 |
}
|
300 |
|
301 |
function extractOldPost(data, id, alwaysProxy) {
|
302 |
+
const shortcodeMedia = data?.gql_data?.shortcode_media || data?.gql_data?.xdt_shortcode_media;
|
303 |
+
const sidecar = shortcodeMedia?.edge_sidecar_to_children;
|
304 |
+
|
305 |
if (sidecar) {
|
306 |
const picker = sidecar.edges.filter(e => e.node?.display_url)
|
307 |
.map((e, i) => {
|
308 |
+
const type = e.node?.is_video && e.node?.video_url ? "video" : "photo";
|
309 |
+
|
310 |
+
let url;
|
311 |
+
if (type === "video") {
|
312 |
+
url = e.node?.video_url;
|
313 |
+
} else if (type === "photo") {
|
314 |
+
url = e.node?.display_url;
|
315 |
+
}
|
316 |
|
317 |
let itemExt = type === "video" ? "mp4" : "jpg";
|
318 |
|
|
|
320 |
if (alwaysProxy) proxyFile = createStream({
|
321 |
service: "instagram",
|
322 |
type: "proxy",
|
323 |
+
url,
|
324 |
filename: `instagram_${id}_${i + 1}.${itemExt}`
|
325 |
});
|
326 |
|
|
|
332 |
thumb: createStream({
|
333 |
service: "instagram",
|
334 |
type: "proxy",
|
335 |
+
url: e.node?.display_url,
|
336 |
filename: `instagram_${id}_${i + 1}.jpg`
|
337 |
})
|
338 |
}
|
339 |
});
|
340 |
|
341 |
if (picker.length) return { picker }
|
342 |
+
}
|
343 |
+
|
344 |
+
if (shortcodeMedia?.video_url) {
|
345 |
return {
|
346 |
+
urls: shortcodeMedia.video_url,
|
347 |
filename: `instagram_${id}.mp4`,
|
348 |
audioFilename: `instagram_${id}_audio`
|
349 |
}
|
350 |
+
}
|
351 |
+
|
352 |
+
if (shortcodeMedia?.display_url) {
|
353 |
return {
|
354 |
+
urls: shortcodeMedia.display_url,
|
355 |
+
isPhoto: true,
|
356 |
+
filename: `instagram_${id}.jpg`,
|
357 |
}
|
358 |
}
|
359 |
}
|
|
|
378 |
if (alwaysProxy) proxyFile = createStream({
|
379 |
service: "instagram",
|
380 |
type: "proxy",
|
381 |
+
url,
|
382 |
filename: `instagram_${id}_${i + 1}.${itemExt}`
|
383 |
});
|
384 |
|
|
|
390 |
thumb: createStream({
|
391 |
service: "instagram",
|
392 |
type: "proxy",
|
393 |
+
url: imageUrl,
|
394 |
filename: `instagram_${id}_${i + 1}.jpg`
|
395 |
})
|
396 |
}
|
|
|
414 |
}
|
415 |
|
416 |
async function getPost(id, alwaysProxy) {
|
417 |
+
const hasData = (data) => data
|
418 |
+
&& data.gql_data !== null
|
419 |
+
&& data?.gql_data?.xdt_shortcode_media !== null;
|
420 |
let data, result;
|
421 |
try {
|
422 |
const cookie = getCookie('instagram');
|
|
|
433 |
if (media_id && token) data = await requestMobileApi(media_id, { token });
|
434 |
|
435 |
// mobile api (no cookie, cookie)
|
436 |
+
if (media_id && !hasData(data)) data = await requestMobileApi(media_id);
|
437 |
+
if (media_id && cookie && !hasData(data)) data = await requestMobileApi(media_id, { cookie });
|
438 |
|
439 |
// html embed (no cookie, cookie)
|
440 |
+
if (!hasData(data)) data = await requestHTML(id);
|
441 |
+
if (!hasData(data) && cookie) data = await requestHTML(id, cookie);
|
442 |
|
443 |
// web app graphql api (no cookie, cookie)
|
444 |
+
if (!hasData(data)) data = await requestGQL(id);
|
445 |
+
if (!hasData(data) && cookie) data = await requestGQL(id, cookie);
|
446 |
} catch {}
|
447 |
|
448 |
+
if (!hasData(data)) {
|
449 |
+
return getErrorContext(id);
|
450 |
+
}
|
451 |
|
452 |
if (data?.gql_data) {
|
453 |
result = extractOldPost(data, id, alwaysProxy)
|
|
|
510 |
if (item.image_versions2?.candidates) {
|
511 |
return {
|
512 |
urls: item.image_versions2.candidates[0].url,
|
513 |
+
isPhoto: true,
|
514 |
+
filename: `instagram_${id}.jpg`,
|
515 |
}
|
516 |
}
|
517 |
|
518 |
return { error: "link.unsupported" };
|
519 |
}
|
520 |
|
521 |
+
const { postId, shareId, storyId, username, alwaysProxy } = obj;
|
522 |
+
|
523 |
+
if (shareId) {
|
524 |
+
return resolveRedirectingURL(
|
525 |
+
`https://www.instagram.com/share/${shareId}/`,
|
526 |
+
dispatcher,
|
527 |
+
// for some reason instagram decides to return HTML
|
528 |
+
// instead of a redirect when requesting with a normal
|
529 |
+
// browser user-agent
|
530 |
+
{'User-Agent': 'curl/7.88.1'}
|
531 |
+
).then(match => instagram({
|
532 |
+
...obj, ...match,
|
533 |
+
shareId: undefined
|
534 |
+
}));
|
535 |
+
}
|
536 |
+
|
537 |
if (postId) return getPost(postId, alwaysProxy);
|
538 |
if (username && storyId) return getStory(username, storyId);
|
539 |
|
api/src/processing/services/ok.js
CHANGED
@@ -1,5 +1,4 @@
|
|
1 |
import { genericUserAgent, env } from "../../config.js";
|
2 |
-
import { cleanString } from "../../misc/utils.js";
|
3 |
|
4 |
const resolutions = {
|
5 |
"ultra": "2160",
|
@@ -44,8 +43,8 @@ export default async function(o) {
|
|
44 |
let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
|
45 |
|
46 |
let fileMetadata = {
|
47 |
-
title:
|
48 |
-
author:
|
49 |
}
|
50 |
|
51 |
if (bestVideo) return {
|
|
|
1 |
import { genericUserAgent, env } from "../../config.js";
|
|
|
2 |
|
3 |
const resolutions = {
|
4 |
"ultra": "2160",
|
|
|
43 |
let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
|
44 |
|
45 |
let fileMetadata = {
|
46 |
+
title: videoData.movie.title.trim(),
|
47 |
+
author: (videoData.author?.name || videoData.compilationTitle)?.trim(),
|
48 |
}
|
49 |
|
50 |
if (bestVideo) return {
|
api/src/processing/services/pinterest.js
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
import { genericUserAgent } from "../../config.js";
|
|
|
2 |
|
3 |
const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g;
|
4 |
const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
|
@@ -7,10 +8,10 @@ export default async function(o) {
|
|
7 |
let id = o.id;
|
8 |
|
9 |
if (!o.id && o.shortLink) {
|
10 |
-
|
11 |
-
|
12 |
-
.catch(() => {});
|
13 |
}
|
|
|
14 |
if (id.includes("--")) id = id.split("--")[1];
|
15 |
if (!id) return { error: "fetch.fail" };
|
16 |
|
@@ -22,12 +23,12 @@ export default async function(o) {
|
|
22 |
|
23 |
const videoLink = [...html.matchAll(videoRegex)]
|
24 |
.map(([, link]) => link)
|
25 |
-
.find(a => a.endsWith('.mp4')
|
26 |
|
27 |
if (videoLink) return {
|
28 |
urls: videoLink,
|
29 |
-
filename: `pinterest_${
|
30 |
-
audioFilename: `pinterest_${
|
31 |
}
|
32 |
|
33 |
const imageLink = [...html.matchAll(imageRegex)]
|
@@ -39,7 +40,7 @@ export default async function(o) {
|
|
39 |
if (imageLink) return {
|
40 |
urls: imageLink,
|
41 |
isPhoto: true,
|
42 |
-
filename: `pinterest_${
|
43 |
}
|
44 |
|
45 |
return { error: "fetch.empty" };
|
|
|
1 |
import { genericUserAgent } from "../../config.js";
|
2 |
+
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;
|
|
|
8 |
let id = o.id;
|
9 |
|
10 |
if (!o.id && o.shortLink) {
|
11 |
+
const patternMatch = await resolveRedirectingURL(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`);
|
12 |
+
id = patternMatch?.id;
|
|
|
13 |
}
|
14 |
+
|
15 |
if (id.includes("--")) id = id.split("--")[1];
|
16 |
if (!id) return { error: "fetch.fail" };
|
17 |
|
|
|
23 |
|
24 |
const videoLink = [...html.matchAll(videoRegex)]
|
25 |
.map(([, link]) => link)
|
26 |
+
.find(a => a.endsWith('.mp4'));
|
27 |
|
28 |
if (videoLink) return {
|
29 |
urls: videoLink,
|
30 |
+
filename: `pinterest_${id}.mp4`,
|
31 |
+
audioFilename: `pinterest_${id}_audio`
|
32 |
}
|
33 |
|
34 |
const imageLink = [...html.matchAll(imageRegex)]
|
|
|
40 |
if (imageLink) return {
|
41 |
urls: imageLink,
|
42 |
isPhoto: true,
|
43 |
+
filename: `pinterest_${id}.${imageType}`
|
44 |
}
|
45 |
|
46 |
return { error: "fetch.empty" };
|
api/src/processing/services/reddit.js
CHANGED
@@ -1,3 +1,4 @@
|
|
|
|
1 |
import { genericUserAgent, env } from "../../config.js";
|
2 |
import { getCookie, updateCookieValues } from "../cookie/manager.js";
|
3 |
|
@@ -48,23 +49,36 @@ async function getAccessToken() {
|
|
48 |
}
|
49 |
|
50 |
export default async function(obj) {
|
51 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
-
if (
|
54 |
-
|
|
|
|
|
|
|
55 |
}
|
56 |
|
57 |
-
|
|
|
|
|
|
|
58 |
if (accessToken) url.hostname = 'oauth.reddit.com';
|
59 |
|
60 |
let data = await fetch(
|
61 |
-
url, {
|
62 |
-
headers: {
|
63 |
-
'User-Agent': genericUserAgent,
|
64 |
-
accept: 'application/json',
|
65 |
-
authorization: accessToken && `Bearer ${accessToken}`
|
66 |
-
}
|
67 |
-
}
|
68 |
).then(r => r.json()).catch(() => {});
|
69 |
|
70 |
if (!data || !Array.isArray(data)) {
|
@@ -73,12 +87,17 @@ export default async function(obj) {
|
|
73 |
|
74 |
data = data[0]?.data?.children[0]?.data;
|
75 |
|
76 |
-
|
|
|
|
|
|
|
|
|
|
|
77 |
|
78 |
if (data?.url?.endsWith('.gif')) return {
|
79 |
typeId: "redirect",
|
80 |
urls: data.url,
|
81 |
-
filename: `reddit_${
|
82 |
}
|
83 |
|
84 |
if (!data.secure_media?.reddit_video)
|
@@ -87,8 +106,9 @@ export default async function(obj) {
|
|
87 |
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
|
88 |
return { error: "content.too_long" };
|
89 |
|
|
|
|
|
90 |
let audio = false,
|
91 |
-
video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
|
92 |
audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`;
|
93 |
|
94 |
if (video.match('.mp4')) {
|
@@ -121,7 +141,7 @@ export default async function(obj) {
|
|
121 |
typeId: "tunnel",
|
122 |
type: "merge",
|
123 |
urls: [video, audioFileLink],
|
124 |
-
audioFilename: `reddit_${
|
125 |
-
filename: `reddit_${
|
126 |
}
|
127 |
}
|
|
|
1 |
+
import { resolveRedirectingURL } from "../url.js";
|
2 |
import { genericUserAgent, env } from "../../config.js";
|
3 |
import { getCookie, updateCookieValues } from "../cookie/manager.js";
|
4 |
|
|
|
49 |
}
|
50 |
|
51 |
export default async function(obj) {
|
52 |
+
let params = obj;
|
53 |
+
const accessToken = await getAccessToken();
|
54 |
+
const headers = {
|
55 |
+
'user-agent': genericUserAgent,
|
56 |
+
authorization: accessToken && `Bearer ${accessToken}`,
|
57 |
+
accept: 'application/json'
|
58 |
+
};
|
59 |
+
|
60 |
+
if (params.shortId) {
|
61 |
+
params = await resolveRedirectingURL(
|
62 |
+
`https://www.reddit.com/video/${params.shortId}`,
|
63 |
+
obj.dispatcher, headers
|
64 |
+
);
|
65 |
+
}
|
66 |
|
67 |
+
if (!params.id && params.shareId) {
|
68 |
+
params = await resolveRedirectingURL(
|
69 |
+
`https://www.reddit.com/r/${params.sub}/s/${params.shareId}`,
|
70 |
+
obj.dispatcher, headers
|
71 |
+
);
|
72 |
}
|
73 |
|
74 |
+
if (!params?.id) return { error: "fetch.short_link" };
|
75 |
+
|
76 |
+
const url = new URL(`https://www.reddit.com/comments/${params.id}.json`);
|
77 |
+
|
78 |
if (accessToken) url.hostname = 'oauth.reddit.com';
|
79 |
|
80 |
let data = await fetch(
|
81 |
+
url, { headers }
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
).then(r => r.json()).catch(() => {});
|
83 |
|
84 |
if (!data || !Array.isArray(data)) {
|
|
|
87 |
|
88 |
data = data[0]?.data?.children[0]?.data;
|
89 |
|
90 |
+
let sourceId;
|
91 |
+
if (params.sub || params.user) {
|
92 |
+
sourceId = `${String(params.sub || params.user).toLowerCase()}_${params.id}`;
|
93 |
+
} else {
|
94 |
+
sourceId = params.id;
|
95 |
+
}
|
96 |
|
97 |
if (data?.url?.endsWith('.gif')) return {
|
98 |
typeId: "redirect",
|
99 |
urls: data.url,
|
100 |
+
filename: `reddit_${sourceId}.gif`,
|
101 |
}
|
102 |
|
103 |
if (!data.secure_media?.reddit_video)
|
|
|
106 |
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
|
107 |
return { error: "content.too_long" };
|
108 |
|
109 |
+
const video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0];
|
110 |
+
|
111 |
let audio = false,
|
|
|
112 |
audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`;
|
113 |
|
114 |
if (video.match('.mp4')) {
|
|
|
141 |
typeId: "tunnel",
|
142 |
type: "merge",
|
143 |
urls: [video, audioFileLink],
|
144 |
+
audioFilename: `reddit_${sourceId}_audio`,
|
145 |
+
filename: `reddit_${sourceId}.mp4`
|
146 |
}
|
147 |
}
|
api/src/processing/services/rutube.js
CHANGED
@@ -1,7 +1,5 @@
|
|
1 |
import HLS from "hls-parser";
|
2 |
-
|
3 |
import { env } from "../../config.js";
|
4 |
-
import { cleanString } from "../../misc/utils.js";
|
5 |
|
6 |
async function requestJSON(url) {
|
7 |
try {
|
@@ -35,6 +33,10 @@ export default async function(obj) {
|
|
35 |
const play = await requestJSON(requestURL);
|
36 |
if (!play) return { error: "fetch.fail" };
|
37 |
|
|
|
|
|
|
|
|
|
38 |
if (play.detail || !play.video_balancer) return { error: "fetch.empty" };
|
39 |
if (play.live_streams?.hls) return { error: "content.video.live" };
|
40 |
|
@@ -59,13 +61,13 @@ export default async function(obj) {
|
|
59 |
});
|
60 |
|
61 |
const fileMetadata = {
|
62 |
-
title:
|
63 |
-
artist:
|
64 |
}
|
65 |
|
66 |
return {
|
67 |
urls: matchingQuality.uri,
|
68 |
-
|
69 |
filenameAttributes: {
|
70 |
service: "rutube",
|
71 |
id: obj.id,
|
|
|
1 |
import HLS from "hls-parser";
|
|
|
2 |
import { env } from "../../config.js";
|
|
|
3 |
|
4 |
async function requestJSON(url) {
|
5 |
try {
|
|
|
33 |
const play = await requestJSON(requestURL);
|
34 |
if (!play) return { error: "fetch.fail" };
|
35 |
|
36 |
+
if (play.detail?.type === "blocking_rule") {
|
37 |
+
return { error: "content.video.region" };
|
38 |
+
}
|
39 |
+
|
40 |
if (play.detail || !play.video_balancer) return { error: "fetch.empty" };
|
41 |
if (play.live_streams?.hls) return { error: "content.video.live" };
|
42 |
|
|
|
61 |
});
|
62 |
|
63 |
const fileMetadata = {
|
64 |
+
title: play.title.trim(),
|
65 |
+
artist: play.author.name.trim(),
|
66 |
}
|
67 |
|
68 |
return {
|
69 |
urls: matchingQuality.uri,
|
70 |
+
isHLS: true,
|
71 |
filenameAttributes: {
|
72 |
service: "rutube",
|
73 |
id: obj.id,
|
api/src/processing/services/snapchat.js
CHANGED
@@ -1,7 +1,6 @@
|
|
1 |
-
import {
|
2 |
import { genericUserAgent } from "../../config.js";
|
3 |
import { createStream } from "../../stream/manage.js";
|
4 |
-
import { getRedirectingURL } from "../../misc/utils.js";
|
5 |
|
6 |
const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
7 |
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
|
@@ -41,9 +40,9 @@ async function getStory(username, storyId, alwaysProxy) {
|
|
41 |
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
|
42 |
if (nextDataString) {
|
43 |
const data = JSON.parse(nextDataString);
|
44 |
-
const storyIdParam = data
|
45 |
|
46 |
-
if (storyIdParam && data
|
47 |
const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);
|
48 |
if (story) {
|
49 |
if (story.snapMediaType === 0) {
|
@@ -62,7 +61,7 @@ async function getStory(username, storyId, alwaysProxy) {
|
|
62 |
}
|
63 |
}
|
64 |
|
65 |
-
const defaultStory = data
|
66 |
if (defaultStory) {
|
67 |
return {
|
68 |
picker: defaultStory.snapList.map(snap => {
|
@@ -73,7 +72,7 @@ async function getStory(username, storyId, alwaysProxy) {
|
|
73 |
const proxy = createStream({
|
74 |
service: "snapchat",
|
75 |
type: "proxy",
|
76 |
-
|
77 |
filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`,
|
78 |
});
|
79 |
|
@@ -81,7 +80,7 @@ async function getStory(username, storyId, alwaysProxy) {
|
|
81 |
if (snapType === "video") thumbProxy = createStream({
|
82 |
service: "snapchat",
|
83 |
type: "proxy",
|
84 |
-
|
85 |
});
|
86 |
|
87 |
if (alwaysProxy) snapUrl = proxy;
|
@@ -100,18 +99,7 @@ async function getStory(username, storyId, alwaysProxy) {
|
|
100 |
export default async function (obj) {
|
101 |
let params = obj;
|
102 |
if (obj.shortLink) {
|
103 |
-
|
104 |
-
|
105 |
-
if (!link?.startsWith('https://www.snapchat.com/')) {
|
106 |
-
return { error: "fetch.short_link" };
|
107 |
-
}
|
108 |
-
|
109 |
-
const extractResult = extract(normalizeURL(link));
|
110 |
-
if (extractResult?.host !== 'snapchat') {
|
111 |
-
return { error: "fetch.short_link" };
|
112 |
-
}
|
113 |
-
|
114 |
-
params = extractResult.patternMatch;
|
115 |
}
|
116 |
|
117 |
if (params.spotlightId) {
|
|
|
1 |
+
import { resolveRedirectingURL } from "../url.js";
|
2 |
import { genericUserAgent } from "../../config.js";
|
3 |
import { createStream } from "../../stream/manage.js";
|
|
|
4 |
|
5 |
const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
|
6 |
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
|
|
|
40 |
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
|
41 |
if (nextDataString) {
|
42 |
const data = JSON.parse(nextDataString);
|
43 |
+
const storyIdParam = data?.query?.profileParams?.[1];
|
44 |
|
45 |
+
if (storyIdParam && data?.props?.pageProps?.story) {
|
46 |
const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);
|
47 |
if (story) {
|
48 |
if (story.snapMediaType === 0) {
|
|
|
61 |
}
|
62 |
}
|
63 |
|
64 |
+
const defaultStory = data?.props?.pageProps?.curatedHighlights?.[0];
|
65 |
if (defaultStory) {
|
66 |
return {
|
67 |
picker: defaultStory.snapList.map(snap => {
|
|
|
72 |
const proxy = createStream({
|
73 |
service: "snapchat",
|
74 |
type: "proxy",
|
75 |
+
url: snapUrl,
|
76 |
filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`,
|
77 |
});
|
78 |
|
|
|
80 |
if (snapType === "video") thumbProxy = createStream({
|
81 |
service: "snapchat",
|
82 |
type: "proxy",
|
83 |
+
url: snap.snapUrls.mediaPreviewUrl.value,
|
84 |
});
|
85 |
|
86 |
if (alwaysProxy) snapUrl = proxy;
|
|
|
99 |
export default async function (obj) {
|
100 |
let params = obj;
|
101 |
if (obj.shortLink) {
|
102 |
+
params = await resolveRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
103 |
}
|
104 |
|
105 |
if (params.spotlightId) {
|
api/src/processing/services/soundcloud.js
CHANGED
@@ -1,5 +1,4 @@
|
|
1 |
import { env } from "../../config.js";
|
2 |
-
import { cleanString } from "../../misc/utils.js";
|
3 |
|
4 |
const cachedID = {
|
5 |
version: '',
|
@@ -63,7 +62,17 @@ export default async function(obj) {
|
|
63 |
|
64 |
if (!json) return { error: "fetch.fail" };
|
65 |
|
66 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
|
68 |
let bestAudio = "opus",
|
69 |
selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"),
|
@@ -75,6 +84,10 @@ export default async function(obj) {
|
|
75 |
bestAudio = "mp3"
|
76 |
}
|
77 |
|
|
|
|
|
|
|
|
|
78 |
let fileUrlBase = selectedStream.url;
|
79 |
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
|
80 |
|
@@ -91,8 +104,8 @@ export default async function(obj) {
|
|
91 |
if (!file) return { error: "fetch.empty" };
|
92 |
|
93 |
let fileMetadata = {
|
94 |
-
title:
|
95 |
-
artist:
|
96 |
}
|
97 |
|
98 |
return {
|
|
|
1 |
import { env } from "../../config.js";
|
|
|
2 |
|
3 |
const cachedID = {
|
4 |
version: '',
|
|
|
62 |
|
63 |
if (!json) return { error: "fetch.fail" };
|
64 |
|
65 |
+
if (json?.policy === "BLOCK") {
|
66 |
+
return { error: "content.region" };
|
67 |
+
}
|
68 |
+
|
69 |
+
if (json?.policy === "SNIP") {
|
70 |
+
return { error: "content.paid" };
|
71 |
+
}
|
72 |
+
|
73 |
+
if (!json?.media?.transcodings || !json?.media?.transcodings.length === 0) {
|
74 |
+
return { error: "fetch.empty" };
|
75 |
+
}
|
76 |
|
77 |
let bestAudio = "opus",
|
78 |
selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"),
|
|
|
84 |
bestAudio = "mp3"
|
85 |
}
|
86 |
|
87 |
+
if (!selectedStream) {
|
88 |
+
return { error: "fetch.empty" };
|
89 |
+
}
|
90 |
+
|
91 |
let fileUrlBase = selectedStream.url;
|
92 |
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
|
93 |
|
|
|
104 |
if (!file) return { error: "fetch.empty" };
|
105 |
|
106 |
let fileMetadata = {
|
107 |
+
title: json.title.trim(),
|
108 |
+
artist: json.user.username.trim(),
|
109 |
}
|
110 |
|
111 |
return {
|
api/src/processing/services/tiktok.js
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
import Cookie from "../cookie/cookie.js";
|
2 |
|
3 |
-
import { extract } from "../url.js";
|
4 |
import { genericUserAgent } from "../../config.js";
|
5 |
import { updateCookie } from "../cookie/manager.js";
|
6 |
import { createStream } from "../../stream/manage.js";
|
@@ -12,7 +12,7 @@ export default async function(obj) {
|
|
12 |
let postId = obj.postId;
|
13 |
|
14 |
if (!postId) {
|
15 |
-
let html = await fetch(`${shortDomain}${obj.
|
16 |
redirect: "manual",
|
17 |
headers: {
|
18 |
"user-agent": genericUserAgent.split(' Chrome/1')[0]
|
@@ -23,14 +23,14 @@ 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(extractedURL);
|
27 |
-
postId = patternMatch
|
28 |
}
|
29 |
}
|
30 |
if (!postId) return { error: "fetch.short_link" };
|
31 |
|
32 |
// should always be /video/, even for photos
|
33 |
-
const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
|
34 |
headers: {
|
35 |
"user-agent": genericUserAgent,
|
36 |
cookie,
|
@@ -44,20 +44,39 @@ export default async function(obj) {
|
|
44 |
try {
|
45 |
const json = html
|
46 |
.split('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">')[1]
|
47 |
-
.split('</script>')[0]
|
48 |
-
|
49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
} catch {
|
51 |
return { error: "fetch.fail" };
|
52 |
}
|
53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
let video, videoFilename, audioFilename, audio, images,
|
55 |
-
filenameBase = `tiktok_${detail.author
|
56 |
bestAudio; // will get defaulted to m4a later on in match-action
|
57 |
|
58 |
images = detail.imagePost?.images;
|
59 |
|
60 |
-
let playAddr = detail.video
|
|
|
61 |
if (obj.h265) {
|
62 |
const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes("h265"))?.PlayAddr.UrlList[0]
|
63 |
playAddr = h265PlayAddr || playAddr
|
@@ -102,7 +121,7 @@ export default async function(obj) {
|
|
102 |
if (obj.alwaysProxy) url = createStream({
|
103 |
service: "tiktok",
|
104 |
type: "proxy",
|
105 |
-
|
106 |
filename: `${filenameBase}_photo_${i + 1}.jpg`
|
107 |
})
|
108 |
|
|
|
1 |
import Cookie from "../cookie/cookie.js";
|
2 |
|
3 |
+
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";
|
|
|
12 |
let postId = obj.postId;
|
13 |
|
14 |
if (!postId) {
|
15 |
+
let html = await fetch(`${shortDomain}${obj.shortLink}`, {
|
16 |
redirect: "manual",
|
17 |
headers: {
|
18 |
"user-agent": genericUserAgent.split(' Chrome/1')[0]
|
|
|
23 |
|
24 |
if (html.startsWith('<a href="https://')) {
|
25 |
const extractedURL = html.split('<a href="')[1].split('?')[0];
|
26 |
+
const { patternMatch } = extract(normalizeURL(extractedURL));
|
27 |
+
postId = patternMatch?.postId;
|
28 |
}
|
29 |
}
|
30 |
if (!postId) return { error: "fetch.short_link" };
|
31 |
|
32 |
// should always be /video/, even for photos
|
33 |
+
const res = await fetch(`https://www.tiktok.com/@i/video/${postId}`, {
|
34 |
headers: {
|
35 |
"user-agent": genericUserAgent,
|
36 |
cookie,
|
|
|
44 |
try {
|
45 |
const json = html
|
46 |
.split('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">')[1]
|
47 |
+
.split('</script>')[0];
|
48 |
+
|
49 |
+
const data = JSON.parse(json);
|
50 |
+
const videoDetail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"];
|
51 |
+
|
52 |
+
if (!videoDetail) throw "no video detail found";
|
53 |
+
|
54 |
+
// status_deleted or etc
|
55 |
+
if (videoDetail.statusMsg) {
|
56 |
+
return { error: "content.post.unavailable"};
|
57 |
+
}
|
58 |
+
|
59 |
+
detail = videoDetail?.itemInfo?.itemStruct;
|
60 |
} catch {
|
61 |
return { error: "fetch.fail" };
|
62 |
}
|
63 |
|
64 |
+
if (detail.isContentClassified) {
|
65 |
+
return { error: "content.post.age" };
|
66 |
+
}
|
67 |
+
|
68 |
+
if (!detail.author) {
|
69 |
+
return { error: "fetch.empty" };
|
70 |
+
}
|
71 |
+
|
72 |
let video, videoFilename, audioFilename, audio, images,
|
73 |
+
filenameBase = `tiktok_${detail.author?.uniqueId}_${postId}`,
|
74 |
bestAudio; // will get defaulted to m4a later on in match-action
|
75 |
|
76 |
images = detail.imagePost?.images;
|
77 |
|
78 |
+
let playAddr = detail.video?.playAddr;
|
79 |
+
|
80 |
if (obj.h265) {
|
81 |
const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes("h265"))?.PlayAddr.UrlList[0]
|
82 |
playAddr = h265PlayAddr || playAddr
|
|
|
121 |
if (obj.alwaysProxy) url = createStream({
|
122 |
service: "tiktok",
|
123 |
type: "proxy",
|
124 |
+
url,
|
125 |
filename: `${filenameBase}_photo_${i + 1}.jpg`
|
126 |
})
|
127 |
|
api/src/processing/services/tumblr.js
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import psl from "psl";
|
2 |
|
3 |
const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';
|
4 |
const API_BASE = 'https://api-http2.tumblr.com';
|
|
|
1 |
+
import psl from "@imput/psl";
|
2 |
|
3 |
const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';
|
4 |
const API_BASE = 'https://api-http2.tumblr.com';
|
api/src/processing/services/twitch.js
CHANGED
@@ -1,5 +1,4 @@
|
|
1 |
import { env } from "../../config.js";
|
2 |
-
import { cleanString } from '../../misc/utils.js';
|
3 |
|
4 |
const gqlURL = "https://gql.twitch.tv/gql";
|
5 |
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
|
@@ -73,13 +72,13 @@ export default async function (obj) {
|
|
73 |
token: req_token[0].data.clip.playbackAccessToken.value
|
74 |
})}`,
|
75 |
fileMetadata: {
|
76 |
-
title:
|
77 |
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
|
78 |
},
|
79 |
filenameAttributes: {
|
80 |
service: "twitch",
|
81 |
id: clipMetadata.id,
|
82 |
-
title:
|
83 |
author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`,
|
84 |
qualityLabel: `${format.quality}p`,
|
85 |
extension: 'mp4'
|
|
|
1 |
import { env } from "../../config.js";
|
|
|
2 |
|
3 |
const gqlURL = "https://gql.twitch.tv/gql";
|
4 |
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
|
|
|
72 |
token: req_token[0].data.clip.playbackAccessToken.value
|
73 |
})}`,
|
74 |
fileMetadata: {
|
75 |
+
title: clipMetadata.title.trim(),
|
76 |
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
|
77 |
},
|
78 |
filenameAttributes: {
|
79 |
service: "twitch",
|
80 |
id: clipMetadata.id,
|
81 |
+
title: clipMetadata.title.trim(),
|
82 |
author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`,
|
83 |
qualityLabel: `${format.quality}p`,
|
84 |
extension: 'mp4'
|
api/src/processing/services/twitter.js
CHANGED
@@ -24,6 +24,11 @@ const badContainerEnd = new Date(1702605600000);
|
|
24 |
|
25 |
function needsFixing(media) {
|
26 |
const representativeId = media.source_status_id_str ?? media.id_str;
|
|
|
|
|
|
|
|
|
|
|
27 |
const mediaTimestamp = new Date(
|
28 |
Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
|
29 |
);
|
@@ -53,6 +58,25 @@ const getGuestToken = async (dispatcher, forceReload = false) => {
|
|
53 |
}
|
54 |
}
|
55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
57 |
const graphqlTweetURL = new URL(graphqlURL);
|
58 |
|
@@ -87,36 +111,24 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
|
87 |
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
|
88 |
updateCookie(cookie, result.headers);
|
89 |
|
90 |
-
// we might have been missing the
|
91 |
if (result.status === 403 && result.headers.get('set-cookie')) {
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
|
|
|
|
|
|
99 |
}
|
100 |
|
101 |
return result
|
102 |
}
|
103 |
|
104 |
-
|
105 |
-
const cookie = await getCookie('twitter');
|
106 |
-
|
107 |
-
let guestToken = await getGuestToken(dispatcher);
|
108 |
-
if (!guestToken) return { error: "fetch.fail" };
|
109 |
-
|
110 |
-
let tweet = await requestTweet(dispatcher, id, guestToken);
|
111 |
-
|
112 |
-
// get new token & retry if old one expired
|
113 |
-
if ([403, 429].includes(tweet.status)) {
|
114 |
-
guestToken = await getGuestToken(dispatcher, true);
|
115 |
-
tweet = await requestTweet(dispatcher, id, guestToken)
|
116 |
-
}
|
117 |
-
|
118 |
-
tweet = await tweet.json();
|
119 |
-
|
120 |
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
121 |
|
122 |
if (!tweetTypename) {
|
@@ -127,13 +139,13 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|
127 |
const reason = tweet?.data?.tweetResult?.result?.reason;
|
128 |
switch(reason) {
|
129 |
case "Protected":
|
130 |
-
return { error: "content.post.private" }
|
131 |
case "NsfwLoggedOut":
|
132 |
if (cookie) {
|
133 |
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
134 |
tweet = await tweet.json();
|
135 |
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
136 |
-
} else return { error: "content.post.age" }
|
137 |
}
|
138 |
}
|
139 |
|
@@ -150,7 +162,69 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|
150 |
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
|
151 |
}
|
152 |
|
153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
|
155 |
// check if there's a video at given index (/video/<index>)
|
156 |
if (index >= 0 && index < media?.length) {
|
@@ -159,11 +233,11 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|
159 |
|
160 |
const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
|
161 |
|
162 |
-
const proxyMedia = (
|
163 |
service: "twitter",
|
164 |
type: "proxy",
|
165 |
-
|
166 |
-
})
|
167 |
|
168 |
switch (media?.length) {
|
169 |
case undefined:
|
@@ -208,7 +282,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|
208 |
|
209 |
let url = bestQuality(content.video_info.variants);
|
210 |
const shouldRenderGif = content.type === "animated_gif" && toGif;
|
211 |
-
const videoFilename = `twitter_${id}_${i + 1}
|
212 |
|
213 |
let type = "video";
|
214 |
if (shouldRenderGif) type = "gif";
|
@@ -217,7 +291,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|
217 |
url = createStream({
|
218 |
service: "twitter",
|
219 |
type: shouldRenderGif ? "gif" : "remux",
|
220 |
-
|
221 |
filename: videoFilename,
|
222 |
})
|
223 |
} else if (alwaysProxy) {
|
|
|
24 |
|
25 |
function needsFixing(media) {
|
26 |
const representativeId = media.source_status_id_str ?? media.id_str;
|
27 |
+
|
28 |
+
// syndication api doesn't have media ids in its response,
|
29 |
+
// so we just assume it's all good
|
30 |
+
if (!representativeId) return false;
|
31 |
+
|
32 |
const mediaTimestamp = new Date(
|
33 |
Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
|
34 |
);
|
|
|
58 |
}
|
59 |
}
|
60 |
|
61 |
+
const requestSyndication = async(dispatcher, tweetId) => {
|
62 |
+
// thank you
|
63 |
+
// https://github.com/yt-dlp/yt-dlp/blob/05c8023a27dd37c49163c0498bf98e3e3c1cb4b9/yt_dlp/extractor/twitter.py#L1334
|
64 |
+
const token = (id) => ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, '');
|
65 |
+
const syndicationUrl = new URL("https://cdn.syndication.twimg.com/tweet-result");
|
66 |
+
|
67 |
+
syndicationUrl.searchParams.set("id", tweetId);
|
68 |
+
syndicationUrl.searchParams.set("token", token(tweetId));
|
69 |
+
|
70 |
+
const result = await fetch(syndicationUrl, {
|
71 |
+
headers: {
|
72 |
+
"user-agent": genericUserAgent
|
73 |
+
},
|
74 |
+
dispatcher
|
75 |
+
});
|
76 |
+
|
77 |
+
return result;
|
78 |
+
}
|
79 |
+
|
80 |
const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
81 |
const graphqlTweetURL = new URL(graphqlURL);
|
82 |
|
|
|
111 |
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
|
112 |
updateCookie(cookie, result.headers);
|
113 |
|
114 |
+
// we might have been missing the ct0 cookie, retry
|
115 |
if (result.status === 403 && result.headers.get('set-cookie')) {
|
116 |
+
const cookieValues = cookie?.values();
|
117 |
+
if (cookieValues?.ct0) {
|
118 |
+
result = await fetch(graphqlTweetURL, {
|
119 |
+
headers: {
|
120 |
+
...headers,
|
121 |
+
'x-csrf-token': cookieValues.ct0
|
122 |
+
},
|
123 |
+
dispatcher
|
124 |
+
});
|
125 |
+
}
|
126 |
}
|
127 |
|
128 |
return result
|
129 |
}
|
130 |
|
131 |
+
const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
132 |
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
133 |
|
134 |
if (!tweetTypename) {
|
|
|
139 |
const reason = tweet?.data?.tweetResult?.result?.reason;
|
140 |
switch(reason) {
|
141 |
case "Protected":
|
142 |
+
return { error: "content.post.private" };
|
143 |
case "NsfwLoggedOut":
|
144 |
if (cookie) {
|
145 |
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
146 |
tweet = await tweet.json();
|
147 |
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
148 |
+
} else return { error: "content.post.age" };
|
149 |
}
|
150 |
}
|
151 |
|
|
|
162 |
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
|
163 |
}
|
164 |
|
165 |
+
return (repostedTweet?.media || baseTweet?.extended_entities?.media);
|
166 |
+
}
|
167 |
+
|
168 |
+
const testResponse = (result) => {
|
169 |
+
const contentLength = result.headers.get("content-length");
|
170 |
+
|
171 |
+
if (!contentLength || contentLength === '0') {
|
172 |
+
return false;
|
173 |
+
}
|
174 |
+
|
175 |
+
if (!result.headers.get("content-type").startsWith("application/json")) {
|
176 |
+
return false;
|
177 |
+
}
|
178 |
+
|
179 |
+
return true;
|
180 |
+
}
|
181 |
+
|
182 |
+
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
183 |
+
const cookie = await getCookie('twitter');
|
184 |
+
|
185 |
+
let syndication = false;
|
186 |
+
|
187 |
+
let guestToken = await getGuestToken(dispatcher);
|
188 |
+
if (!guestToken) return { error: "fetch.fail" };
|
189 |
+
|
190 |
+
// for now we assume that graphql api will come back after some time,
|
191 |
+
// so we try it first
|
192 |
+
|
193 |
+
let tweet = await requestTweet(dispatcher, id, guestToken);
|
194 |
+
|
195 |
+
// get new token & retry if old one expired
|
196 |
+
if ([403, 429].includes(tweet.status)) {
|
197 |
+
guestToken = await getGuestToken(dispatcher, true);
|
198 |
+
if (cookie) {
|
199 |
+
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
200 |
+
} else {
|
201 |
+
tweet = await requestTweet(dispatcher, id, guestToken);
|
202 |
+
}
|
203 |
+
}
|
204 |
+
|
205 |
+
const testGraphql = testResponse(tweet);
|
206 |
+
|
207 |
+
// if graphql requests fail, then resort to tweet embed api
|
208 |
+
if (!testGraphql) {
|
209 |
+
syndication = true;
|
210 |
+
tweet = await requestSyndication(dispatcher, id);
|
211 |
+
|
212 |
+
const testSyndication = testResponse(tweet);
|
213 |
+
|
214 |
+
// if even syndication request failed, then cry out loud
|
215 |
+
if (!testSyndication) {
|
216 |
+
return { error: "fetch.fail" };
|
217 |
+
}
|
218 |
+
}
|
219 |
+
|
220 |
+
tweet = await tweet.json();
|
221 |
+
|
222 |
+
let media =
|
223 |
+
syndication
|
224 |
+
? tweet.mediaDetails
|
225 |
+
: await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
|
226 |
+
|
227 |
+
if (!media) return { error: "fetch.empty" };
|
228 |
|
229 |
// check if there's a video at given index (/video/<index>)
|
230 |
if (index >= 0 && index < media?.length) {
|
|
|
233 |
|
234 |
const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
|
235 |
|
236 |
+
const proxyMedia = (url, filename) => createStream({
|
237 |
service: "twitter",
|
238 |
type: "proxy",
|
239 |
+
url, filename,
|
240 |
+
});
|
241 |
|
242 |
switch (media?.length) {
|
243 |
case undefined:
|
|
|
282 |
|
283 |
let url = bestQuality(content.video_info.variants);
|
284 |
const shouldRenderGif = content.type === "animated_gif" && toGif;
|
285 |
+
const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`;
|
286 |
|
287 |
let type = "video";
|
288 |
if (shouldRenderGif) type = "gif";
|
|
|
291 |
url = createStream({
|
292 |
service: "twitter",
|
293 |
type: shouldRenderGif ? "gif" : "remux",
|
294 |
+
url,
|
295 |
filename: videoFilename,
|
296 |
})
|
297 |
} else if (alwaysProxy) {
|
api/src/processing/services/vimeo.js
CHANGED
@@ -1,7 +1,6 @@
|
|
1 |
import HLS from "hls-parser";
|
2 |
-
|
3 |
import { env } from "../../config.js";
|
4 |
-
import {
|
5 |
|
6 |
const resolutionMatch = {
|
7 |
"3840": 2160,
|
@@ -122,7 +121,7 @@ const getHLS = async (configURL, obj) => {
|
|
122 |
|
123 |
return {
|
124 |
urls,
|
125 |
-
|
126 |
filenameAttributes: {
|
127 |
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
128 |
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
|
@@ -152,8 +151,8 @@ export default async function(obj) {
|
|
152 |
}
|
153 |
|
154 |
const fileMetadata = {
|
155 |
-
title:
|
156 |
-
artist:
|
157 |
};
|
158 |
|
159 |
return merge(
|
|
|
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,
|
|
|
121 |
|
122 |
return {
|
123 |
urls,
|
124 |
+
isHLS: true,
|
125 |
filenameAttributes: {
|
126 |
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
127 |
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
|
|
|
151 |
}
|
152 |
|
153 |
const fileMetadata = {
|
154 |
+
title: info.name,
|
155 |
+
artist: info.user.name,
|
156 |
};
|
157 |
|
158 |
return merge(
|
api/src/processing/services/vk.js
CHANGED
@@ -1,63 +1,140 @@
|
|
1 |
-
import {
|
2 |
-
import { genericUserAgent, env } from "../../config.js";
|
3 |
|
4 |
-
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
|
5 |
|
6 |
-
|
7 |
-
|
8 |
|
9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
headers: {
|
11 |
-
"user-agent":
|
12 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
})
|
14 |
-
.then(r =>
|
15 |
-
|
|
|
|
|
|
|
16 |
|
17 |
-
|
|
|
|
|
|
|
|
|
|
|
18 |
|
19 |
-
|
20 |
-
let decoder = new TextDecoder('windows-1251');
|
21 |
-
html = decoder.decode(html);
|
22 |
|
23 |
-
if (!
|
|
|
|
|
24 |
|
25 |
-
|
26 |
|
27 |
-
if (
|
28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
}
|
30 |
|
31 |
-
if (
|
32 |
return { error: "content.too_long" };
|
33 |
}
|
34 |
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
38 |
break
|
39 |
}
|
40 |
}
|
41 |
-
if (Number(quality) > Number(o.quality)) quality = o.quality;
|
42 |
|
43 |
-
url =
|
|
|
|
|
44 |
|
45 |
-
|
46 |
-
title:
|
47 |
-
author: cleanString(js.player.params[0].md_author.trim()),
|
48 |
}
|
49 |
|
50 |
-
|
51 |
urls: url,
|
|
|
52 |
filenameAttributes: {
|
53 |
service: "vk",
|
54 |
-
id: `${
|
55 |
title: fileMetadata.title,
|
56 |
-
|
57 |
-
|
58 |
-
qualityLabel: `${quality}p`,
|
59 |
extension: "mp4"
|
60 |
}
|
61 |
}
|
62 |
-
return { error: "fetch.empty" }
|
63 |
}
|
|
|
1 |
+
import { env } from "../../config.js";
|
|
|
2 |
|
3 |
+
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
|
4 |
|
5 |
+
const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token";
|
6 |
+
const apiUrl = "https://api.vk.com/method";
|
7 |
|
8 |
+
const clientId = "51552953";
|
9 |
+
const clientSecret = "qgr0yWwXCrsxA1jnRtRX";
|
10 |
+
|
11 |
+
// used in stream/shared.js for accessing media files
|
12 |
+
export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119";
|
13 |
+
|
14 |
+
const cachedToken = {
|
15 |
+
token: "",
|
16 |
+
expiry: 0,
|
17 |
+
device_id: "",
|
18 |
+
};
|
19 |
+
|
20 |
+
const getToken = async () => {
|
21 |
+
if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) {
|
22 |
+
return cachedToken.token;
|
23 |
+
}
|
24 |
+
|
25 |
+
const randomDeviceId = crypto.randomUUID().toUpperCase();
|
26 |
+
|
27 |
+
const anonymOauth = new URL(oauthUrl);
|
28 |
+
anonymOauth.searchParams.set("client_id", clientId);
|
29 |
+
anonymOauth.searchParams.set("client_secret", clientSecret);
|
30 |
+
anonymOauth.searchParams.set("device_id", randomDeviceId);
|
31 |
+
|
32 |
+
const oauthResponse = await fetch(anonymOauth.toString(), {
|
33 |
headers: {
|
34 |
+
"user-agent": vkClientAgent,
|
35 |
}
|
36 |
+
}).then(r => {
|
37 |
+
if (r.status === 200) {
|
38 |
+
return r.json();
|
39 |
+
}
|
40 |
+
});
|
41 |
+
|
42 |
+
if (!oauthResponse) return;
|
43 |
+
|
44 |
+
if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") {
|
45 |
+
cachedToken.token = oauthResponse.token;
|
46 |
+
cachedToken.expiry = oauthResponse.expired_at;
|
47 |
+
cachedToken.device_id = randomDeviceId;
|
48 |
+
}
|
49 |
+
|
50 |
+
if (!cachedToken.token) return;
|
51 |
+
|
52 |
+
return cachedToken.token;
|
53 |
+
}
|
54 |
+
|
55 |
+
const getVideo = async (ownerId, videoId, accessKey) => {
|
56 |
+
const video = await fetch(`${apiUrl}/video.get`, {
|
57 |
+
method: "POST",
|
58 |
+
headers: {
|
59 |
+
"content-type": "application/x-www-form-urlencoded; charset=utf-8",
|
60 |
+
"user-agent": vkClientAgent,
|
61 |
+
},
|
62 |
+
body: new URLSearchParams({
|
63 |
+
anonymous_token: cachedToken.token,
|
64 |
+
device_id: cachedToken.device_id,
|
65 |
+
lang: "en",
|
66 |
+
v: "5.244",
|
67 |
+
videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`
|
68 |
+
}).toString()
|
69 |
})
|
70 |
+
.then(r => {
|
71 |
+
if (r.status === 200) {
|
72 |
+
return r.json();
|
73 |
+
}
|
74 |
+
});
|
75 |
|
76 |
+
return video;
|
77 |
+
}
|
78 |
+
|
79 |
+
export default async function ({ ownerId, videoId, accessKey, quality }) {
|
80 |
+
const token = await getToken();
|
81 |
+
if (!token) return { error: "fetch.fail" };
|
82 |
|
83 |
+
const videoGet = await getVideo(ownerId, videoId, accessKey);
|
|
|
|
|
84 |
|
85 |
+
if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) {
|
86 |
+
return { error: "fetch.empty" };
|
87 |
+
}
|
88 |
|
89 |
+
const video = videoGet.response.items[0];
|
90 |
|
91 |
+
if (video.restriction) {
|
92 |
+
const title = video.restriction.title;
|
93 |
+
if (title.endsWith("country") || title.endsWith("region.")) {
|
94 |
+
return { error: "content.video.region" };
|
95 |
+
}
|
96 |
+
if (title === "Processing video") {
|
97 |
+
return { error: "fetch.empty" };
|
98 |
+
}
|
99 |
+
return { error: "content.video.unavailable" };
|
100 |
+
}
|
101 |
+
|
102 |
+
if (!video.files || !video.duration) {
|
103 |
+
return { error: "fetch.fail" };
|
104 |
}
|
105 |
|
106 |
+
if (video.duration > env.durationLimit) {
|
107 |
return { error: "content.too_long" };
|
108 |
}
|
109 |
|
110 |
+
const userQuality = quality === "max" ? resolutions[0] : quality;
|
111 |
+
let pickedQuality;
|
112 |
+
|
113 |
+
for (const resolution of resolutions) {
|
114 |
+
if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) {
|
115 |
+
pickedQuality = resolution;
|
116 |
break
|
117 |
}
|
118 |
}
|
|
|
119 |
|
120 |
+
const url = video.files[`mp4_${pickedQuality}`];
|
121 |
+
|
122 |
+
if (!url) return { error: "fetch.fail" };
|
123 |
|
124 |
+
const fileMetadata = {
|
125 |
+
title: video.title.trim(),
|
|
|
126 |
}
|
127 |
|
128 |
+
return {
|
129 |
urls: url,
|
130 |
+
fileMetadata,
|
131 |
filenameAttributes: {
|
132 |
service: "vk",
|
133 |
+
id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`,
|
134 |
title: fileMetadata.title,
|
135 |
+
resolution: `${pickedQuality}p`,
|
136 |
+
qualityLabel: `${pickedQuality}p`,
|
|
|
137 |
extension: "mp4"
|
138 |
}
|
139 |
}
|
|
|
140 |
}
|
api/src/processing/services/xiaohongshu.js
ADDED
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { resolveRedirectingURL } from "../url.js";
|
2 |
+
import { genericUserAgent } from "../../config.js";
|
3 |
+
import { createStream } from "../../stream/manage.js";
|
4 |
+
|
5 |
+
const https = (url) => {
|
6 |
+
return url.replace(/^http:/i, 'https:');
|
7 |
+
}
|
8 |
+
|
9 |
+
export default async function ({ id, token, shareId, h265, isAudioOnly, dispatcher }) {
|
10 |
+
let noteId = id;
|
11 |
+
let xsecToken = token;
|
12 |
+
|
13 |
+
if (!noteId) {
|
14 |
+
const patternMatch = await resolveRedirectingURL(
|
15 |
+
`https://xhslink.com/a/${shareId}`,
|
16 |
+
dispatcher
|
17 |
+
);
|
18 |
+
|
19 |
+
noteId = patternMatch?.id;
|
20 |
+
xsecToken = patternMatch?.token;
|
21 |
+
}
|
22 |
+
|
23 |
+
if (!noteId || !xsecToken) return { error: "fetch.short_link" };
|
24 |
+
|
25 |
+
const res = await fetch(`https://www.xiaohongshu.com/explore/${noteId}?xsec_token=${xsecToken}`, {
|
26 |
+
headers: {
|
27 |
+
"user-agent": genericUserAgent,
|
28 |
+
},
|
29 |
+
dispatcher,
|
30 |
+
});
|
31 |
+
|
32 |
+
const html = await res.text();
|
33 |
+
|
34 |
+
let note;
|
35 |
+
try {
|
36 |
+
const initialState = html
|
37 |
+
.split('<script>window.__INITIAL_STATE__=')[1]
|
38 |
+
.split('</script>')[0]
|
39 |
+
.replace(/:\s*undefined/g, ":null");
|
40 |
+
|
41 |
+
const data = JSON.parse(initialState);
|
42 |
+
|
43 |
+
const noteInfo = data?.note?.noteDetailMap;
|
44 |
+
if (!noteInfo) throw "no note detail map";
|
45 |
+
|
46 |
+
const currentNote = noteInfo[noteId];
|
47 |
+
if (!currentNote) throw "no current note in detail map";
|
48 |
+
|
49 |
+
note = currentNote.note;
|
50 |
+
} catch {}
|
51 |
+
|
52 |
+
if (!note) return { error: "fetch.empty" };
|
53 |
+
|
54 |
+
const video = note.video;
|
55 |
+
const images = note.imageList;
|
56 |
+
|
57 |
+
const filenameBase = `xiaohongshu_${noteId}`;
|
58 |
+
|
59 |
+
if (video) {
|
60 |
+
const videoFilename = `${filenameBase}.mp4`;
|
61 |
+
const audioFilename = `${filenameBase}_audio`;
|
62 |
+
|
63 |
+
let videoURL;
|
64 |
+
|
65 |
+
if (h265 && !isAudioOnly && video.consumer?.originVideoKey) {
|
66 |
+
videoURL = `https://sns-video-bd.xhscdn.com/${video.consumer.originVideoKey}`;
|
67 |
+
} else {
|
68 |
+
const h264Streams = video.media?.stream?.h264;
|
69 |
+
|
70 |
+
if (h264Streams?.length) {
|
71 |
+
videoURL = h264Streams.reduce((a, b) => Number(a?.videoBitrate) > Number(b?.videoBitrate) ? a : b).masterUrl;
|
72 |
+
}
|
73 |
+
}
|
74 |
+
|
75 |
+
if (!videoURL) return { error: "fetch.empty" };
|
76 |
+
|
77 |
+
return {
|
78 |
+
urls: https(videoURL),
|
79 |
+
filename: videoFilename,
|
80 |
+
audioFilename: audioFilename,
|
81 |
+
}
|
82 |
+
}
|
83 |
+
|
84 |
+
if (!images || images.length === 0) {
|
85 |
+
return { error: "fetch.empty" };
|
86 |
+
}
|
87 |
+
|
88 |
+
if (images.length === 1) {
|
89 |
+
return {
|
90 |
+
isPhoto: true,
|
91 |
+
urls: https(images[0].urlDefault),
|
92 |
+
filename: `${filenameBase}.jpg`,
|
93 |
+
}
|
94 |
+
}
|
95 |
+
|
96 |
+
const picker = images.map((image, i) => {
|
97 |
+
return {
|
98 |
+
type: "photo",
|
99 |
+
url: createStream({
|
100 |
+
service: "xiaohongshu",
|
101 |
+
type: "proxy",
|
102 |
+
url: https(image.urlDefault),
|
103 |
+
filename: `${filenameBase}_${i + 1}.jpg`,
|
104 |
+
})
|
105 |
+
}
|
106 |
+
});
|
107 |
+
|
108 |
+
return { picker };
|
109 |
+
}
|
api/src/processing/services/youtube.js
CHANGED
@@ -1,16 +1,17 @@
|
|
1 |
-
import
|
2 |
|
|
|
3 |
import { Innertube, Session } from "youtubei.js";
|
4 |
|
5 |
import { env } from "../../config.js";
|
6 |
-
import {
|
7 |
-
import {
|
8 |
|
9 |
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
|
10 |
|
11 |
let innertube, lastRefreshedAt;
|
12 |
|
13 |
-
const
|
14 |
h264: {
|
15 |
videoCodec: "avc1",
|
16 |
audioCodec: "mp4a",
|
@@ -28,32 +29,43 @@ const codecMatch = {
|
|
28 |
}
|
29 |
}
|
30 |
|
31 |
-
const
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
|
|
40 |
}
|
|
|
41 |
|
42 |
-
|
43 |
-
values.expiry_date = values.expires;
|
44 |
-
delete values.expires;
|
45 |
-
} else if (!values.expiry_date) {
|
46 |
-
return;
|
47 |
-
}
|
48 |
|
49 |
-
|
50 |
-
}
|
51 |
|
52 |
-
const cloneInnertube = async (customFetch) => {
|
53 |
const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
if (!innertube || shouldRefreshPlayer) {
|
55 |
innertube = await Innertube.create({
|
56 |
-
fetch: customFetch
|
|
|
|
|
|
|
|
|
57 |
});
|
58 |
lastRefreshedAt = +new Date();
|
59 |
}
|
@@ -64,81 +76,88 @@ const cloneInnertube = async (customFetch) => {
|
|
64 |
innertube.session.api_version,
|
65 |
innertube.session.account_index,
|
66 |
innertube.session.player,
|
67 |
-
|
68 |
customFetch ?? innertube.session.http.fetch,
|
69 |
innertube.session.cache
|
70 |
);
|
71 |
|
72 |
-
const
|
73 |
-
|
|
|
74 |
|
75 |
-
|
76 |
-
|
77 |
-
session.logged_in = true;
|
78 |
-
}
|
79 |
|
80 |
-
|
81 |
-
|
82 |
-
await session.oauth.refreshAccessToken();
|
83 |
-
}
|
84 |
|
85 |
-
|
86 |
-
|
87 |
-
|
|
|
88 |
|
89 |
-
|
90 |
-
|
91 |
-
...session.oauth.client_id,
|
92 |
-
...session.oauth.oauth2_tokens,
|
93 |
-
expiry_date: newExpiry.toISOString()
|
94 |
-
});
|
95 |
-
}
|
96 |
}
|
97 |
|
98 |
-
|
99 |
-
|
100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
101 |
|
102 |
-
export default async function(o) {
|
103 |
let yt;
|
104 |
try {
|
105 |
yt = await cloneInnertube(
|
106 |
(input, init) => fetch(input, {
|
107 |
...init,
|
108 |
dispatcher: o.dispatcher
|
109 |
-
})
|
|
|
110 |
);
|
111 |
-
} catch(e) {
|
112 |
-
if (e
|
|
|
|
|
113 |
return { error: "youtube.decipher" }
|
114 |
} else if (e.message?.includes("refresh access token")) {
|
115 |
return { error: "youtube.token_expired" }
|
116 |
} else throw e;
|
117 |
}
|
118 |
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
127 |
}
|
128 |
|
129 |
-
|
130 |
-
}
|
131 |
-
|
132 |
-
try {
|
133 |
-
info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
|
134 |
-
} catch(e) {
|
135 |
-
if (e?.info?.reason === "This video is private") {
|
136 |
-
return { error: "content.video.private" };
|
137 |
-
} else if (e?.message === "This video is unavailable") {
|
138 |
return { error: "content.video.unavailable" };
|
139 |
-
} else {
|
140 |
-
return { error: "fetch.fail" };
|
141 |
}
|
|
|
|
|
142 |
}
|
143 |
|
144 |
if (!info) return { error: "fetch.fail" };
|
@@ -146,37 +165,47 @@ export default async function(o) {
|
|
146 |
const playability = info.playability_status;
|
147 |
const basicInfo = info.basic_info;
|
148 |
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
|
|
160 |
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
|
|
|
|
|
|
|
|
171 |
}
|
172 |
|
173 |
if (playability.status !== "OK") {
|
174 |
return { error: "content.video.unavailable" };
|
175 |
}
|
|
|
176 |
if (basicInfo.is_live) {
|
177 |
return { error: "content.video.live" };
|
178 |
}
|
179 |
|
|
|
|
|
|
|
|
|
180 |
// return a critical error if returned video is "Video Not Available"
|
181 |
// or a similar stub by youtube
|
182 |
if (basicInfo.id !== o.id) {
|
@@ -186,64 +215,206 @@ export default async function(o) {
|
|
186 |
}
|
187 |
}
|
188 |
|
189 |
-
const
|
190 |
-
|
191 |
-
.
|
192 |
-
|
193 |
-
|| e.mime_type.includes(codecMatch[format].audioCodec)
|
194 |
-
)
|
195 |
-
.sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
|
196 |
|
197 |
-
let
|
|
|
198 |
|
199 |
-
if (
|
200 |
-
|
201 |
-
adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
|
202 |
-
}
|
203 |
|
204 |
-
|
|
|
|
|
205 |
|
206 |
-
|
207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
208 |
|
209 |
-
|
|
|
|
|
210 |
|
211 |
-
|
212 |
-
|
|
|
213 |
|
214 |
-
|
215 |
-
|
|
|
216 |
|
217 |
-
|
|
|
|
|
218 |
|
219 |
-
|
220 |
-
|
221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
222 |
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
229 |
|
230 |
-
|
231 |
-
|
232 |
-
|
|
|
|
|
|
|
|
|
|
|
233 |
}
|
234 |
}
|
235 |
|
236 |
-
if (
|
237 |
-
|
238 |
}
|
239 |
|
240 |
-
|
241 |
-
title:
|
242 |
-
artist:
|
243 |
}
|
244 |
|
245 |
if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
|
246 |
-
|
|
|
247 |
if (descItems.length === 5) {
|
248 |
fileMetadata.album = descItems[2];
|
249 |
fileMetadata.copyright = descItems[3];
|
@@ -253,61 +424,94 @@ export default async function(o) {
|
|
253 |
}
|
254 |
}
|
255 |
|
256 |
-
|
257 |
service: "youtube",
|
258 |
id: o.id,
|
259 |
title: fileMetadata.title,
|
260 |
author: fileMetadata.artist,
|
261 |
-
youtubeDubName:
|
262 |
}
|
263 |
|
264 |
-
|
265 |
-
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
271 |
}
|
272 |
|
273 |
-
|
274 |
-
|
275 |
-
qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].videoCodec),
|
276 |
-
checkRender = i =>
|
277 |
-
qual(i) === matchingQuality && i.has_video && !i.has_audio;
|
278 |
|
279 |
-
|
|
|
|
|
|
|
280 |
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
|
|
287 |
|
288 |
-
|
|
|
289 |
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
|
|
|
|
|
|
298 |
|
299 |
-
if (match) {
|
300 |
-
filenameAttributes.qualityLabel = match.quality_label;
|
301 |
-
filenameAttributes.resolution = `${match.width}x${match.height}`;
|
302 |
-
filenameAttributes.extension = codecMatch[format].container;
|
303 |
-
filenameAttributes.youtubeFormat = format;
|
304 |
return {
|
305 |
-
type,
|
306 |
-
urls
|
|
|
|
|
|
|
307 |
filenameAttributes,
|
308 |
-
fileMetadata
|
|
|
|
|
309 |
}
|
310 |
}
|
311 |
|
312 |
-
return { error: "
|
313 |
}
|
|
|
1 |
+
import HLS from "hls-parser";
|
2 |
|
3 |
+
import { fetch } from "undici";
|
4 |
import { Innertube, Session } from "youtubei.js";
|
5 |
|
6 |
import { env } from "../../config.js";
|
7 |
+
import { getCookie } from "../cookie/manager.js";
|
8 |
+
import { getYouTubeSession } from "../helpers/youtube-session.js";
|
9 |
|
10 |
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
|
11 |
|
12 |
let innertube, lastRefreshedAt;
|
13 |
|
14 |
+
const codecList = {
|
15 |
h264: {
|
16 |
videoCodec: "avc1",
|
17 |
audioCodec: "mp4a",
|
|
|
29 |
}
|
30 |
}
|
31 |
|
32 |
+
const hlsCodecList = {
|
33 |
+
h264: {
|
34 |
+
videoCodec: "avc1",
|
35 |
+
audioCodec: "mp4a",
|
36 |
+
container: "mp4"
|
37 |
+
},
|
38 |
+
vp9: {
|
39 |
+
videoCodec: "vp09",
|
40 |
+
audioCodec: "mp4a",
|
41 |
+
container: "webm"
|
42 |
}
|
43 |
+
}
|
44 |
|
45 |
+
const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID'];
|
|
|
|
|
|
|
|
|
|
|
46 |
|
47 |
+
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
|
|
|
48 |
|
49 |
+
const cloneInnertube = async (customFetch, useSession) => {
|
50 |
const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
|
51 |
+
|
52 |
+
const rawCookie = getCookie('youtube');
|
53 |
+
const cookie = rawCookie?.toString();
|
54 |
+
|
55 |
+
const sessionTokens = getYouTubeSession();
|
56 |
+
const retrieve_player = Boolean(sessionTokens || cookie);
|
57 |
+
|
58 |
+
if (useSession && env.ytSessionServer && !sessionTokens?.potoken) {
|
59 |
+
throw "no_session_tokens";
|
60 |
+
}
|
61 |
+
|
62 |
if (!innertube || shouldRefreshPlayer) {
|
63 |
innertube = await Innertube.create({
|
64 |
+
fetch: customFetch,
|
65 |
+
retrieve_player,
|
66 |
+
cookie,
|
67 |
+
po_token: useSession ? sessionTokens?.potoken : undefined,
|
68 |
+
visitor_data: useSession ? sessionTokens?.visitor_data : undefined,
|
69 |
});
|
70 |
lastRefreshedAt = +new Date();
|
71 |
}
|
|
|
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 |
|
91 |
+
let useHLS = o.youtubeHLS;
|
92 |
+
let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS";
|
|
|
|
|
93 |
|
94 |
+
// HLS playlists from the iOS client don't contain the av1 video format.
|
95 |
+
if (useHLS && o.format === "av1") {
|
96 |
+
useHLS = false;
|
97 |
+
}
|
98 |
|
99 |
+
if (useHLS) {
|
100 |
+
innertubeClient = "IOS";
|
|
|
|
|
|
|
|
|
|
|
101 |
}
|
102 |
|
103 |
+
// iOS client doesn't have adaptive formats of resolution >1080p,
|
104 |
+
// so we use the WEB_EMBEDDED client instead for those cases
|
105 |
+
const useSession =
|
106 |
+
env.ytSessionServer && (
|
107 |
+
(
|
108 |
+
!useHLS
|
109 |
+
&& innertubeClient === "IOS"
|
110 |
+
&& (
|
111 |
+
(quality > 1080 && o.format !== "h264")
|
112 |
+
|| (quality > 1080 && o.format !== "vp9")
|
113 |
+
)
|
114 |
+
)
|
115 |
+
);
|
116 |
+
|
117 |
+
if (useSession) {
|
118 |
+
innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
|
119 |
+
}
|
120 |
|
|
|
121 |
let yt;
|
122 |
try {
|
123 |
yt = await cloneInnertube(
|
124 |
(input, init) => fetch(input, {
|
125 |
...init,
|
126 |
dispatcher: o.dispatcher
|
127 |
+
}),
|
128 |
+
useSession
|
129 |
);
|
130 |
+
} catch (e) {
|
131 |
+
if (e === "no_session_tokens") {
|
132 |
+
return { error: "youtube.no_session_tokens" };
|
133 |
+
} else if (e.message?.endsWith("decipher algorithm")) {
|
134 |
return { error: "youtube.decipher" }
|
135 |
} else if (e.message?.includes("refresh access token")) {
|
136 |
return { error: "youtube.token_expired" }
|
137 |
} else throw e;
|
138 |
}
|
139 |
|
140 |
+
let info;
|
141 |
+
try {
|
142 |
+
info = await yt.getBasicInfo(o.id, innertubeClient);
|
143 |
+
} catch (e) {
|
144 |
+
if (e?.info) {
|
145 |
+
let errorInfo;
|
146 |
+
try { errorInfo = JSON.parse(e?.info); } catch {}
|
147 |
+
|
148 |
+
if (errorInfo?.reason === "This video is private") {
|
149 |
+
return { error: "content.video.private" };
|
150 |
+
}
|
151 |
+
if (["INVALID_ARGUMENT", "UNAUTHENTICATED"].includes(errorInfo?.error?.status)) {
|
152 |
+
return { error: "youtube.api_error" };
|
153 |
+
}
|
154 |
}
|
155 |
|
156 |
+
if (e?.message === "This video is unavailable") {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
157 |
return { error: "content.video.unavailable" };
|
|
|
|
|
158 |
}
|
159 |
+
|
160 |
+
return { error: "fetch.fail" };
|
161 |
}
|
162 |
|
163 |
if (!info) return { error: "fetch.fail" };
|
|
|
165 |
const playability = info.playability_status;
|
166 |
const basicInfo = info.basic_info;
|
167 |
|
168 |
+
switch (playability.status) {
|
169 |
+
case "LOGIN_REQUIRED":
|
170 |
+
if (playability.reason.endsWith("bot")) {
|
171 |
+
return { error: "youtube.login" }
|
172 |
+
}
|
173 |
+
if (playability.reason.endsWith("age") || playability.reason.endsWith("inappropriate for some users.")) {
|
174 |
+
return { error: "content.video.age" }
|
175 |
+
}
|
176 |
+
if (playability?.error_screen?.reason?.text === "Private video") {
|
177 |
+
return { error: "content.video.private" }
|
178 |
+
}
|
179 |
+
break;
|
180 |
|
181 |
+
case "UNPLAYABLE":
|
182 |
+
if (playability?.reason?.endsWith("request limit.")) {
|
183 |
+
return { error: "fetch.rate" }
|
184 |
+
}
|
185 |
+
if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
|
186 |
+
return { error: "content.video.region" }
|
187 |
+
}
|
188 |
+
if (playability?.error_screen?.reason?.text === "Private video") {
|
189 |
+
return { error: "content.video.private" }
|
190 |
+
}
|
191 |
+
break;
|
192 |
+
|
193 |
+
case "AGE_VERIFICATION_REQUIRED":
|
194 |
+
return { error: "content.video.age" };
|
195 |
}
|
196 |
|
197 |
if (playability.status !== "OK") {
|
198 |
return { error: "content.video.unavailable" };
|
199 |
}
|
200 |
+
|
201 |
if (basicInfo.is_live) {
|
202 |
return { error: "content.video.live" };
|
203 |
}
|
204 |
|
205 |
+
if (basicInfo.duration > env.durationLimit) {
|
206 |
+
return { error: "content.too_long" };
|
207 |
+
}
|
208 |
+
|
209 |
// return a critical error if returned video is "Video Not Available"
|
210 |
// or a similar stub by youtube
|
211 |
if (basicInfo.id !== o.id) {
|
|
|
215 |
}
|
216 |
}
|
217 |
|
218 |
+
const normalizeQuality = res => {
|
219 |
+
const shortestSide = Math.min(res.height, res.width);
|
220 |
+
return videoQualities.find(qual => qual >= shortestSide);
|
221 |
+
}
|
|
|
|
|
|
|
222 |
|
223 |
+
let video, audio, dubbedLanguage,
|
224 |
+
codec = o.format || "h264", itag = o.itag;
|
225 |
|
226 |
+
if (useHLS) {
|
227 |
+
const hlsManifest = info.streaming_data.hls_manifest_url;
|
|
|
|
|
228 |
|
229 |
+
if (!hlsManifest) {
|
230 |
+
return { error: "youtube.no_hls_streams" };
|
231 |
+
}
|
232 |
|
233 |
+
const fetchedHlsManifest = await fetch(hlsManifest, {
|
234 |
+
dispatcher: o.dispatcher,
|
235 |
+
}).then(r => {
|
236 |
+
if (r.status === 200) {
|
237 |
+
return r.text();
|
238 |
+
} else {
|
239 |
+
throw new Error("couldn't fetch the HLS playlist");
|
240 |
+
}
|
241 |
+
}).catch(() => { });
|
242 |
|
243 |
+
if (!fetchedHlsManifest) {
|
244 |
+
return { error: "youtube.no_hls_streams" };
|
245 |
+
}
|
246 |
|
247 |
+
const variants = HLS.parse(fetchedHlsManifest).variants.sort(
|
248 |
+
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
|
249 |
+
);
|
250 |
|
251 |
+
if (!variants || variants.length === 0) {
|
252 |
+
return { error: "youtube.no_hls_streams" };
|
253 |
+
}
|
254 |
|
255 |
+
const matchHlsCodec = codecs => (
|
256 |
+
codecs.includes(hlsCodecList[codec].videoCodec)
|
257 |
+
);
|
258 |
|
259 |
+
const best = variants.find(i => matchHlsCodec(i.codecs));
|
260 |
+
|
261 |
+
const preferred = variants.find(i =>
|
262 |
+
matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality
|
263 |
+
);
|
264 |
+
|
265 |
+
let selected = preferred || best;
|
266 |
+
|
267 |
+
if (!selected) {
|
268 |
+
codec = "h264";
|
269 |
+
selected = variants.find(i => matchHlsCodec(i.codecs));
|
270 |
+
}
|
271 |
+
|
272 |
+
if (!selected) {
|
273 |
+
return { error: "youtube.no_matching_format" };
|
274 |
+
}
|
275 |
+
|
276 |
+
audio = selected.audio.find(i => i.isDefault);
|
277 |
+
|
278 |
+
// some videos (mainly those with AI dubs) don't have any tracks marked as default
|
279 |
+
// why? god knows, but we assume that a default track is marked as such in the title
|
280 |
+
if (!audio) {
|
281 |
+
audio = selected.audio.find(i => i.name.endsWith("- original"));
|
282 |
+
}
|
283 |
+
|
284 |
+
if (o.dubLang) {
|
285 |
+
const dubbedAudio = selected.audio.find(i =>
|
286 |
+
i.language?.startsWith(o.dubLang)
|
287 |
+
);
|
288 |
+
|
289 |
+
if (dubbedAudio && !dubbedAudio.isDefault) {
|
290 |
+
dubbedLanguage = dubbedAudio.language;
|
291 |
+
audio = dubbedAudio;
|
292 |
+
}
|
293 |
+
}
|
294 |
+
|
295 |
+
selected.audio = [];
|
296 |
+
selected.subtitles = [];
|
297 |
+
video = selected;
|
298 |
+
} else {
|
299 |
+
// i miss typescript so bad
|
300 |
+
const sorted_formats = {
|
301 |
+
h264: {
|
302 |
+
video: [],
|
303 |
+
audio: [],
|
304 |
+
bestVideo: undefined,
|
305 |
+
bestAudio: undefined,
|
306 |
+
},
|
307 |
+
vp9: {
|
308 |
+
video: [],
|
309 |
+
audio: [],
|
310 |
+
bestVideo: undefined,
|
311 |
+
bestAudio: undefined,
|
312 |
+
},
|
313 |
+
av1: {
|
314 |
+
video: [],
|
315 |
+
audio: [],
|
316 |
+
bestVideo: undefined,
|
317 |
+
bestAudio: undefined,
|
318 |
+
},
|
319 |
+
}
|
320 |
+
|
321 |
+
const checkFormat = (format, pCodec) => format.content_length &&
|
322 |
+
(format.mime_type.includes(codecList[pCodec].videoCodec)
|
323 |
+
|| format.mime_type.includes(codecList[pCodec].audioCodec));
|
324 |
+
|
325 |
+
// sort formats & weed out bad ones
|
326 |
+
info.streaming_data.adaptive_formats.sort((a, b) =>
|
327 |
+
Number(b.bitrate) - Number(a.bitrate)
|
328 |
+
).forEach(format => {
|
329 |
+
Object.keys(codecList).forEach(yCodec => {
|
330 |
+
const matchingItag = slot => !itag?.[slot] || itag[slot] === format.itag;
|
331 |
+
const sorted = sorted_formats[yCodec];
|
332 |
+
const goodFormat = checkFormat(format, yCodec);
|
333 |
+
if (!goodFormat) return;
|
334 |
+
|
335 |
+
if (format.has_video && matchingItag('video')) {
|
336 |
+
sorted.video.push(format);
|
337 |
+
if (!sorted.bestVideo)
|
338 |
+
sorted.bestVideo = format;
|
339 |
+
}
|
340 |
+
|
341 |
+
if (format.has_audio && matchingItag('audio')) {
|
342 |
+
sorted.audio.push(format);
|
343 |
+
if (!sorted.bestAudio)
|
344 |
+
sorted.bestAudio = format;
|
345 |
+
}
|
346 |
+
})
|
347 |
+
});
|
348 |
+
|
349 |
+
const noBestMedia = () => {
|
350 |
+
const vid = sorted_formats[codec]?.bestVideo;
|
351 |
+
const aud = sorted_formats[codec]?.bestAudio;
|
352 |
+
return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly)
|
353 |
+
};
|
354 |
+
|
355 |
+
if (noBestMedia()) {
|
356 |
+
if (codec === "av1") codec = "vp9";
|
357 |
+
else if (codec === "vp9") codec = "av1";
|
358 |
|
359 |
+
// if there's no higher quality fallback, then use h264
|
360 |
+
if (noBestMedia()) codec = "h264";
|
361 |
+
}
|
362 |
+
|
363 |
+
// if there's no proper combo of av1, vp9, or h264, then give up
|
364 |
+
if (noBestMedia()) {
|
365 |
+
return { error: "youtube.no_matching_format" };
|
366 |
+
}
|
367 |
+
|
368 |
+
audio = sorted_formats[codec].bestAudio;
|
369 |
+
|
370 |
+
if (audio?.audio_track && !audio?.audio_track?.audio_is_default) {
|
371 |
+
audio = sorted_formats[codec].audio.find(i =>
|
372 |
+
i?.audio_track?.audio_is_default
|
373 |
+
);
|
374 |
+
}
|
375 |
+
|
376 |
+
if (o.dubLang) {
|
377 |
+
const dubbedAudio = sorted_formats[codec].audio.find(i =>
|
378 |
+
i.language?.startsWith(o.dubLang) && i.audio_track
|
379 |
+
);
|
380 |
+
|
381 |
+
if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
|
382 |
+
audio = dubbedAudio;
|
383 |
+
dubbedLanguage = dubbedAudio.language;
|
384 |
+
}
|
385 |
+
}
|
386 |
+
|
387 |
+
if (!o.isAudioOnly) {
|
388 |
+
const qual = (i) => {
|
389 |
+
return normalizeQuality({
|
390 |
+
width: i.width,
|
391 |
+
height: i.height,
|
392 |
+
})
|
393 |
+
}
|
394 |
|
395 |
+
const bestQuality = qual(sorted_formats[codec].bestVideo);
|
396 |
+
const useBestQuality = quality >= bestQuality;
|
397 |
+
|
398 |
+
video = useBestQuality
|
399 |
+
? sorted_formats[codec].bestVideo
|
400 |
+
: sorted_formats[codec].video.find(i => qual(i) === quality);
|
401 |
+
|
402 |
+
if (!video) video = sorted_formats[codec].bestVideo;
|
403 |
}
|
404 |
}
|
405 |
|
406 |
+
if (video?.drm_families || audio?.drm_families) {
|
407 |
+
return { error: "youtube.drm" };
|
408 |
}
|
409 |
|
410 |
+
const fileMetadata = {
|
411 |
+
title: basicInfo.title.trim(),
|
412 |
+
artist: basicInfo.author.replace("- Topic", "").trim()
|
413 |
}
|
414 |
|
415 |
if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
|
416 |
+
const descItems = basicInfo.short_description.split("\n\n", 5);
|
417 |
+
|
418 |
if (descItems.length === 5) {
|
419 |
fileMetadata.album = descItems[2];
|
420 |
fileMetadata.copyright = descItems[3];
|
|
|
424 |
}
|
425 |
}
|
426 |
|
427 |
+
const filenameAttributes = {
|
428 |
service: "youtube",
|
429 |
id: o.id,
|
430 |
title: fileMetadata.title,
|
431 |
author: fileMetadata.artist,
|
432 |
+
youtubeDubName: dubbedLanguage || false,
|
433 |
}
|
434 |
|
435 |
+
itag = {
|
436 |
+
video: video?.itag,
|
437 |
+
audio: audio?.itag
|
438 |
+
};
|
439 |
+
|
440 |
+
const originalRequest = {
|
441 |
+
...o,
|
442 |
+
dispatcher: undefined,
|
443 |
+
itag,
|
444 |
+
innertubeClient
|
445 |
+
};
|
446 |
+
|
447 |
+
if (audio && o.isAudioOnly) {
|
448 |
+
let bestAudio = codec === "h264" ? "m4a" : "opus";
|
449 |
+
let urls = audio.url;
|
450 |
+
|
451 |
+
if (useHLS) {
|
452 |
+
bestAudio = "mp3";
|
453 |
+
urls = audio.uri;
|
454 |
+
}
|
455 |
+
|
456 |
+
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
|
457 |
+
urls = audio.decipher(innertube.session.player);
|
458 |
+
}
|
459 |
+
|
460 |
+
return {
|
461 |
+
type: "audio",
|
462 |
+
isAudioOnly: true,
|
463 |
+
urls,
|
464 |
+
filenameAttributes,
|
465 |
+
fileMetadata,
|
466 |
+
bestAudio,
|
467 |
+
isHLS: useHLS,
|
468 |
+
originalRequest
|
469 |
+
}
|
470 |
}
|
471 |
|
472 |
+
if (video && audio) {
|
473 |
+
let resolution;
|
|
|
|
|
|
|
474 |
|
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;
|
482 |
+
} else {
|
483 |
+
resolution = normalizeQuality({
|
484 |
+
width: video.width,
|
485 |
+
height: video.height,
|
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);
|
493 |
+
audio = audio.decipher(innertube.session.player);
|
494 |
+
} else {
|
495 |
+
video = video.url;
|
496 |
+
audio = audio.url;
|
497 |
+
}
|
498 |
+
}
|
499 |
+
|
500 |
+
filenameAttributes.qualityLabel = `${resolution}p`;
|
501 |
+
filenameAttributes.youtubeFormat = codec;
|
502 |
|
|
|
|
|
|
|
|
|
|
|
503 |
return {
|
504 |
+
type: "merge",
|
505 |
+
urls: [
|
506 |
+
video,
|
507 |
+
audio,
|
508 |
+
],
|
509 |
filenameAttributes,
|
510 |
+
fileMetadata,
|
511 |
+
isHLS: useHLS,
|
512 |
+
originalRequest
|
513 |
}
|
514 |
}
|
515 |
|
516 |
+
return { error: "youtube.no_matching_format" };
|
517 |
}
|
api/src/processing/url.js
CHANGED
@@ -1,8 +1,9 @@
|
|
1 |
-
import psl from "psl";
|
2 |
import { strict as assert } from "node:assert";
|
3 |
|
4 |
import { env } from "../config.js";
|
5 |
import { services } from "./service-config.js";
|
|
|
6 |
import { friendlyServiceName } from "./service-alias.js";
|
7 |
|
8 |
function aliasURL(url) {
|
@@ -42,7 +43,7 @@ function aliasURL(url) {
|
|
42 |
case "fixvx":
|
43 |
case "x":
|
44 |
if (services.twitter.altDomains.includes(url.hostname)) {
|
45 |
-
url.hostname = 'twitter.com'
|
46 |
}
|
47 |
break;
|
48 |
|
@@ -85,9 +86,37 @@ function aliasURL(url) {
|
|
85 |
url.hostname = 'instagram.com';
|
86 |
}
|
87 |
break;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
}
|
89 |
|
90 |
-
return url
|
91 |
}
|
92 |
|
93 |
function cleanURL(url) {
|
@@ -107,31 +136,41 @@ function cleanURL(url) {
|
|
107 |
break;
|
108 |
case "vk":
|
109 |
if (url.pathname.includes('/clip') && url.searchParams.get('z')) {
|
110 |
-
limitQuery('z')
|
111 |
}
|
112 |
break;
|
113 |
case "youtube":
|
114 |
if (url.searchParams.get('v')) {
|
115 |
-
limitQuery('v')
|
116 |
}
|
117 |
break;
|
118 |
case "rutube":
|
119 |
if (url.searchParams.get('p')) {
|
120 |
-
limitQuery('p')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
}
|
122 |
break;
|
123 |
}
|
124 |
|
125 |
if (stripQuery) {
|
126 |
-
url.search = ''
|
127 |
}
|
128 |
|
129 |
-
url.username = url.password = url.port = url.hash = ''
|
130 |
|
131 |
if (url.pathname.endsWith('/'))
|
132 |
url.pathname = url.pathname.slice(0, -1);
|
133 |
|
134 |
-
return url
|
135 |
}
|
136 |
|
137 |
function getHostIfValid(url) {
|
@@ -169,6 +208,11 @@ export function extract(url) {
|
|
169 |
}
|
170 |
|
171 |
if (!env.enabledServices.has(host)) {
|
|
|
|
|
|
|
|
|
|
|
172 |
return { error: "service.disabled" };
|
173 |
}
|
174 |
|
@@ -194,3 +238,17 @@ export function extract(url) {
|
|
194 |
|
195 |
return { host, patternMatch };
|
196 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import psl from "@imput/psl";
|
2 |
import { strict as assert } from "node:assert";
|
3 |
|
4 |
import { env } from "../config.js";
|
5 |
import { services } from "./service-config.js";
|
6 |
+
import { getRedirectingURL } from "../misc/utils.js";
|
7 |
import { friendlyServiceName } from "./service-alias.js";
|
8 |
|
9 |
function aliasURL(url) {
|
|
|
43 |
case "fixvx":
|
44 |
case "x":
|
45 |
if (services.twitter.altDomains.includes(url.hostname)) {
|
46 |
+
url.hostname = 'twitter.com';
|
47 |
}
|
48 |
break;
|
49 |
|
|
|
86 |
url.hostname = 'instagram.com';
|
87 |
}
|
88 |
break;
|
89 |
+
|
90 |
+
case "vk":
|
91 |
+
case "vkvideo":
|
92 |
+
if (services.vk.altDomains.includes(url.hostname)) {
|
93 |
+
url.hostname = 'vk.com';
|
94 |
+
}
|
95 |
+
break;
|
96 |
+
|
97 |
+
case "xhslink":
|
98 |
+
if (url.hostname === 'xhslink.com' && parts.length === 3) {
|
99 |
+
url = new URL(`https://www.xiaohongshu.com/a/${parts[2]}`);
|
100 |
+
}
|
101 |
+
break;
|
102 |
+
|
103 |
+
case "loom":
|
104 |
+
const idPart = parts[parts.length - 1];
|
105 |
+
if (idPart.length > 32) {
|
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>.*/
|
113 |
+
if (url.hostname === "v.redd.it" && parts.length === 2) {
|
114 |
+
url = new URL(`https://www.reddit.com/video/${parts[1]}`);
|
115 |
+
}
|
116 |
+
break;
|
117 |
}
|
118 |
|
119 |
+
return url;
|
120 |
}
|
121 |
|
122 |
function cleanURL(url) {
|
|
|
136 |
break;
|
137 |
case "vk":
|
138 |
if (url.pathname.includes('/clip') && url.searchParams.get('z')) {
|
139 |
+
limitQuery('z');
|
140 |
}
|
141 |
break;
|
142 |
case "youtube":
|
143 |
if (url.searchParams.get('v')) {
|
144 |
+
limitQuery('v');
|
145 |
}
|
146 |
break;
|
147 |
case "rutube":
|
148 |
if (url.searchParams.get('p')) {
|
149 |
+
limitQuery('p');
|
150 |
+
}
|
151 |
+
break;
|
152 |
+
case "twitter":
|
153 |
+
if (url.searchParams.get('post_id')) {
|
154 |
+
limitQuery('post_id');
|
155 |
+
}
|
156 |
+
break;
|
157 |
+
case "xiaohongshu":
|
158 |
+
if (url.searchParams.get('xsec_token')) {
|
159 |
+
limitQuery('xsec_token');
|
160 |
}
|
161 |
break;
|
162 |
}
|
163 |
|
164 |
if (stripQuery) {
|
165 |
+
url.search = '';
|
166 |
}
|
167 |
|
168 |
+
url.username = url.password = url.port = url.hash = '';
|
169 |
|
170 |
if (url.pathname.endsWith('/'))
|
171 |
url.pathname = url.pathname.slice(0, -1);
|
172 |
|
173 |
+
return url;
|
174 |
}
|
175 |
|
176 |
function getHostIfValid(url) {
|
|
|
208 |
}
|
209 |
|
210 |
if (!env.enabledServices.has(host)) {
|
211 |
+
// show a different message when youtube is disabled on official instances
|
212 |
+
// as it only happens when shit hits the fan
|
213 |
+
if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
|
214 |
+
return { error: "youtube.temporary_disabled" };
|
215 |
+
}
|
216 |
return { error: "service.disabled" };
|
217 |
}
|
218 |
|
|
|
238 |
|
239 |
return { host, patternMatch };
|
240 |
}
|
241 |
+
|
242 |
+
export async function resolveRedirectingURL(url, dispatcher, headers) {
|
243 |
+
const originalService = getHostIfValid(normalizeURL(url));
|
244 |
+
if (!originalService) return;
|
245 |
+
|
246 |
+
const canonicalURL = await getRedirectingURL(url, dispatcher, headers);
|
247 |
+
if (!canonicalURL) return;
|
248 |
+
|
249 |
+
const { host, patternMatch } = extract(normalizeURL(canonicalURL));
|
250 |
+
|
251 |
+
if (host === originalService) {
|
252 |
+
return patternMatch;
|
253 |
+
}
|
254 |
+
}
|
api/src/security/api-keys.js
ADDED
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { env } from "../config.js";
|
2 |
+
import { readFile } from "node:fs/promises";
|
3 |
+
import { Green, Yellow } from "../misc/console-text.js";
|
4 |
+
import ip from "ipaddr.js";
|
5 |
+
import * as cluster from "../misc/cluster.js";
|
6 |
+
|
7 |
+
// this function is a modified variation of code
|
8 |
+
// from https://stackoverflow.com/a/32402438/14855621
|
9 |
+
const generateWildcardRegex = rule => {
|
10 |
+
var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
11 |
+
return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$");
|
12 |
+
}
|
13 |
+
|
14 |
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
15 |
+
|
16 |
+
let keys = {};
|
17 |
+
|
18 |
+
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
|
19 |
+
|
20 |
+
/* Expected format pseudotype:
|
21 |
+
** type KeyFileContents = Record<
|
22 |
+
** UUIDv4String,
|
23 |
+
** {
|
24 |
+
** name?: string,
|
25 |
+
** limit?: number | "unlimited",
|
26 |
+
** ips?: CIDRString[],
|
27 |
+
** userAgents?: string[]
|
28 |
+
** }
|
29 |
+
** >;
|
30 |
+
*/
|
31 |
+
|
32 |
+
const validateKeys = (input) => {
|
33 |
+
if (typeof input !== 'object' || input === null) {
|
34 |
+
throw "input is not an object";
|
35 |
+
}
|
36 |
+
|
37 |
+
if (Object.keys(input).some(x => !UUID_REGEX.test(x))) {
|
38 |
+
throw "key file contains invalid key(s)";
|
39 |
+
}
|
40 |
+
|
41 |
+
Object.values(input).forEach(details => {
|
42 |
+
if (typeof details !== 'object' || details === null) {
|
43 |
+
throw "some key(s) are incorrectly configured";
|
44 |
+
}
|
45 |
+
|
46 |
+
const unexpected_key = Object.keys(details).find(k => !ALLOWED_KEYS.has(k));
|
47 |
+
if (unexpected_key) {
|
48 |
+
throw "detail object contains unexpected key: " + unexpected_key;
|
49 |
+
}
|
50 |
+
|
51 |
+
if (details.limit && details.limit !== 'unlimited') {
|
52 |
+
if (typeof details.limit !== 'number')
|
53 |
+
throw "detail object contains invalid limit (not a number)";
|
54 |
+
else if (details.limit < 1)
|
55 |
+
throw "detail object contains invalid limit (not a positive number)";
|
56 |
+
}
|
57 |
+
|
58 |
+
if (details.ips) {
|
59 |
+
if (!Array.isArray(details.ips))
|
60 |
+
throw "details object contains value for `ips` which is not an array";
|
61 |
+
|
62 |
+
const invalid_ip = details.ips.find(
|
63 |
+
addr => typeof addr !== 'string' || (!ip.isValidCIDR(addr) && !ip.isValid(addr))
|
64 |
+
);
|
65 |
+
|
66 |
+
if (invalid_ip) {
|
67 |
+
throw "`ips` in details contains an invalid IP or CIDR range: " + invalid_ip;
|
68 |
+
}
|
69 |
+
}
|
70 |
+
|
71 |
+
if (details.userAgents) {
|
72 |
+
if (!Array.isArray(details.userAgents))
|
73 |
+
throw "details object contains value for `userAgents` which is not an array";
|
74 |
+
|
75 |
+
const invalid_ua = details.userAgents.find(ua => typeof ua !== 'string');
|
76 |
+
if (invalid_ua) {
|
77 |
+
throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
|
78 |
+
}
|
79 |
+
}
|
80 |
+
});
|
81 |
+
}
|
82 |
+
|
83 |
+
const formatKeys = (keyData) => {
|
84 |
+
const formatted = {};
|
85 |
+
|
86 |
+
for (let key in keyData) {
|
87 |
+
const data = keyData[key];
|
88 |
+
key = key.toLowerCase();
|
89 |
+
|
90 |
+
formatted[key] = {};
|
91 |
+
|
92 |
+
if (data.limit) {
|
93 |
+
if (data.limit === "unlimited") {
|
94 |
+
data.limit = Infinity;
|
95 |
+
}
|
96 |
+
|
97 |
+
formatted[key].limit = data.limit;
|
98 |
+
}
|
99 |
+
|
100 |
+
if (data.ips) {
|
101 |
+
formatted[key].ips = data.ips.map(addr => {
|
102 |
+
if (ip.isValid(addr)) {
|
103 |
+
const parsed = ip.parse(addr);
|
104 |
+
const range = parsed.kind() === 'ipv6' ? 128 : 32;
|
105 |
+
return [ parsed, range ];
|
106 |
+
}
|
107 |
+
|
108 |
+
return ip.parseCIDR(addr);
|
109 |
+
});
|
110 |
+
}
|
111 |
+
|
112 |
+
if (data.userAgents) {
|
113 |
+
formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);
|
114 |
+
}
|
115 |
+
}
|
116 |
+
|
117 |
+
return formatted;
|
118 |
+
}
|
119 |
+
|
120 |
+
const updateKeys = (newKeys) => {
|
121 |
+
keys = formatKeys(newKeys);
|
122 |
+
}
|
123 |
+
|
124 |
+
const loadKeys = async (source) => {
|
125 |
+
let updated;
|
126 |
+
if (source.protocol === 'file:') {
|
127 |
+
const pathname = source.pathname === '/' ? '' : source.pathname;
|
128 |
+
updated = JSON.parse(
|
129 |
+
await readFile(
|
130 |
+
decodeURIComponent(source.host + pathname),
|
131 |
+
'utf8'
|
132 |
+
)
|
133 |
+
);
|
134 |
+
} else {
|
135 |
+
updated = await fetch(source).then(a => a.json());
|
136 |
+
}
|
137 |
+
|
138 |
+
validateKeys(updated);
|
139 |
+
|
140 |
+
cluster.broadcast({ api_keys: updated });
|
141 |
+
|
142 |
+
updateKeys(updated);
|
143 |
+
}
|
144 |
+
|
145 |
+
const wrapLoad = (url, initial = false) => {
|
146 |
+
loadKeys(url)
|
147 |
+
.then(() => {
|
148 |
+
if (initial) {
|
149 |
+
console.log(`${Green('[✓]')} api keys loaded successfully!`)
|
150 |
+
}
|
151 |
+
})
|
152 |
+
.catch((e) => {
|
153 |
+
console.error(`${Yellow('[!]')} Failed loading API keys at ${new Date().toISOString()}.`);
|
154 |
+
console.error('Error:', e);
|
155 |
+
})
|
156 |
+
}
|
157 |
+
|
158 |
+
const err = (reason) => ({ success: false, error: reason });
|
159 |
+
|
160 |
+
export const validateAuthorization = (req) => {
|
161 |
+
const authHeader = req.get('Authorization');
|
162 |
+
|
163 |
+
if (typeof authHeader !== 'string') {
|
164 |
+
return err("missing");
|
165 |
+
}
|
166 |
+
|
167 |
+
const [ authType, keyString ] = authHeader.split(' ', 2);
|
168 |
+
if (authType.toLowerCase() !== 'api-key') {
|
169 |
+
return err("not_api_key");
|
170 |
+
}
|
171 |
+
|
172 |
+
if (!UUID_REGEX.test(keyString) || `${authType} ${keyString}` !== authHeader) {
|
173 |
+
return err("invalid");
|
174 |
+
}
|
175 |
+
|
176 |
+
const matchingKey = keys[keyString.toLowerCase()];
|
177 |
+
if (!matchingKey) {
|
178 |
+
return err("not_found");
|
179 |
+
}
|
180 |
+
|
181 |
+
if (matchingKey.ips) {
|
182 |
+
let addr;
|
183 |
+
try {
|
184 |
+
addr = ip.parse(req.ip);
|
185 |
+
} catch {
|
186 |
+
return err("invalid_ip");
|
187 |
+
}
|
188 |
+
|
189 |
+
const ip_allowed = matchingKey.ips.some(
|
190 |
+
([ allowed, size ]) => {
|
191 |
+
return addr.kind() === allowed.kind()
|
192 |
+
&& addr.match(allowed, size);
|
193 |
+
}
|
194 |
+
);
|
195 |
+
|
196 |
+
if (!ip_allowed) {
|
197 |
+
return err("ip_not_allowed");
|
198 |
+
}
|
199 |
+
}
|
200 |
+
|
201 |
+
if (matchingKey.userAgents) {
|
202 |
+
const userAgent = req.get('User-Agent');
|
203 |
+
if (!matchingKey.userAgents.some(regex => regex.test(userAgent))) {
|
204 |
+
return err("ua_not_allowed");
|
205 |
+
}
|
206 |
+
}
|
207 |
+
|
208 |
+
req.rateLimitKey = keyString.toLowerCase();
|
209 |
+
req.rateLimitMax = matchingKey.limit;
|
210 |
+
|
211 |
+
return { success: true };
|
212 |
+
}
|
213 |
+
|
214 |
+
export const setup = (url) => {
|
215 |
+
if (cluster.isPrimary) {
|
216 |
+
wrapLoad(url, true);
|
217 |
+
if (env.keyReloadInterval > 0) {
|
218 |
+
setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000);
|
219 |
+
}
|
220 |
+
} else if (cluster.isWorker) {
|
221 |
+
process.on('message', (message) => {
|
222 |
+
if ('api_keys' in message) {
|
223 |
+
updateKeys(message.api_keys);
|
224 |
+
}
|
225 |
+
});
|
226 |
+
}
|
227 |
+
}
|
api/src/security/jwt.js
CHANGED
@@ -6,12 +6,19 @@ import { env } from "../config.js";
|
|
6 |
const toBase64URL = (b) => Buffer.from(b).toString("base64url");
|
7 |
const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
|
8 |
|
9 |
-
const makeHmac = (
|
10 |
-
createHmac("sha256", env.jwtSecret)
|
11 |
-
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
|
14 |
-
const generate = () => {
|
15 |
const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
|
16 |
|
17 |
const header = toBase64URL(JSON.stringify({
|
@@ -21,10 +28,11 @@ const generate = () => {
|
|
21 |
|
22 |
const payload = toBase64URL(JSON.stringify({
|
23 |
jti: nanoid(8),
|
|
|
24 |
exp,
|
25 |
}));
|
26 |
|
27 |
-
const signature =
|
28 |
|
29 |
return {
|
30 |
token: `${header}.${payload}.${signature}`,
|
@@ -32,7 +40,7 @@ const generate = () => {
|
|
32 |
};
|
33 |
}
|
34 |
|
35 |
-
const verify = (jwt) => {
|
36 |
const [header, payload, signature] = jwt.split(".", 3);
|
37 |
const timestamp = Math.floor(new Date().getTime() / 1000);
|
38 |
|
@@ -40,17 +48,16 @@ const verify = (jwt) => {
|
|
40 |
return false;
|
41 |
}
|
42 |
|
43 |
-
const verifySignature =
|
44 |
|
45 |
if (verifySignature !== signature) {
|
46 |
return false;
|
47 |
}
|
48 |
|
49 |
-
|
50 |
-
return false;
|
51 |
-
}
|
52 |
|
53 |
-
return
|
|
|
54 |
}
|
55 |
|
56 |
export default {
|
|
|
6 |
const toBase64URL = (b) => Buffer.from(b).toString("base64url");
|
7 |
const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
|
8 |
|
9 |
+
const makeHmac = (data) => {
|
10 |
+
return createHmac("sha256", env.jwtSecret)
|
11 |
+
.update(data)
|
12 |
+
.digest("base64url");
|
13 |
+
}
|
14 |
+
|
15 |
+
const sign = (header, payload) =>
|
16 |
+
makeHmac(`${header}.${payload}`);
|
17 |
+
|
18 |
+
const getIPHash = (ip) =>
|
19 |
+
makeHmac(ip).slice(0, 8);
|
20 |
|
21 |
+
const generate = (ip) => {
|
22 |
const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
|
23 |
|
24 |
const header = toBase64URL(JSON.stringify({
|
|
|
28 |
|
29 |
const payload = toBase64URL(JSON.stringify({
|
30 |
jti: nanoid(8),
|
31 |
+
sub: getIPHash(ip),
|
32 |
exp,
|
33 |
}));
|
34 |
|
35 |
+
const signature = sign(header, payload);
|
36 |
|
37 |
return {
|
38 |
token: `${header}.${payload}.${signature}`,
|
|
|
40 |
};
|
41 |
}
|
42 |
|
43 |
+
const verify = (jwt, ip) => {
|
44 |
const [header, payload, signature] = jwt.split(".", 3);
|
45 |
const timestamp = Math.floor(new Date().getTime() / 1000);
|
46 |
|
|
|
48 |
return false;
|
49 |
}
|
50 |
|
51 |
+
const verifySignature = sign(header, payload);
|
52 |
|
53 |
if (verifySignature !== signature) {
|
54 |
return false;
|
55 |
}
|
56 |
|
57 |
+
const data = JSON.parse(fromBase64URL(payload));
|
|
|
|
|
58 |
|
59 |
+
return getIPHash(ip) === data.sub
|
60 |
+
&& timestamp <= data.exp;
|
61 |
}
|
62 |
|
63 |
export default {
|
api/src/security/secrets.js
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import cluster from "node:cluster";
|
2 |
+
import { createHmac, randomBytes } from "node:crypto";
|
3 |
+
|
4 |
+
const generateSalt = () => {
|
5 |
+
if (cluster.isPrimary)
|
6 |
+
return randomBytes(64);
|
7 |
+
|
8 |
+
return null;
|
9 |
+
}
|
10 |
+
|
11 |
+
let rateSalt = generateSalt();
|
12 |
+
let streamSalt = generateSalt();
|
13 |
+
|
14 |
+
export const syncSecrets = () => {
|
15 |
+
return new Promise((resolve, reject) => {
|
16 |
+
if (cluster.isPrimary) {
|
17 |
+
let remaining = Object.values(cluster.workers).length;
|
18 |
+
const handleReady = (worker, m) => {
|
19 |
+
if (m.ready)
|
20 |
+
worker.send({ rateSalt, streamSalt });
|
21 |
+
|
22 |
+
if (!--remaining)
|
23 |
+
resolve();
|
24 |
+
}
|
25 |
+
|
26 |
+
for (const worker of Object.values(cluster.workers)) {
|
27 |
+
worker.once(
|
28 |
+
'message',
|
29 |
+
(m) => handleReady(worker, m)
|
30 |
+
);
|
31 |
+
}
|
32 |
+
} else if (cluster.isWorker) {
|
33 |
+
if (rateSalt || streamSalt)
|
34 |
+
return reject();
|
35 |
+
|
36 |
+
process.send({ ready: true });
|
37 |
+
process.once('message', (message) => {
|
38 |
+
if (rateSalt || streamSalt)
|
39 |
+
return reject();
|
40 |
+
|
41 |
+
if (message.rateSalt && message.streamSalt) {
|
42 |
+
streamSalt = Buffer.from(message.streamSalt);
|
43 |
+
rateSalt = Buffer.from(message.rateSalt);
|
44 |
+
resolve();
|
45 |
+
}
|
46 |
+
});
|
47 |
+
} else reject();
|
48 |
+
});
|
49 |
+
}
|
50 |
+
|
51 |
+
|
52 |
+
export const hashHmac = (value, type) => {
|
53 |
+
let salt;
|
54 |
+
if (type === 'rate')
|
55 |
+
salt = rateSalt;
|
56 |
+
else if (type === 'stream')
|
57 |
+
salt = streamSalt;
|
58 |
+
else
|
59 |
+
throw "unknown salt";
|
60 |
+
|
61 |
+
return createHmac("sha256", salt).update(value).digest();
|
62 |
+
}
|
api/src/store/base-store.js
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const _stores = new Set();
|
2 |
+
|
3 |
+
export class Store {
|
4 |
+
id;
|
5 |
+
|
6 |
+
constructor(name) {
|
7 |
+
name = name.toUpperCase();
|
8 |
+
|
9 |
+
if (_stores.has(name))
|
10 |
+
throw `${name} store already exists`;
|
11 |
+
_stores.add(name);
|
12 |
+
|
13 |
+
this.id = name;
|
14 |
+
}
|
15 |
+
|
16 |
+
async _has(_key) { await Promise.reject("needs implementation"); }
|
17 |
+
has(key) {
|
18 |
+
if (typeof key !== 'string') {
|
19 |
+
key = key.toString();
|
20 |
+
}
|
21 |
+
|
22 |
+
return this._has(key);
|
23 |
+
}
|
24 |
+
|
25 |
+
async _get(_key) { await Promise.reject("needs implementation"); }
|
26 |
+
async get(key) {
|
27 |
+
if (typeof key !== 'string') {
|
28 |
+
key = key.toString();
|
29 |
+
}
|
30 |
+
|
31 |
+
const val = await this._get(key);
|
32 |
+
if (val === null)
|
33 |
+
return null;
|
34 |
+
|
35 |
+
return val;
|
36 |
+
}
|
37 |
+
|
38 |
+
async _set(_key, _val, _exp_sec = -1) { await Promise.reject("needs implementation") }
|
39 |
+
set(key, val, exp_sec = -1) {
|
40 |
+
if (typeof key !== 'string') {
|
41 |
+
key = key.toString();
|
42 |
+
}
|
43 |
+
|
44 |
+
exp_sec = Math.round(exp_sec);
|
45 |
+
|
46 |
+
return this._set(key, val, exp_sec);
|
47 |
+
}
|
48 |
+
};
|
api/src/store/memory-store.js
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { MinPriorityQueue } from '@datastructures-js/priority-queue';
|
2 |
+
import { Store } from './base-store.js';
|
3 |
+
|
4 |
+
// minimum delay between sweeps to avoid repeatedly
|
5 |
+
// sweeping entries close in proximity one by one.
|
6 |
+
const MIN_THRESHOLD_MS = 2500;
|
7 |
+
|
8 |
+
export default class MemoryStore extends Store {
|
9 |
+
#store = new Map();
|
10 |
+
#timeouts = new MinPriorityQueue/*<{ t: number, k: unknown }>*/((obj) => obj.t);
|
11 |
+
#nextSweep = { id: null, t: null };
|
12 |
+
|
13 |
+
constructor(name) {
|
14 |
+
super(name);
|
15 |
+
}
|
16 |
+
|
17 |
+
_has(key) {
|
18 |
+
return this.#store.has(key);
|
19 |
+
}
|
20 |
+
|
21 |
+
_get(key) {
|
22 |
+
const val = this.#store.get(key);
|
23 |
+
|
24 |
+
return val === undefined ? null : val;
|
25 |
+
}
|
26 |
+
|
27 |
+
_set(key, val, exp_sec = -1) {
|
28 |
+
if (this.#store.has(key)) {
|
29 |
+
this.#timeouts.remove(o => o.k === key);
|
30 |
+
}
|
31 |
+
|
32 |
+
if (exp_sec > 0) {
|
33 |
+
const exp = 1000 * exp_sec;
|
34 |
+
const timeout_at = +new Date() + exp;
|
35 |
+
|
36 |
+
this.#timeouts.enqueue({ k: key, t: timeout_at });
|
37 |
+
}
|
38 |
+
|
39 |
+
this.#store.set(key, val);
|
40 |
+
this.#reschedule();
|
41 |
+
}
|
42 |
+
|
43 |
+
#reschedule() {
|
44 |
+
const current_time = new Date().getTime();
|
45 |
+
const time = this.#timeouts.front()?.t;
|
46 |
+
if (!time) {
|
47 |
+
return;
|
48 |
+
} else if (time < current_time) {
|
49 |
+
return this.#sweepNow();
|
50 |
+
}
|
51 |
+
|
52 |
+
const sweep = this.#nextSweep;
|
53 |
+
if (sweep.id === null || sweep.t > time) {
|
54 |
+
if (sweep.id) {
|
55 |
+
clearTimeout(sweep.id);
|
56 |
+
}
|
57 |
+
|
58 |
+
sweep.t = time;
|
59 |
+
sweep.id = setTimeout(
|
60 |
+
() => this.#sweepNow(),
|
61 |
+
Math.max(MIN_THRESHOLD_MS, time - current_time)
|
62 |
+
);
|
63 |
+
sweep.id.unref();
|
64 |
+
}
|
65 |
+
}
|
66 |
+
|
67 |
+
#sweepNow() {
|
68 |
+
while (this.#timeouts.front()?.t < new Date().getTime()) {
|
69 |
+
const item = this.#timeouts.dequeue();
|
70 |
+
this.#store.delete(item.k);
|
71 |
+
}
|
72 |
+
|
73 |
+
this.#nextSweep.id = null;
|
74 |
+
this.#nextSweep.t = null;
|
75 |
+
this.#reschedule();
|
76 |
+
}
|
77 |
+
}
|
api/src/store/redis-ratelimit.js
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { env } from "../config.js";
|
2 |
+
|
3 |
+
let client, redis, redisLimiter;
|
4 |
+
|
5 |
+
export const createStore = async (name) => {
|
6 |
+
if (!env.redisURL) return;
|
7 |
+
|
8 |
+
if (!client) {
|
9 |
+
redis = await import('redis');
|
10 |
+
redisLimiter = await import('rate-limit-redis');
|
11 |
+
client = redis.createClient({ url: env.redisURL });
|
12 |
+
await client.connect();
|
13 |
+
}
|
14 |
+
|
15 |
+
return new redisLimiter.default({
|
16 |
+
prefix: `RL${name}_`,
|
17 |
+
sendCommand: (...args) => client.sendCommand(args),
|
18 |
+
});
|
19 |
+
}
|
api/src/store/redis-store.js
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { commandOptions, createClient } from "redis";
|
2 |
+
import { env } from "../config.js";
|
3 |
+
import { Store } from "./base-store.js";
|
4 |
+
|
5 |
+
export default class RedisStore extends Store {
|
6 |
+
#client = createClient({
|
7 |
+
url: env.redisURL,
|
8 |
+
});
|
9 |
+
#connected;
|
10 |
+
|
11 |
+
constructor(name) {
|
12 |
+
super(name);
|
13 |
+
this.#connected = this.#client.connect();
|
14 |
+
}
|
15 |
+
|
16 |
+
#keyOf(key) {
|
17 |
+
return this.id + '_' + key;
|
18 |
+
}
|
19 |
+
|
20 |
+
async _has(key) {
|
21 |
+
await this.#connected;
|
22 |
+
|
23 |
+
return this.#client.hExists(key);
|
24 |
+
}
|
25 |
+
|
26 |
+
async _get(key) {
|
27 |
+
await this.#connected;
|
28 |
+
|
29 |
+
const valueType = await this.#client.get(this.#keyOf(key) + '_t');
|
30 |
+
const value = await this.#client.get(
|
31 |
+
commandOptions({ returnBuffers: true }),
|
32 |
+
this.#keyOf(key)
|
33 |
+
);
|
34 |
+
|
35 |
+
if (!value) {
|
36 |
+
return null;
|
37 |
+
}
|
38 |
+
|
39 |
+
if (valueType === 'b')
|
40 |
+
return value;
|
41 |
+
else
|
42 |
+
return JSON.parse(value);
|
43 |
+
}
|
44 |
+
|
45 |
+
async _set(key, val, exp_sec = -1) {
|
46 |
+
await this.#connected;
|
47 |
+
|
48 |
+
const options = exp_sec > 0 ? { EX: exp_sec } : undefined;
|
49 |
+
|
50 |
+
if (val instanceof Buffer) {
|
51 |
+
await this.#client.set(
|
52 |
+
this.#keyOf(key) + '_t',
|
53 |
+
'b',
|
54 |
+
options
|
55 |
+
);
|
56 |
+
}
|
57 |
+
|
58 |
+
await this.#client.set(
|
59 |
+
this.#keyOf(key),
|
60 |
+
val,
|
61 |
+
options
|
62 |
+
);
|
63 |
+
}
|
64 |
+
}
|
api/src/store/store.js
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { env } from '../config.js';
|
2 |
+
|
3 |
+
let _export;
|
4 |
+
if (env.redisURL) {
|
5 |
+
_export = await import('./redis-store.js');
|
6 |
+
} else {
|
7 |
+
_export = await import('./memory-store.js');
|
8 |
+
}
|
9 |
+
|
10 |
+
export default _export.default;
|
api/src/stream/internal-hls.js
CHANGED
@@ -16,15 +16,17 @@ function transformObject(streamInfo, hlsObject) {
|
|
16 |
|
17 |
let fullUrl;
|
18 |
if (getURL(hlsObject.uri)) {
|
19 |
-
fullUrl = hlsObject.uri;
|
20 |
} else {
|
21 |
fullUrl = new URL(hlsObject.uri, streamInfo.url);
|
22 |
}
|
23 |
|
24 |
-
|
|
|
25 |
|
26 |
-
|
27 |
-
|
|
|
28 |
}
|
29 |
|
30 |
return hlsObject;
|
@@ -53,7 +55,7 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
|
|
53 |
|
54 |
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
|
55 |
|
56 |
-
export function
|
57 |
return HLS_MIME_TYPES.includes(req.headers['content-type']);
|
58 |
}
|
59 |
|
|
|
16 |
|
17 |
let fullUrl;
|
18 |
if (getURL(hlsObject.uri)) {
|
19 |
+
fullUrl = new URL(hlsObject.uri);
|
20 |
} else {
|
21 |
fullUrl = new URL(hlsObject.uri, streamInfo.url);
|
22 |
}
|
23 |
|
24 |
+
if (fullUrl.hostname !== '127.0.0.1') {
|
25 |
+
hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);
|
26 |
|
27 |
+
if (hlsObject.map) {
|
28 |
+
hlsObject.map = transformObject(streamInfo, hlsObject.map);
|
29 |
+
}
|
30 |
}
|
31 |
|
32 |
return hlsObject;
|
|
|
55 |
|
56 |
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
|
57 |
|
58 |
+
export function isHlsResponse (req) {
|
59 |
return HLS_MIME_TYPES.includes(req.headers['content-type']);
|
60 |
}
|
61 |
|