playingapi commited on
Commit
43a06dc
·
verified ·
1 Parent(s): 2f797a8

Upload 376 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +15 -0
  2. api/README.md +90 -9
  3. api/package.json +12 -12
  4. api/src/cobalt.js +15 -10
  5. api/src/config.js +41 -1
  6. api/src/core/api.js +139 -68
  7. api/src/misc/cluster.js +71 -0
  8. api/src/misc/console-text.js +30 -10
  9. api/src/misc/crypto.js +1 -9
  10. api/src/misc/run-test.js +10 -1
  11. api/src/misc/utils.js +24 -48
  12. api/src/processing/cookie/cookie.js +16 -5
  13. api/src/processing/cookie/manager.js +119 -29
  14. api/src/processing/create-filename.js +15 -1
  15. api/src/processing/helpers/youtube-session.js +81 -0
  16. api/src/processing/match-action.js +23 -15
  17. api/src/processing/match.js +22 -16
  18. api/src/processing/request.js +3 -4
  19. api/src/processing/schema.js +9 -5
  20. api/src/processing/service-config.js +60 -21
  21. api/src/processing/service-patterns.js +15 -9
  22. api/src/processing/services/bilibili.js +3 -13
  23. api/src/processing/services/bluesky.js +68 -27
  24. api/src/processing/services/dailymotion.js +1 -1
  25. api/src/processing/services/facebook.js +5 -5
  26. api/src/processing/services/instagram.js +211 -42
  27. api/src/processing/services/ok.js +2 -3
  28. api/src/processing/services/pinterest.js +8 -7
  29. api/src/processing/services/reddit.js +36 -16
  30. api/src/processing/services/rutube.js +7 -5
  31. api/src/processing/services/snapchat.js +7 -19
  32. api/src/processing/services/soundcloud.js +17 -4
  33. api/src/processing/services/tiktok.js +30 -11
  34. api/src/processing/services/tumblr.js +1 -1
  35. api/src/processing/services/twitch.js +2 -3
  36. api/src/processing/services/twitter.js +106 -32
  37. api/src/processing/services/vimeo.js +4 -5
  38. api/src/processing/services/vk.js +109 -32
  39. api/src/processing/services/xiaohongshu.js +109 -0
  40. api/src/processing/services/youtube.js +373 -169
  41. api/src/processing/url.js +67 -9
  42. api/src/security/api-keys.js +227 -0
  43. api/src/security/jwt.js +19 -12
  44. api/src/security/secrets.js +62 -0
  45. api/src/store/base-store.js +48 -0
  46. api/src/store/memory-store.js +77 -0
  47. api/src/store/redis-ratelimit.js +19 -0
  48. api/src/store/redis-store.js +64 -0
  49. api/src/store/store.js +10 -0
  50. 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
- ## running your own instance
13
- if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
14
- it's *highly* recommended to use a docker compose method unless you run for developing/debugging purposes.
15
 
16
- ## accessing the api
17
- currently, there is no publicly accessible main api. we plan on providing a public api for
18
- cobalt 10 in some form in the future. we recommend deploying your own instance if you wish
19
- to use the latest api. you can access [the documentation](/docs/api.md) for it here.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- if you are looking for the documentation for the old (7.x) api, you can find
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.0",
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:youtube": "node src/util/generate-youtube-tokens"
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
- "esbuild": "^0.14.51",
32
- "express": "^4.18.1",
33
- "express-rate-limit": "^6.3.0",
34
  "ffmpeg-static": "^5.1.0",
35
  "hls-parser": "^0.10.7",
36
- "ipaddr.js": "2.1.0",
37
- "nanoid": "^4.0.2",
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": "^10.3.0",
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 'path';
6
- import { fileURLToPath } from 'url';
7
 
8
- import { env } from "./config.js"
9
- import { Bright, Green, Red } from "./misc/console-text.js";
 
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('x-powered-by');
17
 
18
  if (env.apiURL) {
19
- const { runAPI } = await import('./core/api.js');
20
- runAPI(express, app, __dirname)
 
 
 
 
 
21
  } else {
22
  console.log(
23
- Red(`cobalt wasn't configured yet or configuration is invalid.\n`)
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 { languageCode } from "../misc/utils.js";
13
- import { Bright, Cyan } from "../misc/console-text.js";
14
- import { generateHmac, generateSalt } from "../misc/crypto.js";
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
- max: env.rateLimitMax,
63
- standardHeaders: true,
64
  legacyHeaders: false,
65
- keyGenerator: req => {
66
- if (req.authorized) {
67
- return generateHmac(req.header("Authorization"), ipSalt);
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 apiLimiterStream = rateLimit({
83
  windowMs: env.rateLimitWindow * 1000,
84
- max: env.rateLimitMax,
85
- standardHeaders: true,
86
  legacyHeaders: false,
87
- keyGenerator: req => generateHmac(getIP(req), ipSalt),
88
- handler: (req, res) => {
 
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.sessionEnabled) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (!authorization.startsWith("Bearer ") || authorization.length > 256) {
131
  return fail(res, "error.api.auth.jwt.invalid");
132
  }
133
 
134
- const verifyJwt = jwt.verify(
135
- authorization.split("Bearer ", 2)[1]
136
- );
 
137
 
138
- if (!verifyJwt) {
139
  return fail(res, "error.api.auth.jwt.invalid");
140
  }
141
 
142
- req.authorized = true;
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
- app.get('/itunnel', (req, res) => {
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', ...streamInfo });
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(env.apiPort, env.listenAddress, () => {
314
- console.log(`\n` +
315
- Bright(Cyan("cobalt ")) + Bright("API ^ω⁠^") + "\n" +
316
-
317
- "~~~~~~\n" +
318
- Bright("version: ") + version + "\n" +
319
- Bright("commit: ") + git.commit + "\n" +
320
- Bright("branch: ") + git.branch + "\n" +
321
- Bright("remote: ") + git.remote + "\n" +
322
- Bright("start time: ") + startTime.toUTCString() + "\n" +
323
- "~~~~~~\n" +
324
-
325
- Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
326
- Bright("port: ") + env.apiPort + "\n"
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
- function t(color, tt) {
2
- return color + tt + "\x1b[0m"
 
 
 
 
 
3
  }
4
 
5
- export function Bright(tt) {
6
- return t("\x1b[1m", tt)
 
 
 
 
 
 
 
 
 
 
 
 
7
  }
8
- export function Red(tt) {
9
- return t("\x1b[31m", tt)
 
10
  }
11
- export function Green(tt) {
12
- return t("\x1b[32m", tt)
 
13
  }
14
- export function Cyan(tt) {
15
- return t("\x1b[36m", tt)
 
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 { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto";
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
- const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
2
-
3
- export function metadataManager(obj) {
4
- const keys = Object.keys(obj);
5
- const tags = [
6
- "album",
7
- "copyright",
8
- "title",
9
- "artist",
10
- "track",
11
- "date"
12
- ]
13
- let commands = []
14
-
15
- for (const i in keys) {
16
- if (tags.includes(keys[i]))
17
- commands.push('-metadata', `${keys[i]}=${obj[keys[i]]}`)
18
  }
19
- return commands;
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
- export function getRedirectingURL(url) {
49
- return fetch(url, { redirect: 'manual' }).then((r) => {
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
- this.set(input)
 
 
8
  }
9
- set(values) {
10
- Object.entries(values).forEach(
11
- ([ key, value ]) => this._values[key] = value
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 { env } from '../../config.js';
 
5
 
6
- const WRITE_INTERVAL = 60000,
7
- cookiePath = env.cookiePath,
8
- COUNTER = Symbol('counter');
 
 
 
 
 
9
 
 
10
  let cookies = {}, dirty = false, intervalId;
11
 
12
- const setup = async () => {
13
- try {
14
- if (!cookiePath) return;
 
 
 
 
 
 
 
 
 
15
 
 
 
16
  cookies = await readFile(cookiePath, 'utf8');
17
  cookies = JSON.parse(cookies);
18
- intervalId = setInterval(writeChanges, WRITE_INTERVAL)
19
- } catch { /* no cookies for you */ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
- setup();
 
 
 
 
 
 
 
 
 
23
 
24
- function writeChanges() {
25
- if (!dirty) return;
26
  dirty = false;
 
27
 
28
- writeFile(cookiePath, JSON.stringify(cookies, null, 4)).catch(() => {
29
- clearInterval(intervalId)
30
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
 
33
  export function getCookie(service) {
 
 
 
 
 
 
 
 
34
  if (!cookies[service] || !cookies[service].length) return;
35
 
36
- let n;
37
- if (cookies[service][COUNTER] === undefined) {
38
- n = cookies[service][COUNTER] = 0
39
- } else {
40
- ++cookies[service][COUNTER]
41
- n = (cookies[service][COUNTER] %= cookies[service].length)
42
  }
43
 
44
- const cookie = cookies[service][n];
45
- if (typeof cookie === 'string') cookies[service][n] = Cookie.fromString(cookie);
 
46
 
47
- return cookies[service][n]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- export function updateCookieValues(cookie, values) {
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
- const title = `${f.title} - ${f.author}`;
 
 
 
 
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
- u: r.urls,
13
  headers: r.headers,
14
  service: host,
15
  filename: r.filenameAttributes ?
16
  createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
17
  fileMetadata: !disableMetadata ? r.fileMetadata : false,
18
- requestIP
 
19
  },
20
  params = {};
21
 
@@ -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.isM3U8) action = "m3u8";
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
- responseType = "redirect";
51
  break;
52
 
53
  case "gif":
54
  params = { type: "gif" };
55
  break;
56
 
57
- case "m3u8":
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.isM3U8) {
66
  muteType = "proxy";
67
  }
68
  params = {
69
  type: muteType,
70
- u: Array.isArray(r.urls) ? r.urls[0] : r.urls
 
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
- u: createStream({
96
  service: "tiktok",
97
  type: audioStreamType,
98
- u: r.urls,
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.fetch.empty"
163
  })
164
  }
165
 
@@ -183,18 +189,20 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
183
  }
184
  }
185
 
186
- if (r.isM3U8 || host === "vimeo") {
187
  copy = false;
188
  processType = "audio";
189
  }
190
 
191
  params = {
192
  type: processType,
193
- u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
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
- userId: patternMatch.userId,
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
- dispatcher
107
  }
108
 
109
  if (url.hostname === "music.youtube.com" || isAudioOnly) {
110
- fetchInfo.quality = "max";
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
- sub: patternMatch.sub,
122
- id: patternMatch.id,
123
- user: patternMatch.user
124
  });
125
  break;
126
 
127
  case "tiktok":
128
  r = await tiktok({
129
  postId: patternMatch.postId,
130
- id: patternMatch.id,
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?.u,
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?.u,
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
- .length(2)
37
- .transform(verifyLanguageCode)
 
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
- youtubeDubBrowserLang: z.boolean().default(false),
 
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
- "stories/:username/:storyId"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "user/:user/comments/:id/:title"
 
 
 
 
 
 
 
 
 
 
 
 
69
  ],
70
  subdomains: "*",
71
  },
@@ -111,12 +138,13 @@ export const services = {
111
  tiktok: {
112
  patterns: [
113
  ":user/video/:postId",
114
- ":id",
115
- "t/:id",
 
116
  ":user/photo/:postId",
117
- "v/:id.html"
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:userId_:videoId",
161
- "clip:userId_:videoId",
162
- "clips:duplicate?z=clip:userId_:videoId"
 
 
 
 
 
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 <= 12
 
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
- (pattern.sub?.length <= 22 && pattern.id?.length <= 10)
23
- || (pattern.user?.length <= 22 && pattern.id?.length <= 10),
 
 
 
24
 
25
  "rutube": pattern =>
26
  (pattern.id?.length === 32 && pattern.key?.length <= 32) ||
@@ -36,10 +40,10 @@ export const testers = {
36
  || pattern.shortLink?.length <= 16,
37
 
38
  "streamable": pattern =>
39
- pattern.id?.length === 6,
40
 
41
  "tiktok": pattern =>
42
- pattern.postId?.length <= 21 || pattern.id?.length <= 13,
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.userId?.length <= 10 && pattern.videoId?.length <= 10,
 
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
- comId = await com_resolveShortlink(comShortLink);
 
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
- const urlMasterHLS = media?.playlist;
7
- if (!urlMasterHLS) return { error: "fetch.empty" };
8
- if (!urlMasterHLS.startsWith("https://video.bsky.app/")) return { error: "fetch.empty" };
9
 
10
- const masterHLS = await fetch(urlMasterHLS)
 
 
 
 
 
 
 
 
 
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
- isM3U8: true,
30
  }
31
  }
32
 
@@ -48,7 +55,7 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
48
  let proxiedImage = createStream({
49
  service: "bluesky",
50
  type: "proxy",
51
- u: url,
52
  filename: `${filename}_${i + 1}.jpg`,
53
  });
54
 
@@ -64,7 +71,25 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
64
  return { picker };
65
  }
66
 
67
- export default async function ({ user, post, alwaysProxy }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "fetch.empty" };
91
  }
92
  }
93
 
94
  const embedType = getPost?.thread?.post?.embed?.$type;
95
  const filename = `bluesky_${user}_${post}`;
96
 
97
- if (embedType === "app.bsky.embed.video#view") {
98
- return extractVideo({
99
- media: getPost.thread?.post?.embed,
100
- filename,
101
- })
102
- }
103
-
104
- if (embedType === "app.bsky.embed.recordWithMedia#view") {
105
- return extractVideo({
106
- media: getPost.thread?.post?.embed?.media,
107
- filename,
108
- })
109
- }
110
-
111
- if (embedType === "app.bsky.embed.images#view") {
112
- return extractImages({ getPost, filename, alwaysProxy });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- isM3U8: true,
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": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
37
  }
38
 
39
  const cachedDtsg = {
@@ -41,7 +45,17 @@ const cachedDtsg = {
41
  expiry: 0
42
  }
43
 
44
- export default function(obj) {
 
 
 
 
 
 
 
 
 
 
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
- let dtsgId;
141
 
142
- if (cookie) {
143
- dtsgId = await findDtsgId(cookie);
144
- }
145
- const url = new URL('https://www.instagram.com/api/graphql/');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
- const requestData = {
148
- jazoest: '26406',
149
- variables: JSON.stringify({
150
- shortcode: id,
151
- __relay_internal__pv__PolarisShareMenurelayprovider: false
152
- }),
153
- doc_id: '7153618348081770'
154
  };
155
- if (dtsgId) {
156
- requestData.fb_dtsg = dtsgId;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
 
159
- return (await request(url, cookie, 'POST', requestData))
160
- .data
161
- ?.xdt_api__v1__media__shortcode__web_info
162
- ?.items
163
- ?.[0];
164
  }
165
 
166
  function extractOldPost(data, id, alwaysProxy) {
167
- const sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children;
 
 
168
  if (sidecar) {
169
  const picker = sidecar.edges.filter(e => e.node?.display_url)
170
  .map((e, i) => {
171
- const type = e.node?.is_video ? "video" : "photo";
172
- const url = type === "video" ? e.node?.video_url : e.node?.display_url;
 
 
 
 
 
 
173
 
174
  let itemExt = type === "video" ? "mp4" : "jpg";
175
 
@@ -177,7 +320,7 @@ export default function(obj) {
177
  if (alwaysProxy) proxyFile = createStream({
178
  service: "instagram",
179
  type: "proxy",
180
- u: url,
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
- u: e.node?.display_url,
193
  filename: `instagram_${id}_${i + 1}.jpg`
194
  })
195
  }
196
  });
197
 
198
  if (picker.length) return { picker }
199
- } else if (data?.gql_data?.shortcode_media?.video_url) {
 
 
200
  return {
201
- urls: data.gql_data.shortcode_media.video_url,
202
  filename: `instagram_${id}.mp4`,
203
  audioFilename: `instagram_${id}_audio`
204
  }
205
- } else if (data?.gql_data?.shortcode_media?.display_url) {
 
 
206
  return {
207
- urls: data.gql_data?.shortcode_media.display_url,
208
- isPhoto: true
 
209
  }
210
  }
211
  }
@@ -230,7 +378,7 @@ export default function(obj) {
230
  if (alwaysProxy) proxyFile = createStream({
231
  service: "instagram",
232
  type: "proxy",
233
- u: url,
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
- u: imageUrl,
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) return { error: "fetch.fail" };
 
 
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: cleanString(videoData.movie.title.trim()),
48
- author: cleanString((videoData.author?.name || videoData.compilationTitle).trim()),
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
- id = await fetch(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`, { redirect: "manual" })
11
- .then(r => r.headers.get("location").split('pin/')[1].split('/')[0])
12
- .catch(() => {});
13
  }
 
14
  if (id.includes("--")) id = id.split("--")[1];
15
  if (!id) return { error: "fetch.fail" };
16
 
@@ -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') && a.includes('720p'));
26
 
27
  if (videoLink) return {
28
  urls: videoLink,
29
- filename: `pinterest_${o.id}.mp4`,
30
- audioFilename: `pinterest_${o.id}_audio`
31
  }
32
 
33
  const imageLink = [...html.matchAll(imageRegex)]
@@ -39,7 +40,7 @@ export default async function(o) {
39
  if (imageLink) return {
40
  urls: imageLink,
41
  isPhoto: true,
42
- filename: `pinterest_${o.id}.${imageType}`
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 url = new URL(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}.json`);
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- if (obj.user) {
54
- url.pathname = `/user/${obj.user}/comments/${obj.id}.json`;
 
 
 
55
  }
56
 
57
- const accessToken = await getAccessToken();
 
 
 
58
  if (accessToken) url.hostname = 'oauth.reddit.com';
59
 
60
  let data = await fetch(
61
- url, {
62
- headers: {
63
- 'User-Agent': genericUserAgent,
64
- accept: 'application/json',
65
- authorization: accessToken && `Bearer ${accessToken}`
66
- }
67
- }
68
  ).then(r => r.json()).catch(() => {});
69
 
70
  if (!data || !Array.isArray(data)) {
@@ -73,12 +87,17 @@ export default async function(obj) {
73
 
74
  data = data[0]?.data?.children[0]?.data;
75
 
76
- const id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
 
 
 
 
 
77
 
78
  if (data?.url?.endsWith('.gif')) return {
79
  typeId: "redirect",
80
  urls: data.url,
81
- filename: `reddit_${id}.gif`,
82
  }
83
 
84
  if (!data.secure_media?.reddit_video)
@@ -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_${id}_audio`,
125
- filename: `reddit_${id}.mp4`
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: cleanString(play.title.trim()),
63
- artist: cleanString(play.author.name.trim()),
64
  }
65
 
66
  return {
67
  urls: matchingQuality.uri,
68
- isM3U8: true,
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 { extract, normalizeURL } from "../url.js";
2
  import { genericUserAgent } from "../../config.js";
3
  import { createStream } from "../../stream/manage.js";
4
- import { getRedirectingURL } from "../../misc/utils.js";
5
 
6
  const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="([^"]+)" as="video"\/>/;
7
  const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
@@ -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.query.profileParams[1];
45
 
46
- if (storyIdParam && data.props.pageProps.story) {
47
  const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);
48
  if (story) {
49
  if (story.snapMediaType === 0) {
@@ -62,7 +61,7 @@ async function getStory(username, storyId, alwaysProxy) {
62
  }
63
  }
64
 
65
- const defaultStory = data.props.pageProps.curatedHighlights[0];
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
- u: snapUrl,
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
- u: snap.snapUrls.mediaPreviewUrl.value,
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
- const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
104
-
105
- if (!link?.startsWith('https://www.snapchat.com/')) {
106
- return { error: "fetch.short_link" };
107
- }
108
-
109
- const extractResult = extract(normalizeURL(link));
110
- if (extractResult?.host !== 'snapchat') {
111
- return { error: "fetch.short_link" };
112
- }
113
-
114
- params = extractResult.patternMatch;
115
  }
116
 
117
  if (params.spotlightId) {
 
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 (!json.media.transcodings) return { error: "fetch.empty" };
 
 
 
 
 
 
 
 
 
 
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: cleanString(json.title.trim()),
95
- artist: cleanString(json.user.username.trim()),
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.id}`, {
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.postId
28
  }
29
  }
30
  if (!postId) return { error: "fetch.short_link" };
31
 
32
  // should always be /video/, even for photos
33
- const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
34
  headers: {
35
  "user-agent": genericUserAgent,
36
  cookie,
@@ -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
- const data = JSON.parse(json)
49
- detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
 
 
 
 
 
 
 
 
 
 
50
  } catch {
51
  return { error: "fetch.fail" };
52
  }
53
 
 
 
 
 
 
 
 
 
54
  let video, videoFilename, audioFilename, audio, images,
55
- filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`,
56
  bestAudio; // will get defaulted to m4a later on in match-action
57
 
58
  images = detail.imagePost?.images;
59
 
60
- let playAddr = detail.video.playAddr;
 
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
- u: url,
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: cleanString(clipMetadata.title.trim()),
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: cleanString(clipMetadata.title.trim()),
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 `ct0` cookie, retry
91
  if (result.status === 403 && result.headers.get('set-cookie')) {
92
- result = await fetch(graphqlTweetURL, {
93
- headers: {
94
- ...headers,
95
- 'x-csrf-token': cookie.values().ct0
96
- },
97
- dispatcher
98
- });
 
 
 
99
  }
100
 
101
  return result
102
  }
103
 
104
- export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
105
- const cookie = await getCookie('twitter');
106
-
107
- let guestToken = await getGuestToken(dispatcher);
108
- if (!guestToken) return { error: "fetch.fail" };
109
-
110
- let tweet = await requestTweet(dispatcher, id, guestToken);
111
-
112
- // get new token & retry if old one expired
113
- if ([403, 429].includes(tweet.status)) {
114
- guestToken = await getGuestToken(dispatcher, true);
115
- tweet = await requestTweet(dispatcher, id, guestToken)
116
- }
117
-
118
- tweet = await tweet.json();
119
-
120
  let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
121
 
122
  if (!tweetTypename) {
@@ -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
- let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  // check if there's a video at given index (/video/<index>)
156
  if (index >= 0 && index < media?.length) {
@@ -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 = (u, filename) => createStream({
163
  service: "twitter",
164
  type: "proxy",
165
- u, filename,
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}.mp4`;
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
- u: url,
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 { cleanString, merge } from '../../misc/utils.js';
5
 
6
  const resolutionMatch = {
7
  "3840": 2160,
@@ -122,7 +121,7 @@ const getHLS = async (configURL, obj) => {
122
 
123
  return {
124
  urls,
125
- isM3U8: true,
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: cleanString(info.name),
156
- artist: cleanString(info.user.name),
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 { cleanString } from "../../misc/utils.js";
2
- import { genericUserAgent, env } from "../../config.js";
3
 
4
- const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
5
 
6
- export default async function(o) {
7
- let html, url, quality = o.quality === "max" ? 2160 : o.quality;
8
 
9
- html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  headers: {
11
- "user-agent": genericUserAgent
12
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  })
14
- .then(r => r.arrayBuffer())
15
- .catch(() => {});
 
 
 
16
 
17
- if (!html) return { error: "fetch.fail" };
 
 
 
 
 
18
 
19
- // decode cyrillic from windows-1251 because vk still uses apis from prehistoric times
20
- let decoder = new TextDecoder('windows-1251');
21
- html = decoder.decode(html);
22
 
23
- if (!html.includes(`{"lang":`)) return { error: "fetch.empty" };
 
 
24
 
25
- let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
26
 
27
- if (Number(js.mvData.is_active_live) !== 0) {
28
- return { error: "content.video.live" };
 
 
 
 
 
 
 
 
 
 
 
29
  }
30
 
31
- if (js.mvData.duration > env.durationLimit) {
32
  return { error: "content.too_long" };
33
  }
34
 
35
- for (let i in resolutions) {
36
- if (js.player.params[0][`url${resolutions[i]}`]) {
37
- quality = resolutions[i];
 
 
 
38
  break
39
  }
40
  }
41
- if (Number(quality) > Number(o.quality)) quality = o.quality;
42
 
43
- url = js.player.params[0][`url${quality}`];
 
 
44
 
45
- let fileMetadata = {
46
- title: cleanString(js.player.params[0].md_title.trim()),
47
- author: cleanString(js.player.params[0].md_author.trim()),
48
  }
49
 
50
- if (url) return {
51
  urls: url,
 
52
  filenameAttributes: {
53
  service: "vk",
54
- id: `${o.userId}_${o.videoId}`,
55
  title: fileMetadata.title,
56
- author: fileMetadata.author,
57
- resolution: `${quality}p`,
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 { fetch } from "undici";
2
 
 
3
  import { Innertube, Session } from "youtubei.js";
4
 
5
  import { env } from "../../config.js";
6
- import { cleanString } from "../../misc/utils.js";
7
- import { getCookie, updateCookieValues } from "../cookie/manager.js";
8
 
9
  const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
10
 
11
  let innertube, lastRefreshedAt;
12
 
13
- const codecMatch = {
14
  h264: {
15
  videoCodec: "avc1",
16
  audioCodec: "mp4a",
@@ -28,32 +29,43 @@ const codecMatch = {
28
  }
29
  }
30
 
31
- const transformSessionData = (cookie) => {
32
- if (!cookie)
33
- return;
34
-
35
- const values = { ...cookie.values() };
36
- const REQUIRED_VALUES = [ 'access_token', 'refresh_token' ];
37
-
38
- if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
39
- return;
 
40
  }
 
41
 
42
- if (values.expires) {
43
- values.expiry_date = values.expires;
44
- delete values.expires;
45
- } else if (!values.expiry_date) {
46
- return;
47
- }
48
 
49
- return values;
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
- undefined,
68
  customFetch ?? innertube.session.http.fetch,
69
  innertube.session.cache
70
  );
71
 
72
- const cookie = getCookie('youtube_oauth');
73
- const oauthData = transformSessionData(cookie);
 
74
 
75
- if (!session.logged_in && oauthData) {
76
- await session.oauth.init(oauthData);
77
- session.logged_in = true;
78
- }
79
 
80
- if (session.logged_in) {
81
- if (session.oauth.shouldRefreshToken()) {
82
- await session.oauth.refreshAccessToken();
83
- }
84
 
85
- const cookieValues = cookie.values();
86
- const oldExpiry = new Date(cookieValues.expiry_date);
87
- const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date);
 
88
 
89
- if (oldExpiry.getTime() !== newExpiry.getTime()) {
90
- updateCookieValues(cookie, {
91
- ...session.oauth.client_id,
92
- ...session.oauth.oauth2_tokens,
93
- expiry_date: newExpiry.toISOString()
94
- });
95
- }
96
  }
97
 
98
- const yt = new Innertube(session);
99
- return yt;
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.message?.endsWith("decipher algorithm")) {
 
 
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
- const quality = o.quality === "max" ? "9000" : o.quality;
120
-
121
- let info, isDubbed,
122
- format = o.format || "h264";
123
-
124
- function qual(i) {
125
- if (!i.quality_label) {
126
- return;
 
 
 
 
 
 
127
  }
128
 
129
- return i.quality_label.split('p')[0].split('s')[0]
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
- if (playability.status === "LOGIN_REQUIRED") {
150
- if (playability.reason.endsWith("bot")) {
151
- return { error: "youtube.login" }
152
- }
153
- if (playability.reason.endsWith("age")) {
154
- return { error: "content.video.age" }
155
- }
156
- if (playability?.error_screen?.reason?.text === "Private video") {
157
- return { error: "content.video.private" }
158
- }
159
- }
 
160
 
161
- if (playability.status === "UNPLAYABLE") {
162
- if (playability?.reason?.endsWith("request limit.")) {
163
- return { error: "fetch.rate" }
164
- }
165
- if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
166
- return { error: "content.video.region" }
167
- }
168
- if (playability?.error_screen?.reason?.text === "Private video") {
169
- return { error: "content.video.private" }
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 filterByCodec = (formats) =>
190
- formats
191
- .filter(e =>
192
- e.mime_type.includes(codecMatch[format].videoCodec)
193
- || e.mime_type.includes(codecMatch[format].audioCodec)
194
- )
195
- .sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
196
 
197
- let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats);
 
198
 
199
- if (adaptive_formats.length === 0 && format === "vp9") {
200
- format = "h264"
201
- adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
202
- }
203
 
204
- let bestQuality;
 
 
205
 
206
- const bestVideo = adaptive_formats.find(i => i.has_video && i.content_length);
207
- const hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
 
 
 
 
 
 
 
208
 
209
- if (bestVideo) bestQuality = qual(bestVideo);
 
 
210
 
211
- if ((!bestQuality && !o.isAudioOnly) || !hasAudio)
212
- return { error: "youtube.codec" };
 
213
 
214
- if (basicInfo.duration > env.durationLimit)
215
- return { error: "content.too_long" };
 
216
 
217
- const checkBestAudio = (i) => (i.has_audio && !i.has_video);
 
 
218
 
219
- let audio = adaptive_formats.find(i =>
220
- checkBestAudio(i) && i.is_original
221
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
- if (o.dubLang) {
224
- let dubbedAudio = adaptive_formats.find(i =>
225
- checkBestAudio(i)
226
- && i.language === o.dubLang
227
- && i.audio_track
228
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
- if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
231
- audio = dubbedAudio;
232
- isDubbed = true;
 
 
 
 
 
233
  }
234
  }
235
 
236
- if (!audio) {
237
- audio = adaptive_formats.find(i => checkBestAudio(i));
238
  }
239
 
240
- let fileMetadata = {
241
- title: cleanString(basicInfo.title.trim()),
242
- artist: cleanString(basicInfo.author.replace("- Topic", "").trim()),
243
  }
244
 
245
  if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
246
- let descItems = basicInfo.short_description.split("\n\n", 5);
 
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
- let filenameAttributes = {
257
  service: "youtube",
258
  id: o.id,
259
  title: fileMetadata.title,
260
  author: fileMetadata.artist,
261
- youtubeDubName: isDubbed ? o.dubLang : false
262
  }
263
 
264
- if (audio && o.isAudioOnly) return {
265
- type: "audio",
266
- isAudioOnly: true,
267
- urls: audio.decipher(yt.session.player),
268
- filenameAttributes: filenameAttributes,
269
- fileMetadata: fileMetadata,
270
- bestAudio: format === "h264" ? "m4a" : "opus"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  }
272
 
273
- const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality,
274
- checkSingle = i =>
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
- let match, type, urls;
 
 
 
280
 
281
- // prefer good premuxed videos if available
282
- if (!o.isAudioOnly && !o.isAudioMuted && format === "h264" && bestVideo.fps <= 30) {
283
- match = info.streaming_data.formats.find(checkSingle);
284
- type = "proxy";
285
- urls = match?.decipher(yt.session.player);
286
- }
 
287
 
288
- const video = adaptive_formats.find(checkRender);
 
289
 
290
- if (!match && video && audio) {
291
- match = video;
292
- type = "merge";
293
- urls = [
294
- video.decipher(yt.session.player),
295
- audio.decipher(yt.session.player)
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: "fetch.fail" }
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 = (header, payload) =>
10
- createHmac("sha256", env.jwtSecret)
11
- .update(`${header}.${payload}`)
12
- .digest("base64url");
 
 
 
 
 
 
 
13
 
14
- const generate = () => {
15
  const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
16
 
17
  const header = toBase64URL(JSON.stringify({
@@ -21,10 +28,11 @@ const generate = () => {
21
 
22
  const payload = toBase64URL(JSON.stringify({
23
  jti: nanoid(8),
 
24
  exp,
25
  }));
26
 
27
- const signature = makeHmac(header, payload);
28
 
29
  return {
30
  token: `${header}.${payload}.${signature}`,
@@ -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 = makeHmac(header, payload);
44
 
45
  if (verifySignature !== signature) {
46
  return false;
47
  }
48
 
49
- if (timestamp >= JSON.parse(fromBase64URL(payload)).exp) {
50
- return false;
51
- }
52
 
53
- return true;
 
54
  }
55
 
56
  export default {
 
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
- hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);
 
25
 
26
- if (hlsObject.map) {
27
- hlsObject.map = transformObject(streamInfo, hlsObject.map);
 
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 isHlsRequest (req) {
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