diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..4ce1b2af4dd2a82a6a02d0b4d2267a50dedff997 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,18 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/bettertogether.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/catmakeup.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/catphonestand.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/catsleep.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/catswitchboxes.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/cattired.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/developers.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/meowthball.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/meowthcooking.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/meowthpolishegg.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/meowthproductions.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/meowthsnap.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/meowthstrong.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/millionusers.webp filter=lfs diff=lfs merge=lfs -text
+web/static/update-banners/valentines.webp filter=lfs diff=lfs merge=lfs -text
diff --git a/api/README.md b/api/README.md
index 5c281246fe2d145cb3acabe14ab7889c4cf05a1a..70d85de674a2e9c9d25ea6f5510dcd2949562bee 100644
--- a/api/README.md
+++ b/api/README.md
@@ -1,4 +1,64 @@
# cobalt api
+this directory includes the source code for cobalt api. it's made with [express.js](https://www.npmjs.com/package/express) and love!
+
+## running your own instance
+if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
+we recommend to use docker compose unless you intend to run cobalt for developing/debugging purposes.
+
+## accessing the api
+there is currently no publicly available pre-hosted api.
+we recommend [deploying your own instance](/docs/run-an-instance.md) if you wish to use the cobalt api.
+
+you can read [the api documentation here](/docs/api.md).
+
+## supported services
+this list is not final and keeps expanding over time!
+if the desired service isn't supported yet, feel free to create an appropriate issue (or a pull request 👀).
+
+| service | video + audio | only audio | only video | metadata | rich file names |
+| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
+| bilibili | ✅ | ✅ | ✅ | ➖ | ➖ |
+| bluesky | ✅ | ✅ | ✅ | ➖ | ➖ |
+| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
+| instagram | ✅ | ✅ | ✅ | ➖ | ➖ |
+| facebook | ✅ | ❌ | ✅ | ➖ | ➖ |
+| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
+| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
+| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
+| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
+| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
+| snapchat | ✅ | ✅ | ✅ | ➖ | ➖ |
+| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
+| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
+| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
+| tumblr | ✅ | ✅ | ✅ | ➖ | ➖ |
+| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
+| twitter/x | ✅ | ✅ | ✅ | ➖ | ➖ |
+| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
+| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
+| xiaohongshu | ✅ | ✅ | ✅ | ➖ | ➖ |
+| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
+
+| emoji | meaning |
+| :-----: | :---------------------- |
+| ✅ | supported |
+| ➖ | unreasonable/impossible |
+| ❌ | not supported |
+
+### additional notes or features (per service)
+| service | notes or features |
+| :-------- | :----- |
+| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
+| facebook | supports public accessible videos content only. |
+| pinterest | supports photos, gifs, videos and stories. |
+| reddit | supports gifs and videos. |
+| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
+| rutube | supports yappy & private links. |
+| soundcloud | supports private links. |
+| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
+| twitter/x | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management. |
+| vimeo | audio downloads are only available for dash. |
+| youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. |
## license
cobalt api code is licensed under [AGPL-3.0](LICENSE).
@@ -9,14 +69,35 @@ as long as you:
- provide a link to the license and indicate if changes to the code were made, and
- release the code under the **same license**
-## running your own instance
-if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md).
-it's *highly* recommended to use a docker compose method unless you run for developing/debugging purposes.
+## open source acknowledgements
+### ffmpeg
+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.
-## accessing the api
-currently, there is no publicly accessible main api. we plan on providing a public api for
-cobalt 10 in some form in the future. we recommend deploying your own instance if you wish
-to use the latest api. you can access [the documentation](/docs/api.md) for it here.
+you can [support ffmpeg here](https://ffmpeg.org/donations.html)!
+
+### youtube.js
+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.
+
+you can support the developer via various methods listed on their github page!
+(linked above)
+
+### many others
+cobalt-api also depends on:
+
+- **[content-disposition-header](https://www.npmjs.com/package/content-disposition-header)** to simplify the provision of `content-disposition` headers.
+- **[cors](https://www.npmjs.com/package/cors)** to manage cross-origin resource sharing within expressjs.
+- **[dotenv](https://www.npmjs.com/package/dotenv)** to load environment variables from the `.env` file.
+- **[express](https://www.npmjs.com/package/express)** as the backbone of cobalt servers.
+- **[express-rate-limit](https://www.npmjs.com/package/express-rate-limit)** to rate limit api endpoints.
+- **[ffmpeg-static](https://www.npmjs.com/package/ffmpeg-static)** to get binaries for ffmpeg depending on the platform.
+- **[hls-parser](https://www.npmjs.com/package/hls-parser)** to parse HLS playlists according to spec (very impressive stuff).
+- **[ipaddr.js](https://www.npmjs.com/package/ipaddr.js)** to parse ip addresses (used for rate limiting).
+- **[nanoid](https://www.npmjs.com/package/nanoid)** to generate unique identifiers for each requested tunnel.
+- **[set-cookie-parser](https://www.npmjs.com/package/set-cookie-parser)** to parse cookies that cobalt receives from certain services.
+- **[undici](https://www.npmjs.com/package/undici)** for making http requests.
+- **[url-pattern](https://www.npmjs.com/package/url-pattern)** to match provided links with supported patterns.
+- **[zod](https://www.npmjs.com/package/zod)** to lock down the api request schema.
+- **[@datastructures-js/priority-queue](https://www.npmjs.com/package/@datastructures-js/priority-queue)** for sorting stream caches for future clean up (without redis).
+- **[@imput/psl](https://www.npmjs.com/package/@imput/psl)** as the domain name parser, our fork of [psl](https://www.npmjs.com/package/psl).
-if you are looking for the documentation for the old (7.x) api, you can find
-it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md)
\ No newline at end of file
+...and many other packages that these packages rely on.
diff --git a/api/package.json b/api/package.json
index e37c839dd3213f4c393f73689be87b281c3c1181..20b86b15effeb500a4f7049236689cec6050da1c 100644
--- a/api/package.json
+++ b/api/package.json
@@ -1,7 +1,7 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
- "version": "10.1.0",
+ "version": "10.9.1",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
@@ -10,9 +10,8 @@
},
"scripts": {
"start": "node src/cobalt",
- "setup": "node src/util/setup",
"test": "node src/util/test",
- "token:youtube": "node src/util/generate-youtube-tokens"
+ "token:jwt": "node src/util/generate-jwt-secret"
},
"repository": {
"type": "git",
@@ -24,26 +23,27 @@
},
"homepage": "https://github.com/imputnet/cobalt#readme",
"dependencies": {
+ "@datastructures-js/priority-queue": "^6.3.1",
+ "@imput/psl": "^2.0.4",
"@imput/version-info": "workspace:^",
"content-disposition-header": "0.6.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
- "esbuild": "^0.14.51",
- "express": "^4.18.1",
- "express-rate-limit": "^6.3.0",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.4.1",
"ffmpeg-static": "^5.1.0",
"hls-parser": "^0.10.7",
- "ipaddr.js": "2.1.0",
- "nanoid": "^4.0.2",
- "node-cache": "^5.1.2",
- "psl": "1.9.0",
+ "ipaddr.js": "2.2.0",
+ "nanoid": "^5.0.9",
"set-cookie-parser": "2.6.0",
"undici": "^5.19.1",
"url-pattern": "1.0.3",
- "youtubei.js": "^10.3.0",
+ "youtubei.js": "^13.3.0",
"zod": "^3.23.8"
},
"optionalDependencies": {
- "freebind": "^0.2.2"
+ "freebind": "^0.2.2",
+ "rate-limit-redis": "^4.2.0",
+ "redis": "^4.7.0"
}
}
diff --git a/api/src/cobalt.js b/api/src/cobalt.js
index c548e792bd382e33ef788c8c4ac327f6cc21c793..5cac208daee73eb50d7517ef5b9e0cc0bacaabb9 100644
--- a/api/src/cobalt.js
+++ b/api/src/cobalt.js
@@ -1,27 +1,32 @@
import "dotenv/config";
import express from "express";
+import cluster from "node:cluster";
-import path from 'path';
-import { fileURLToPath } from 'url';
+import path from "path";
+import { fileURLToPath } from "url";
-import { env } from "./config.js"
-import { Bright, Green, Red } from "./misc/console-text.js";
+import { env, isCluster } from "./config.js"
+import { Red } from "./misc/console-text.js";
+import { initCluster } from "./misc/cluster.js";
const app = express();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename).slice(0, -4);
-app.disable('x-powered-by');
+app.disable("x-powered-by");
if (env.apiURL) {
- const { runAPI } = await import('./core/api.js');
- runAPI(express, app, __dirname)
+ const { runAPI } = await import("./core/api.js");
+
+ if (isCluster) {
+ await initCluster();
+ }
+
+ runAPI(express, app, __dirname, cluster.isPrimary);
} else {
console.log(
- Red(`cobalt wasn't configured yet or configuration is invalid.\n`)
- + Bright(`please run the setup script to fix this: `)
- + Green(`npm run setup`)
+ Red("API_URL env variable is missing, cobalt api can't start.")
)
}
diff --git a/api/src/config.js b/api/src/config.js
index 5f3e52cc8f0615b91ae0fdcbe6664d7e4d5dd6bf..bb4994c0e30f9a9e451adb2b648f9b1673858797 100644
--- a/api/src/config.js
+++ b/api/src/config.js
@@ -1,5 +1,7 @@
+import { Constants } from "youtubei.js";
import { getVersion } from "@imput/version-info";
import { services } from "./processing/service-config.js";
+import { supportsReusePort } from "./misc/cluster.js";
const version = await getVersion();
@@ -13,6 +15,7 @@ const enabledServices = new Set(Object.keys(services).filter(e => {
const env = {
apiURL: process.env.API_URL || '',
apiPort: process.env.API_PORT || 9000,
+ tunnelPort: process.env.API_PORT || 9000,
listenAddress: process.env.API_LISTEN_ADDRESS,
freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR,
@@ -25,8 +28,11 @@ const env = {
rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60,
rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20,
+ sessionRateLimitWindow: (process.env.SESSION_RATELIMIT_WINDOW && parseInt(process.env.SESSION_RATELIMIT_WINDOW)) || 60,
+ sessionRateLimit: (process.env.SESSION_RATELIMIT && parseInt(process.env.SESSION_RATELIMIT)) || 10,
+
durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800,
- streamLifespan: 90,
+ streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90,
processingPriority: process.platform !== 'win32'
&& process.env.PROCESSING_PRIORITY
@@ -43,12 +49,46 @@ const env = {
&& process.env.TURNSTILE_SECRET
&& process.env.JWT_SECRET,
+ apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL),
+ authRequired: process.env.API_AUTH_REQUIRED === '1',
+ redisURL: process.env.API_REDIS_URL,
+ instanceCount: (process.env.API_INSTANCE_COUNT && parseInt(process.env.API_INSTANCE_COUNT)) || 1,
+ keyReloadInterval: 900,
+
enabledServices,
+
+ customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
+ ytSessionServer: process.env.YOUTUBE_SESSION_SERVER,
+ ytSessionReloadInterval: 300,
+ ytSessionInnertubeClient: process.env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
}
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";
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
+export const setTunnelPort = (port) => env.tunnelPort = port;
+export const isCluster = env.instanceCount > 1;
+
+if (env.sessionEnabled && env.jwtSecret.length < 16) {
+ throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)");
+}
+
+if (env.instanceCount > 1 && !env.redisURL) {
+ throw new Error("API_REDIS_URL is required when API_INSTANCE_COUNT is >= 2");
+} else if (env.instanceCount > 1 && !await supportsReusePort()) {
+ console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js');
+ console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux');
+ console.error('(or other OS that supports it). for more info, see `reusePort` option on');
+ console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback');
+ throw new Error('SO_REUSEPORT is not supported');
+}
+
+if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
+ console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
+ console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
+ throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
+}
+
export {
env,
genericUserAgent,
diff --git a/api/src/core/api.js b/api/src/core/api.js
index 78d4359e6bf144ba1080bed56760745f5e223542..f1b54422e5da1bb09b7c0048d89ab724f8ec9a90 100644
--- a/api/src/core/api.js
+++ b/api/src/core/api.js
@@ -1,4 +1,5 @@
import cors from "cors";
+import http from "node:http";
import rateLimit from "express-rate-limit";
import { setGlobalDispatcher, ProxyAgent } from "undici";
import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info";
@@ -7,17 +8,21 @@ import jwt from "../security/jwt.js";
import stream from "../stream/stream.js";
import match from "../processing/match.js";
-import { env } from "../config.js";
+import { env, isCluster, setTunnelPort } from "../config.js";
import { extract } from "../processing/url.js";
-import { languageCode } from "../misc/utils.js";
-import { Bright, Cyan } from "../misc/console-text.js";
-import { generateHmac, generateSalt } from "../misc/crypto.js";
+import { Green, Bright, Cyan } from "../misc/console-text.js";
+import { hashHmac } from "../security/secrets.js";
+import { createStore } from "../store/redis-ratelimit.js";
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
import { verifyTurnstileToken } from "../security/turnstile.js";
import { friendlyServiceName } from "../processing/service-alias.js";
import { verifyStream, getInternalStream } from "../stream/manage.js";
import { createResponse, normalizeRequest, getIP } from "../processing/request.js";
+import * as APIKeys from "../security/api-keys.js";
+import * as Cookies from "../processing/cookie/manager.js";
+import * as YouTubeSession from "../processing/helpers/youtube-session.js";
+
const git = {
branch: await getBranch(),
commit: await getCommit(),
@@ -28,7 +33,6 @@ const version = await getVersion();
const acceptRegex = /^application\/json(; charset=utf-8)?$/;
-const ipSalt = generateSalt();
const corsConfig = env.corsWildcard ? {} : {
origin: env.corsURL,
optionsSuccessStatus: 200
@@ -39,7 +43,7 @@ const fail = (res, code, context) => {
res.status(status).json(body);
}
-export const runAPI = (express, app, __dirname) => {
+export const runAPI = async (express, app, __dirname, isPrimary = true) => {
const startTime = new Date();
const startTimestamp = startTime.getTime();
@@ -57,38 +61,49 @@ export const runAPI = (express, app, __dirname) => {
git,
})
+ const handleRateExceeded = (_, res) => {
+ const { status, body } = createResponse("error", {
+ code: "error.api.rate_exceeded",
+ context: {
+ limit: env.rateLimitWindow
+ }
+ });
+ return res.status(status).json(body);
+ };
+
+ const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url');
+
+ const sessionLimiter = rateLimit({
+ windowMs: env.sessionRateLimitWindow * 1000,
+ limit: env.sessionRateLimit,
+ standardHeaders: 'draft-6',
+ legacyHeaders: false,
+ keyGenerator,
+ store: await createStore('session'),
+ handler: handleRateExceeded
+ });
+
const apiLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
- max: env.rateLimitMax,
- standardHeaders: true,
+ limit: (req) => req.rateLimitMax || env.rateLimitMax,
+ standardHeaders: 'draft-6',
legacyHeaders: false,
- keyGenerator: req => {
- if (req.authorized) {
- return generateHmac(req.header("Authorization"), ipSalt);
- }
- return generateHmac(getIP(req), ipSalt);
- },
- handler: (req, res) => {
- const { status, body } = createResponse("error", {
- code: "error.api.rate_exceeded",
- context: {
- limit: env.rateLimitWindow
- }
- });
- return res.status(status).json(body);
- }
- })
+ keyGenerator: req => req.rateLimitKey || keyGenerator(req),
+ store: await createStore('api'),
+ handler: handleRateExceeded
+ });
- const apiLimiterStream = rateLimit({
+ const apiTunnelLimiter = rateLimit({
windowMs: env.rateLimitWindow * 1000,
- max: env.rateLimitMax,
- standardHeaders: true,
+ limit: (req) => req.rateLimitMax || env.rateLimitMax,
+ standardHeaders: 'draft-6',
legacyHeaders: false,
- keyGenerator: req => generateHmac(getIP(req), ipSalt),
- handler: (req, res) => {
+ keyGenerator: req => req.rateLimitKey || keyGenerator(req),
+ store: await createStore('tunnel'),
+ handler: (_, res) => {
return res.sendStatus(429)
}
- })
+ });
app.set('trust proxy', ['loopback', 'uniquelocal']);
@@ -103,9 +118,6 @@ export const runAPI = (express, app, __dirname) => {
...corsConfig,
}));
- app.post('/', apiLimiter);
- app.use('/tunnel', apiLimiterStream);
-
app.post('/', (req, res, next) => {
if (!acceptRegex.test(req.header('Accept'))) {
return fail(res, "error.api.header.accept");
@@ -117,7 +129,34 @@ export const runAPI = (express, app, __dirname) => {
});
app.post('/', (req, res, next) => {
- if (!env.sessionEnabled) {
+ if (!env.apiKeyURL) {
+ return next();
+ }
+
+ const { success, error } = APIKeys.validateAuthorization(req);
+ if (!success) {
+ // We call next() here if either if:
+ // a) we have user sessions enabled, meaning the request
+ // will still need a Bearer token to not be rejected, or
+ // b) we do not require the user to be authenticated, and
+ // so they can just make the request with the regular
+ // rate limit configuration;
+ // otherwise, we reject the request.
+ if (
+ (env.sessionEnabled || !env.authRequired)
+ && ['missing', 'not_api_key'].includes(error)
+ ) {
+ return next();
+ }
+
+ return fail(res, `error.api.auth.key.${error}`);
+ }
+
+ return next();
+ });
+
+ app.post('/', (req, res, next) => {
+ if (!env.sessionEnabled || req.rateLimitKey) {
return next();
}
@@ -127,26 +166,29 @@ export const runAPI = (express, app, __dirname) => {
return fail(res, "error.api.auth.jwt.missing");
}
- if (!authorization.startsWith("Bearer ") || authorization.length > 256) {
+ if (authorization.length >= 256) {
return fail(res, "error.api.auth.jwt.invalid");
}
- const verifyJwt = jwt.verify(
- authorization.split("Bearer ", 2)[1]
- );
+ const [ type, token, ...rest ] = authorization.split(" ");
+ if (!token || type.toLowerCase() !== 'bearer' || rest.length) {
+ return fail(res, "error.api.auth.jwt.invalid");
+ }
- if (!verifyJwt) {
+ if (!jwt.verify(token, getIP(req, 32))) {
return fail(res, "error.api.auth.jwt.invalid");
}
- req.authorized = true;
+ req.rateLimitKey = hashHmac(token, 'rate');
} catch {
return fail(res, "error.api.generic");
}
next();
});
+ app.post('/', apiLimiter);
app.use('/', express.json({ limit: 1024 }));
+
app.use('/', (err, _, res, next) => {
if (err) {
const { status, body } = createResponse("error", {
@@ -158,7 +200,7 @@ export const runAPI = (express, app, __dirname) => {
next();
});
- app.post("/session", async (req, res) => {
+ app.post("/session", sessionLimiter, async (req, res) => {
if (!env.sessionEnabled) {
return fail(res, "error.api.auth.not_configured")
}
@@ -179,7 +221,7 @@ export const runAPI = (express, app, __dirname) => {
}
try {
- res.json(jwt.generate());
+ res.json(jwt.generate(getIP(req, 32)));
} catch {
return fail(res, "error.api.generic");
}
@@ -187,16 +229,11 @@ export const runAPI = (express, app, __dirname) => {
app.post('/', async (req, res) => {
const request = req.body;
- const lang = languageCode(req);
if (!request.url) {
return fail(res, "error.api.link.missing");
}
- if (request.youtubeDubBrowserLang) {
- request.youtubeDubLang = lang;
- }
-
const { success, data: normalizedRequest } = await normalizeRequest(request);
if (!success) {
return fail(res, "error.api.invalid_body");
@@ -228,7 +265,7 @@ export const runAPI = (express, app, __dirname) => {
}
})
- app.get('/tunnel', (req, res) => {
+ app.get('/tunnel', apiTunnelLimiter, async (req, res) => {
const id = String(req.query.id);
const exp = String(req.query.exp);
const sig = String(req.query.sig);
@@ -247,7 +284,7 @@ export const runAPI = (express, app, __dirname) => {
return res.status(200).end();
}
- const streamInfo = verifyStream(id, sig, exp, sec, iv);
+ const streamInfo = await verifyStream(id, sig, exp, sec, iv);
if (!streamInfo?.service) {
return res.status(streamInfo.status).end();
}
@@ -259,7 +296,7 @@ export const runAPI = (express, app, __dirname) => {
return stream(res, streamInfo);
})
- app.get('/itunnel', (req, res) => {
+ const itunnelHandler = (req, res) => {
if (!req.ip.endsWith('127.0.0.1')) {
return res.sendStatus(403);
}
@@ -278,8 +315,10 @@ export const runAPI = (express, app, __dirname) => {
...Object.entries(req.headers)
]);
- return stream(res, { type: 'internal', ...streamInfo });
- })
+ return stream(res, { type: 'internal', data: streamInfo });
+ };
+
+ app.get('/itunnel', itunnelHandler);
app.get('/', (_, res) => {
res.type('json');
@@ -310,20 +349,52 @@ export const runAPI = (express, app, __dirname) => {
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
}
- app.listen(env.apiPort, env.listenAddress, () => {
- console.log(`\n` +
- Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
-
- "~~~~~~\n" +
- Bright("version: ") + version + "\n" +
- Bright("commit: ") + git.commit + "\n" +
- Bright("branch: ") + git.branch + "\n" +
- Bright("remote: ") + git.remote + "\n" +
- Bright("start time: ") + startTime.toUTCString() + "\n" +
- "~~~~~~\n" +
-
- Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
- Bright("port: ") + env.apiPort + "\n"
- )
- })
+ http.createServer(app).listen({
+ port: env.apiPort,
+ host: env.listenAddress,
+ reusePort: env.instanceCount > 1 || undefined
+ }, () => {
+ if (isPrimary) {
+ console.log(`\n` +
+ Bright(Cyan("cobalt ")) + Bright("API ^ω^") + "\n" +
+
+ "~~~~~~\n" +
+ Bright("version: ") + version + "\n" +
+ Bright("commit: ") + git.commit + "\n" +
+ Bright("branch: ") + git.branch + "\n" +
+ Bright("remote: ") + git.remote + "\n" +
+ Bright("start time: ") + startTime.toUTCString() + "\n" +
+ "~~~~~~\n" +
+
+ Bright("url: ") + Bright(Cyan(env.apiURL)) + "\n" +
+ Bright("port: ") + env.apiPort + "\n"
+ );
+ }
+
+ if (env.apiKeyURL) {
+ APIKeys.setup(env.apiKeyURL);
+ }
+
+ if (env.cookiePath) {
+ Cookies.setup(env.cookiePath);
+ }
+
+ if (env.ytSessionServer) {
+ YouTubeSession.setup();
+ }
+ });
+
+ if (isCluster) {
+ const istreamer = express();
+ istreamer.get('/itunnel', itunnelHandler);
+ const server = istreamer.listen({
+ port: 0,
+ host: '127.0.0.1',
+ exclusive: true
+ }, () => {
+ const { port } = server.address();
+ console.log(`${Green('[✓]')} cobalt sub-instance running on 127.0.0.1:${port}`);
+ setTunnelPort(port);
+ });
+ }
}
diff --git a/api/src/misc/cluster.js b/api/src/misc/cluster.js
new file mode 100644
index 0000000000000000000000000000000000000000..56664d15f5b488fbd2e9e1290928334d135e5b7d
--- /dev/null
+++ b/api/src/misc/cluster.js
@@ -0,0 +1,71 @@
+import cluster from "node:cluster";
+import net from "node:net";
+import { syncSecrets } from "../security/secrets.js";
+import { env, isCluster } from "../config.js";
+
+export { isPrimary, isWorker } from "node:cluster";
+
+export const supportsReusePort = async () => {
+ try {
+ await new Promise((resolve, reject) => {
+ const server = net.createServer().listen({ port: 0, reusePort: true });
+ server.on('listening', () => server.close(resolve));
+ server.on('error', (err) => (server.close(), reject(err)));
+ });
+
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export const initCluster = async () => {
+ if (cluster.isPrimary) {
+ for (let i = 1; i < env.instanceCount; ++i) {
+ cluster.fork();
+ }
+ }
+
+ await syncSecrets();
+}
+
+export const broadcast = (message) => {
+ if (!isCluster || !cluster.isPrimary || !cluster.workers) {
+ return;
+ }
+
+ for (const worker of Object.values(cluster.workers)) {
+ worker.send(message);
+ }
+}
+
+export const send = (message) => {
+ if (!isCluster) {
+ return;
+ }
+
+ if (cluster.isPrimary) {
+ return broadcast(message);
+ } else {
+ return process.send(message);
+ }
+}
+
+export const waitFor = (key) => {
+ return new Promise(resolve => {
+ const listener = (message) => {
+ if (key in message) {
+ process.off('message', listener);
+ return resolve(message);
+ }
+ }
+
+ process.on('message', listener);
+ });
+}
+
+export const mainOnMessage = (cb) => {
+ for (const worker of Object.values(cluster.workers)) {
+ worker.on('message', cb);
+ }
+}
diff --git a/api/src/misc/console-text.js b/api/src/misc/console-text.js
index 014584aefc88cfcbc5c1e28a3fbd9a7a4a4cab4f..8df8fcc649a1aff426b9c36fbf8044a7379c40a1 100644
--- a/api/src/misc/console-text.js
+++ b/api/src/misc/console-text.js
@@ -1,16 +1,36 @@
-function t(color, tt) {
- return color + tt + "\x1b[0m"
+const ANSI = {
+ RESET: "\x1b[0m",
+ BRIGHT: "\x1b[1m",
+ RED: "\x1b[31m",
+ GREEN: "\x1b[32m",
+ CYAN: "\x1b[36m",
+ YELLOW: "\x1b[93m"
}
-export function Bright(tt) {
- return t("\x1b[1m", tt)
+function wrap(color, text) {
+ if (!ANSI[color.toUpperCase()]) {
+ throw "invalid color";
+ }
+
+ return ANSI[color.toUpperCase()] + text + ANSI.RESET;
+}
+
+export function Bright(text) {
+ return wrap('bright', text);
+}
+
+export function Red(text) {
+ return wrap('red', text);
}
-export function Red(tt) {
- return t("\x1b[31m", tt)
+
+export function Green(text) {
+ return wrap('green', text);
}
-export function Green(tt) {
- return t("\x1b[32m", tt)
+
+export function Cyan(text) {
+ return wrap('cyan', text);
}
-export function Cyan(tt) {
- return t("\x1b[36m", tt)
+
+export function Yellow(text) {
+ return wrap('yellow', text);
}
diff --git a/api/src/misc/crypto.js b/api/src/misc/crypto.js
index 3a52015664b9e91e6c8f9da2f8c84dc6ff67bd05..e0f8858bcd4870acbb9401aa17257a1ed467e9cd 100644
--- a/api/src/misc/crypto.js
+++ b/api/src/misc/crypto.js
@@ -1,15 +1,7 @@
-import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto";
+import { createCipheriv, createDecipheriv } from "crypto";
const algorithm = "aes256";
-export function generateSalt() {
- return randomBytes(64).toString('hex');
-}
-
-export function generateHmac(str, salt) {
- return createHmac("sha256", salt).update(str).digest("base64url");
-}
-
export function encryptStream(plaintext, iv, secret) {
const buff = Buffer.from(JSON.stringify(plaintext));
const key = Buffer.from(secret, "base64url");
diff --git a/api/src/misc/run-test.js b/api/src/misc/run-test.js
index 10d19aef744e311adbb8910067887b62511374b4..6dd08183107bc9e36179176cf2a9ce6ca3357a68 100644
--- a/api/src/misc/run-test.js
+++ b/api/src/misc/run-test.js
@@ -23,6 +23,15 @@ export async function runTest(url, params, expect) {
if (expect.status !== result.body.status) {
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
error.push(`status mismatch: ${detail}`);
+
+ if (result.body.status === 'error') {
+ error.push(`error code: ${result.body?.error?.code}`);
+ }
+ }
+
+ if (expect.errorCode && expect.errorCode !== result.body?.error?.code) {
+ const detail = `${expect.errorCode} (expected) != ${result.body.error.code} (actual)`
+ error.push(`error mismatch: ${detail}`);
}
if (expect.code !== result.status) {
@@ -41,4 +50,4 @@ export async function runTest(url, params, expect) {
if (result.body.status === 'tunnel') {
// TODO: stream testing
}
-}
\ No newline at end of file
+}
diff --git a/api/src/misc/utils.js b/api/src/misc/utils.js
index 34666d1c658e65be913a207ff97f9a746f443774..62bf6351b1706edb2ab92f7070c8f0e2d8a646f3 100644
--- a/api/src/misc/utils.js
+++ b/api/src/misc/utils.js
@@ -1,55 +1,27 @@
-const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
-
-export function metadataManager(obj) {
- const keys = Object.keys(obj);
- const tags = [
- "album",
- "copyright",
- "title",
- "artist",
- "track",
- "date"
- ]
- let commands = []
-
- for (const i in keys) {
- if (tags.includes(keys[i]))
- commands.push('-metadata', `${keys[i]}=${obj[keys[i]]}`)
+import { request } from 'undici';
+const redirectStatuses = new Set([301, 302, 303, 307, 308]);
+
+export async function getRedirectingURL(url, dispatcher, headers) {
+ const params = {
+ dispatcher,
+ method: 'HEAD',
+ headers,
+ redirect: 'manual'
+ };
+
+ let location = await request(url, params).then(r => {
+ if (redirectStatuses.has(r.statusCode) && r.headers['location']) {
+ return r.headers['location'];
}
- return commands;
-}
-
-export function cleanString(string) {
- for (const i in forbiddenCharsString) {
- string = string.replaceAll("/", "_")
- .replaceAll(forbiddenCharsString[i], '')
- }
- return string;
-}
-export function verifyLanguageCode(code) {
- const langCode = String(code.slice(0, 2).toLowerCase());
- if (RegExp(/[a-z]{2}/).test(code)) {
- return langCode
- }
- return "en"
-}
-export function languageCode(req) {
- if (req.header('Accept-Language')) {
- return verifyLanguageCode(req.header('Accept-Language'))
- }
- return "en"
-}
-export function cleanHTML(html) {
- let clean = html.replace(/ {4}/g, '');
- clean = clean.replace(/\n/g, '');
- return clean
-}
+ }).catch(() => null);
-export function getRedirectingURL(url) {
- return fetch(url, { redirect: 'manual' }).then((r) => {
- if ([301, 302, 303].includes(r.status) && r.headers.has('location'))
+ location ??= await fetch(url, params).then(r => {
+ if (redirectStatuses.has(r.status) && r.headers.has('location')) {
return r.headers.get('location');
+ }
}).catch(() => null);
+
+ return location;
}
export function merge(a, b) {
@@ -76,3 +48,7 @@ export function splitFilenameExtension(filename) {
return [ parts.join('.'), ext ]
}
}
+
+export function zip(a, b) {
+ return a.map((value, i) => [ value, b[i] ]);
+}
diff --git a/api/src/processing/cookie/cookie.js b/api/src/processing/cookie/cookie.js
index 6dd95fc30497593ff17026e91d306ea9fc5208f1..1d9636d5fe5c11bd17d01354704a91b3e1543912 100644
--- a/api/src/processing/cookie/cookie.js
+++ b/api/src/processing/cookie/cookie.js
@@ -4,16 +4,24 @@ export default class Cookie {
constructor(input) {
assert(typeof input === 'object');
this._values = {};
- this.set(input)
+
+ for (const [ k, v ] of Object.entries(input))
+ this.set(k, v);
}
- set(values) {
- Object.entries(values).forEach(
- ([ key, value ]) => this._values[key] = value
- )
+
+ set(key, value) {
+ const old = this._values[key];
+ if (old === value)
+ return false;
+
+ this._values[key] = value;
+ return true;
}
+
unset(keys) {
for (const key of keys) delete this._values[key]
}
+
static fromString(str) {
const obj = {};
@@ -25,12 +33,15 @@ export default class Cookie {
return new Cookie(obj)
}
+
toString() {
return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ')
}
+
toJSON() {
return this.toString()
}
+
values() {
return Object.freeze({ ...this._values })
}
diff --git a/api/src/processing/cookie/manager.js b/api/src/processing/cookie/manager.js
index 25bf9c90ece8b4379c5c8dae9312ebd9503c1579..9e23374b97fcd5b74eacfb2f1c0897df4fb95c27 100644
--- a/api/src/processing/cookie/manager.js
+++ b/api/src/processing/cookie/manager.js
@@ -1,50 +1,144 @@
import Cookie from './cookie.js';
+
import { readFile, writeFile } from 'fs/promises';
+import { Red, Green, Yellow } from '../../misc/console-text.js';
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
-import { env } from '../../config.js';
+import * as cluster from '../../misc/cluster.js';
+import { isCluster } from '../../config.js';
-const WRITE_INTERVAL = 60000,
- cookiePath = env.cookiePath,
- COUNTER = Symbol('counter');
+const WRITE_INTERVAL = 60000;
+const VALID_SERVICES = new Set([
+ 'instagram',
+ 'instagram_bearer',
+ 'reddit',
+ 'twitter',
+ 'youtube',
+]);
+const invalidCookies = {};
let cookies = {}, dirty = false, intervalId;
-const setup = async () => {
- try {
- if (!cookiePath) return;
+function writeChanges(cookiePath) {
+ if (!dirty) return;
+ dirty = false;
+
+ const cookieData = JSON.stringify({ ...cookies, ...invalidCookies }, null, 4);
+ writeFile(cookiePath, cookieData).catch((e) => {
+ console.warn(`${Yellow('[!]')} failed writing updated cookies to storage`);
+ console.warn(e);
+ clearInterval(intervalId);
+ intervalId = null;
+ })
+}
+const setupMain = async (cookiePath) => {
+ try {
cookies = await readFile(cookiePath, 'utf8');
cookies = JSON.parse(cookies);
- intervalId = setInterval(writeChanges, WRITE_INTERVAL)
- } catch { /* no cookies for you */ }
+ for (const serviceName in cookies) {
+ if (!VALID_SERVICES.has(serviceName)) {
+ console.warn(`${Yellow('[!]')} ignoring unknown service in cookie file: ${serviceName}`);
+ } else if (!Array.isArray(cookies[serviceName])) {
+ console.warn(`${Yellow('[!]')} ${serviceName} in cookies file is not an array, ignoring it`);
+ } else if (cookies[serviceName].some(c => typeof c !== 'string')) {
+ console.warn(`${Yellow('[!]')} some cookie for ${serviceName} contains non-string value in cookies file`);
+ } else continue;
+
+ invalidCookies[serviceName] = cookies[serviceName];
+ delete cookies[serviceName];
+ }
+
+ if (!intervalId) {
+ intervalId = setInterval(() => writeChanges(cookiePath), WRITE_INTERVAL);
+ }
+
+ cluster.broadcast({ cookies });
+
+ console.log(`${Green('[✓]')} cookies loaded successfully!`);
+ } catch (e) {
+ console.error(`${Yellow('[!]')} failed to load cookies.`);
+ console.error('error:', e);
+ }
}
-setup();
+const setupWorker = async () => {
+ cookies = (await cluster.waitFor('cookies')).cookies;
+}
+
+export const loadFromFile = async (path) => {
+ if (cluster.isPrimary) {
+ await setupMain(path);
+ } else if (cluster.isWorker) {
+ await setupWorker();
+ }
-function writeChanges() {
- if (!dirty) return;
dirty = false;
+}
- writeFile(cookiePath, JSON.stringify(cookies, null, 4)).catch(() => {
- clearInterval(intervalId)
- })
+export const setup = async (path) => {
+ await loadFromFile(path);
+
+ if (isCluster) {
+ const messageHandler = (message) => {
+ if ('cookieUpdate' in message) {
+ const { cookieUpdate } = message;
+
+ if (cluster.isPrimary) {
+ dirty = true;
+ cluster.broadcast({ cookieUpdate });
+ }
+
+ const { service, idx, cookie } = cookieUpdate;
+ cookies[service][idx] = cookie;
+ }
+ }
+
+ if (cluster.isPrimary) {
+ cluster.mainOnMessage(messageHandler);
+ } else {
+ process.on('message', messageHandler);
+ }
+ }
}
export function getCookie(service) {
+ if (!VALID_SERVICES.has(service)) {
+ console.error(
+ `${Red('[!]')} ${service} not in allowed services list for cookies.`
+ + ' if adding a new cookie type, include it there.'
+ );
+ return;
+ }
+
if (!cookies[service] || !cookies[service].length) return;
- let n;
- if (cookies[service][COUNTER] === undefined) {
- n = cookies[service][COUNTER] = 0
- } else {
- ++cookies[service][COUNTER]
- n = (cookies[service][COUNTER] %= cookies[service].length)
+ const idx = Math.floor(Math.random() * cookies[service].length);
+
+ const cookie = cookies[service][idx];
+ if (typeof cookie === 'string') {
+ cookies[service][idx] = Cookie.fromString(cookie);
}
- const cookie = cookies[service][n];
- if (typeof cookie === 'string') cookies[service][n] = Cookie.fromString(cookie);
+ cookies[service][idx].meta = { service, idx };
+ return cookies[service][idx];
+}
- return cookies[service][n]
+export function updateCookieValues(cookie, values) {
+ let changed = false;
+
+ for (const [ key, value ] of Object.entries(values)) {
+ changed = cookie.set(key, value) || changed;
+ }
+
+ if (changed && cookie.meta) {
+ dirty = true;
+ if (isCluster) {
+ const message = { cookieUpdate: { ...cookie.meta, cookie } };
+ cluster.send(message);
+ }
+ }
+
+ return changed;
}
export function updateCookie(cookie, headers) {
@@ -57,10 +151,6 @@ export function updateCookie(cookie, headers) {
cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name));
parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value);
- updateCookieValues(cookie, values);
-}
-export function updateCookieValues(cookie, values) {
- cookie.set(values);
- if (Object.keys(values).length) dirty = true
+ updateCookieValues(cookie, values);
}
diff --git a/api/src/processing/create-filename.js b/api/src/processing/create-filename.js
index 216b15a42ea6cca2f9889cfe61f89b2661681284..911b5603eb517bfa2f6b2b191e76e30971cc6a97 100644
--- a/api/src/processing/create-filename.js
+++ b/api/src/processing/create-filename.js
@@ -1,3 +1,13 @@
+const illegalCharacters = ['}', '{', '%', '>', '<', '^', ';', ':', '`', '$', '"', "@", '=', '?', '|', '*'];
+
+const sanitizeString = (string) => {
+ for (const i in illegalCharacters) {
+ string = string.replaceAll("/", "_").replaceAll("\\", "_")
+ .replaceAll(illegalCharacters[i], '')
+ }
+ return string;
+}
+
export default (f, style, isAudioOnly, isAudioMuted) => {
let filename = '';
@@ -5,7 +15,11 @@ export default (f, style, isAudioOnly, isAudioMuted) => {
let classicTags = [...infoBase];
let basicTags = [];
- const title = `${f.title} - ${f.author}`;
+ let title = sanitizeString(f.title);
+
+ if (f.author) {
+ title += ` - ${sanitizeString(f.author)}`;
+ }
if (f.resolution) {
classicTags.push(f.resolution);
diff --git a/api/src/processing/helpers/youtube-session.js b/api/src/processing/helpers/youtube-session.js
new file mode 100644
index 0000000000000000000000000000000000000000..85f1a6e13de899c8b47abff010a56d1bded540bc
--- /dev/null
+++ b/api/src/processing/helpers/youtube-session.js
@@ -0,0 +1,81 @@
+import * as cluster from "../../misc/cluster.js";
+
+import { Agent } from "undici";
+import { env } from "../../config.js";
+import { Green, Yellow } from "../../misc/console-text.js";
+
+const defaultAgent = new Agent();
+
+let session;
+
+const validateSession = (sessionResponse) => {
+ if (!sessionResponse.potoken) {
+ throw "no poToken in session response";
+ }
+
+ if (!sessionResponse.visitor_data) {
+ throw "no visitor_data in session response";
+ }
+
+ if (!sessionResponse.updated) {
+ throw "no last update timestamp in session response";
+ }
+
+ // https://github.com/iv-org/youtube-trusted-session-generator/blob/c2dfe3f/potoken_generator/main.py#L25
+ if (sessionResponse.potoken.length < 160) {
+ console.error(`${Yellow('[!]')} poToken is too short and might not work (${new Date().toISOString()})`);
+ }
+}
+
+const updateSession = (newSession) => {
+ session = newSession;
+}
+
+const loadSession = async () => {
+ const sessionServerUrl = new URL(env.ytSessionServer);
+ sessionServerUrl.pathname = "/token";
+
+ const newSession = await fetch(
+ sessionServerUrl,
+ { dispatcher: defaultAgent }
+ ).then(a => a.json());
+
+ validateSession(newSession);
+
+ if (!session || session.updated < newSession?.updated) {
+ cluster.broadcast({ youtube_session: newSession });
+ updateSession(newSession);
+ }
+}
+
+const wrapLoad = (initial = false) => {
+ loadSession()
+ .then(() => {
+ if (initial) {
+ console.log(`${Green('[✓]')} poToken & visitor_data loaded successfully!`);
+ }
+ })
+ .catch((e) => {
+ console.error(`${Yellow('[!]')} Failed loading poToken & visitor_data at ${new Date().toISOString()}.`);
+ console.error('Error:', e);
+ })
+}
+
+export const getYouTubeSession = () => {
+ return session;
+}
+
+export const setup = () => {
+ if (cluster.isPrimary) {
+ wrapLoad(true);
+ if (env.ytSessionReloadInterval > 0) {
+ setInterval(wrapLoad, env.ytSessionReloadInterval * 1000);
+ }
+ } else if (cluster.isWorker) {
+ process.on('message', (message) => {
+ if ('youtube_session' in message) {
+ updateSession(message.youtube_session);
+ }
+ });
+ }
+}
diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js
index 4fdb24f649bac89e95672509ed8a9f204d4053b8..363cb4031933dc4f523d7edf3ff7c89097d38147 100644
--- a/api/src/processing/match-action.js
+++ b/api/src/processing/match-action.js
@@ -9,13 +9,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
let action,
responseType = "tunnel",
defaultParams = {
- u: r.urls,
+ url: r.urls,
headers: r.headers,
service: host,
filename: r.filenameAttributes ?
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
fileMetadata: !disableMetadata ? r.fileMetadata : false,
- requestIP
+ requestIP,
+ originalRequest: r.originalRequest
},
params = {};
@@ -24,7 +25,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
else if (r.isGif && twitterGif) action = "gif";
else if (isAudioOnly) action = "audio";
else if (isAudioMuted) action = "muteVideo";
- else if (r.isM3U8) action = "m3u8";
+ else if (r.isHLS) action = "hls";
else action = "video";
if (action === "picker" || action === "audio") {
@@ -47,27 +48,29 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
});
case "photo":
- responseType = "redirect";
+ params = { type: "proxy" };
break;
case "gif":
params = { type: "gif" };
break;
- case "m3u8":
+ case "hls":
params = {
- type: Array.isArray(r.urls) ? "merge" : "remux"
+ type: Array.isArray(r.urls) ? "merge" : "remux",
+ isHLS: true,
}
break;
case "muteVideo":
let muteType = "mute";
- if (Array.isArray(r.urls) && !r.isM3U8) {
+ if (Array.isArray(r.urls) && !r.isHLS) {
muteType = "proxy";
}
params = {
type: muteType,
- u: Array.isArray(r.urls) ? r.urls[0] : r.urls
+ url: Array.isArray(r.urls) ? r.urls[0] : r.urls,
+ isHLS: r.isHLS
}
if (host === "reddit" && r.typeId === "redirect") {
responseType = "redirect";
@@ -81,6 +84,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "twitter":
case "snapchat":
case "bsky":
+ case "xiaohongshu":
params = { picker: r.picker };
break;
@@ -92,14 +96,15 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
params = {
picker: r.picker,
- u: createStream({
+ url: createStream({
service: "tiktok",
type: audioStreamType,
- u: r.urls,
+ url: r.urls,
headers: r.headers,
- filename: r.audioFilename,
+ filename: `${r.audioFilename}.${audioFormat}`,
isAudioOnly: true,
audioFormat,
+ audioBitrate
})
}
break;
@@ -137,13 +142,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
break;
+ case "ok":
case "vk":
case "tiktok":
+ case "xiaohongshu":
params = { type: "proxy" };
break;
case "facebook":
- case "vine":
case "instagram":
case "tumblr":
case "pinterest":
@@ -159,7 +165,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "audio":
if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
return createResponse("error", {
- code: "error.api.fetch.empty"
+ code: "error.api.service.audio_not_supported"
})
}
@@ -183,18 +189,20 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
}
- if (r.isM3U8 || host === "vimeo") {
+ if (r.isHLS || host === "vimeo") {
copy = false;
processType = "audio";
}
params = {
type: processType,
- u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
+ url: Array.isArray(r.urls) ? r.urls[1] : r.urls,
audioBitrate,
audioCopy: copy,
audioFormat,
+
+ isHLS: r.isHLS,
}
break;
}
diff --git a/api/src/processing/match.js b/api/src/processing/match.js
index ffb92c23fe15c1ccc412376a0cb4bbf3e8f2bbd1..ee4fdc1a57e64839955eb6b70170644ef0e4fb57 100644
--- a/api/src/processing/match.js
+++ b/api/src/processing/match.js
@@ -19,7 +19,6 @@ import tumblr from "./services/tumblr.js";
import vimeo from "./services/vimeo.js";
import soundcloud from "./services/soundcloud.js";
import instagram from "./services/instagram.js";
-import vine from "./services/vine.js";
import pinterest from "./services/pinterest.js";
import streamable from "./services/streamable.js";
import twitch from "./services/twitch.js";
@@ -29,6 +28,7 @@ import snapchat from "./services/snapchat.js";
import loom from "./services/loom.js";
import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js";
+import xiaohongshu from "./services/xiaohongshu.js";
let freebind;
@@ -78,8 +78,9 @@ export default async function({ host, patternMatch, params }) {
case "vk":
r = await vk({
- userId: patternMatch.userId,
+ ownerId: patternMatch.ownerId,
videoId: patternMatch.videoId,
+ accessKey: patternMatch.accessKey,
quality: params.videoQuality
});
break;
@@ -97,17 +98,18 @@ export default async function({ host, patternMatch, params }) {
case "youtube":
let fetchInfo = {
+ dispatcher,
id: patternMatch.id.slice(0, 11),
quality: params.videoQuality,
format: params.youtubeVideoCodec,
isAudioOnly,
isAudioMuted,
dubLang: params.youtubeDubLang,
- dispatcher
+ youtubeHLS: params.youtubeHLS,
}
if (url.hostname === "music.youtube.com" || isAudioOnly) {
- fetchInfo.quality = "max";
+ fetchInfo.quality = "1080";
fetchInfo.format = "vp9";
fetchInfo.isAudioOnly = true;
fetchInfo.isAudioMuted = false;
@@ -118,16 +120,15 @@ export default async function({ host, patternMatch, params }) {
case "reddit":
r = await reddit({
- sub: patternMatch.sub,
- id: patternMatch.id,
- user: patternMatch.user
+ ...patternMatch,
+ dispatcher,
});
break;
case "tiktok":
r = await tiktok({
postId: patternMatch.postId,
- id: patternMatch.id,
+ shortLink: patternMatch.shortLink,
fullAudio: params.tiktokFullAudio,
isAudioOnly,
h265: params.tiktokH265,
@@ -174,12 +175,6 @@ export default async function({ host, patternMatch, params }) {
})
break;
- case "vine":
- r = await vine({
- id: patternMatch.id
- });
- break;
-
case "pinterest":
r = await pinterest({
id: patternMatch.id,
@@ -232,14 +227,25 @@ export default async function({ host, patternMatch, params }) {
case "facebook":
r = await facebook({
- ...patternMatch
+ ...patternMatch,
+ dispatcher
});
break;
case "bsky":
r = await bluesky({
...patternMatch,
- alwaysProxy: params.alwaysProxy
+ alwaysProxy: params.alwaysProxy,
+ dispatcher
+ });
+ break;
+
+ case "xiaohongshu":
+ r = await xiaohongshu({
+ ...patternMatch,
+ h265: params.tiktokH265,
+ isAudioOnly,
+ dispatcher,
});
break;
diff --git a/api/src/processing/request.js b/api/src/processing/request.js
index 4287267c8d385f5c581eb60c35700da895969c9e..61bf027b1ecba6fa2423758cb0849a1109b20858 100644
--- a/api/src/processing/request.js
+++ b/api/src/processing/request.js
@@ -37,7 +37,7 @@ export function createResponse(responseType, responseData) {
case "redirect":
response = {
- url: responseData?.u,
+ url: responseData?.url,
filename: responseData?.filename
}
break;
@@ -52,7 +52,7 @@ export function createResponse(responseType, responseData) {
case "picker":
response = {
picker: responseData?.picker,
- audio: responseData?.u,
+ audio: responseData?.url,
audioFilename: responseData?.filename
}
break;
@@ -82,14 +82,13 @@ export function normalizeRequest(request) {
));
}
-export function getIP(req) {
+export function getIP(req, prefix = 56) {
const strippedIP = req.ip.replace(/^::ffff:/, '');
const ip = ipaddr.parse(strippedIP);
if (ip.kind() === 'ipv4') {
return strippedIP;
}
- const prefix = 56;
const v6Bytes = ip.toByteArray();
v6Bytes.fill(0, prefix / 8);
diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js
index 172d480cd5a937fe3066a9a6dc278e329948336b..48d8b0580b73b6d5996b32af83c2f3607dc96c6e 100644
--- a/api/src/processing/schema.js
+++ b/api/src/processing/schema.js
@@ -1,7 +1,5 @@
import { z } from "zod";
-
import { normalizeURL } from "./url.js";
-import { verifyLanguageCode } from "../misc/utils.js";
export const apiSchema = z.object({
url: z.string()
@@ -33,15 +31,21 @@ export const apiSchema = z.object({
).default("1080"),
youtubeDubLang: z.string()
- .length(2)
- .transform(verifyLanguageCode)
+ .min(2)
+ .max(8)
+ .regex(/^[0-9a-zA-Z\-]+$/)
.optional(),
+ // TODO: remove this variable as it's no longer used
+ // and is kept for schema compatibility reasons
+ youtubeDubBrowserLang: z.boolean().default(false),
+
alwaysProxy: z.boolean().default(false),
disableMetadata: z.boolean().default(false),
tiktokFullAudio: z.boolean().default(false),
tiktokH265: z.boolean().default(false),
twitterGif: z.boolean().default(true),
- youtubeDubBrowserLang: z.boolean().default(false),
+
+ youtubeHLS: z.boolean().default(false),
})
.strict();
diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js
index f091d448b810280b7c1ce3d31931e6b0b81d53ec..87a71c38ec158ce925f8442d294d133a931359f5 100644
--- a/api/src/processing/service-config.js
+++ b/api/src/processing/service-config.js
@@ -1,7 +1,7 @@
import UrlPattern from "url-pattern";
export const audioIgnore = ["vk", "ok", "loom"];
-export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky"];
+export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"];
export const services = {
bilibili: {
@@ -30,23 +30,35 @@ export const services = {
"reel/:id",
"share/:shareType/:id"
],
- subdomains: ["web"],
+ subdomains: ["web", "m"],
altDomains: ["fb.watch"],
},
instagram: {
patterns: [
- "reels/:postId",
- ":username/reel/:postId",
- "reel/:postId",
"p/:postId",
- ":username/p/:postId",
"tv/:postId",
- "stories/:username/:storyId"
+ "reel/:postId",
+ "reels/:postId",
+ "stories/:username/:storyId",
+
+ /*
+ share & username links use the same url pattern,
+ so we test the share pattern first, cuz id type is different.
+ however, if someone has the "share" username and the user
+ somehow gets a link of this ancient style, it's joever.
+ */
+
+ "share/:shareId",
+ "share/p/:shareId",
+ "share/reel/:shareId",
+
+ ":username/p/:postId",
+ ":username/reel/:postId",
],
altDomains: ["ddinstagram.com"],
},
loom: {
- patterns: ["share/:id"],
+ patterns: ["share/:id", "embed/:id"],
},
ok: {
patterns: [
@@ -64,8 +76,23 @@ export const services = {
},
reddit: {
patterns: [
+ "comments/:id",
+
+ "r/:sub/comments/:id",
"r/:sub/comments/:id/:title",
- "user/:user/comments/:id/:title"
+ "r/:sub/comments/:id/comment/:commentId",
+
+ "user/:user/comments/:id",
+ "user/:user/comments/:id/:title",
+ "user/:user/comments/:id/comment/:commentId",
+
+ "r/u_:user/comments/:id",
+ "r/u_:user/comments/:id/:title",
+ "r/u_:user/comments/:id/comment/:commentId",
+
+ "r/:sub/s/:shareId",
+
+ "video/:shortId",
],
subdomains: "*",
},
@@ -111,12 +138,13 @@ export const services = {
tiktok: {
patterns: [
":user/video/:postId",
- ":id",
- "t/:id",
+ "i18n/share/video/:postId",
+ ":shortLink",
+ "t/:shortLink",
":user/photo/:postId",
- "v/:id.html"
+ "v/:postId.html"
],
- subdomains: ["vt", "vm", "m"],
+ subdomains: ["vt", "vm", "m", "t"],
},
tumblr: {
patterns: [
@@ -137,15 +165,12 @@ export const services = {
":user/status/:id/video/:index",
":user/status/:id/photo/:index",
":user/status/:id/mediaviewer",
- ":user/status/:id/mediaViewer"
+ ":user/status/:id/mediaViewer",
+ "i/bookmarks?post_id=:id"
],
subdomains: ["mobile"],
altDomains: ["x.com", "vxtwitter.com", "fixvx.com"],
},
- vine: {
- patterns: ["v/:id"],
- tld: "co",
- },
vimeo: {
patterns: [
":id",
@@ -157,11 +182,25 @@ export const services = {
},
vk: {
patterns: [
- "video:userId_:videoId",
- "clip:userId_:videoId",
- "clips:duplicate?z=clip:userId_:videoId"
+ "video:ownerId_:videoId",
+ "clip:ownerId_:videoId",
+ "clips:duplicate?z=clip:ownerId_:videoId",
+ "videos:duplicate?z=video:ownerId_:videoId",
+ "video:ownerId_:videoId_:accessKey",
+ "clip:ownerId_:videoId_:accessKey",
+ "clips:duplicate?z=clip:ownerId_:videoId_:accessKey",
+ "videos:duplicate?z=video:ownerId_:videoId_:accessKey"
],
subdomains: ["m"],
+ altDomains: ["vkvideo.ru", "vk.ru"],
+ },
+ xiaohongshu: {
+ patterns: [
+ "explore/:id?xsec_token=:token",
+ "discovery/item/:id?xsec_token=:token",
+ "a/:shareId"
+ ],
+ altDomains: ["xhslink.com"],
},
youtube: {
patterns: [
diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js
index 2105a563c1ef87b99e01d151f6cf42044cf0b8ec..2412fd46442f96c3b4d2adfb08dfb39da75b5743 100644
--- a/api/src/processing/service-patterns.js
+++ b/api/src/processing/service-patterns.js
@@ -6,7 +6,8 @@ export const testers = {
"dailymotion": pattern => pattern.id?.length <= 32,
"instagram": pattern =>
- pattern.postId?.length <= 12
+ pattern.postId?.length <= 48
+ || pattern.shareId?.length <= 16
|| (pattern.username?.length <= 30 && pattern.storyId?.length <= 24),
"loom": pattern =>
@@ -19,8 +20,11 @@ export const testers = {
pattern.id?.length <= 128 || pattern.shortLink?.length <= 32,
"reddit": pattern =>
- (pattern.sub?.length <= 22 && pattern.id?.length <= 10)
- || (pattern.user?.length <= 22 && pattern.id?.length <= 10),
+ pattern.id?.length <= 16 && !pattern.sub && !pattern.user
+ || (pattern.sub?.length <= 22 && pattern.id?.length <= 16)
+ || (pattern.user?.length <= 22 && pattern.id?.length <= 16)
+ || (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16)
+ || (pattern.shortId?.length <= 16),
"rutube": pattern =>
(pattern.id?.length === 32 && pattern.key?.length <= 32) ||
@@ -36,10 +40,10 @@ export const testers = {
|| pattern.shortLink?.length <= 16,
"streamable": pattern =>
- pattern.id?.length === 6,
+ pattern.id?.length <= 6,
"tiktok": pattern =>
- pattern.postId?.length <= 21 || pattern.id?.length <= 13,
+ pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13,
"tumblr": pattern =>
pattern.id?.length < 21
@@ -55,11 +59,9 @@ export const testers = {
pattern.id?.length <= 11
&& (!pattern.password || pattern.password.length < 16),
- "vine": pattern =>
- pattern.id?.length <= 12,
-
"vk": pattern =>
- pattern.userId?.length <= 10 && pattern.videoId?.length <= 10,
+ (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||
+ (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),
"youtube": pattern =>
pattern.id?.length <= 11,
@@ -73,4 +75,8 @@ export const testers = {
"bsky": pattern =>
pattern.user?.length <= 128 && pattern.post?.length <= 128,
+
+ "xiaohongshu": pattern =>
+ pattern.id?.length <= 24 && pattern.token?.length <= 64
+ || pattern.shareId?.length <= 12,
}
diff --git a/api/src/processing/services/bilibili.js b/api/src/processing/services/bilibili.js
index b47b0bc2dd14425685a91a63b595e217992b691b..4ee148dbb76c686b16879da160cd560f7870e8c4 100644
--- a/api/src/processing/services/bilibili.js
+++ b/api/src/processing/services/bilibili.js
@@ -1,19 +1,8 @@
import { genericUserAgent, env } from "../../config.js";
+import { resolveRedirectingURL } from "../url.js";
// TO-DO: higher quality downloads (currently requires an account)
-function com_resolveShortlink(shortId) {
- return fetch(`https://b23.tv/${shortId}`, { redirect: 'manual' })
- .then(r => r.status > 300 && r.status < 400 && r.headers.get('location'))
- .then(url => {
- if (!url) return;
- const path = new URL(url).pathname;
- if (path.startsWith('/video/'))
- return path.split('/')[2];
- })
- .catch(() => {})
-}
-
function getBest(content) {
return content?.filter(v => v.baseUrl || v.url)
.map(v => (v.baseUrl = v.baseUrl || v.url, v))
@@ -99,7 +88,8 @@ async function tv_download(id) {
export default async function({ comId, tvId, comShortLink }) {
if (comShortLink) {
- comId = await com_resolveShortlink(comShortLink);
+ const patternMatch = await resolveRedirectingURL(`https://b23.tv/${comShortLink}`);
+ comId = patternMatch?.comId;
}
if (comId) {
diff --git a/api/src/processing/services/bluesky.js b/api/src/processing/services/bluesky.js
index 5f5cbcec7162c4c86150a9026f99673034e63394..598e97394c968a06eb1d64a11bc2042b5bef1851 100644
--- a/api/src/processing/services/bluesky.js
+++ b/api/src/processing/services/bluesky.js
@@ -2,12 +2,19 @@ import HLS from "hls-parser";
import { cobaltUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
-const extractVideo = async ({ media, filename }) => {
- const urlMasterHLS = media?.playlist;
- if (!urlMasterHLS) return { error: "fetch.empty" };
- if (!urlMasterHLS.startsWith("https://video.bsky.app/")) return { error: "fetch.empty" };
+const extractVideo = async ({ media, filename, dispatcher }) => {
+ let urlMasterHLS = media?.playlist;
- const masterHLS = await fetch(urlMasterHLS)
+ if (!urlMasterHLS || !urlMasterHLS.startsWith("https://video.bsky.app/")) {
+ return { error: "fetch.empty" };
+ }
+
+ urlMasterHLS = urlMasterHLS.replace(
+ "video.bsky.app/watch/",
+ "video.cdn.bsky.app/hls/"
+ );
+
+ const masterHLS = await fetch(urlMasterHLS, { dispatcher })
.then(r => {
if (r.status !== 200) return;
return r.text();
@@ -26,7 +33,7 @@ const extractVideo = async ({ media, filename }) => {
urls: videoURL,
filename: `${filename}.mp4`,
audioFilename: `${filename}_audio`,
- isM3U8: true,
+ isHLS: true,
}
}
@@ -48,7 +55,7 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
let proxiedImage = createStream({
service: "bluesky",
type: "proxy",
- u: url,
+ url,
filename: `${filename}_${i + 1}.jpg`,
});
@@ -64,7 +71,25 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
return { picker };
}
-export default async function ({ user, post, alwaysProxy }) {
+const extractGif = ({ url, filename }) => {
+ const gifUrl = new URL(url);
+
+ if (!gifUrl || gifUrl.hostname !== "media.tenor.com") {
+ return { error: "fetch.empty" };
+ }
+
+ // remove downscaling params from gif url
+ // such as "?hh=498&ww=498"
+ gifUrl.search = "";
+
+ return {
+ urls: gifUrl,
+ isPhoto: true,
+ filename: `${filename}.gif`,
+ }
+}
+
+export default async function ({ user, post, alwaysProxy, dispatcher }) {
const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
apiEndpoint.searchParams.set(
"uri",
@@ -73,8 +98,9 @@ export default async function ({ user, post, alwaysProxy }) {
const getPost = await fetch(apiEndpoint, {
headers: {
- "user-agent": cobaltUserAgent
- }
+ "user-agent": cobaltUserAgent,
+ },
+ dispatcher
}).then(r => r.json()).catch(() => {});
if (!getPost) return { error: "fetch.empty" };
@@ -87,29 +113,44 @@ export default async function ({ user, post, alwaysProxy }) {
case "InvalidRequest":
return { error: "link.unsupported" };
default:
- return { error: "fetch.empty" };
+ return { error: "content.post.unavailable" };
}
}
const embedType = getPost?.thread?.post?.embed?.$type;
const filename = `bluesky_${user}_${post}`;
- if (embedType === "app.bsky.embed.video#view") {
- return extractVideo({
- media: getPost.thread?.post?.embed,
- filename,
- })
- }
-
- if (embedType === "app.bsky.embed.recordWithMedia#view") {
- return extractVideo({
- media: getPost.thread?.post?.embed?.media,
- filename,
- })
- }
-
- if (embedType === "app.bsky.embed.images#view") {
- return extractImages({ getPost, filename, alwaysProxy });
+ switch (embedType) {
+ case "app.bsky.embed.video#view":
+ return extractVideo({
+ media: getPost.thread?.post?.embed,
+ filename,
+ });
+
+ case "app.bsky.embed.images#view":
+ return extractImages({
+ getPost,
+ filename,
+ alwaysProxy
+ });
+
+ case "app.bsky.embed.external#view":
+ return extractGif({
+ url: getPost?.thread?.post?.embed?.external?.uri,
+ filename,
+ });
+
+ case "app.bsky.embed.recordWithMedia#view":
+ if (getPost?.thread?.post?.embed?.media?.$type === "app.bsky.embed.external#view") {
+ return extractGif({
+ url: getPost?.thread?.post?.embed?.media?.external?.uri,
+ filename,
+ });
+ }
+ return extractVideo({
+ media: getPost.thread?.post?.embed?.media,
+ filename,
+ });
}
return { error: "fetch.empty" };
diff --git a/api/src/processing/services/dailymotion.js b/api/src/processing/services/dailymotion.js
index a403a16b57ad104e1aabca5adba7cd3c436166c5..a30a8bc7474322c21f1acb7ad2775e4bcee80ac7 100644
--- a/api/src/processing/services/dailymotion.js
+++ b/api/src/processing/services/dailymotion.js
@@ -92,7 +92,7 @@ export default async function({ id }) {
return {
urls: bestQuality.uri,
- isM3U8: true,
+ isHLS: true,
filenameAttributes: {
service: 'dailymotion',
id: media.xid,
diff --git a/api/src/processing/services/facebook.js b/api/src/processing/services/facebook.js
index 7bfd4751e4b7f4eb484378ea85bd9d5cc7849d4f..9e9d060d4dd90582eca5562e1aa34ecbdcaab3e3 100644
--- a/api/src/processing/services/facebook.js
+++ b/api/src/processing/services/facebook.js
@@ -8,8 +8,8 @@ const headers = {
'Sec-Fetch-Site': 'none',
}
-const resolveUrl = (url) => {
- return fetch(url, { headers })
+const resolveUrl = (url, dispatcher) => {
+ return fetch(url, { headers, dispatcher })
.then(r => {
if (r.headers.get('location')) {
return decodeURIComponent(r.headers.get('location'));
@@ -23,13 +23,13 @@ const resolveUrl = (url) => {
.catch(() => false);
}
-export default async function({ id, shareType, shortLink }) {
+export default async function({ id, shareType, shortLink, dispatcher }) {
let url = `https://web.facebook.com/i/videos/${id}`;
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
- if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`);
+ if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`, dispatcher);
- const html = await fetch(url, { headers })
+ const html = await fetch(url, { headers, dispatcher })
.then(r => r.text())
.catch(() => false);
diff --git a/api/src/processing/services/instagram.js b/api/src/processing/services/instagram.js
index 17e78ec4110339bac52037425cf9c3a604b87f02..0fa255279cbd2363c0c2ee236e7394b737343286 100644
--- a/api/src/processing/services/instagram.js
+++ b/api/src/processing/services/instagram.js
@@ -1,3 +1,5 @@
+import { randomBytes } from "node:crypto";
+import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
import { getCookie, updateCookie } from "../cookie/manager.js";
@@ -8,6 +10,7 @@ const commonHeaders = {
"sec-fetch-site": "same-origin",
"x-ig-app-id": "936619743392459"
}
+
const mobileHeaders = {
"x-ig-app-locale": "en_US",
"x-ig-device-locale": "en_US",
@@ -19,6 +22,7 @@ const mobileHeaders = {
"x-fb-server-cluster": "True",
"content-length": "0",
}
+
const embedHeaders = {
"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",
"Accept-Language": "en-GB,en;q=0.9",
@@ -33,7 +37,7 @@ const embedHeaders = {
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
- "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",
+ "User-Agent": genericUserAgent,
}
const cachedDtsg = {
@@ -41,7 +45,17 @@ const cachedDtsg = {
expiry: 0
}
-export default function(obj) {
+const getNumberFromQuery = (name, data) => {
+ const s = data?.match(new RegExp(name + '=(\\d+)'))?.[1];
+ if (+s) return +s;
+}
+
+const getObjectFromEntries = (name, data) => {
+ const obj = data?.match(new RegExp('\\["' + name + '",.*?,({.*?}),\\d+\\]'))?.[1];
+ return obj && JSON.parse(obj);
+}
+
+export default function instagram(obj) {
const dispatcher = obj.dispatcher;
async function findDtsgId(cookie) {
@@ -91,6 +105,7 @@ export default function(obj) {
updateCookie(cookie, data.headers);
return data.json();
}
+
async function getMediaId(id, { cookie, token } = {}) {
const oembedURL = new URL('https://i.instagram.com/api/v1/oembed/');
oembedURL.searchParams.set('url', `https://www.instagram.com/p/${id}/`);
@@ -119,6 +134,7 @@ export default function(obj) {
return mediaInfo?.items?.[0];
}
+
async function requestHTML(id, cookie) {
const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, {
headers: {
@@ -136,40 +152,167 @@ export default function(obj) {
return embedData;
}
+
+ async function getGQLParams(id, cookie) {
+ const req = await fetch(`https://www.instagram.com/p/${id}/`, {
+ headers: {
+ ...embedHeaders,
+ cookie
+ },
+ dispatcher
+ });
+
+ const html = await req.text();
+ const siteData = getObjectFromEntries('SiteData', html);
+ const polarisSiteData = getObjectFromEntries('PolarisSiteData', html);
+ const webConfig = getObjectFromEntries('DGWWebConfig', html);
+ const pushInfo = getObjectFromEntries('InstagramWebPushInfo', html);
+ const lsd = getObjectFromEntries('LSD', html)?.token || randomBytes(8).toString('base64url');
+ const csrf = getObjectFromEntries('InstagramSecurityConfig', html)?.csrf_token;
+
+ const anon_cookie = [
+ csrf && "csrftoken=" + csrf,
+ polarisSiteData?.device_id && "ig_did=" + polarisSiteData?.device_id,
+ "wd=1280x720",
+ "dpr=2",
+ polarisSiteData?.machine_id && "mid=" + polarisSiteData.machine_id,
+ "ig_nrcb=1"
+ ].filter(a => a).join('; ');
+
+ return {
+ headers: {
+ 'x-ig-app-id': webConfig?.appId || '936619743392459',
+ 'X-FB-LSD': lsd,
+ 'X-CSRFToken': csrf,
+ 'X-Bloks-Version-Id': getObjectFromEntries('WebBloksVersioningID', html)?.versioningID,
+ 'x-asbd-id': 129477,
+ cookie: anon_cookie
+ },
+ body: {
+ __d: 'www',
+ __a: '1',
+ __s: '::' + Math.random().toString(36).substring(2).replace(/\d/g, '').slice(0, 6),
+ __hs: siteData?.haste_session || '20126.HYP:instagram_web_pkg.2.1...0',
+ __req: 'b',
+ __ccg: 'EXCELLENT',
+ __rev: pushInfo?.rollout_hash || '1019933358',
+ __hsi: siteData?.hsi || '7436540909012459023',
+ __dyn: randomBytes(154).toString('base64url'),
+ __csr: randomBytes(154).toString('base64url'),
+ __user: '0',
+ __comet_req: getNumberFromQuery('__comet_req', html) || '7',
+ av: '0',
+ dpr: '2',
+ lsd,
+ jazoest: getNumberFromQuery('jazoest', html) || Math.floor(Math.random() * 10000),
+ __spin_r: siteData?.__spin_r || '1019933358',
+ __spin_b: siteData?.__spin_b || 'trunk',
+ __spin_t: siteData?.__spin_t || Math.floor(new Date().getTime() / 1000),
+ }
+ };
+ }
+
async function requestGQL(id, cookie) {
- let dtsgId;
+ const { headers, body } = await getGQLParams(id, cookie);
- if (cookie) {
- dtsgId = await findDtsgId(cookie);
- }
- const url = new URL('https://www.instagram.com/api/graphql/');
+ const req = await fetch('https://www.instagram.com/graphql/query', {
+ method: 'POST',
+ dispatcher,
+ headers: {
+ ...embedHeaders,
+ ...headers,
+ cookie,
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'X-FB-Friendly-Name': 'PolarisPostActionLoadPostQueryQuery',
+ },
+ body: new URLSearchParams({
+ ...body,
+ fb_api_caller_class: 'RelayModern',
+ fb_api_req_friendly_name: 'PolarisPostActionLoadPostQueryQuery',
+ variables: JSON.stringify({
+ shortcode: id,
+ fetch_tagged_user_count: null,
+ hoisted_comment_id: null,
+ hoisted_reply_id: null
+ }),
+ server_timestamps: true,
+ doc_id: '8845758582119845'
+ }).toString()
+ });
- const requestData = {
- jazoest: '26406',
- variables: JSON.stringify({
- shortcode: id,
- __relay_internal__pv__PolarisShareMenurelayprovider: false
- }),
- doc_id: '7153618348081770'
+ return {
+ gql_data: await req.json()
+ .then(r => r.data)
+ .catch(() => null)
};
- if (dtsgId) {
- requestData.fb_dtsg = dtsgId;
+ }
+
+ async function getErrorContext(id) {
+ try {
+ const { headers, body } = await getGQLParams(id);
+
+ const req = await fetch('https://www.instagram.com/ajax/bulk-route-definitions/', {
+ method: 'POST',
+ dispatcher,
+ headers: {
+ ...embedHeaders,
+ ...headers,
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'X-Ig-D': 'www',
+ },
+ body: new URLSearchParams({
+ 'route_urls[0]': `/p/${id}/`,
+ routing_namespace: 'igx_www',
+ ...body
+ }).toString()
+ });
+
+ const response = await req.text();
+ if (response.includes('"tracePolicy":"polaris.privatePostPage"'))
+ return { error: 'content.post.private' };
+
+ const [, mediaId, mediaOwnerId] = response.match(
+ /"media_id":\s*?"(\d+)","media_owner_id":\s*?"(\d+)"/
+ ) || [];
+
+ if (mediaId && mediaOwnerId) {
+ const rulingURL = new URL('https://www.instagram.com/api/v1/web/get_ruling_for_media_content_logged_out');
+ rulingURL.searchParams.set('media_id', mediaId);
+ rulingURL.searchParams.set('owner_id', mediaOwnerId);
+
+ const rulingResponse = await fetch(rulingURL, {
+ headers: {
+ ...headers,
+ ...commonHeaders
+ },
+ dispatcher,
+ }).then(a => a.json()).catch(() => ({}));
+
+ if (rulingResponse?.title?.includes('Restricted'))
+ return { error: "content.post.age" };
+ }
+ } catch {
+ return { error: "fetch.fail" };
}
- return (await request(url, cookie, 'POST', requestData))
- .data
- ?.xdt_api__v1__media__shortcode__web_info
- ?.items
- ?.[0];
+ return { error: "fetch.empty" };
}
function extractOldPost(data, id, alwaysProxy) {
- const sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children;
+ const shortcodeMedia = data?.gql_data?.shortcode_media || data?.gql_data?.xdt_shortcode_media;
+ const sidecar = shortcodeMedia?.edge_sidecar_to_children;
+
if (sidecar) {
const picker = sidecar.edges.filter(e => e.node?.display_url)
.map((e, i) => {
- const type = e.node?.is_video ? "video" : "photo";
- const url = type === "video" ? e.node?.video_url : e.node?.display_url;
+ const type = e.node?.is_video && e.node?.video_url ? "video" : "photo";
+
+ let url;
+ if (type === "video") {
+ url = e.node?.video_url;
+ } else if (type === "photo") {
+ url = e.node?.display_url;
+ }
let itemExt = type === "video" ? "mp4" : "jpg";
@@ -177,7 +320,7 @@ export default function(obj) {
if (alwaysProxy) proxyFile = createStream({
service: "instagram",
type: "proxy",
- u: url,
+ url,
filename: `instagram_${id}_${i + 1}.${itemExt}`
});
@@ -189,23 +332,28 @@ export default function(obj) {
thumb: createStream({
service: "instagram",
type: "proxy",
- u: e.node?.display_url,
+ url: e.node?.display_url,
filename: `instagram_${id}_${i + 1}.jpg`
})
}
});
if (picker.length) return { picker }
- } else if (data?.gql_data?.shortcode_media?.video_url) {
+ }
+
+ if (shortcodeMedia?.video_url) {
return {
- urls: data.gql_data.shortcode_media.video_url,
+ urls: shortcodeMedia.video_url,
filename: `instagram_${id}.mp4`,
audioFilename: `instagram_${id}_audio`
}
- } else if (data?.gql_data?.shortcode_media?.display_url) {
+ }
+
+ if (shortcodeMedia?.display_url) {
return {
- urls: data.gql_data?.shortcode_media.display_url,
- isPhoto: true
+ urls: shortcodeMedia.display_url,
+ isPhoto: true,
+ filename: `instagram_${id}.jpg`,
}
}
}
@@ -230,7 +378,7 @@ export default function(obj) {
if (alwaysProxy) proxyFile = createStream({
service: "instagram",
type: "proxy",
- u: url,
+ url,
filename: `instagram_${id}_${i + 1}.${itemExt}`
});
@@ -242,7 +390,7 @@ export default function(obj) {
thumb: createStream({
service: "instagram",
type: "proxy",
- u: imageUrl,
+ url: imageUrl,
filename: `instagram_${id}_${i + 1}.jpg`
})
}
@@ -266,6 +414,9 @@ export default function(obj) {
}
async function getPost(id, alwaysProxy) {
+ const hasData = (data) => data
+ && data.gql_data !== null
+ && data?.gql_data?.xdt_shortcode_media !== null;
let data, result;
try {
const cookie = getCookie('instagram');
@@ -282,19 +433,21 @@ export default function(obj) {
if (media_id && token) data = await requestMobileApi(media_id, { token });
// mobile api (no cookie, cookie)
- if (media_id && !data) data = await requestMobileApi(media_id);
- if (media_id && cookie && !data) data = await requestMobileApi(media_id, { cookie });
+ if (media_id && !hasData(data)) data = await requestMobileApi(media_id);
+ if (media_id && cookie && !hasData(data)) data = await requestMobileApi(media_id, { cookie });
// html embed (no cookie, cookie)
- if (!data) data = await requestHTML(id);
- if (!data && cookie) data = await requestHTML(id, cookie);
+ if (!hasData(data)) data = await requestHTML(id);
+ if (!hasData(data) && cookie) data = await requestHTML(id, cookie);
// web app graphql api (no cookie, cookie)
- if (!data) data = await requestGQL(id);
- if (!data && cookie) data = await requestGQL(id, cookie);
+ if (!hasData(data)) data = await requestGQL(id);
+ if (!hasData(data) && cookie) data = await requestGQL(id, cookie);
} catch {}
- if (!data) return { error: "fetch.fail" };
+ if (!hasData(data)) {
+ return getErrorContext(id);
+ }
if (data?.gql_data) {
result = extractOldPost(data, id, alwaysProxy)
@@ -357,14 +510,30 @@ export default function(obj) {
if (item.image_versions2?.candidates) {
return {
urls: item.image_versions2.candidates[0].url,
- isPhoto: true
+ isPhoto: true,
+ filename: `instagram_${id}.jpg`,
}
}
return { error: "link.unsupported" };
}
- const { postId, storyId, username, alwaysProxy } = obj;
+ const { postId, shareId, storyId, username, alwaysProxy } = obj;
+
+ if (shareId) {
+ return resolveRedirectingURL(
+ `https://www.instagram.com/share/${shareId}/`,
+ dispatcher,
+ // for some reason instagram decides to return HTML
+ // instead of a redirect when requesting with a normal
+ // browser user-agent
+ {'User-Agent': 'curl/7.88.1'}
+ ).then(match => instagram({
+ ...obj, ...match,
+ shareId: undefined
+ }));
+ }
+
if (postId) return getPost(postId, alwaysProxy);
if (username && storyId) return getStory(username, storyId);
diff --git a/api/src/processing/services/ok.js b/api/src/processing/services/ok.js
index 2fb6082d74fd170e3cf2a11d46e20b3c2bee9132..cfe18e49fae97c486b26b9d525deda05b718287b 100644
--- a/api/src/processing/services/ok.js
+++ b/api/src/processing/services/ok.js
@@ -1,5 +1,4 @@
import { genericUserAgent, env } from "../../config.js";
-import { cleanString } from "../../misc/utils.js";
const resolutions = {
"ultra": "2160",
@@ -44,8 +43,8 @@ export default async function(o) {
let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
let fileMetadata = {
- title: cleanString(videoData.movie.title.trim()),
- author: cleanString((videoData.author?.name || videoData.compilationTitle).trim()),
+ title: videoData.movie.title.trim(),
+ author: (videoData.author?.name || videoData.compilationTitle)?.trim(),
}
if (bestVideo) return {
diff --git a/api/src/processing/services/pinterest.js b/api/src/processing/services/pinterest.js
index 9c0ac9c5046568542df17d75610a928351b445fa..15566cc49aef6f8e0e7b95030c00727f2f859084 100644
--- a/api/src/processing/services/pinterest.js
+++ b/api/src/processing/services/pinterest.js
@@ -1,4 +1,5 @@
import { genericUserAgent } from "../../config.js";
+import { resolveRedirectingURL } from "../url.js";
const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g;
const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g;
@@ -7,10 +8,10 @@ export default async function(o) {
let id = o.id;
if (!o.id && o.shortLink) {
- id = await fetch(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`, { redirect: "manual" })
- .then(r => r.headers.get("location").split('pin/')[1].split('/')[0])
- .catch(() => {});
+ const patternMatch = await resolveRedirectingURL(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`);
+ id = patternMatch?.id;
}
+
if (id.includes("--")) id = id.split("--")[1];
if (!id) return { error: "fetch.fail" };
@@ -22,12 +23,12 @@ export default async function(o) {
const videoLink = [...html.matchAll(videoRegex)]
.map(([, link]) => link)
- .find(a => a.endsWith('.mp4') && a.includes('720p'));
+ .find(a => a.endsWith('.mp4'));
if (videoLink) return {
urls: videoLink,
- filename: `pinterest_${o.id}.mp4`,
- audioFilename: `pinterest_${o.id}_audio`
+ filename: `pinterest_${id}.mp4`,
+ audioFilename: `pinterest_${id}_audio`
}
const imageLink = [...html.matchAll(imageRegex)]
@@ -39,7 +40,7 @@ export default async function(o) {
if (imageLink) return {
urls: imageLink,
isPhoto: true,
- filename: `pinterest_${o.id}.${imageType}`
+ filename: `pinterest_${id}.${imageType}`
}
return { error: "fetch.empty" };
diff --git a/api/src/processing/services/reddit.js b/api/src/processing/services/reddit.js
index 701db2362f9defd6cbbe701453550d21708ca1cd..0f506eeab73bca612c36adb61d20d3cdc6f91416 100644
--- a/api/src/processing/services/reddit.js
+++ b/api/src/processing/services/reddit.js
@@ -1,3 +1,4 @@
+import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent, env } from "../../config.js";
import { getCookie, updateCookieValues } from "../cookie/manager.js";
@@ -48,23 +49,36 @@ async function getAccessToken() {
}
export default async function(obj) {
- let url = new URL(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}.json`);
+ let params = obj;
+ const accessToken = await getAccessToken();
+ const headers = {
+ 'user-agent': genericUserAgent,
+ authorization: accessToken && `Bearer ${accessToken}`,
+ accept: 'application/json'
+ };
+
+ if (params.shortId) {
+ params = await resolveRedirectingURL(
+ `https://www.reddit.com/video/${params.shortId}`,
+ obj.dispatcher, headers
+ );
+ }
- if (obj.user) {
- url.pathname = `/user/${obj.user}/comments/${obj.id}.json`;
+ if (!params.id && params.shareId) {
+ params = await resolveRedirectingURL(
+ `https://www.reddit.com/r/${params.sub}/s/${params.shareId}`,
+ obj.dispatcher, headers
+ );
}
- const accessToken = await getAccessToken();
+ if (!params?.id) return { error: "fetch.short_link" };
+
+ const url = new URL(`https://www.reddit.com/comments/${params.id}.json`);
+
if (accessToken) url.hostname = 'oauth.reddit.com';
let data = await fetch(
- url, {
- headers: {
- 'User-Agent': genericUserAgent,
- accept: 'application/json',
- authorization: accessToken && `Bearer ${accessToken}`
- }
- }
+ url, { headers }
).then(r => r.json()).catch(() => {});
if (!data || !Array.isArray(data)) {
@@ -73,12 +87,17 @@ export default async function(obj) {
data = data[0]?.data?.children[0]?.data;
- const id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
+ let sourceId;
+ if (params.sub || params.user) {
+ sourceId = `${String(params.sub || params.user).toLowerCase()}_${params.id}`;
+ } else {
+ sourceId = params.id;
+ }
if (data?.url?.endsWith('.gif')) return {
typeId: "redirect",
urls: data.url,
- filename: `reddit_${id}.gif`,
+ filename: `reddit_${sourceId}.gif`,
}
if (!data.secure_media?.reddit_video)
@@ -87,8 +106,9 @@ export default async function(obj) {
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
return { error: "content.too_long" };
+ const video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0];
+
let audio = false,
- video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`;
if (video.match('.mp4')) {
@@ -121,7 +141,7 @@ export default async function(obj) {
typeId: "tunnel",
type: "merge",
urls: [video, audioFileLink],
- audioFilename: `reddit_${id}_audio`,
- filename: `reddit_${id}.mp4`
+ audioFilename: `reddit_${sourceId}_audio`,
+ filename: `reddit_${sourceId}.mp4`
}
}
diff --git a/api/src/processing/services/rutube.js b/api/src/processing/services/rutube.js
index 4305241ad8fe96cd87492eec6076503e754acb14..5b502452922c5905d930556306cef0d4e26b6baf 100644
--- a/api/src/processing/services/rutube.js
+++ b/api/src/processing/services/rutube.js
@@ -1,7 +1,5 @@
import HLS from "hls-parser";
-
import { env } from "../../config.js";
-import { cleanString } from "../../misc/utils.js";
async function requestJSON(url) {
try {
@@ -35,6 +33,10 @@ export default async function(obj) {
const play = await requestJSON(requestURL);
if (!play) return { error: "fetch.fail" };
+ if (play.detail?.type === "blocking_rule") {
+ return { error: "content.video.region" };
+ }
+
if (play.detail || !play.video_balancer) return { error: "fetch.empty" };
if (play.live_streams?.hls) return { error: "content.video.live" };
@@ -59,13 +61,13 @@ export default async function(obj) {
});
const fileMetadata = {
- title: cleanString(play.title.trim()),
- artist: cleanString(play.author.name.trim()),
+ title: play.title.trim(),
+ artist: play.author.name.trim(),
}
return {
urls: matchingQuality.uri,
- isM3U8: true,
+ isHLS: true,
filenameAttributes: {
service: "rutube",
id: obj.id,
diff --git a/api/src/processing/services/snapchat.js b/api/src/processing/services/snapchat.js
index acb6813ad1f3c328fe7f00cecce8906263676fb2..f5d66136298ac657119bc29662259fdefa2ba908 100644
--- a/api/src/processing/services/snapchat.js
+++ b/api/src/processing/services/snapchat.js
@@ -1,7 +1,6 @@
-import { extract, normalizeURL } from "../url.js";
+import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
-import { getRedirectingURL } from "../../misc/utils.js";
const SPOTLIGHT_VIDEO_REGEX = //;
const NEXT_DATA_REGEX = /')[0]
- const data = JSON.parse(json)
- detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
+ .split('')[0];
+
+ const data = JSON.parse(json);
+ const videoDetail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"];
+
+ if (!videoDetail) throw "no video detail found";
+
+ // status_deleted or etc
+ if (videoDetail.statusMsg) {
+ return { error: "content.post.unavailable"};
+ }
+
+ detail = videoDetail?.itemInfo?.itemStruct;
} catch {
return { error: "fetch.fail" };
}
+ if (detail.isContentClassified) {
+ return { error: "content.post.age" };
+ }
+
+ if (!detail.author) {
+ return { error: "fetch.empty" };
+ }
+
let video, videoFilename, audioFilename, audio, images,
- filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`,
+ filenameBase = `tiktok_${detail.author?.uniqueId}_${postId}`,
bestAudio; // will get defaulted to m4a later on in match-action
images = detail.imagePost?.images;
- let playAddr = detail.video.playAddr;
+ let playAddr = detail.video?.playAddr;
+
if (obj.h265) {
const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes("h265"))?.PlayAddr.UrlList[0]
playAddr = h265PlayAddr || playAddr
@@ -102,7 +121,7 @@ export default async function(obj) {
if (obj.alwaysProxy) url = createStream({
service: "tiktok",
type: "proxy",
- u: url,
+ url,
filename: `${filenameBase}_photo_${i + 1}.jpg`
})
diff --git a/api/src/processing/services/tumblr.js b/api/src/processing/services/tumblr.js
index b361b98c8f0d18a7778a031ff5359e49d74f664a..2b8aa4ce28950c726e7830c787204c99c059e00d 100644
--- a/api/src/processing/services/tumblr.js
+++ b/api/src/processing/services/tumblr.js
@@ -1,4 +1,4 @@
-import psl from "psl";
+import psl from "@imput/psl";
const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';
const API_BASE = 'https://api-http2.tumblr.com';
diff --git a/api/src/processing/services/twitch.js b/api/src/processing/services/twitch.js
index ac85fbcf9f601be64a692bf4fb54e0ba2df1909a..4b9d4551a15773b073e17d54dc43c1d6951a862f 100644
--- a/api/src/processing/services/twitch.js
+++ b/api/src/processing/services/twitch.js
@@ -1,5 +1,4 @@
import { env } from "../../config.js";
-import { cleanString } from '../../misc/utils.js';
const gqlURL = "https://gql.twitch.tv/gql";
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
@@ -73,13 +72,13 @@ export default async function (obj) {
token: req_token[0].data.clip.playbackAccessToken.value
})}`,
fileMetadata: {
- title: cleanString(clipMetadata.title.trim()),
+ title: clipMetadata.title.trim(),
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
},
filenameAttributes: {
service: "twitch",
id: clipMetadata.id,
- title: cleanString(clipMetadata.title.trim()),
+ title: clipMetadata.title.trim(),
author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`,
qualityLabel: `${format.quality}p`,
extension: 'mp4'
diff --git a/api/src/processing/services/twitter.js b/api/src/processing/services/twitter.js
index 18866b4927c45db929844a5e3f5fa68e99d08def..a4f4505ede3f4164327835d2d0928b31d692450c 100644
--- a/api/src/processing/services/twitter.js
+++ b/api/src/processing/services/twitter.js
@@ -24,6 +24,11 @@ const badContainerEnd = new Date(1702605600000);
function needsFixing(media) {
const representativeId = media.source_status_id_str ?? media.id_str;
+
+ // syndication api doesn't have media ids in its response,
+ // so we just assume it's all good
+ if (!representativeId) return false;
+
const mediaTimestamp = new Date(
Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
);
@@ -53,6 +58,25 @@ const getGuestToken = async (dispatcher, forceReload = false) => {
}
}
+const requestSyndication = async(dispatcher, tweetId) => {
+ // thank you
+ // https://github.com/yt-dlp/yt-dlp/blob/05c8023a27dd37c49163c0498bf98e3e3c1cb4b9/yt_dlp/extractor/twitter.py#L1334
+ const token = (id) => ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, '');
+ const syndicationUrl = new URL("https://cdn.syndication.twimg.com/tweet-result");
+
+ syndicationUrl.searchParams.set("id", tweetId);
+ syndicationUrl.searchParams.set("token", token(tweetId));
+
+ const result = await fetch(syndicationUrl, {
+ headers: {
+ "user-agent": genericUserAgent
+ },
+ dispatcher
+ });
+
+ return result;
+}
+
const requestTweet = async(dispatcher, tweetId, token, cookie) => {
const graphqlTweetURL = new URL(graphqlURL);
@@ -87,36 +111,24 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
updateCookie(cookie, result.headers);
- // we might have been missing the `ct0` cookie, retry
+ // we might have been missing the ct0 cookie, retry
if (result.status === 403 && result.headers.get('set-cookie')) {
- result = await fetch(graphqlTweetURL, {
- headers: {
- ...headers,
- 'x-csrf-token': cookie.values().ct0
- },
- dispatcher
- });
+ const cookieValues = cookie?.values();
+ if (cookieValues?.ct0) {
+ result = await fetch(graphqlTweetURL, {
+ headers: {
+ ...headers,
+ 'x-csrf-token': cookieValues.ct0
+ },
+ dispatcher
+ });
+ }
}
return result
}
-export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
- const cookie = await getCookie('twitter');
-
- let guestToken = await getGuestToken(dispatcher);
- if (!guestToken) return { error: "fetch.fail" };
-
- let tweet = await requestTweet(dispatcher, id, guestToken);
-
- // get new token & retry if old one expired
- if ([403, 429].includes(tweet.status)) {
- guestToken = await getGuestToken(dispatcher, true);
- tweet = await requestTweet(dispatcher, id, guestToken)
- }
-
- tweet = await tweet.json();
-
+const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) => {
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
if (!tweetTypename) {
@@ -127,13 +139,13 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
const reason = tweet?.data?.tweetResult?.result?.reason;
switch(reason) {
case "Protected":
- return { error: "content.post.private" }
+ return { error: "content.post.private" };
case "NsfwLoggedOut":
if (cookie) {
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
tweet = await tweet.json();
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
- } else return { error: "content.post.age" }
+ } else return { error: "content.post.age" };
}
}
@@ -150,7 +162,69 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
}
- let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
+ return (repostedTweet?.media || baseTweet?.extended_entities?.media);
+}
+
+const testResponse = (result) => {
+ const contentLength = result.headers.get("content-length");
+
+ if (!contentLength || contentLength === '0') {
+ return false;
+ }
+
+ if (!result.headers.get("content-type").startsWith("application/json")) {
+ return false;
+ }
+
+ return true;
+}
+
+export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
+ const cookie = await getCookie('twitter');
+
+ let syndication = false;
+
+ let guestToken = await getGuestToken(dispatcher);
+ if (!guestToken) return { error: "fetch.fail" };
+
+ // for now we assume that graphql api will come back after some time,
+ // so we try it first
+
+ let tweet = await requestTweet(dispatcher, id, guestToken);
+
+ // get new token & retry if old one expired
+ if ([403, 429].includes(tweet.status)) {
+ guestToken = await getGuestToken(dispatcher, true);
+ if (cookie) {
+ tweet = await requestTweet(dispatcher, id, guestToken, cookie);
+ } else {
+ tweet = await requestTweet(dispatcher, id, guestToken);
+ }
+ }
+
+ const testGraphql = testResponse(tweet);
+
+ // if graphql requests fail, then resort to tweet embed api
+ if (!testGraphql) {
+ syndication = true;
+ tweet = await requestSyndication(dispatcher, id);
+
+ const testSyndication = testResponse(tweet);
+
+ // if even syndication request failed, then cry out loud
+ if (!testSyndication) {
+ return { error: "fetch.fail" };
+ }
+ }
+
+ tweet = await tweet.json();
+
+ let media =
+ syndication
+ ? tweet.mediaDetails
+ : await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
+
+ if (!media) return { error: "fetch.empty" };
// check if there's a video at given index (/video/)
if (index >= 0 && index < media?.length) {
@@ -159,11 +233,11 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
- const proxyMedia = (u, filename) => createStream({
+ const proxyMedia = (url, filename) => createStream({
service: "twitter",
type: "proxy",
- u, filename,
- })
+ url, filename,
+ });
switch (media?.length) {
case undefined:
@@ -208,7 +282,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
let url = bestQuality(content.video_info.variants);
const shouldRenderGif = content.type === "animated_gif" && toGif;
- const videoFilename = `twitter_${id}_${i + 1}.mp4`;
+ const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`;
let type = "video";
if (shouldRenderGif) type = "gif";
@@ -217,7 +291,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
url = createStream({
service: "twitter",
type: shouldRenderGif ? "gif" : "remux",
- u: url,
+ url,
filename: videoFilename,
})
} else if (alwaysProxy) {
diff --git a/api/src/processing/services/vimeo.js b/api/src/processing/services/vimeo.js
index 23e84191f5797fc394452467336af1ca074640f0..8d70477199dce368729e0921ea44ff62ce1af669 100644
--- a/api/src/processing/services/vimeo.js
+++ b/api/src/processing/services/vimeo.js
@@ -1,7 +1,6 @@
import HLS from "hls-parser";
-
import { env } from "../../config.js";
-import { cleanString, merge } from '../../misc/utils.js';
+import { merge } from '../../misc/utils.js';
const resolutionMatch = {
"3840": 2160,
@@ -122,7 +121,7 @@ const getHLS = async (configURL, obj) => {
return {
urls,
- isM3U8: true,
+ isHLS: true,
filenameAttributes: {
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
qualityLabel: `${resolutionMatch[bestQuality.resolution.width]}p`,
@@ -152,8 +151,8 @@ export default async function(obj) {
}
const fileMetadata = {
- title: cleanString(info.name),
- artist: cleanString(info.user.name),
+ title: info.name,
+ artist: info.user.name,
};
return merge(
diff --git a/api/src/processing/services/vk.js b/api/src/processing/services/vk.js
index e3c18e474dd7c1549c1455341b244594dde8070e..33224d69599d2f4fcaa9c01351791d5c096c8b12 100644
--- a/api/src/processing/services/vk.js
+++ b/api/src/processing/services/vk.js
@@ -1,63 +1,140 @@
-import { cleanString } from "../../misc/utils.js";
-import { genericUserAgent, env } from "../../config.js";
+import { env } from "../../config.js";
-const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
+const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
-export default async function(o) {
- let html, url, quality = o.quality === "max" ? 2160 : o.quality;
+const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token";
+const apiUrl = "https://api.vk.com/method";
- html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
+const clientId = "51552953";
+const clientSecret = "qgr0yWwXCrsxA1jnRtRX";
+
+// used in stream/shared.js for accessing media files
+export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119";
+
+const cachedToken = {
+ token: "",
+ expiry: 0,
+ device_id: "",
+};
+
+const getToken = async () => {
+ if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) {
+ return cachedToken.token;
+ }
+
+ const randomDeviceId = crypto.randomUUID().toUpperCase();
+
+ const anonymOauth = new URL(oauthUrl);
+ anonymOauth.searchParams.set("client_id", clientId);
+ anonymOauth.searchParams.set("client_secret", clientSecret);
+ anonymOauth.searchParams.set("device_id", randomDeviceId);
+
+ const oauthResponse = await fetch(anonymOauth.toString(), {
headers: {
- "user-agent": genericUserAgent
+ "user-agent": vkClientAgent,
}
+ }).then(r => {
+ if (r.status === 200) {
+ return r.json();
+ }
+ });
+
+ if (!oauthResponse) return;
+
+ if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") {
+ cachedToken.token = oauthResponse.token;
+ cachedToken.expiry = oauthResponse.expired_at;
+ cachedToken.device_id = randomDeviceId;
+ }
+
+ if (!cachedToken.token) return;
+
+ return cachedToken.token;
+}
+
+const getVideo = async (ownerId, videoId, accessKey) => {
+ const video = await fetch(`${apiUrl}/video.get`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/x-www-form-urlencoded; charset=utf-8",
+ "user-agent": vkClientAgent,
+ },
+ body: new URLSearchParams({
+ anonymous_token: cachedToken.token,
+ device_id: cachedToken.device_id,
+ lang: "en",
+ v: "5.244",
+ videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`
+ }).toString()
})
- .then(r => r.arrayBuffer())
- .catch(() => {});
+ .then(r => {
+ if (r.status === 200) {
+ return r.json();
+ }
+ });
- if (!html) return { error: "fetch.fail" };
+ return video;
+}
+
+export default async function ({ ownerId, videoId, accessKey, quality }) {
+ const token = await getToken();
+ if (!token) return { error: "fetch.fail" };
- // decode cyrillic from windows-1251 because vk still uses apis from prehistoric times
- let decoder = new TextDecoder('windows-1251');
- html = decoder.decode(html);
+ const videoGet = await getVideo(ownerId, videoId, accessKey);
- if (!html.includes(`{"lang":`)) return { error: "fetch.empty" };
+ if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) {
+ return { error: "fetch.empty" };
+ }
- let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
+ const video = videoGet.response.items[0];
- if (Number(js.mvData.is_active_live) !== 0) {
- return { error: "content.video.live" };
+ if (video.restriction) {
+ const title = video.restriction.title;
+ if (title.endsWith("country") || title.endsWith("region.")) {
+ return { error: "content.video.region" };
+ }
+ if (title === "Processing video") {
+ return { error: "fetch.empty" };
+ }
+ return { error: "content.video.unavailable" };
+ }
+
+ if (!video.files || !video.duration) {
+ return { error: "fetch.fail" };
}
- if (js.mvData.duration > env.durationLimit) {
+ if (video.duration > env.durationLimit) {
return { error: "content.too_long" };
}
- for (let i in resolutions) {
- if (js.player.params[0][`url${resolutions[i]}`]) {
- quality = resolutions[i];
+ const userQuality = quality === "max" ? resolutions[0] : quality;
+ let pickedQuality;
+
+ for (const resolution of resolutions) {
+ if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) {
+ pickedQuality = resolution;
break
}
}
- if (Number(quality) > Number(o.quality)) quality = o.quality;
- url = js.player.params[0][`url${quality}`];
+ const url = video.files[`mp4_${pickedQuality}`];
+
+ if (!url) return { error: "fetch.fail" };
- let fileMetadata = {
- title: cleanString(js.player.params[0].md_title.trim()),
- author: cleanString(js.player.params[0].md_author.trim()),
+ const fileMetadata = {
+ title: video.title.trim(),
}
- if (url) return {
+ return {
urls: url,
+ fileMetadata,
filenameAttributes: {
service: "vk",
- id: `${o.userId}_${o.videoId}`,
+ id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`,
title: fileMetadata.title,
- author: fileMetadata.author,
- resolution: `${quality}p`,
- qualityLabel: `${quality}p`,
+ resolution: `${pickedQuality}p`,
+ qualityLabel: `${pickedQuality}p`,
extension: "mp4"
}
}
- return { error: "fetch.empty" }
}
diff --git a/api/src/processing/services/xiaohongshu.js b/api/src/processing/services/xiaohongshu.js
new file mode 100644
index 0000000000000000000000000000000000000000..06de21aa57b2b0143be55b879e47c59f1981a0a5
--- /dev/null
+++ b/api/src/processing/services/xiaohongshu.js
@@ -0,0 +1,109 @@
+import { resolveRedirectingURL } from "../url.js";
+import { genericUserAgent } from "../../config.js";
+import { createStream } from "../../stream/manage.js";
+
+const https = (url) => {
+ return url.replace(/^http:/i, 'https:');
+}
+
+export default async function ({ id, token, shareId, h265, isAudioOnly, dispatcher }) {
+ let noteId = id;
+ let xsecToken = token;
+
+ if (!noteId) {
+ const patternMatch = await resolveRedirectingURL(
+ `https://xhslink.com/a/${shareId}`,
+ dispatcher
+ );
+
+ noteId = patternMatch?.id;
+ xsecToken = patternMatch?.token;
+ }
+
+ if (!noteId || !xsecToken) return { error: "fetch.short_link" };
+
+ const res = await fetch(`https://www.xiaohongshu.com/explore/${noteId}?xsec_token=${xsecToken}`, {
+ headers: {
+ "user-agent": genericUserAgent,
+ },
+ dispatcher,
+ });
+
+ const html = await res.text();
+
+ let note;
+ try {
+ const initialState = html
+ .split('')[0]
+ .replace(/:\s*undefined/g, ":null");
+
+ const data = JSON.parse(initialState);
+
+ const noteInfo = data?.note?.noteDetailMap;
+ if (!noteInfo) throw "no note detail map";
+
+ const currentNote = noteInfo[noteId];
+ if (!currentNote) throw "no current note in detail map";
+
+ note = currentNote.note;
+ } catch {}
+
+ if (!note) return { error: "fetch.empty" };
+
+ const video = note.video;
+ const images = note.imageList;
+
+ const filenameBase = `xiaohongshu_${noteId}`;
+
+ if (video) {
+ const videoFilename = `${filenameBase}.mp4`;
+ const audioFilename = `${filenameBase}_audio`;
+
+ let videoURL;
+
+ if (h265 && !isAudioOnly && video.consumer?.originVideoKey) {
+ videoURL = `https://sns-video-bd.xhscdn.com/${video.consumer.originVideoKey}`;
+ } else {
+ const h264Streams = video.media?.stream?.h264;
+
+ if (h264Streams?.length) {
+ videoURL = h264Streams.reduce((a, b) => Number(a?.videoBitrate) > Number(b?.videoBitrate) ? a : b).masterUrl;
+ }
+ }
+
+ if (!videoURL) return { error: "fetch.empty" };
+
+ return {
+ urls: https(videoURL),
+ filename: videoFilename,
+ audioFilename: audioFilename,
+ }
+ }
+
+ if (!images || images.length === 0) {
+ return { error: "fetch.empty" };
+ }
+
+ if (images.length === 1) {
+ return {
+ isPhoto: true,
+ urls: https(images[0].urlDefault),
+ filename: `${filenameBase}.jpg`,
+ }
+ }
+
+ const picker = images.map((image, i) => {
+ return {
+ type: "photo",
+ url: createStream({
+ service: "xiaohongshu",
+ type: "proxy",
+ url: https(image.urlDefault),
+ filename: `${filenameBase}_${i + 1}.jpg`,
+ })
+ }
+ });
+
+ return { picker };
+}
diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js
index 46f72a5b5210cc6efe236ceaf98764348be9e66d..e1cbf018586115e87612dff526a8bf5baf87adf3 100644
--- a/api/src/processing/services/youtube.js
+++ b/api/src/processing/services/youtube.js
@@ -1,16 +1,17 @@
-import { fetch } from "undici";
+import HLS from "hls-parser";
+import { fetch } from "undici";
import { Innertube, Session } from "youtubei.js";
import { env } from "../../config.js";
-import { cleanString } from "../../misc/utils.js";
-import { getCookie, updateCookieValues } from "../cookie/manager.js";
+import { getCookie } from "../cookie/manager.js";
+import { getYouTubeSession } from "../helpers/youtube-session.js";
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
let innertube, lastRefreshedAt;
-const codecMatch = {
+const codecList = {
h264: {
videoCodec: "avc1",
audioCodec: "mp4a",
@@ -28,32 +29,43 @@ const codecMatch = {
}
}
-const transformSessionData = (cookie) => {
- if (!cookie)
- return;
-
- const values = { ...cookie.values() };
- const REQUIRED_VALUES = [ 'access_token', 'refresh_token' ];
-
- if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
- return;
+const hlsCodecList = {
+ h264: {
+ videoCodec: "avc1",
+ audioCodec: "mp4a",
+ container: "mp4"
+ },
+ vp9: {
+ videoCodec: "vp09",
+ audioCodec: "mp4a",
+ container: "webm"
}
+}
- if (values.expires) {
- values.expiry_date = values.expires;
- delete values.expires;
- } else if (!values.expiry_date) {
- return;
- }
+const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID'];
- return values;
-}
+const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
-const cloneInnertube = async (customFetch) => {
+const cloneInnertube = async (customFetch, useSession) => {
const shouldRefreshPlayer = lastRefreshedAt + PLAYER_REFRESH_PERIOD < new Date();
+
+ const rawCookie = getCookie('youtube');
+ const cookie = rawCookie?.toString();
+
+ const sessionTokens = getYouTubeSession();
+ const retrieve_player = Boolean(sessionTokens || cookie);
+
+ if (useSession && env.ytSessionServer && !sessionTokens?.potoken) {
+ throw "no_session_tokens";
+ }
+
if (!innertube || shouldRefreshPlayer) {
innertube = await Innertube.create({
- fetch: customFetch
+ fetch: customFetch,
+ retrieve_player,
+ cookie,
+ po_token: useSession ? sessionTokens?.potoken : undefined,
+ visitor_data: useSession ? sessionTokens?.visitor_data : undefined,
});
lastRefreshedAt = +new Date();
}
@@ -64,81 +76,88 @@ const cloneInnertube = async (customFetch) => {
innertube.session.api_version,
innertube.session.account_index,
innertube.session.player,
- undefined,
+ cookie,
customFetch ?? innertube.session.http.fetch,
innertube.session.cache
);
- const cookie = getCookie('youtube_oauth');
- const oauthData = transformSessionData(cookie);
+ const yt = new Innertube(session);
+ return yt;
+}
- if (!session.logged_in && oauthData) {
- await session.oauth.init(oauthData);
- session.logged_in = true;
- }
+export default async function (o) {
+ const quality = o.quality === "max" ? 9000 : Number(o.quality);
- if (session.logged_in) {
- if (session.oauth.shouldRefreshToken()) {
- await session.oauth.refreshAccessToken();
- }
+ let useHLS = o.youtubeHLS;
+ let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS";
- const cookieValues = cookie.values();
- const oldExpiry = new Date(cookieValues.expiry_date);
- const newExpiry = new Date(session.oauth.oauth2_tokens.expiry_date);
+ // HLS playlists from the iOS client don't contain the av1 video format.
+ if (useHLS && o.format === "av1") {
+ useHLS = false;
+ }
- if (oldExpiry.getTime() !== newExpiry.getTime()) {
- updateCookieValues(cookie, {
- ...session.oauth.client_id,
- ...session.oauth.oauth2_tokens,
- expiry_date: newExpiry.toISOString()
- });
- }
+ if (useHLS) {
+ innertubeClient = "IOS";
}
- const yt = new Innertube(session);
- return yt;
-}
+ // iOS client doesn't have adaptive formats of resolution >1080p,
+ // so we use the WEB_EMBEDDED client instead for those cases
+ const useSession =
+ env.ytSessionServer && (
+ (
+ !useHLS
+ && innertubeClient === "IOS"
+ && (
+ (quality > 1080 && o.format !== "h264")
+ || (quality > 1080 && o.format !== "vp9")
+ )
+ )
+ );
+
+ if (useSession) {
+ innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
+ }
-export default async function(o) {
let yt;
try {
yt = await cloneInnertube(
(input, init) => fetch(input, {
...init,
dispatcher: o.dispatcher
- })
+ }),
+ useSession
);
- } catch(e) {
- if (e.message?.endsWith("decipher algorithm")) {
+ } catch (e) {
+ if (e === "no_session_tokens") {
+ return { error: "youtube.no_session_tokens" };
+ } else if (e.message?.endsWith("decipher algorithm")) {
return { error: "youtube.decipher" }
} else if (e.message?.includes("refresh access token")) {
return { error: "youtube.token_expired" }
} else throw e;
}
- const quality = o.quality === "max" ? "9000" : o.quality;
-
- let info, isDubbed,
- format = o.format || "h264";
-
- function qual(i) {
- if (!i.quality_label) {
- return;
+ let info;
+ try {
+ info = await yt.getBasicInfo(o.id, innertubeClient);
+ } catch (e) {
+ if (e?.info) {
+ let errorInfo;
+ try { errorInfo = JSON.parse(e?.info); } catch {}
+
+ if (errorInfo?.reason === "This video is private") {
+ return { error: "content.video.private" };
+ }
+ if (["INVALID_ARGUMENT", "UNAUTHENTICATED"].includes(errorInfo?.error?.status)) {
+ return { error: "youtube.api_error" };
+ }
}
- return i.quality_label.split('p')[0].split('s')[0]
- }
-
- try {
- info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
- } catch(e) {
- if (e?.info?.reason === "This video is private") {
- return { error: "content.video.private" };
- } else if (e?.message === "This video is unavailable") {
+ if (e?.message === "This video is unavailable") {
return { error: "content.video.unavailable" };
- } else {
- return { error: "fetch.fail" };
}
+
+ return { error: "fetch.fail" };
}
if (!info) return { error: "fetch.fail" };
@@ -146,37 +165,47 @@ export default async function(o) {
const playability = info.playability_status;
const basicInfo = info.basic_info;
- if (playability.status === "LOGIN_REQUIRED") {
- if (playability.reason.endsWith("bot")) {
- return { error: "youtube.login" }
- }
- if (playability.reason.endsWith("age")) {
- return { error: "content.video.age" }
- }
- if (playability?.error_screen?.reason?.text === "Private video") {
- return { error: "content.video.private" }
- }
- }
+ switch (playability.status) {
+ case "LOGIN_REQUIRED":
+ if (playability.reason.endsWith("bot")) {
+ return { error: "youtube.login" }
+ }
+ if (playability.reason.endsWith("age") || playability.reason.endsWith("inappropriate for some users.")) {
+ return { error: "content.video.age" }
+ }
+ if (playability?.error_screen?.reason?.text === "Private video") {
+ return { error: "content.video.private" }
+ }
+ break;
- if (playability.status === "UNPLAYABLE") {
- if (playability?.reason?.endsWith("request limit.")) {
- return { error: "fetch.rate" }
- }
- if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
- return { error: "content.video.region" }
- }
- if (playability?.error_screen?.reason?.text === "Private video") {
- return { error: "content.video.private" }
- }
+ case "UNPLAYABLE":
+ if (playability?.reason?.endsWith("request limit.")) {
+ return { error: "fetch.rate" }
+ }
+ if (playability?.error_screen?.subreason?.text?.endsWith("in your country")) {
+ return { error: "content.video.region" }
+ }
+ if (playability?.error_screen?.reason?.text === "Private video") {
+ return { error: "content.video.private" }
+ }
+ break;
+
+ case "AGE_VERIFICATION_REQUIRED":
+ return { error: "content.video.age" };
}
if (playability.status !== "OK") {
return { error: "content.video.unavailable" };
}
+
if (basicInfo.is_live) {
return { error: "content.video.live" };
}
+ if (basicInfo.duration > env.durationLimit) {
+ return { error: "content.too_long" };
+ }
+
// return a critical error if returned video is "Video Not Available"
// or a similar stub by youtube
if (basicInfo.id !== o.id) {
@@ -186,64 +215,206 @@ export default async function(o) {
}
}
- const filterByCodec = (formats) =>
- formats
- .filter(e =>
- e.mime_type.includes(codecMatch[format].videoCodec)
- || e.mime_type.includes(codecMatch[format].audioCodec)
- )
- .sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
+ const normalizeQuality = res => {
+ const shortestSide = Math.min(res.height, res.width);
+ return videoQualities.find(qual => qual >= shortestSide);
+ }
- let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats);
+ let video, audio, dubbedLanguage,
+ codec = o.format || "h264", itag = o.itag;
- if (adaptive_formats.length === 0 && format === "vp9") {
- format = "h264"
- adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats)
- }
+ if (useHLS) {
+ const hlsManifest = info.streaming_data.hls_manifest_url;
- let bestQuality;
+ if (!hlsManifest) {
+ return { error: "youtube.no_hls_streams" };
+ }
- const bestVideo = adaptive_formats.find(i => i.has_video && i.content_length);
- const hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length);
+ const fetchedHlsManifest = await fetch(hlsManifest, {
+ dispatcher: o.dispatcher,
+ }).then(r => {
+ if (r.status === 200) {
+ return r.text();
+ } else {
+ throw new Error("couldn't fetch the HLS playlist");
+ }
+ }).catch(() => { });
- if (bestVideo) bestQuality = qual(bestVideo);
+ if (!fetchedHlsManifest) {
+ return { error: "youtube.no_hls_streams" };
+ }
- if ((!bestQuality && !o.isAudioOnly) || !hasAudio)
- return { error: "youtube.codec" };
+ const variants = HLS.parse(fetchedHlsManifest).variants.sort(
+ (a, b) => Number(b.bandwidth) - Number(a.bandwidth)
+ );
- if (basicInfo.duration > env.durationLimit)
- return { error: "content.too_long" };
+ if (!variants || variants.length === 0) {
+ return { error: "youtube.no_hls_streams" };
+ }
- const checkBestAudio = (i) => (i.has_audio && !i.has_video);
+ const matchHlsCodec = codecs => (
+ codecs.includes(hlsCodecList[codec].videoCodec)
+ );
- let audio = adaptive_formats.find(i =>
- checkBestAudio(i) && i.is_original
- );
+ const best = variants.find(i => matchHlsCodec(i.codecs));
+
+ const preferred = variants.find(i =>
+ matchHlsCodec(i.codecs) && normalizeQuality(i.resolution) === quality
+ );
+
+ let selected = preferred || best;
+
+ if (!selected) {
+ codec = "h264";
+ selected = variants.find(i => matchHlsCodec(i.codecs));
+ }
+
+ if (!selected) {
+ return { error: "youtube.no_matching_format" };
+ }
+
+ audio = selected.audio.find(i => i.isDefault);
+
+ // some videos (mainly those with AI dubs) don't have any tracks marked as default
+ // why? god knows, but we assume that a default track is marked as such in the title
+ if (!audio) {
+ audio = selected.audio.find(i => i.name.endsWith("- original"));
+ }
+
+ if (o.dubLang) {
+ const dubbedAudio = selected.audio.find(i =>
+ i.language?.startsWith(o.dubLang)
+ );
+
+ if (dubbedAudio && !dubbedAudio.isDefault) {
+ dubbedLanguage = dubbedAudio.language;
+ audio = dubbedAudio;
+ }
+ }
+
+ selected.audio = [];
+ selected.subtitles = [];
+ video = selected;
+ } else {
+ // i miss typescript so bad
+ const sorted_formats = {
+ h264: {
+ video: [],
+ audio: [],
+ bestVideo: undefined,
+ bestAudio: undefined,
+ },
+ vp9: {
+ video: [],
+ audio: [],
+ bestVideo: undefined,
+ bestAudio: undefined,
+ },
+ av1: {
+ video: [],
+ audio: [],
+ bestVideo: undefined,
+ bestAudio: undefined,
+ },
+ }
+
+ const checkFormat = (format, pCodec) => format.content_length &&
+ (format.mime_type.includes(codecList[pCodec].videoCodec)
+ || format.mime_type.includes(codecList[pCodec].audioCodec));
+
+ // sort formats & weed out bad ones
+ info.streaming_data.adaptive_formats.sort((a, b) =>
+ Number(b.bitrate) - Number(a.bitrate)
+ ).forEach(format => {
+ Object.keys(codecList).forEach(yCodec => {
+ const matchingItag = slot => !itag?.[slot] || itag[slot] === format.itag;
+ const sorted = sorted_formats[yCodec];
+ const goodFormat = checkFormat(format, yCodec);
+ if (!goodFormat) return;
+
+ if (format.has_video && matchingItag('video')) {
+ sorted.video.push(format);
+ if (!sorted.bestVideo)
+ sorted.bestVideo = format;
+ }
+
+ if (format.has_audio && matchingItag('audio')) {
+ sorted.audio.push(format);
+ if (!sorted.bestAudio)
+ sorted.bestAudio = format;
+ }
+ })
+ });
+
+ const noBestMedia = () => {
+ const vid = sorted_formats[codec]?.bestVideo;
+ const aud = sorted_formats[codec]?.bestAudio;
+ return (!vid && !o.isAudioOnly) || (!aud && o.isAudioOnly)
+ };
+
+ if (noBestMedia()) {
+ if (codec === "av1") codec = "vp9";
+ else if (codec === "vp9") codec = "av1";
- if (o.dubLang) {
- let dubbedAudio = adaptive_formats.find(i =>
- checkBestAudio(i)
- && i.language === o.dubLang
- && i.audio_track
- )
+ // if there's no higher quality fallback, then use h264
+ if (noBestMedia()) codec = "h264";
+ }
+
+ // if there's no proper combo of av1, vp9, or h264, then give up
+ if (noBestMedia()) {
+ return { error: "youtube.no_matching_format" };
+ }
+
+ audio = sorted_formats[codec].bestAudio;
+
+ if (audio?.audio_track && !audio?.audio_track?.audio_is_default) {
+ audio = sorted_formats[codec].audio.find(i =>
+ i?.audio_track?.audio_is_default
+ );
+ }
+
+ if (o.dubLang) {
+ const dubbedAudio = sorted_formats[codec].audio.find(i =>
+ i.language?.startsWith(o.dubLang) && i.audio_track
+ );
+
+ if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
+ audio = dubbedAudio;
+ dubbedLanguage = dubbedAudio.language;
+ }
+ }
+
+ if (!o.isAudioOnly) {
+ const qual = (i) => {
+ return normalizeQuality({
+ width: i.width,
+ height: i.height,
+ })
+ }
- if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) {
- audio = dubbedAudio;
- isDubbed = true;
+ const bestQuality = qual(sorted_formats[codec].bestVideo);
+ const useBestQuality = quality >= bestQuality;
+
+ video = useBestQuality
+ ? sorted_formats[codec].bestVideo
+ : sorted_formats[codec].video.find(i => qual(i) === quality);
+
+ if (!video) video = sorted_formats[codec].bestVideo;
}
}
- if (!audio) {
- audio = adaptive_formats.find(i => checkBestAudio(i));
+ if (video?.drm_families || audio?.drm_families) {
+ return { error: "youtube.drm" };
}
- let fileMetadata = {
- title: cleanString(basicInfo.title.trim()),
- artist: cleanString(basicInfo.author.replace("- Topic", "").trim()),
+ const fileMetadata = {
+ title: basicInfo.title.trim(),
+ artist: basicInfo.author.replace("- Topic", "").trim()
}
if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
- let descItems = basicInfo.short_description.split("\n\n", 5);
+ const descItems = basicInfo.short_description.split("\n\n", 5);
+
if (descItems.length === 5) {
fileMetadata.album = descItems[2];
fileMetadata.copyright = descItems[3];
@@ -253,61 +424,94 @@ export default async function(o) {
}
}
- let filenameAttributes = {
+ const filenameAttributes = {
service: "youtube",
id: o.id,
title: fileMetadata.title,
author: fileMetadata.artist,
- youtubeDubName: isDubbed ? o.dubLang : false
+ youtubeDubName: dubbedLanguage || false,
}
- if (audio && o.isAudioOnly) return {
- type: "audio",
- isAudioOnly: true,
- urls: audio.decipher(yt.session.player),
- filenameAttributes: filenameAttributes,
- fileMetadata: fileMetadata,
- bestAudio: format === "h264" ? "m4a" : "opus"
+ itag = {
+ video: video?.itag,
+ audio: audio?.itag
+ };
+
+ const originalRequest = {
+ ...o,
+ dispatcher: undefined,
+ itag,
+ innertubeClient
+ };
+
+ if (audio && o.isAudioOnly) {
+ let bestAudio = codec === "h264" ? "m4a" : "opus";
+ let urls = audio.url;
+
+ if (useHLS) {
+ bestAudio = "mp3";
+ urls = audio.uri;
+ }
+
+ if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
+ urls = audio.decipher(innertube.session.player);
+ }
+
+ return {
+ type: "audio",
+ isAudioOnly: true,
+ urls,
+ filenameAttributes,
+ fileMetadata,
+ bestAudio,
+ isHLS: useHLS,
+ originalRequest
+ }
}
- const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality,
- checkSingle = i =>
- qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].videoCodec),
- checkRender = i =>
- qual(i) === matchingQuality && i.has_video && !i.has_audio;
+ if (video && audio) {
+ let resolution;
- let match, type, urls;
+ if (useHLS) {
+ resolution = normalizeQuality(video.resolution);
+ filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
+ filenameAttributes.extension = hlsCodecList[codec].container;
- // prefer good premuxed videos if available
- if (!o.isAudioOnly && !o.isAudioMuted && format === "h264" && bestVideo.fps <= 30) {
- match = info.streaming_data.formats.find(checkSingle);
- type = "proxy";
- urls = match?.decipher(yt.session.player);
- }
+ video = video.uri;
+ audio = audio.uri;
+ } else {
+ resolution = normalizeQuality({
+ width: video.width,
+ height: video.height,
+ });
- const video = adaptive_formats.find(checkRender);
+ filenameAttributes.resolution = `${video.width}x${video.height}`;
+ filenameAttributes.extension = codecList[codec].container;
- if (!match && video && audio) {
- match = video;
- type = "merge";
- urls = [
- video.decipher(yt.session.player),
- audio.decipher(yt.session.player)
- ]
- }
+ if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
+ video = video.decipher(innertube.session.player);
+ audio = audio.decipher(innertube.session.player);
+ } else {
+ video = video.url;
+ audio = audio.url;
+ }
+ }
+
+ filenameAttributes.qualityLabel = `${resolution}p`;
+ filenameAttributes.youtubeFormat = codec;
- if (match) {
- filenameAttributes.qualityLabel = match.quality_label;
- filenameAttributes.resolution = `${match.width}x${match.height}`;
- filenameAttributes.extension = codecMatch[format].container;
- filenameAttributes.youtubeFormat = format;
return {
- type,
- urls,
+ type: "merge",
+ urls: [
+ video,
+ audio,
+ ],
filenameAttributes,
- fileMetadata
+ fileMetadata,
+ isHLS: useHLS,
+ originalRequest
}
}
- return { error: "fetch.fail" }
+ return { error: "youtube.no_matching_format" };
}
diff --git a/api/src/processing/url.js b/api/src/processing/url.js
index a8e699372b3be2141832099fd9db6541bf5e3a0a..86c333f6b3918b4a80ce26e84770dcce4056a4fc 100644
--- a/api/src/processing/url.js
+++ b/api/src/processing/url.js
@@ -1,8 +1,9 @@
-import psl from "psl";
+import psl from "@imput/psl";
import { strict as assert } from "node:assert";
import { env } from "../config.js";
import { services } from "./service-config.js";
+import { getRedirectingURL } from "../misc/utils.js";
import { friendlyServiceName } from "./service-alias.js";
function aliasURL(url) {
@@ -42,7 +43,7 @@ function aliasURL(url) {
case "fixvx":
case "x":
if (services.twitter.altDomains.includes(url.hostname)) {
- url.hostname = 'twitter.com'
+ url.hostname = 'twitter.com';
}
break;
@@ -85,9 +86,37 @@ function aliasURL(url) {
url.hostname = 'instagram.com';
}
break;
+
+ case "vk":
+ case "vkvideo":
+ if (services.vk.altDomains.includes(url.hostname)) {
+ url.hostname = 'vk.com';
+ }
+ break;
+
+ case "xhslink":
+ if (url.hostname === 'xhslink.com' && parts.length === 3) {
+ url = new URL(`https://www.xiaohongshu.com/a/${parts[2]}`);
+ }
+ break;
+
+ case "loom":
+ const idPart = parts[parts.length - 1];
+ if (idPart.length > 32) {
+ url.pathname = `/share/${idPart.slice(-32)}`;
+ }
+ break;
+
+ case "redd":
+ /* reddit short video links can be treated by changing https://v.redd.it/
+ to https://reddit.com/video/.*/
+ if (url.hostname === "v.redd.it" && parts.length === 2) {
+ url = new URL(`https://www.reddit.com/video/${parts[1]}`);
+ }
+ break;
}
- return url
+ return url;
}
function cleanURL(url) {
@@ -107,31 +136,41 @@ function cleanURL(url) {
break;
case "vk":
if (url.pathname.includes('/clip') && url.searchParams.get('z')) {
- limitQuery('z')
+ limitQuery('z');
}
break;
case "youtube":
if (url.searchParams.get('v')) {
- limitQuery('v')
+ limitQuery('v');
}
break;
case "rutube":
if (url.searchParams.get('p')) {
- limitQuery('p')
+ limitQuery('p');
+ }
+ break;
+ case "twitter":
+ if (url.searchParams.get('post_id')) {
+ limitQuery('post_id');
+ }
+ break;
+ case "xiaohongshu":
+ if (url.searchParams.get('xsec_token')) {
+ limitQuery('xsec_token');
}
break;
}
if (stripQuery) {
- url.search = ''
+ url.search = '';
}
- url.username = url.password = url.port = url.hash = ''
+ url.username = url.password = url.port = url.hash = '';
if (url.pathname.endsWith('/'))
url.pathname = url.pathname.slice(0, -1);
- return url
+ return url;
}
function getHostIfValid(url) {
@@ -169,6 +208,11 @@ export function extract(url) {
}
if (!env.enabledServices.has(host)) {
+ // show a different message when youtube is disabled on official instances
+ // as it only happens when shit hits the fan
+ if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
+ return { error: "youtube.temporary_disabled" };
+ }
return { error: "service.disabled" };
}
@@ -194,3 +238,17 @@ export function extract(url) {
return { host, patternMatch };
}
+
+export async function resolveRedirectingURL(url, dispatcher, headers) {
+ const originalService = getHostIfValid(normalizeURL(url));
+ if (!originalService) return;
+
+ const canonicalURL = await getRedirectingURL(url, dispatcher, headers);
+ if (!canonicalURL) return;
+
+ const { host, patternMatch } = extract(normalizeURL(canonicalURL));
+
+ if (host === originalService) {
+ return patternMatch;
+ }
+}
diff --git a/api/src/security/api-keys.js b/api/src/security/api-keys.js
new file mode 100644
index 0000000000000000000000000000000000000000..d534999cd3055c628c5d9f13880f12f3a08b0462
--- /dev/null
+++ b/api/src/security/api-keys.js
@@ -0,0 +1,227 @@
+import { env } from "../config.js";
+import { readFile } from "node:fs/promises";
+import { Green, Yellow } from "../misc/console-text.js";
+import ip from "ipaddr.js";
+import * as cluster from "../misc/cluster.js";
+
+// this function is a modified variation of code
+// from https://stackoverflow.com/a/32402438/14855621
+const generateWildcardRegex = rule => {
+ var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
+ return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$");
+}
+
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+
+let keys = {};
+
+const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
+
+/* Expected format pseudotype:
+** type KeyFileContents = Record<
+** UUIDv4String,
+** {
+** name?: string,
+** limit?: number | "unlimited",
+** ips?: CIDRString[],
+** userAgents?: string[]
+** }
+** >;
+*/
+
+const validateKeys = (input) => {
+ if (typeof input !== 'object' || input === null) {
+ throw "input is not an object";
+ }
+
+ if (Object.keys(input).some(x => !UUID_REGEX.test(x))) {
+ throw "key file contains invalid key(s)";
+ }
+
+ Object.values(input).forEach(details => {
+ if (typeof details !== 'object' || details === null) {
+ throw "some key(s) are incorrectly configured";
+ }
+
+ const unexpected_key = Object.keys(details).find(k => !ALLOWED_KEYS.has(k));
+ if (unexpected_key) {
+ throw "detail object contains unexpected key: " + unexpected_key;
+ }
+
+ if (details.limit && details.limit !== 'unlimited') {
+ if (typeof details.limit !== 'number')
+ throw "detail object contains invalid limit (not a number)";
+ else if (details.limit < 1)
+ throw "detail object contains invalid limit (not a positive number)";
+ }
+
+ if (details.ips) {
+ if (!Array.isArray(details.ips))
+ throw "details object contains value for `ips` which is not an array";
+
+ const invalid_ip = details.ips.find(
+ addr => typeof addr !== 'string' || (!ip.isValidCIDR(addr) && !ip.isValid(addr))
+ );
+
+ if (invalid_ip) {
+ throw "`ips` in details contains an invalid IP or CIDR range: " + invalid_ip;
+ }
+ }
+
+ if (details.userAgents) {
+ if (!Array.isArray(details.userAgents))
+ throw "details object contains value for `userAgents` which is not an array";
+
+ const invalid_ua = details.userAgents.find(ua => typeof ua !== 'string');
+ if (invalid_ua) {
+ throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
+ }
+ }
+ });
+}
+
+const formatKeys = (keyData) => {
+ const formatted = {};
+
+ for (let key in keyData) {
+ const data = keyData[key];
+ key = key.toLowerCase();
+
+ formatted[key] = {};
+
+ if (data.limit) {
+ if (data.limit === "unlimited") {
+ data.limit = Infinity;
+ }
+
+ formatted[key].limit = data.limit;
+ }
+
+ if (data.ips) {
+ formatted[key].ips = data.ips.map(addr => {
+ if (ip.isValid(addr)) {
+ const parsed = ip.parse(addr);
+ const range = parsed.kind() === 'ipv6' ? 128 : 32;
+ return [ parsed, range ];
+ }
+
+ return ip.parseCIDR(addr);
+ });
+ }
+
+ if (data.userAgents) {
+ formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);
+ }
+ }
+
+ return formatted;
+}
+
+const updateKeys = (newKeys) => {
+ keys = formatKeys(newKeys);
+}
+
+const loadKeys = async (source) => {
+ let updated;
+ if (source.protocol === 'file:') {
+ const pathname = source.pathname === '/' ? '' : source.pathname;
+ updated = JSON.parse(
+ await readFile(
+ decodeURIComponent(source.host + pathname),
+ 'utf8'
+ )
+ );
+ } else {
+ updated = await fetch(source).then(a => a.json());
+ }
+
+ validateKeys(updated);
+
+ cluster.broadcast({ api_keys: updated });
+
+ updateKeys(updated);
+}
+
+const wrapLoad = (url, initial = false) => {
+ loadKeys(url)
+ .then(() => {
+ if (initial) {
+ console.log(`${Green('[✓]')} api keys loaded successfully!`)
+ }
+ })
+ .catch((e) => {
+ console.error(`${Yellow('[!]')} Failed loading API keys at ${new Date().toISOString()}.`);
+ console.error('Error:', e);
+ })
+}
+
+const err = (reason) => ({ success: false, error: reason });
+
+export const validateAuthorization = (req) => {
+ const authHeader = req.get('Authorization');
+
+ if (typeof authHeader !== 'string') {
+ return err("missing");
+ }
+
+ const [ authType, keyString ] = authHeader.split(' ', 2);
+ if (authType.toLowerCase() !== 'api-key') {
+ return err("not_api_key");
+ }
+
+ if (!UUID_REGEX.test(keyString) || `${authType} ${keyString}` !== authHeader) {
+ return err("invalid");
+ }
+
+ const matchingKey = keys[keyString.toLowerCase()];
+ if (!matchingKey) {
+ return err("not_found");
+ }
+
+ if (matchingKey.ips) {
+ let addr;
+ try {
+ addr = ip.parse(req.ip);
+ } catch {
+ return err("invalid_ip");
+ }
+
+ const ip_allowed = matchingKey.ips.some(
+ ([ allowed, size ]) => {
+ return addr.kind() === allowed.kind()
+ && addr.match(allowed, size);
+ }
+ );
+
+ if (!ip_allowed) {
+ return err("ip_not_allowed");
+ }
+ }
+
+ if (matchingKey.userAgents) {
+ const userAgent = req.get('User-Agent');
+ if (!matchingKey.userAgents.some(regex => regex.test(userAgent))) {
+ return err("ua_not_allowed");
+ }
+ }
+
+ req.rateLimitKey = keyString.toLowerCase();
+ req.rateLimitMax = matchingKey.limit;
+
+ return { success: true };
+}
+
+export const setup = (url) => {
+ if (cluster.isPrimary) {
+ wrapLoad(url, true);
+ if (env.keyReloadInterval > 0) {
+ setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000);
+ }
+ } else if (cluster.isWorker) {
+ process.on('message', (message) => {
+ if ('api_keys' in message) {
+ updateKeys(message.api_keys);
+ }
+ });
+ }
+}
diff --git a/api/src/security/jwt.js b/api/src/security/jwt.js
index 91d6cf9ea16053cc19331e314d584483465e8d40..557f0b68ce8546d9018f87b1f2f64405508c5de7 100644
--- a/api/src/security/jwt.js
+++ b/api/src/security/jwt.js
@@ -6,12 +6,19 @@ import { env } from "../config.js";
const toBase64URL = (b) => Buffer.from(b).toString("base64url");
const fromBase64URL = (b) => Buffer.from(b, "base64url").toString();
-const makeHmac = (header, payload) =>
- createHmac("sha256", env.jwtSecret)
- .update(`${header}.${payload}`)
- .digest("base64url");
+const makeHmac = (data) => {
+ return createHmac("sha256", env.jwtSecret)
+ .update(data)
+ .digest("base64url");
+}
+
+const sign = (header, payload) =>
+ makeHmac(`${header}.${payload}`);
+
+const getIPHash = (ip) =>
+ makeHmac(ip).slice(0, 8);
-const generate = () => {
+const generate = (ip) => {
const exp = Math.floor(new Date().getTime() / 1000) + env.jwtLifetime;
const header = toBase64URL(JSON.stringify({
@@ -21,10 +28,11 @@ const generate = () => {
const payload = toBase64URL(JSON.stringify({
jti: nanoid(8),
+ sub: getIPHash(ip),
exp,
}));
- const signature = makeHmac(header, payload);
+ const signature = sign(header, payload);
return {
token: `${header}.${payload}.${signature}`,
@@ -32,7 +40,7 @@ const generate = () => {
};
}
-const verify = (jwt) => {
+const verify = (jwt, ip) => {
const [header, payload, signature] = jwt.split(".", 3);
const timestamp = Math.floor(new Date().getTime() / 1000);
@@ -40,17 +48,16 @@ const verify = (jwt) => {
return false;
}
- const verifySignature = makeHmac(header, payload);
+ const verifySignature = sign(header, payload);
if (verifySignature !== signature) {
return false;
}
- if (timestamp >= JSON.parse(fromBase64URL(payload)).exp) {
- return false;
- }
+ const data = JSON.parse(fromBase64URL(payload));
- return true;
+ return getIPHash(ip) === data.sub
+ && timestamp <= data.exp;
}
export default {
diff --git a/api/src/security/secrets.js b/api/src/security/secrets.js
new file mode 100644
index 0000000000000000000000000000000000000000..fff24f844f4f2cf16790501de730fa5f5a294695
--- /dev/null
+++ b/api/src/security/secrets.js
@@ -0,0 +1,62 @@
+import cluster from "node:cluster";
+import { createHmac, randomBytes } from "node:crypto";
+
+const generateSalt = () => {
+ if (cluster.isPrimary)
+ return randomBytes(64);
+
+ return null;
+}
+
+let rateSalt = generateSalt();
+let streamSalt = generateSalt();
+
+export const syncSecrets = () => {
+ return new Promise((resolve, reject) => {
+ if (cluster.isPrimary) {
+ let remaining = Object.values(cluster.workers).length;
+ const handleReady = (worker, m) => {
+ if (m.ready)
+ worker.send({ rateSalt, streamSalt });
+
+ if (!--remaining)
+ resolve();
+ }
+
+ for (const worker of Object.values(cluster.workers)) {
+ worker.once(
+ 'message',
+ (m) => handleReady(worker, m)
+ );
+ }
+ } else if (cluster.isWorker) {
+ if (rateSalt || streamSalt)
+ return reject();
+
+ process.send({ ready: true });
+ process.once('message', (message) => {
+ if (rateSalt || streamSalt)
+ return reject();
+
+ if (message.rateSalt && message.streamSalt) {
+ streamSalt = Buffer.from(message.streamSalt);
+ rateSalt = Buffer.from(message.rateSalt);
+ resolve();
+ }
+ });
+ } else reject();
+ });
+}
+
+
+export const hashHmac = (value, type) => {
+ let salt;
+ if (type === 'rate')
+ salt = rateSalt;
+ else if (type === 'stream')
+ salt = streamSalt;
+ else
+ throw "unknown salt";
+
+ return createHmac("sha256", salt).update(value).digest();
+}
diff --git a/api/src/store/base-store.js b/api/src/store/base-store.js
new file mode 100644
index 0000000000000000000000000000000000000000..c2a59ff8f5ad9cdea41a7301ffceebd25ab178d5
--- /dev/null
+++ b/api/src/store/base-store.js
@@ -0,0 +1,48 @@
+const _stores = new Set();
+
+export class Store {
+ id;
+
+ constructor(name) {
+ name = name.toUpperCase();
+
+ if (_stores.has(name))
+ throw `${name} store already exists`;
+ _stores.add(name);
+
+ this.id = name;
+ }
+
+ async _has(_key) { await Promise.reject("needs implementation"); }
+ has(key) {
+ if (typeof key !== 'string') {
+ key = key.toString();
+ }
+
+ return this._has(key);
+ }
+
+ async _get(_key) { await Promise.reject("needs implementation"); }
+ async get(key) {
+ if (typeof key !== 'string') {
+ key = key.toString();
+ }
+
+ const val = await this._get(key);
+ if (val === null)
+ return null;
+
+ return val;
+ }
+
+ async _set(_key, _val, _exp_sec = -1) { await Promise.reject("needs implementation") }
+ set(key, val, exp_sec = -1) {
+ if (typeof key !== 'string') {
+ key = key.toString();
+ }
+
+ exp_sec = Math.round(exp_sec);
+
+ return this._set(key, val, exp_sec);
+ }
+};
diff --git a/api/src/store/memory-store.js b/api/src/store/memory-store.js
new file mode 100644
index 0000000000000000000000000000000000000000..100a0e094dd5dfeaac99900e2644c8141e65b02e
--- /dev/null
+++ b/api/src/store/memory-store.js
@@ -0,0 +1,77 @@
+import { MinPriorityQueue } from '@datastructures-js/priority-queue';
+import { Store } from './base-store.js';
+
+// minimum delay between sweeps to avoid repeatedly
+// sweeping entries close in proximity one by one.
+const MIN_THRESHOLD_MS = 2500;
+
+export default class MemoryStore extends Store {
+ #store = new Map();
+ #timeouts = new MinPriorityQueue/*<{ t: number, k: unknown }>*/((obj) => obj.t);
+ #nextSweep = { id: null, t: null };
+
+ constructor(name) {
+ super(name);
+ }
+
+ _has(key) {
+ return this.#store.has(key);
+ }
+
+ _get(key) {
+ const val = this.#store.get(key);
+
+ return val === undefined ? null : val;
+ }
+
+ _set(key, val, exp_sec = -1) {
+ if (this.#store.has(key)) {
+ this.#timeouts.remove(o => o.k === key);
+ }
+
+ if (exp_sec > 0) {
+ const exp = 1000 * exp_sec;
+ const timeout_at = +new Date() + exp;
+
+ this.#timeouts.enqueue({ k: key, t: timeout_at });
+ }
+
+ this.#store.set(key, val);
+ this.#reschedule();
+ }
+
+ #reschedule() {
+ const current_time = new Date().getTime();
+ const time = this.#timeouts.front()?.t;
+ if (!time) {
+ return;
+ } else if (time < current_time) {
+ return this.#sweepNow();
+ }
+
+ const sweep = this.#nextSweep;
+ if (sweep.id === null || sweep.t > time) {
+ if (sweep.id) {
+ clearTimeout(sweep.id);
+ }
+
+ sweep.t = time;
+ sweep.id = setTimeout(
+ () => this.#sweepNow(),
+ Math.max(MIN_THRESHOLD_MS, time - current_time)
+ );
+ sweep.id.unref();
+ }
+ }
+
+ #sweepNow() {
+ while (this.#timeouts.front()?.t < new Date().getTime()) {
+ const item = this.#timeouts.dequeue();
+ this.#store.delete(item.k);
+ }
+
+ this.#nextSweep.id = null;
+ this.#nextSweep.t = null;
+ this.#reschedule();
+ }
+}
diff --git a/api/src/store/redis-ratelimit.js b/api/src/store/redis-ratelimit.js
new file mode 100644
index 0000000000000000000000000000000000000000..64d11e5e3f9659225316287ccf880da21f772dbc
--- /dev/null
+++ b/api/src/store/redis-ratelimit.js
@@ -0,0 +1,19 @@
+import { env } from "../config.js";
+
+let client, redis, redisLimiter;
+
+export const createStore = async (name) => {
+ if (!env.redisURL) return;
+
+ if (!client) {
+ redis = await import('redis');
+ redisLimiter = await import('rate-limit-redis');
+ client = redis.createClient({ url: env.redisURL });
+ await client.connect();
+ }
+
+ return new redisLimiter.default({
+ prefix: `RL${name}_`,
+ sendCommand: (...args) => client.sendCommand(args),
+ });
+}
diff --git a/api/src/store/redis-store.js b/api/src/store/redis-store.js
new file mode 100644
index 0000000000000000000000000000000000000000..0b3595264c8a945aac320150e5e10d76be74ea02
--- /dev/null
+++ b/api/src/store/redis-store.js
@@ -0,0 +1,64 @@
+import { commandOptions, createClient } from "redis";
+import { env } from "../config.js";
+import { Store } from "./base-store.js";
+
+export default class RedisStore extends Store {
+ #client = createClient({
+ url: env.redisURL,
+ });
+ #connected;
+
+ constructor(name) {
+ super(name);
+ this.#connected = this.#client.connect();
+ }
+
+ #keyOf(key) {
+ return this.id + '_' + key;
+ }
+
+ async _has(key) {
+ await this.#connected;
+
+ return this.#client.hExists(key);
+ }
+
+ async _get(key) {
+ await this.#connected;
+
+ const valueType = await this.#client.get(this.#keyOf(key) + '_t');
+ const value = await this.#client.get(
+ commandOptions({ returnBuffers: true }),
+ this.#keyOf(key)
+ );
+
+ if (!value) {
+ return null;
+ }
+
+ if (valueType === 'b')
+ return value;
+ else
+ return JSON.parse(value);
+ }
+
+ async _set(key, val, exp_sec = -1) {
+ await this.#connected;
+
+ const options = exp_sec > 0 ? { EX: exp_sec } : undefined;
+
+ if (val instanceof Buffer) {
+ await this.#client.set(
+ this.#keyOf(key) + '_t',
+ 'b',
+ options
+ );
+ }
+
+ await this.#client.set(
+ this.#keyOf(key),
+ val,
+ options
+ );
+ }
+}
diff --git a/api/src/store/store.js b/api/src/store/store.js
new file mode 100644
index 0000000000000000000000000000000000000000..e268d88db16c2b8e2762e9ad90ce258876dba9c6
--- /dev/null
+++ b/api/src/store/store.js
@@ -0,0 +1,10 @@
+import { env } from '../config.js';
+
+let _export;
+if (env.redisURL) {
+ _export = await import('./redis-store.js');
+} else {
+ _export = await import('./memory-store.js');
+}
+
+export default _export.default;
diff --git a/api/src/stream/internal-hls.js b/api/src/stream/internal-hls.js
index 07fcebdea6bdfb7da196cc4696183851a17e3cc1..55634c711484891de837e0836fb346c562a548d5 100644
--- a/api/src/stream/internal-hls.js
+++ b/api/src/stream/internal-hls.js
@@ -16,15 +16,17 @@ function transformObject(streamInfo, hlsObject) {
let fullUrl;
if (getURL(hlsObject.uri)) {
- fullUrl = hlsObject.uri;
+ fullUrl = new URL(hlsObject.uri);
} else {
fullUrl = new URL(hlsObject.uri, streamInfo.url);
}
- hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);
+ if (fullUrl.hostname !== '127.0.0.1') {
+ hlsObject.uri = createInternalStream(fullUrl.toString(), streamInfo);
- if (hlsObject.map) {
- hlsObject.map = transformObject(streamInfo, hlsObject.map);
+ if (hlsObject.map) {
+ hlsObject.map = transformObject(streamInfo, hlsObject.map);
+ }
}
return hlsObject;
@@ -53,7 +55,7 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) {
const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"];
-export function isHlsRequest (req) {
+export function isHlsResponse (req) {
return HLS_MIME_TYPES.includes(req.headers['content-type']);
}
diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js
index 51552d4ca274c8126f02e6eb64f4d21e171d6a72..8c97c65673587e14e0df3f72f8fcda8944418faa 100644
--- a/api/src/stream/internal.js
+++ b/api/src/stream/internal.js
@@ -1,13 +1,13 @@
import { request } from "undici";
import { Readable } from "node:stream";
import { closeRequest, getHeaders, pipe } from "./shared.js";
-import { handleHlsPlaylist, isHlsRequest } from "./internal-hls.js";
+import { handleHlsPlaylist, isHlsResponse } from "./internal-hls.js";
const CHUNK_SIZE = BigInt(8e6); // 8 MB
const min = (a, b) => a < b ? a : b;
async function* readChunks(streamInfo, size) {
- let read = 0n;
+ let read = 0n, chunksSinceTransplant = 0;
while (read < size) {
if (streamInfo.controller.signal.aborted) {
throw new Error("controller aborted");
@@ -19,9 +19,20 @@ async function* readChunks(streamInfo, size) {
Range: `bytes=${read}-${read + CHUNK_SIZE}`
},
dispatcher: streamInfo.dispatcher,
- signal: streamInfo.controller.signal
+ signal: streamInfo.controller.signal,
+ maxRedirections: 4
});
+ if (chunk.statusCode === 403 && chunksSinceTransplant >= 3 && streamInfo.transplant) {
+ chunksSinceTransplant = 0;
+ try {
+ await streamInfo.transplant(streamInfo.dispatcher);
+ continue;
+ } catch {}
+ }
+
+ chunksSinceTransplant++;
+
const expected = min(CHUNK_SIZE, size - read);
const received = BigInt(chunk.headers['content-length']);
@@ -42,14 +53,25 @@ async function handleYoutubeStream(streamInfo, res) {
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
try {
- const req = await fetch(streamInfo.url, {
- headers: getHeaders('youtube'),
- method: 'HEAD',
- dispatcher: streamInfo.dispatcher,
- signal
- });
+ let req, attempts = 3;
+ while (attempts--) {
+ req = await fetch(streamInfo.url, {
+ headers: getHeaders('youtube'),
+ method: 'HEAD',
+ dispatcher: streamInfo.dispatcher,
+ signal
+ });
+
+ streamInfo.url = req.url;
+ if (req.status === 403 && streamInfo.transplant) {
+ try {
+ await streamInfo.transplant(streamInfo.dispatcher);
+ } catch {
+ break;
+ }
+ } else break;
+ }
- streamInfo.url = req.url;
const size = BigInt(req.headers.get('content-length'));
if (req.status !== 200 || !size) {
@@ -83,7 +105,7 @@ async function handleGenericStream(streamInfo, res) {
const cleanup = () => res.end();
try {
- const req = await request(streamInfo.url, {
+ const fileResponse = await request(streamInfo.url, {
headers: {
...Object.fromEntries(streamInfo.headers),
host: undefined
@@ -93,19 +115,28 @@ async function handleGenericStream(streamInfo, res) {
maxRedirections: 16
});
- res.status(req.statusCode);
- req.body.on('error', () => {});
+ res.status(fileResponse.statusCode);
+ fileResponse.body.on('error', () => {});
- for (const [ name, value ] of Object.entries(req.headers))
- res.setHeader(name, value)
+ // bluesky's cdn responds with wrong content-type for the hls playlist,
+ // so we enforce it here until they fix it
+ const isHls = isHlsResponse(fileResponse)
+ || (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8'));
- if (req.statusCode < 200 || req.statusCode > 299)
+ for (const [ name, value ] of Object.entries(fileResponse.headers)) {
+ if (!isHls || name.toLowerCase() !== 'content-length') {
+ res.setHeader(name, value);
+ }
+ }
+
+ if (fileResponse.statusCode < 200 || fileResponse.statusCode > 299) {
return cleanup();
+ }
- if (isHlsRequest(req)) {
- await handleHlsPlaylist(streamInfo, req, res);
+ if (isHls) {
+ await handleHlsPlaylist(streamInfo, fileResponse, res);
} else {
- pipe(req.body, res, cleanup);
+ pipe(fileResponse.body, res, cleanup);
}
} catch {
closeRequest(streamInfo.controller);
@@ -114,7 +145,11 @@ async function handleGenericStream(streamInfo, res) {
}
export function internalStream(streamInfo, res) {
- if (streamInfo.service === 'youtube') {
+ if (streamInfo.headers) {
+ streamInfo.headers.delete('icy-metadata');
+ }
+
+ if (streamInfo.service === 'youtube' && !streamInfo.isHLS) {
return handleYoutubeStream(streamInfo, res);
}
diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js
index e25f443466dc96784db2e077ed9b964c5a0ca940..ebb5c6c76291790e99501dbd2e1783fb93c11389 100644
--- a/api/src/stream/manage.js
+++ b/api/src/stream/manage.js
@@ -1,4 +1,4 @@
-import NodeCache from "node-cache";
+import Store from "../store/store.js";
import { nanoid } from "nanoid";
import { randomBytes } from "crypto";
@@ -7,34 +7,27 @@ import { setMaxListeners } from "node:events";
import { env } from "../config.js";
import { closeRequest } from "./shared.js";
-import { decryptStream, encryptStream, generateHmac } from "../misc/crypto.js";
+import { decryptStream, encryptStream } from "../misc/crypto.js";
+import { hashHmac } from "../security/secrets.js";
+import { zip } from "../misc/utils.js";
// optional dependency
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
-const streamCache = new NodeCache({
- stdTTL: env.streamLifespan,
- checkperiod: 10,
- deleteOnExpire: true
-})
-
-streamCache.on("expired", (key) => {
- streamCache.del(key);
-})
+const streamCache = new Store('streams');
const internalStreamCache = new Map();
-const hmacSalt = randomBytes(64).toString('hex');
export function createStream(obj) {
const streamID = nanoid(),
iv = randomBytes(16).toString('base64url'),
secret = randomBytes(32).toString('base64url'),
exp = new Date().getTime() + env.streamLifespan * 1000,
- hmac = generateHmac(`${streamID},${exp},${iv},${secret}`, hmacSalt),
+ hmac = hashHmac(`${streamID},${exp},${iv},${secret}`, 'stream').toString('base64url'),
streamData = {
exp: exp,
type: obj.type,
- urls: obj.u,
+ urls: obj.url,
service: obj.service,
filename: obj.filename,
@@ -46,12 +39,19 @@ export function createStream(obj) {
audioBitrate: obj.audioBitrate,
audioCopy: !!obj.audioCopy,
audioFormat: obj.audioFormat,
+
+ isHLS: obj.isHLS || false,
+ originalRequest: obj.originalRequest
};
+ // FIXME: this is now a Promise, but it is not awaited
+ // here. it may happen that the stream is not
+ // stored in the Store before it is requested.
streamCache.set(
streamID,
- encryptStream(streamData, iv, secret)
- )
+ encryptStream(streamData, iv, secret),
+ env.streamLifespan
+ );
let streamLink = new URL('/tunnel', env.apiURL);
@@ -77,7 +77,7 @@ export function getInternalStream(id) {
export function createInternalStream(url, obj = {}) {
assert(typeof url === 'string');
- let dispatcher;
+ let dispatcher = obj.dispatcher;
if (obj.requestIP) {
dispatcher = freebind?.dispatcherFromIP(obj.requestIP, { strict: false })
}
@@ -100,10 +100,12 @@ export function createInternalStream(url, obj = {}) {
service: obj.service,
headers,
controller,
- dispatcher
+ dispatcher,
+ isHLS: obj.isHLS,
+ transplant: obj.transplant
});
- let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.apiPort}`);
+ let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`);
streamLink.searchParams.set('id', streamID);
const cleanup = () => {
@@ -116,13 +118,17 @@ export function createInternalStream(url, obj = {}) {
return streamLink.toString();
}
-export function destroyInternalStream(url) {
+function getInternalTunnelId(url) {
url = new URL(url);
if (url.hostname !== '127.0.0.1') {
return;
}
- const id = url.searchParams.get('id');
+ return url.searchParams.get('id');
+}
+
+export function destroyInternalStream(url) {
+ const id = getInternalTunnelId(url);
if (internalStreamCache.has(id)) {
closeRequest(getInternalStream(id)?.controller);
@@ -130,9 +136,68 @@ export function destroyInternalStream(url) {
}
}
+const transplantInternalTunnels = function(tunnelUrls, transplantUrls) {
+ if (tunnelUrls.length !== transplantUrls.length) {
+ return;
+ }
+
+ for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) {
+ const id = getInternalTunnelId(tun);
+ const itunnel = getInternalStream(id);
+
+ if (!itunnel) continue;
+ itunnel.url = url;
+ }
+}
+
+const transplantTunnel = async function (dispatcher) {
+ if (this.pendingTransplant) {
+ await this.pendingTransplant;
+ return;
+ }
+
+ let finished;
+ this.pendingTransplant = new Promise(r => finished = r);
+
+ try {
+ const handler = await import(`../processing/services/${this.service}.js`);
+ const response = await handler.default({
+ ...this.originalRequest,
+ dispatcher
+ });
+
+ if (!response.urls) {
+ return;
+ }
+
+ response.urls = [response.urls].flat();
+ if (this.originalRequest.isAudioOnly && response.urls.length > 1) {
+ response.urls = [response.urls[1]];
+ } else if (this.originalRequest.isAudioMuted) {
+ response.urls = [response.urls[0]];
+ }
+
+ const tunnels = [this.urls].flat();
+ if (tunnels.length !== response.urls.length) {
+ return;
+ }
+
+ transplantInternalTunnels(tunnels, response.urls);
+ }
+ catch {}
+ finally {
+ finished();
+ delete this.pendingTransplant;
+ }
+}
+
function wrapStream(streamInfo) {
const url = streamInfo.urls;
+ if (streamInfo.originalRequest) {
+ streamInfo.transplant = transplantTunnel.bind(streamInfo);
+ }
+
if (typeof url === 'string') {
streamInfo.urls = createInternalStream(url, streamInfo);
} else if (Array.isArray(url)) {
@@ -146,10 +211,10 @@ function wrapStream(streamInfo) {
return streamInfo;
}
-export function verifyStream(id, hmac, exp, secret, iv) {
+export async function verifyStream(id, hmac, exp, secret, iv) {
try {
- const ghmac = generateHmac(`${id},${exp},${iv},${secret}`, hmacSalt);
- const cache = streamCache.get(id.toString());
+ const ghmac = hashHmac(`${id},${exp},${iv},${secret}`, 'stream').toString('base64url');
+ const cache = await streamCache.get(id.toString());
if (ghmac !== String(hmac)) return { status: 401 };
if (!cache) return { status: 404 };
diff --git a/api/src/stream/shared.js b/api/src/stream/shared.js
index 91e1ac2f7c9defd2f902ca8acef4b36e713db5a7..65af03f0ef35da9593e744843a1a0df8c6679c3f 100644
--- a/api/src/stream/shared.js
+++ b/api/src/stream/shared.js
@@ -1,4 +1,5 @@
import { genericUserAgent } from "../config.js";
+import { vkClientAgent } from "../processing/services/vk.js";
const defaultHeaders = {
'user-agent': genericUserAgent
@@ -13,6 +14,9 @@ const serviceHeaders = {
origin: 'https://www.youtube.com',
referer: 'https://www.youtube.com',
DNT: '?1'
+ },
+ vk: {
+ 'user-agent': vkClientAgent
}
}
diff --git a/api/src/stream/stream.js b/api/src/stream/stream.js
index a6d41200a818fc0613f992a21b5adb8d36fbdf46..c7cf7b561d9760456a18a47ba3007b8717825078 100644
--- a/api/src/stream/stream.js
+++ b/api/src/stream/stream.js
@@ -10,7 +10,7 @@ export default async function(res, streamInfo) {
return await stream.proxy(streamInfo, res);
case "internal":
- return internalStream(streamInfo, res);
+ return internalStream(streamInfo.data, res);
case "merge":
return stream.merge(streamInfo, res);
diff --git a/api/src/stream/types.js b/api/src/stream/types.js
index 184af873d96041fdb78f04b21d5769388de72c68..0a4e2d47ff805ef580b8d51f5e80c0c4b9e15910 100644
--- a/api/src/stream/types.js
+++ b/api/src/stream/types.js
@@ -1,10 +1,9 @@
-import { request } from "undici";
+import { Agent, request } from "undici";
import ffmpeg from "ffmpeg-static";
import { spawn } from "child_process";
import { create as contentDisposition } from "content-disposition-header";
import { env } from "../config.js";
-import { metadataManager } from "../misc/utils.js";
import { destroyInternalStream } from "./manage.js";
import { hlsExceptions } from "../processing/service-config.js";
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
@@ -16,6 +15,29 @@ const ffmpegArgs = {
gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"]
}
+const metadataTags = [
+ "album",
+ "copyright",
+ "title",
+ "artist",
+ "track",
+ "date",
+];
+
+const convertMetadataToFFmpeg = (metadata) => {
+ let args = [];
+
+ for (const [ name, value ] of Object.entries(metadata)) {
+ if (metadataTags.includes(name)) {
+ args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`);
+ } else {
+ throw `${name} metadata tag is not supported.`;
+ }
+ }
+
+ return args;
+}
+
const toRawHeaders = (headers) => {
return Object.entries(headers)
.map(([key, value]) => `${key}: ${value}\r\n`)
@@ -38,6 +60,8 @@ const getCommand = (args) => {
return [ffmpeg, args]
}
+const defaultAgent = new Agent();
+
const proxy = async (streamInfo, res) => {
const abortController = new AbortController();
const shutdown = () => (
@@ -56,7 +80,8 @@ const proxy = async (streamInfo, res) => {
Range: streamInfo.range
},
signal: abortController.signal,
- maxRedirections: 16
+ maxRedirections: 16,
+ dispatcher: defaultAgent,
});
res.status(statusCode);
@@ -101,12 +126,16 @@ const merge = (streamInfo, res) => {
args = args.concat(ffmpegArgs[format]);
- if (hlsExceptions.includes(streamInfo.service)) {
- args.push('-bsf:a', 'aac_adtstoasc')
+ if (hlsExceptions.includes(streamInfo.service) && streamInfo.isHLS) {
+ if (streamInfo.service === "youtube" && format === "webm") {
+ args.push('-c:a', 'libopus');
+ } else {
+ args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');
+ }
}
if (streamInfo.metadata) {
- args = args.concat(metadataManager(streamInfo.metadata))
+ args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata))
}
args.push('-f', format, 'pipe:3');
@@ -238,7 +267,7 @@ const convertAudio = (streamInfo, res) => {
}
if (streamInfo.metadata) {
- args = args.concat(metadataManager(streamInfo.metadata))
+ args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata))
}
args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3');
diff --git a/api/src/util/generate-jwt-secret.js b/api/src/util/generate-jwt-secret.js
new file mode 100644
index 0000000000000000000000000000000000000000..8db6e230ee8f70708d220a8bc784270801c102b0
--- /dev/null
+++ b/api/src/util/generate-jwt-secret.js
@@ -0,0 +1,22 @@
+// run with `pnpm -r token:jwt`
+
+const makeSecureString = (length = 64) => {
+ const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
+ const out = [];
+
+ while (out.length < length) {
+ for (const byte of crypto.getRandomValues(new Uint8Array(length))) {
+ if (byte < alphabet.length) {
+ out.push(alphabet[byte]);
+ }
+
+ if (out.length === length) {
+ break;
+ }
+ }
+ }
+
+ return out.join('');
+}
+
+console.log(`JWT_SECRET: ${JSON.stringify(makeSecureString(64))}`)
diff --git a/api/src/util/test.js b/api/src/util/test.js
index 34afde7ef9429bd7f93cd8e1114cb87a1683680e..abb9b3cd70366db022a8aba5a975f192c2d6266d 100644
--- a/api/src/util/test.js
+++ b/api/src/util/test.js
@@ -1,84 +1,135 @@
-import "dotenv/config";
+import path from "node:path";
-import { services } from "../processing/service-config.js";
-import { extract } from "../processing/url.js";
-import match from "../processing/match.js";
-import { loadJSON } from "../misc/load-from-fs.js";
-import { normalizeRequest } from "../processing/request.js";
import { env } from "../config.js";
+import { runTest } from "../misc/run-test.js";
+import { loadJSON } from "../misc/load-from-fs.js";
+import { Red, Bright } from "../misc/console-text.js";
+import { setGlobalDispatcher, ProxyAgent } from "undici";
+import { randomizeCiphers } from "../misc/randomize-ciphers.js";
-env.apiURL = 'http://localhost:9000'
-let tests = loadJSON('./src/util/tests.json');
-
-let noTest = [];
-let failed = [];
-let success = 0;
-
-function addToFail(service, testName, url, status, response) {
- failed.push({
- service: service,
- name: testName,
- url: url,
- status: status,
- response: response
- })
-}
-for (let i in services) {
- if (tests[i]) {
- console.log(`\nRunning tests for ${i}...\n`)
- for (let k = 0; k < tests[i].length; k++) {
- let test = tests[i][k];
-
- console.log(`Running test ${k+1}: ${test.name}`);
- console.log('params:');
- let params = {...{url: test.url}, ...test.params};
- console.log(params);
-
- let chck = await normalizeRequest(params);
- if (chck.success) {
- chck = chck.data;
-
- const parsed = extract(chck.url);
- if (parsed === null) {
- throw `Invalid URL: ${chck.url}`
- }
+import { services } from "../processing/service-config.js";
- let j = await match({
- host: parsed.host,
- patternMatch: parsed.patternMatch,
- params: chck,
- });
- console.log('\nReceived:');
- console.log(j)
- if (j.status === test.expected.code && j.body.status === test.expected.status) {
- console.log("\n✅ Success.\n");
- success++
- } else {
- console.log(`\n❌ Fail. Expected: ${test.expected.code} & ${test.expected.status}, received: ${j.status} & ${j.body.status}\n`);
- addToFail(i, test.name, test.url, j.body.status, j)
- }
- } else {
- console.log("\n❌ couldn't validate the request JSON.\n");
- addToFail(i, test.name, test.url, "unknown", {})
+const getTestPath = service => path.join('./src/util/tests/', `./${service}.json`);
+const getTests = (service) => loadJSON(getTestPath(service));
+
+// services that are known to frequently fail due to external
+// factors (e.g. rate limiting)
+const finnicky = new Set(
+ process.env.TEST_IGNORE_SERVICES
+ ? process.env.TEST_IGNORE_SERVICES.split(',')
+ : ['bilibili', 'instagram', 'facebook', 'youtube', 'vk', 'twitter', 'reddit']
+);
+
+const runTestsFor = async (service) => {
+ const tests = getTests(service);
+ let softFails = 0, fails = 0;
+
+ if (!tests) {
+ throw "no such service: " + service;
+ }
+
+ for (const test of tests) {
+ const { name, url, params, expected } = test;
+ const canFail = test.canFail || finnicky.has(service);
+
+ try {
+ await runTest(url, params, expected);
+ console.log(`${service}/${name}: ok`);
+
+ } catch (e) {
+ softFails += !canFail;
+ fails++;
+
+ let failText = canFail ? `${Red('FAIL')} (ignored)` : Bright(Red('FAIL'));
+ if (canFail && process.env.GITHUB_ACTION) {
+ console.log(`::warning title=${service}/${name.replace(/,/g, ';')}::failed and was ignored`);
}
+
+ console.error(`${service}/${name}: ${failText}`);
+ const errorString = e.toString().split('\n');
+ let c = '┃';
+ errorString.forEach((line, index) => {
+ line = line.replace('!=', Red('!='));
+
+ if (index === errorString.length - 1) {
+ c = '┗';
+ }
+
+ console.error(` ${c}`, line);
+ });
}
- console.log("\n\n")
- } else {
- console.warn(`No tests found for ${i}.`);
- noTest.push(i)
}
+
+ return { fails, softFails };
}
-console.log(`✅ ${success} tests succeeded.`);
-console.log(`❌ ${failed.length} tests failed.`);
-console.log(`❔ ${noTest.length} services weren't tested.`);
+const printHeader = (service, padLen) => {
+ const padding = padLen - service.length;
+ service = service.padEnd(1 + service.length + padding, ' ');
+ console.log(service + '='.repeat(50));
+}
-if (failed.length > 0) {
- console.log(`\nFailed tests:`);
- console.log(failed)
+if (env.externalProxy) {
+ setGlobalDispatcher(new ProxyAgent(env.externalProxy));
}
-if (noTest.length > 0) {
- console.log(`\nMissing tests:`);
- console.log(noTest)
+env.streamLifespan = 10000;
+env.apiURL = 'http://x/';
+randomizeCiphers();
+
+const action = process.argv[2];
+switch (action) {
+ case "get-services":
+ const fromConfig = Object.keys(services);
+
+ const missingTests = fromConfig.filter(
+ service => {
+ const tests = getTests(service);
+ return !tests || tests.length === 0
+ }
+ );
+
+ if (missingTests.length) {
+ console.error('services have no tests:', missingTests);
+ process.exitCode = 1;
+ break;
+ }
+
+ console.log(JSON.stringify(fromConfig));
+ break;
+
+ case "run-tests-for":
+
+ try {
+ const { softFails } = await runTestsFor(process.argv[3]);
+ process.exitCode = Number(!!softFails);
+ } catch (e) {
+ console.error(e);
+ process.exitCode = 1;
+ break;
+ }
+
+ break;
+ default:
+ const maxHeaderLen = Object.keys(services).reduce((n, v) => v.length > n ? v.length : n, 0);
+ const failCounters = {};
+
+ for (const service in services) {
+ printHeader(service, maxHeaderLen);
+ const { fails, softFails } = await runTestsFor(service);
+ failCounters[service] = fails;
+ console.log();
+
+ if (!process.exitCode && softFails)
+ process.exitCode = 1;
+ }
+
+ console.log('='.repeat(50 + maxHeaderLen));
+ console.log(
+ Bright('total fails:'),
+ Object.values(failCounters).reduce((a, b) => a + b)
+ );
+ for (const [ service, fails ] of Object.entries(failCounters)) {
+ if (fails) console.log(`${Bright(service)} fails: ${fails}`);
+ }
}
diff --git a/api/src/util/tests/bilibili.json b/api/src/util/tests/bilibili.json
new file mode 100644
index 0000000000000000000000000000000000000000..c67202952319c2cd3feede3f07fc05672162113f
--- /dev/null
+++ b/api/src/util/tests/bilibili.json
@@ -0,0 +1,60 @@
+[
+ {
+ "name": "1080p video",
+ "url": "https://www.bilibili.com/video/BV18i4y1m7xV/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "1080p video muted",
+ "url": "https://www.bilibili.com/video/BV18i4y1m7xV/",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "1080p vertical video",
+ "url": "https://www.bilibili.com/video/BV1uu411z7VV/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "1080p vertical video muted",
+ "url": "https://www.bilibili.com/video/BV1uu411z7VV/",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "b23.tv shortlink",
+ "url": "https://b23.tv/av32430100",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "bilibili.tv link",
+ "url": "https://www.bilibili.tv/en/video/4789599404426256",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
diff --git a/api/src/util/tests/bsky.json b/api/src/util/tests/bsky.json
new file mode 100644
index 0000000000000000000000000000000000000000..6e1d6b2b90c87d338d6b4b7656d6509b7d96f20b
--- /dev/null
+++ b/api/src/util/tests/bsky.json
@@ -0,0 +1,96 @@
+[
+ {
+ "name": "horizontal video",
+ "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3giwtwp222m",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "horizontal video, recordWithMedia",
+ "url": "https://bsky.app/profile/did:plc:ywbm3iywnhzep3ckt6efhoh7/post/3l3wonhk23g2i",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "vertical video",
+ "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "vertical video (muted)",
+ "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "vertical video (audio)",
+ "url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "single image",
+ "url": "https://bsky.app/profile/did:plc:k4a7d65fcyevbrnntjxh57go/post/3l33flpoygt26",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "gif with a quoted post",
+ "url": "https://bsky.app/profile/imlunahey.com/post/3lgajpn5dtk2t",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "gif alone in a post",
+ "url": "https://bsky.app/profile/imlunahey.com/post/3lgah3ovxnc2q",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "several images",
+ "url": "https://bsky.app/profile/did:plc:rai7s6su2sy22ss7skouedl7/post/3kzxuxbiul626",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ },
+ {
+ "name": "deleted post/invalid user",
+ "url": "https://bsky.app/profile/notreal.bsky.team/post/3l2udah76ch2c",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/dailymotion.json b/api/src/util/tests/dailymotion.json
new file mode 100644
index 0000000000000000000000000000000000000000..4de9302c87544f81af2da08ac09110816995d8d3
--- /dev/null
+++ b/api/src/util/tests/dailymotion.json
@@ -0,0 +1,29 @@
+[
+ {
+ "name": "regular video",
+ "url": "https://www.dailymotion.com/video/x8t1eho",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "private video",
+ "url": "https://www.dailymotion.com/video/k41fZWpx2TaAORA2nok",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "dai.ly shortened link",
+ "url": "https://dai.ly/k41fZWpx2TaAORA2nok",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/facebook.json b/api/src/util/tests/facebook.json
new file mode 100644
index 0000000000000000000000000000000000000000..70e2db6892e508e9b1755f5344703ed61fa7fb0c
--- /dev/null
+++ b/api/src/util/tests/facebook.json
@@ -0,0 +1,65 @@
+[
+ {
+ "name": "direct video with username and id",
+ "url": "https://web.facebook.com/100048111287134/videos/1157798148685638/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "direct video with id as query param",
+ "url": "https://web.facebook.com/watch/?v=883839773514682&ref=sharing",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "direct video with caption",
+ "url": "https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "shortlink video",
+ "url": "https://fb.watch/r1K6XHMfGT/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "reel video",
+ "url": "https://web.facebook.com/reel/730293269054758",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "shared video link",
+ "url": "https://www.facebook.com/share/v/6EJK4Z8EAEAHtz8K/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "shared video link v2",
+ "url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ }
+]
diff --git a/api/src/util/tests/instagram.json b/api/src/util/tests/instagram.json
new file mode 100644
index 0000000000000000000000000000000000000000..4adcf6f801fd1ab2377ea198c0108d8717df31bc
--- /dev/null
+++ b/api/src/util/tests/instagram.json
@@ -0,0 +1,134 @@
+[
+ {
+ "name": "single photo post",
+ "url": "https://www.instagram.com/p/DFx6KVduFWy/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "various picker (photos + video)",
+ "url": "https://www.instagram.com/p/CvYrSgnsKjv/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ },
+ {
+ "name": "reel",
+ "url": "https://www.instagram.com/reel/DFQe23tOWKz/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "regular video",
+ "url": "https://www.instagram.com/p/CmCVWoIr9OH/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "reel (isAudioOnly)",
+ "url": "https://www.instagram.com/reel/DFQe23tOWKz/",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "reel (isAudioMuted)",
+ "url": "https://www.instagram.com/reel/DFQe23tOWKz/",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "inexistent reel",
+ "url": "https://www.instagram.com/reel/XXXXXXXXXX/",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "inexistent post",
+ "url": "https://www.instagram.com/p/XXXXXXXXXX/",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "post info in an array (for whatever reason??)",
+ "url": "https://www.instagram.com/reel/CrVB9tatUDv/?igshid=blaBlABALALbLABULLSHIT==",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "prone to get rate limited",
+ "url": "https://www.instagram.com/reel/CrO-T7Qo6rq/?igshid=fuckYouNoTrackingIdForYou==",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "ddinstagram link",
+ "url": "https://ddinstagram.com/p/CmCVWoIr9OH/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "d.ddinstagram.com link",
+ "url": "https://d.ddinstagram.com/p/CmCVWoIr9OH/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "g.ddinstagram.com link",
+ "url": "https://g.ddinstagram.com/p/CmCVWoIr9OH/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "private instagram post",
+ "url": "https://www.instagram.com/p/C5_A1TQNPrYw4c2g9KAUTPUl8RVHqiAdAcOOSY0",
+ "canFail": true,
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error",
+ "errorCode": "error.api.content.post.private"
+ }
+ }
+]
diff --git a/api/src/util/tests/loom.json b/api/src/util/tests/loom.json
new file mode 100644
index 0000000000000000000000000000000000000000..cc4273d37cd23bb1c74feaed279cb6a9862d6dd5
--- /dev/null
+++ b/api/src/util/tests/loom.json
@@ -0,0 +1,33 @@
+[
+ {
+ "name": "1080p video",
+ "url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "1080p video (muted)",
+ "url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "1080p video (audio only)",
+ "url": "https://www.loom.com/share/d165fd054a294d8a8587807bcc50c885",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/ok.json b/api/src/util/tests/ok.json
new file mode 100644
index 0000000000000000000000000000000000000000..8eb103eb7a9882dc61371077437fd20bff22a77c
--- /dev/null
+++ b/api/src/util/tests/ok.json
@@ -0,0 +1,11 @@
+[
+ {
+ "name": "regular video",
+ "url": "https://ok.ru/video/7204071410346",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/pinterest.json b/api/src/util/tests/pinterest.json
new file mode 100644
index 0000000000000000000000000000000000000000..4760dd363a4d253d9372cccc7a12733a9b55630d
--- /dev/null
+++ b/api/src/util/tests/pinterest.json
@@ -0,0 +1,87 @@
+[
+ {
+ "name": "regular video",
+ "url": "https://www.pinterest.com/pin/70437485604616/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "regular video (isAudioOnly)",
+ "url": "https://www.pinterest.com/pin/70437485604616/",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "regular video (isAudioMuted)",
+ "url": "https://www.pinterest.com/pin/70437485604616/",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "regular video (.ca TLD)",
+ "url": "https://www.pinterest.ca/pin/70437485604616/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "story",
+ "url": "https://www.pinterest.com/pin/gadget-cool-products-amazon-product-technology-kitchen-gadgets--1084663891475263837/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "regular picture",
+ "url": "https://www.pinterest.com/pin/412994228343400946/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "regular picture (.ca TLD)",
+ "url": "https://www.pinterest.ca/pin/412994228343400946/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "regular gif",
+ "url": "https://www.pinterest.com/pin/643170390530326178/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "regular gif (.ca TLD)",
+ "url": "https://www.pinterest.ca/pin/643170390530326178/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/reddit.json b/api/src/util/tests/reddit.json
new file mode 100644
index 0000000000000000000000000000000000000000..1dd10ee58e7bc71cba7bb2be6f020cc683a9d0cc
--- /dev/null
+++ b/api/src/util/tests/reddit.json
@@ -0,0 +1,78 @@
+[
+ {
+ "name": "video with audio",
+ "url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "video with audio (isAudioOnly)",
+ "url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "video with audio (isAudioMuted)",
+ "url": "https://www.reddit.com/r/TikTokCringe/comments/wup1fg/id_be_escaping_at_the_first_chance_i_got/?utm_source=share&utm_medium=web2x&context=3",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "video without audio",
+ "url": "https://www.reddit.com/r/catvideos/comments/ftoeo7/luna_doesnt_want_to_be_bothered_while_shes_napping/?utm_source=share&utm_medium=web2x&context=3",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "actual gif, not looping video",
+ "url": "https://www.reddit.com/r/whenthe/comments/109wqy1/god_really_did_some_trolling/?utm_source=share&utm_medium=web2x&context=3",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "different audio link, live render",
+ "url": "https://www.reddit.com/r/TikTokCringe/comments/15hce91/asian_daddy_kink/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "shortened video link",
+ "url": "https://v.redd.it/ifg2emt5ck0e1",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "shortened video link (alternative)",
+ "url": "https://reddit.com/video/ifg2emt5ck0e1",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/rutube.json b/api/src/util/tests/rutube.json
new file mode 100644
index 0000000000000000000000000000000000000000..2eaf69bf415c84548cd690550dcefc56e2df86bd
--- /dev/null
+++ b/api/src/util/tests/rutube.json
@@ -0,0 +1,100 @@
+[
+ {
+ "name": "regular video",
+ "url": "https://rutube.ru/video/b2f6c27649907c2fde0af411b03825eb/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "vertical video (isAudioMuted)",
+ "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "russian region lock",
+ "url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "vertical video",
+ "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "yappy",
+ "url": "https://rutube.ru/yappy/c8c32bf7aee04412837656ea26c2b25b/",
+ "canFail": true,
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "shorts",
+ "url": "https://rutube.ru/shorts/935c1afafd0e7d52836d671967d53dac/",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "vertical video (isAudioOnly)",
+ "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "vertical video (isAudioMuted)",
+ "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "private video",
+ "url": "https://rutube.ru/video/private/1161415be0e686214bb2a498165cab3e/?p=_IL1G8RSnKutunnTYwhZ5A",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "region locked video, should fail",
+ "canFail": true,
+ "url": "https://rutube.ru/video/e7ac82708cc22bd068a3bf6a7004d1b1/",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/snapchat.json b/api/src/util/tests/snapchat.json
new file mode 100644
index 0000000000000000000000000000000000000000..bf0c9da35c7769580f135546ade2a8edaae7f0b2
--- /dev/null
+++ b/api/src/util/tests/snapchat.json
@@ -0,0 +1,29 @@
+[
+ {
+ "name": "spotlight",
+ "url": "https://www.snapchat.com/spotlight/W7_EDlXWTBiXAEEniNoMPwAAYdWxucG9pZmNqAY46j0a5AY46j0YbAAAAAQ",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "shortlinked spotlight",
+ "url": "https://t.snapchat.com/4ZsiBLDi",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "story",
+ "url": "https://www.snapchat.com/add/bazerkmakane",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ }
+]
diff --git a/api/src/util/tests/soundcloud.json b/api/src/util/tests/soundcloud.json
new file mode 100644
index 0000000000000000000000000000000000000000..04ed8632305b613870bea99790c3a46c2f3866db
--- /dev/null
+++ b/api/src/util/tests/soundcloud.json
@@ -0,0 +1,106 @@
+[
+ {
+ "name": "public song (best)",
+ "url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing",
+ "params": {
+ "audioFormat": "best"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "public song (mp3, isAudioMuted)",
+ "url": "https://soundcloud.com/l2share77/loona-butterfly?utm_source=clipboard&utm_medium=text&utm_campaign=social_sharing",
+ "params": {
+ "downloadMode": "mute",
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "private song",
+ "url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90",
+ "params": {
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "private song (wav, isAudioMuted)",
+ "url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90",
+ "params": {
+ "downloadMode": "mute",
+ "audioFormat": "wav"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "private song (ogg, isAudioMuted, isAudioOnly)",
+ "url": "https://soundcloud.com/user-798052861/asdasdasdsdsd/s-9TqZ7edLJ90",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "ogg"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "on.soundcloud link",
+ "url": "https://on.soundcloud.com/wLZre",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "on.soundcloud link, different stream type",
+ "url": "https://on.soundcloud.com/AG4c",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "no opus audio, fallback to mp3",
+ "url": "https://soundcloud.com/frums/credits",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "go+ song, should fail",
+ "url": "https://soundcloud.com/dualipa/illusion-1",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "region locked song, should fail",
+ "canFail": true,
+ "url": "https://soundcloud.com/gotye/somebody-2024-feat-kimbra",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/streamable.json b/api/src/util/tests/streamable.json
new file mode 100644
index 0000000000000000000000000000000000000000..bf03c2282e900534066b5eaed721faecbc7f613c
--- /dev/null
+++ b/api/src/util/tests/streamable.json
@@ -0,0 +1,51 @@
+[
+ {
+ "name": "regular video",
+ "url": "https://streamable.com/p9cln4",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "embedded link",
+ "url": "https://streamable.com/e/rsmo56",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "regular video (isAudioOnly)",
+ "url": "https://streamable.com/p9cln4",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "regular video (isAudioMuted)",
+ "url": "https://streamable.com/p9cln4",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "inexistent video",
+ "url": "https://streamable.com/XXXXXX",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/tiktok.json b/api/src/util/tests/tiktok.json
new file mode 100644
index 0000000000000000000000000000000000000000..c8dbce8c5c4266e3edb5a42a190e7d6b55c300ea
--- /dev/null
+++ b/api/src/util/tests/tiktok.json
@@ -0,0 +1,47 @@
+[
+ {
+ "name": "long link video",
+ "url": "https://www.tiktok.com/@fatfatmillycat/video/7195741644585454894",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "images",
+ "url": "https://www.tiktok.com/@matryoshk4/video/7231234675476532526",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ },
+ {
+ "name": "long link inexistent",
+ "url": "https://www.tiktok.com/@blablabla/video/7120851458451417478",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "short link inexistent",
+ "url": "https://vt.tiktok.com/2p4ewa7/",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "age restricted video",
+ "url": "https://www.tiktok.com/@.kyle.films/video/7415757181145877793",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/tumblr.json b/api/src/util/tests/tumblr.json
new file mode 100644
index 0000000000000000000000000000000000000000..873522555dfb76ebbb3de65cbb93cba1803372ed
--- /dev/null
+++ b/api/src/util/tests/tumblr.json
@@ -0,0 +1,49 @@
+[
+ {
+ "name": "at.tumblr link",
+ "url": "https://at.tumblr.com/music/704177038274281472/n7x7pr7x4w2b",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "user subdomain link",
+ "url": "https://garfield-69.tumblr.com/post/696499862852780032",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "web app link",
+ "url": "https://www.tumblr.com/rongzhi/707729381162958848/english-added-by-me?source=share",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "tumblr audio",
+ "url": "https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "tumblr video converted to audio",
+ "url": "https://garfield-69.tumblr.com/post/696499862852780032",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/twitch.json b/api/src/util/tests/twitch.json
new file mode 100644
index 0000000000000000000000000000000000000000..fd6b84afcaec117e353a4c0d82804b5998ef3ef1
--- /dev/null
+++ b/api/src/util/tests/twitch.json
@@ -0,0 +1,33 @@
+[
+ {
+ "name": "clip",
+ "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "clip (isAudioOnly)",
+ "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "clip (isAudioMuted)",
+ "url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
+ "params": {
+ "downloadMode": "mute"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/twitter.json b/api/src/util/tests/twitter.json
new file mode 100644
index 0000000000000000000000000000000000000000..4139e39d7f5d25c556c26a106ea8d800b135247b
--- /dev/null
+++ b/api/src/util/tests/twitter.json
@@ -0,0 +1,221 @@
+[
+ {
+ "name": "regular video",
+ "url": "https://twitter.com/X/status/1697304622749086011",
+ "params": {
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "video with mobile web mediaviewer",
+ "url": "https://twitter.com/X/status/1697304622749086011/mediaViewer?currentTweet=1697304622749086011¤tTweetUser=X¤tTweet=1697304622749086011¤tTweetUser=X",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "embedded twitter video",
+ "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
+ "params": {
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "mixed media (image + gif)",
+ "url": "https://twitter.com/sky_mj26/status/1807756010712428565",
+ "params": {
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ },
+ {
+ "name": "picker: mixed media (3 videos)",
+ "url": "https://twitter.com/DankGameAlert/status/1584726006094794774",
+ "params": {
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ },
+ {
+ "name": "audio from embedded twitter video (mp3, isAudioOnly)",
+ "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "audio from embedded twitter video (best, isAudioOnly)",
+ "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "best"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "audio from embedded twitter video (ogg, isAudioOnly, isAudioMuted)",
+ "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "best"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "muted embedded twitter video",
+ "url": "https://twitter.com/dustbin_nie/status/1624596567188717568?s=20",
+ "params": {
+ "downloadMode": "mute",
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "retweeted video",
+ "url": "https://twitter.com/schlizzawg/status/1869017025055793405",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "age restricted video",
+ "url": "https://x.com/XSpaces/status/1526955853743546372",
+ "params": {},
+ "canFail": true,
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "twitter voice + x.com link",
+ "url": "https://x.com/eggsaladscreams/status/1693089534886506756?s=46",
+ "params": {},
+ "canFail": true,
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "vxtwitter link",
+ "url": "https://vxtwitter.com/dustbin_nie/status/1624596567188717568?s=20",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "post with 1 image",
+ "url": "https://x.com/PopCrave/status/1815960083475423235",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "post with 4 images",
+ "url": "https://x.com/PopCrave/status/1816260887147114696",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ },
+ {
+ "name": "retweeted video, isAudioOnly",
+ "url": "https://twitter.com/hugekiwinuts/status/1618671150829309953?s=46&t=gItGzgwGQQJJaJrO6qc1Pg",
+ "params": {
+ "downloadMode": "mute",
+ "audioFormat": "mp3"
+ },
+ "canFail": true,
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "gif",
+ "url": "https://x.com/thelastromances/status/1897839691212202479",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "inexistent post",
+ "url": "https://twitter.com/test/status/9487653",
+ "params": {
+ "audioFormat": "best"
+ },
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "post with no media content",
+ "url": "https://twitter.com/elonmusk/status/1604617643973124097?s=20",
+ "params": {
+ "audioFormat": "best"
+ },
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "bookmarked video",
+ "url": "https://twitter.com/i/bookmarks?post_id=1828099210220294314",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "bookmarked photo",
+ "url": "https://twitter.com/i/bookmarks?post_id=1887450602164396149",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
diff --git a/api/src/util/tests/vimeo.json b/api/src/util/tests/vimeo.json
new file mode 100644
index 0000000000000000000000000000000000000000..6c44a47d51a0291f041b728e1087b587ef936c41
--- /dev/null
+++ b/api/src/util/tests/vimeo.json
@@ -0,0 +1,64 @@
+[
+ {
+ "name": "4k progressive",
+ "url": "https://vimeo.com/288386543",
+ "params": {
+ "videoQuality": "2160"
+ },
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "720p progressive",
+ "url": "https://vimeo.com/288386543",
+ "params": {
+ "videoQuality": "720"
+ },
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "1080p dash parcel",
+ "url": "https://vimeo.com/967252742",
+ "params": {
+ "videoQuality": "1440"
+ },
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "720p dash parcel",
+ "url": "https://vimeo.com/967252742",
+ "params": {
+ "videoQuality": "360"
+ },
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "private video",
+ "url": "https://vimeo.com/903115595/f14d06da38",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ },
+ {
+ "name": "mature video",
+ "url": "https://vimeo.com/973212054",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "redirect"
+ }
+ }
+]
\ No newline at end of file
diff --git a/api/src/util/tests/vk.json b/api/src/util/tests/vk.json
new file mode 100644
index 0000000000000000000000000000000000000000..71720af5a6d0c424656b62ed5a4d95a3e83e7dea
--- /dev/null
+++ b/api/src/util/tests/vk.json
@@ -0,0 +1,82 @@
+[
+ {
+ "name": "clip, defaults",
+ "url": "https://vk.com/clip-57274055_456239788",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "clip, 360",
+ "url": "https://vk.com/clip-57274055_456239788",
+ "params": {
+ "videoQuality": "360"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "clip different link, max",
+ "url": "https://vk.com/clips-57274055?z=clip-57274055_456239788",
+ "params": {
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "video, defaults",
+ "url": "https://vk.com/video-57274055_456239399",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "big 4k video",
+ "url": "https://vk.com/video-1112285_456248465",
+ "params": {
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "short 4k video, 480p, vkvideo.ru domain",
+ "url": "https://vkvideo.ru/video-26006257_456245538",
+ "params": {
+ "videoQuality": "480"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "ancient video (fallback to 240p)",
+ "url": "https://vk.com/video-1959_28496479",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "inexistent video",
+ "url": "https://vk.com/video-53333333_456233333",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
diff --git a/api/src/util/tests/xiaohongshu.json b/api/src/util/tests/xiaohongshu.json
new file mode 100644
index 0000000000000000000000000000000000000000..a169cc23c5d0e1c0542a27fd61b6d96dc622b505
--- /dev/null
+++ b/api/src/util/tests/xiaohongshu.json
@@ -0,0 +1,60 @@
+[
+ {
+ "name": "video (might have expired)",
+ "url": "https://www.xiaohongshu.com/explore/67cc17a3000000000e00726a?xsec_token=CBSFRtbF57so920elY1kbIX4fE1nhrwlpGZs9m6pIFpwo=",
+ "canFail": true,
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "picker with multiple live photos (might have expired)",
+ "url": "https://www.xiaohongshu.com/explore/67c691b4000000000d0159cc?xsec_token=CB8p1eyB5DiFkwlUpy1BTeVsI9oOve6ppNjuDzo8V8p5w=",
+ "canFail": true,
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "picker"
+ }
+ },
+ {
+ "name": "one photo (might have expired)",
+ "url": "https://www.xiaohongshu.com/explore/676e132d000000000b016f68?xsec_token=ABRv6LKzizOFeSaf2HnnBkdBqniB5Ak1fI8tMAHzO31jA",
+ "canFail": true,
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "short link (might have expired)",
+ "url": "https://xhslink.com/a/czn4z6c1tic4",
+ "canFail": true,
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "wrong note id",
+ "url": "https://www.xiaohongshu.com/discovery/item/6789065911100000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "short link, wrong id",
+ "url": "https://xhslink.com/a/aaaaaa",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ }
+]
diff --git a/api/src/util/tests/youtube.json b/api/src/util/tests/youtube.json
new file mode 100644
index 0000000000000000000000000000000000000000..cb4964bebfa4dc8649d6b77aae99bc42a5c67e9b
--- /dev/null
+++ b/api/src/util/tests/youtube.json
@@ -0,0 +1,244 @@
+[
+ {
+ "name": "4k video (h264, 1440)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "youtubeVideoCodec": "h264",
+ "videoQuality": "1440"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "4k video (vp9, 720)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "youtubeVideoCodec": "vp9",
+ "videoQuality": "720"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "4k video (av1, max)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "youtubeVideoCodec": "av1",
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "4k video (h264, 720)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "youtubeVideoCodec": "h264",
+ "videoQuality": "720"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "4k video (vp9, max, isAudioMuted)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "downloadMode": "mute",
+ "youtubeVideoCodec": "vp9",
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "4k video (h264, max, isAudioMuted)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "downloadMode": "mute",
+ "youtubeVideoCodec": "h264",
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "4k video (av1, max, isAudioMuted, isAudioOnly, mp3)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "mp3",
+ "youtubeVideoCodec": "av1",
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "4k video (av1, max, isAudioMuted, isAudioOnly, best)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "best",
+ "youtubeVideoCodec": "av1",
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "music (mp3, isAudioOnly, isAudioMuted)",
+ "url": "https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "music (mp3)",
+ "url": "https://music.youtube.com/watch?v=5rGTsvZCEdk&feature=share",
+ "params": {
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "audio bitrate higher than video, no vp9 video in response (mp3, isAudioOnly)",
+ "url": "https://www.youtube.com/watch?v=t5nC_ucYBrc",
+ "params": {
+ "downloadMode": "audio",
+ "audioFormat": "mp3"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "short, defaults",
+ "url": "https://www.youtube.com/shorts/r5FpeOJItbw",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "vr 360, av1, max",
+ "url": "https://www.youtube.com/watch?v=hEdzv7D4CbQ",
+ "params": {
+ "youtubeVideoCodec": "vp9",
+ "videoQuality": "max"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "live link, defaults",
+ "url": "https://www.youtube.com/live/ENxZS6PUDuI?feature=shared",
+ "params": {},
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "inexistent video",
+ "url": "https://youtube.com/watch?v=gnjuHYWGEW",
+ "params": {},
+ "expected": {
+ "code": 400,
+ "status": "error"
+ }
+ },
+ {
+ "name": "broken audioOnly download",
+ "url": "https://www.youtube.com/watch?v=ink80Al5nbw",
+ "params": {
+ "downloadMode": "audio"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "hls video (h264, 1440p)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "canFail": true,
+ "params": {
+ "youtubeVideoCodec": "h264",
+ "videoQuality": "1440",
+ "youtubeHLS": true
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "hls video (vp9, 360p)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "canFail": true,
+ "params": {
+ "youtubeVideoCodec": "vp9",
+ "videoQuality": "360",
+ "youtubeHLS": true
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "hls video (audio mode)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "canFail": true,
+ "params": {
+ "downloadMode": "audio",
+ "youtubeHLS": true
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ },
+ {
+ "name": "hls video (audio mode, best format)",
+ "url": "https://www.youtube.com/watch?v=vPwaXytZcgI",
+ "canFail": true,
+ "params": {
+ "downloadMode": "audio",
+ "youtubeHLS": true,
+ "audioFormat": "best"
+ },
+ "expected": {
+ "code": 200,
+ "status": "tunnel"
+ }
+ }
+]
\ No newline at end of file
diff --git a/docs/api-env-variables.md b/docs/api-env-variables.md
new file mode 100644
index 0000000000000000000000000000000000000000..34de4b0a4141bf3ec6bb84f43bf87d360b032a66
--- /dev/null
+++ b/docs/api-env-variables.md
@@ -0,0 +1,228 @@
+# cobalt api instance environment variables
+you can customize your processing instance's behavior using these environment variables. all of them but `API_URL` are optional.
+this document is not final and will expand over time. feel free to improve it!
+
+### general vars
+| name | default | value example |
+|:--------------------|:----------|:--------------------------------------|
+| API_URL | | `https://api.url.example/` |
+| API_PORT | `9000` | `1337` |
+| COOKIE_PATH | | `/cookies.json` |
+| PROCESSING_PRIORITY | | `10` |
+| API_INSTANCE_COUNT | | `6` |
+| API_REDIS_URL | | `redis://localhost:6379` |
+| DISABLED_SERVICES | | `bilibili,youtube` |
+
+[*view details*](#general)
+
+### networking vars
+| name | default | value example |
+|:--------------------|:----------|:--------------------------------------|
+| API_LISTEN_ADDRESS | `0.0.0.0` | `127.0.0.1` |
+| API_EXTERNAL_PROXY | | `http://user:password@127.0.0.1:8080` |
+| FREEBIND_CIDR | | `2001:db8::/32` |
+
+[*view details*](#networking)
+
+### limit vars
+| name | default | value example |
+|:-------------------------|:--------|:--------------|
+| DURATION_LIMIT | `10800` | `18000` |
+| TUNNEL_LIFESPAN | `90` | `120` |
+| RATELIMIT_WINDOW | `60` | `120` |
+| RATELIMIT_MAX | `20` | `30` |
+| SESSION_RATELIMIT_WINDOW | `60` | `60` |
+| SESSION_RATELIMIT | `10` | `10` |
+
+[*view details*](#limits)
+
+### security vars
+| name | default | value example |
+|:------------------|:--------|:--------------------------------------|
+| CORS_WILDCARD | `1` | `0` |
+| CORS_URL | | `https://web.url.example` |
+| TURNSTILE_SITEKEY | | `1x00000000000000000000BB` |
+| TURNSTILE_SECRET | | `1x0000000000000000000000000000000AA` |
+| JWT_SECRET | | see [details](#security) |
+| JWT_EXPIRY | `120` | `240` |
+| API_KEY_URL | | `file://keys.json` |
+| API_AUTH_REQUIRED | | `1` |
+
+[*view details*](#security)
+
+### service-specific vars
+| name | value example |
+|:---------------------------------|:-------------------------|
+| CUSTOM_INNERTUBE_CLIENT | `IOS` |
+| YOUTUBE_SESSION_SERVER | `http://localhost:8080/` |
+| YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED` |
+
+[*view details*](#service-specific)
+
+## general
+[*jump to the table*](#general-vars)
+
+### API_URL
+> [!NOTE]
+> API_URL is required to run the API instance.
+
+the URL from which your instance will be accessible. can be external or internal, but it must be a valid URL or else tunnels will not work.
+
+the value is a URL.
+
+### API_PORT
+port from which the API server will be accessible.
+
+the value is a number from 1024 to 65535.
+
+### COOKIE_PATH
+path to the `cookies.json` file relative to the current working directory of your cobalt instance (usually the main (src/api) folder).
+
+### PROCESSING_PRIORITY
+`nice` value for ffmpeg subprocesses. available only on unix systems.
+
+note: the higher the nice value, the lower the priority. you can [read more about nice here](https://en.wikipedia.org/wiki/Nice_(Unix)).
+
+the value is a number.
+
+### API_INSTANCE_COUNT
+supported only on linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. `API_REDIS_URL` is required to use this option.
+
+the value is a number.
+
+### API_REDIS_URL
+when configured, cobalt will use this redis instance for tunnel cache. required when `API_INSTANCE_COUNT` is more than 1, because else sub-instance wouldn't be able to share cache.
+
+the value is a URL.
+
+### DISABLED_SERVICES
+comma-separated list which disables certain services from being used.
+
+the value is a string of cobalt-supported services.
+
+## networking
+[*jump to the table*](#networking-vars)
+
+### API_LISTEN_ADDRESS
+defines the local address for the api instance. if you are using a docker container, you usually don't need to configure this.
+
+the value is a local IP address.
+
+### API_EXTERNAL_PROXY
+URL of the proxy that will be passed to [`ProxyAgent`](https://undici.nodejs.org/#/docs/api/ProxyAgent) and used for all external requests. HTTP(S) only.
+
+if some feature breaks when using a proxy, please make a new issue about it!
+
+the value is a URL.
+
+### FREEBIND_CIDR
+IPv6 prefix used for randomly assigning addresses to cobalt requests. available only on linux systems.
+
+setting a `FREEBIND_CIDR` allows cobalt to pick a random IP for every download and use it for all requests it makes for that particular download.
+
+to use freebind in cobalt, you need to follow its [setup instructions](https://github.com/imputnet/freebind.js?tab=readme-ov-file#setup) first.
+
+if you want to use this option and run cobalt in a docker container, you also need to set the `API_LISTEN_ADDRESS` env variable to `127.0.0.1` and set `network_mode` for the container to `host`.
+
+the value is an IPv6 range.
+
+## limits
+[*jump to the table*](#limit-vars)
+
+### DURATION_LIMIT
+media duration limit, in **seconds**
+
+the value is a number.
+
+### TUNNEL_LIFESPAN
+the duration for which tunnel info is stored in ram, **in seconds**.
+
+it's recommended to keep this value either default or as low as possible to preserve efficiency and user privacy.
+
+the value is a number.
+
+### RATELIMIT_WINDOW
+rate limit time window for api requests, but not session requests, in **seconds**.
+
+the value is a number.
+
+### RATELIMIT_MAX
+amount of api requests to be allowed within the time window of `RATELIMIT_WINDOW`.
+
+the value is a number.
+
+### SESSION_RATELIMIT_WINDOW
+rate limit time window for session creation requests, in **seconds**.
+
+the value is a number.
+
+### SESSION_RATELIMIT
+amount of session requests to be allowed within the time window of `SESSION_RATELIMIT_WINDOW`.
+
+the value is a number.
+
+## security
+[*jump to the table*](#security-vars)
+
+> [!NOTE]
+> in order to enable turnstile bot protection, `TURNSTILE_SITEKEY`, `TURNSTILE_SECRET`, and `JWT_SECRET` must be set. all three at once.
+
+### CORS_WILDCARD
+defines whether cross-origin resource sharing is enabled. when enabled, your instance will be accessible from foreign web pages.
+
+the value is a number. 0: disabled. 1: enabled.
+
+### CORS_URL
+configures the [cross-origin resource sharing origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin). your instance will be available only from this URL if `CORS_WILDCARD` is set to `0`.
+
+the value is a URL.
+
+### TURNSTILE_SITEKEY
+[cloudflare turnstile](https://www.cloudflare.com/products/turnstile/) sitekey used by the web client to request & solve a challenge to prove that the user is not a bot.
+
+the value is a specific key.
+
+### TURNSTILE_SECRET
+[cloudflare turnstile](https://www.cloudflare.com/products/turnstile/) secret used by the processing instance to verify that the client solved the challenge successfully.
+
+the value is a specific key.
+
+### JWT_SECRET
+the secret used for issuing JWT tokens for request authentication. the value must be a random, secure, and long string (over 16 characters).
+
+the value is a specific key.
+
+### JWT_EXPIRY
+the duration of how long a cobalt-issued JWT token will remain valid, in seconds.
+
+the value is a number.
+
+### API_KEY_URL
+the URL to the the external or local key database. for local files you have to specify a local path using the `file://` protocol.
+
+see [the api key section](/docs/protect-an-instance.md#api-key-file-format) in the "how to protect your cobalt instance" document for more details.
+
+the value is a URL.
+
+### API_AUTH_REQUIRED
+when set to `1`, the user always needs to be authenticated in some way before they can access the API (either via an api key or via turnstile, if enabled).
+
+the value is a number.
+
+## service-specific
+[*jump to the table*](#service-specific-vars)
+
+### CUSTOM_INNERTUBE_CLIENT
+innertube client that will be used instead of the default one.
+
+the value is a string.
+
+### YOUTUBE_SESSION_SERVER
+URL to an instance of [yt-session-generator](https://github.com/imputnet/yt-session-generator). used for automatically pulling `poToken` & `visitor_data` for youtube. can be local or remote.
+
+the value is a URL.
+
+### YOUTUBE_SESSION_INNERTUBE_CLIENT
+innertube client that's compatible with botguard's (web) `poToken` and `visitor_data`.
+
+the value is a string.
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000000000000000000000000000000000000..fb1a145095de86107315b471cabb5162f1b1a230
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,161 @@
+# cobalt api documentation
+this document provides info about methods and acceptable variables for all cobalt api requests.
+
+> [!IMPORTANT]
+> hosted api instances (such as `api.cobalt.tools`) use bot protection and are **not** intended to be used in other projects without explicit permission. if you want to access the cobalt api reliably, you should [host your own instance](/docs/run-an-instance.md) or ask an instance owner for access.
+
+## authentication
+an api instance may be configured to require you to authenticate yourself.
+if this is the case, you will typically receive an [error response](#error-response)
+with a **`api.auth..missing`** code, which tells you that a particular method
+of authentication is required.
+
+authentication is done by passing the `Authorization` header, containing
+the authentication scheme and the token:
+```
+Authorization:
+```
+
+currently, cobalt supports two ways of authentication. an instance can
+choose to configure both, or neither:
+- [`Api-Key`](#api-key-authentication)
+- [`Bearer`](#bearer-authentication)
+
+### api-key authentication
+the api key authentication is the most straightforward. the instance owner
+will assign you an api key which you can then use to authenticate like so:
+```
+Authorization: Api-Key aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
+```
+
+if you are an instance owner and wish to configure api key authentication,
+see the [instance](run-an-instance.md#api-key-file-format) documentation!
+
+### bearer authentication
+the cobalt server may be configured to issue JWT bearers, which are short-lived
+tokens intended for use by regular users (e.g. after passing a challenge).
+currently, cobalt can issue tokens for successfully solved [turnstile](run-an-instance.md#list-of-all-environment-variables)
+challenge, if the instance has turnstile configured. the resulting token is passed like so:
+```
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+## POST: `/`
+cobalt's main processing endpoint.
+
+request body type: `application/json`
+response body type: `application/json`
+
+> [!IMPORTANT]
+> you must include `Accept` and `Content-Type` headers with every `POST /` request.
+
+```
+Accept: application/json
+Content-Type: application/json
+```
+
+### request body
+| key | type | expected value(s) | default | description |
+|:-----------------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
+| `url` | `string` | URL to download | -- | **must** be included in every request. |
+| `videoQuality` | `string` | `144 / ... / 2160 / 4320 / max` | `1080` | `720` quality is recommended for phones. |
+| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
+| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` | `128` | specifies the bitrate to use for the audio. applies only to audio conversion. |
+| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
+| `downloadMode` | `string` | `auto / audio / mute` | `auto` | `audio` downloads only the audio, `mute` skips the audio track in videos. |
+| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | `h264` is recommended for phones. |
+| `youtubeDubLang` | `string` | `en / ru / cs / ja / es-US / ...` | -- | specifies the language of audio to download when a youtube video is dubbed. |
+| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
+| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
+| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
+| `tiktokH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. |
+| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif |
+| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
+
+### response
+the response will always be a JSON object containing the `status` key, which will be one of:
+- `error` - something went wrong
+- `picker` - we have multiple items to choose from
+- `redirect` - you are being redirected to the direct service URL
+- `tunnel` - cobalt is proxying the download for you
+
+### tunnel/redirect response
+| key | type | values |
+|:-------------|:---------|:------------------------------------------------------------|
+| `status` | `string` | `tunnel / redirect` |
+| `url` | `string` | url for the cobalt tunnel, or redirect to an external link |
+| `filename` | `string` | cobalt-generated filename for the file being downloaded |
+
+### picker response
+| key | type | values |
+|:----------------|:---------|:-------------------------------------------------------------------------------------------------|
+| `status` | `string` | `picker` |
+| `audio` | `string` | **optional** returned when an image slideshow (such as on tiktok) has a general background audio |
+| `audioFilename` | `string` | **optional** cobalt-generated filename, returned if `audio` exists |
+| `picker` | `array` | array of objects containing the individual media |
+
+#### picker object
+| key | type | values |
+|:-------------|:----------|:------------------------------------------------------------|
+| `type` | `string` | `photo` / `video` / `gif` |
+| `url` | `string` | |
+| `thumb` | `string` | **optional** thumbnail url |
+
+### error response
+| key | type | values |
+|:-------------|:---------|:------------------------------------------------------------|
+| `status` | `string` | `error` |
+| `error` | `object` | contains more context about the error |
+
+#### error object
+| key | type | values |
+|:-------------|:---------|:------------------------------------------------------------|
+| `code` | `string` | machine-readable error code explaining the failure reason |
+| `context` | `object` | **optional** container for providing more context |
+
+#### error.context object
+| key | type | values |
+|:-------------|:---------|:---------------------------------------------------------------------------------------------------------------|
+| `service` | `string` | **optional**, stating which service was being downloaded from |
+| `limit` | `number` | **optional** number providing the ratelimit maximum number of requests, or maximum downloadable video duration |
+
+## GET: `/`
+returns current basic server info.
+response body type: `application/json`
+
+### response body
+| key | type | variables |
+|:------------|:---------|:---------------------------------------------------------|
+| `cobalt` | `object` | information about the cobalt instance |
+| `git` | `object` | information about the codebase that is currently running |
+
+#### cobalt object
+| key | type | description |
+|:----------------|:-----------|:-----------------------------------------------|
+| `version` | `string` | current version |
+| `url` | `string` | server url |
+| `startTime` | `string` | server start time in unix milliseconds |
+| `durationLimit` | `number` | maximum downloadable video length in seconds |
+| `services` | `string[]` | array of services which this instance supports |
+
+#### git object
+| key | type | variables |
+|:------------|:---------|:------------------|
+| `commit` | `string` | commit hash |
+| `branch` | `string` | git branch |
+| `remote` | `string` | git remote |
+
+## POST: `/session`
+
+used for generating JWT tokens, if enabled. currently, cobalt only supports
+generating tokens when a [turnstile](run-an-instance.md#list-of-all-environment-variables) challenge solution
+is submitted by the client.
+
+the turnstile challenge response is submitted via the `cf-turnstile-response` header.
+### response body
+| key | type | description |
+|:----------------|:-----------|:-------------------------------------------------------|
+| `token` | `string` | a `Bearer` token used for later request authentication |
+| `exp` | `number` | number in seconds indicating the token lifetime |
+
+on failure, an [error response](#error-response) is returned.
diff --git a/docs/examples/cookies.example.json b/docs/examples/cookies.example.json
new file mode 100644
index 0000000000000000000000000000000000000000..d788b2ddd3db1116f371ca5bbb6302469287f0c7
--- /dev/null
+++ b/docs/examples/cookies.example.json
@@ -0,0 +1,17 @@
+{
+ "instagram": [
+ "mid=; ig_did=; csrftoken=; ds_user_id=; sessionid="
+ ],
+ "instagram_bearer": [
+ "token=", "token=IGT:2:"
+ ],
+ "reddit": [
+ "client_id=; client_secret=; refresh_token="
+ ],
+ "twitter": [
+ "auth_token=; ct0="
+ ],
+ "youtube": [
+ "cookie=; b="
+ ]
+}
diff --git a/docs/examples/docker-compose.example.yml b/docs/examples/docker-compose.example.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b2ad73c16cafb9c9ac1914ac8662edcfbdbc6597
--- /dev/null
+++ b/docs/examples/docker-compose.example.yml
@@ -0,0 +1,53 @@
+services:
+ cobalt-api:
+ image: ghcr.io/imputnet/cobalt:10
+
+ init: true
+ read_only: true
+ restart: unless-stopped
+ container_name: cobalt-api
+
+ ports:
+ - 9000:9000/tcp
+ # if you use a reverse proxy (such as nginx),
+ # uncomment the next line and remove the one above (9000:9000/tcp):
+ # - 127.0.0.1:9000:9000
+
+ environment:
+ # replace https://api.url.example/ with your instance's url
+ # or else tunneling functionality won't work properly
+ API_URL: "https://api.url.example/"
+
+ # if you want to use cookies for fetching data from services,
+ # uncomment the next line & volumes section
+ # COOKIE_PATH: "/cookies.json"
+
+ # it's recommended to configure bot protection or api keys if the instance is public,
+ # see /docs/protect-an-instance.md for more info
+
+ # see /docs/run-an-instance.md for more variables that you can use here
+
+ labels:
+ - com.centurylinklabs.watchtower.scope=cobalt
+
+ # uncomment only if you use the COOKIE_PATH variable
+ # volumes:
+ # - ./cookies.json:/cookies.json
+
+ # watchtower updates the cobalt image automatically
+ watchtower:
+ image: ghcr.io/containrrr/watchtower
+ restart: unless-stopped
+ command: --cleanup --scope cobalt --interval 900 --include-restarting
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+
+ # if needed, use this image for automatically generating poToken & visitor_data
+ # yt-session-generator:
+ # image: ghcr.io/imputnet/yt-session-generator:webserver
+
+ # init: true
+ # restart: unless-stopped
+ # container_name: yt-session-generator
+ # labels:
+ # - com.centurylinklabs.watchtower.scope=cobalt
diff --git a/docs/images/protect-an-instance/add.png b/docs/images/protect-an-instance/add.png
new file mode 100644
index 0000000000000000000000000000000000000000..e186a65ce99e5a3b31d98b524348af049fba3a38
Binary files /dev/null and b/docs/images/protect-an-instance/add.png differ
diff --git a/docs/images/protect-an-instance/created.png b/docs/images/protect-an-instance/created.png
new file mode 100644
index 0000000000000000000000000000000000000000..546a68978caa3b604e5ef283c178aae7112d16e3
Binary files /dev/null and b/docs/images/protect-an-instance/created.png differ
diff --git a/docs/images/protect-an-instance/domain.png b/docs/images/protect-an-instance/domain.png
new file mode 100644
index 0000000000000000000000000000000000000000..249a8a9250c51e5939bc6067c11d43436f2f3ec7
Binary files /dev/null and b/docs/images/protect-an-instance/domain.png differ
diff --git a/docs/images/protect-an-instance/mode.png b/docs/images/protect-an-instance/mode.png
new file mode 100644
index 0000000000000000000000000000000000000000..242b35a5b00c4f293e7cddb480d9660f131ffd88
Binary files /dev/null and b/docs/images/protect-an-instance/mode.png differ
diff --git a/docs/images/protect-an-instance/name.png b/docs/images/protect-an-instance/name.png
new file mode 100644
index 0000000000000000000000000000000000000000..fd39dc954e9925f2f97b58ccdcfb4baf7d65e867
Binary files /dev/null and b/docs/images/protect-an-instance/name.png differ
diff --git a/docs/images/protect-an-instance/sidebar.png b/docs/images/protect-an-instance/sidebar.png
new file mode 100644
index 0000000000000000000000000000000000000000..8294c4a059a39b5f922bd40521998cb6a20bdd4b
Binary files /dev/null and b/docs/images/protect-an-instance/sidebar.png differ
diff --git a/docs/protect-an-instance.md b/docs/protect-an-instance.md
new file mode 100644
index 0000000000000000000000000000000000000000..305841026093aececbe12ef0e663fe93625c98a0
--- /dev/null
+++ b/docs/protect-an-instance.md
@@ -0,0 +1,202 @@
+# how to protect your cobalt instance
+if you keep getting a ton of unknown traffic that hurts the performance of your instance, then it might be a good idea to enable bot protection.
+
+> [!NOTE]
+> this tutorial will work reliably on the latest official version of cobalt 10.
+we can't promise full compatibility with anything else.
+
+## configure cloudflare turnstile
+turnstile is a free, safe, and privacy-respecting alternative to captcha.
+cobalt uses it automatically to weed out bots and automated scripts.
+your instance doesn't have to be proxied by cloudflare to use turnstile.
+all you need is a free cloudflare account to get started.
+
+cloudflare dashboard interface might change over time, but basics should stay the same.
+
+> [!WARNING]
+> never share the turnstile secret key, always keep it private. if accidentally exposed, rotate it in widget settings.
+
+1. open [the cloudflare dashboard](https://dash.cloudflare.com/) and log into your account
+
+2. once logged in, select `Turnstile` in the sidebar
+
+
+
+
+
+
+3. press `Add widget`
+
+
+
+
+
+
+4. enter the widget name (can be anything, such as "cobalt")
+
+
+
+
+
+
+5. add cobalt frontend domains you want the widget to work with, you can change this list later at any time
+ - if you want to use your processing instance with [cobalt.tools](https://cobalt.tools/) frontend, then add `cobalt.tools` to the list
+
+
+
+
+
+
+6. select `invisible` widget mode
+
+
+
+
+
+
+7. press `create`
+
+8. keep the page with sitekey and secret key open, you'll need them later.
+if you closed it, no worries!
+just open the same turnstile page and press "settings" on your freshly made turnstile widget.
+
+
+
+
+
+
+
+you've successfully created a turnstile widget!
+time to add it to your processing instance.
+
+### enable turnstile on your processing instance
+this tutorial assumes that you only have `API_URL` in your `environment` variables list.
+if you have other variables there, just add new ones after existing ones.
+
+> [!CAUTION]
+> never use any values from the tutorial, especially `JWT_SECRET`!
+
+1. open your `docker-compose.yml` config file in any text editor of choice.
+2. copy the turnstile sitekey & secret key and paste them to their respective variables.
+`TURNSTILE_SITEKEY` for the sitekey and `TURNSTILE_SECRET` for the secret key:
+```yml
+environment:
+ API_URL: "https://your.instance.url.here.local/"
+ TURNSTILE_SITEKEY: "2x00000000000000000000BB" # use your key
+ TURNSTILE_SECRET: "2x0000000000000000000000000000000AA" # use your key
+```
+3. generate a `JWT_SECRET`. we recommend using an alphanumeric collection with a length of at least 64 characters.
+this string will be used as salt for all JWT keys.
+
+ you can generate a random secret with `pnpm -r token:jwt` or use any other that you like.
+
+```yml
+environment:
+ API_URL: "https://your.instance.url.here.local/"
+ TURNSTILE_SITEKEY: "2x00000000000000000000BB" # use your key
+ TURNSTILE_SECRET: "2x0000000000000000000000000000000AA" # use your key
+ JWT_SECRET: "bgBmF4efNCKPirD" # create a new secret, NEVER use this one
+```
+4. restart the docker container.
+
+## configure api keys
+if you want to use your instance outside of web interface, you'll need an api key!
+
+> [!NOTE]
+> this tutorial assumes that you'll keep your keys file locally, on the instance server.
+> if you wish to upload your file to a remote location,
+> replace the value for `API_KEYS_URL` with a direct url to the file
+> and skip the second step.
+
+> [!WARNING]
+> when storing keys file remotely, make sure that it's not publicly accessible
+> and that link to it is either authenticated (via query) or impossible to guess.
+>
+> if api keys leak, you'll have to update/remove all UUIDs to revoke them.
+
+1. create a `keys.json` file following [the schema and example down below](#api-key-file-format).
+
+2. expose the `keys.json` to the docker container:
+```yml
+volumes:
+ - ./keys.json:/keys.json:ro # ro - read-only
+```
+
+3. add a path to the keys file to container environment:
+```yml
+environment:
+ # ... other variables here ...
+ API_KEY_URL: "file:///keys.json"
+```
+
+4. restart the docker container.
+
+## limit access to an instance with api keys but no turnstile
+by default, api keys are additional, meaning that they're not *required*,
+but work alongside with turnstile or no auth (regular ip hash rate limiting).
+
+to always require auth (via keys or turnstile, if configured), set `API_AUTH_REQUIRED` to 1:
+```yml
+environment:
+ # ... other variables here ...
+ API_AUTH_REQUIRED: 1
+```
+
+- if both keys and turnstile are enabled, then nothing will change.
+- if only keys are configured, then all requests without a valid api key will be refused.
+
+### why not make keys exclusive by default?
+keys may be useful for going around rate limiting,
+while keeping the rest of api rate limited, with no turnstile in place.
+
+## api key file format
+the file is a JSON-serialized object with the following structure:
+```typescript
+
+type KeyFileContents = Record<
+ UUIDv4String,
+ {
+ name?: string,
+ limit?: number | "unlimited",
+ ips?: (CIDRString | IPString)[],
+ userAgents?: string[]
+ }
+>;
+```
+
+where *`UUIDv4String`* is a stringified version of a UUIDv4 identifier.
+- **name** is a field for your own reference, it is not used by cobalt anywhere.
+
+- **`limit`** specifies how many requests the API key can make during the window specified in the `RATELIMIT_WINDOW` env.
+ - when omitted, the limit specified in `RATELIMIT_MAX` will be used.
+ - it can be also set to `"unlimited"`, in which case the API key bypasses all rate limits.
+
+- **`ips`** contains an array of allowlisted IP ranges, which can be specified both as individual ips or CIDR ranges (e.g. *`["192.168.42.69", "2001:db8::48", "10.0.0.0/8", "fe80::/10"]`*).
+ - when specified, only requests from these ip ranges can use the specified api key.
+ - when omitted, any IP can be used to make requests with that API key.
+
+- **`userAgents`** contains an array of allowed user agents, with support for wildcards (e.g. *`["cobaltbot/1.0", "Mozilla/5.0 * Chrome/*"]`*).
+ - when specified, requests with a `user-agent` that does not appear in this array will be rejected.
+ - when omitted, any user agent can be specified to make requests with that API key.
+
+- if both `ips` and `userAgents` are set, the tokens will be limited by both parameters.
+- if cobalt detects any problem with your key file, it will be ignored and a warning will be printed to the console.
+
+an example key file could look like this:
+```json
+{
+ "b5c7160a-b655-4c7a-b500-de839f094550": {
+ "limit": 10,
+ "ips": ["10.0.0.0/8", "192.168.42.42"],
+ "userAgents": ["*Chrome*"]
+ },
+ "b00b1234-a3e5-99b1-c6d1-dba4512ae190": {
+ "limit": "unlimited",
+ "ips": ["192.168.1.2"],
+ "userAgents": ["cobaltbot/1.0"]
+ }
+}
+```
+
+if you are configuring a key file, **do not use the UUID from the example** but instead generate your own. you can do this by running the following command if you have node.js installed:
+`node -e "console.log(crypto.randomUUID())"`
diff --git a/docs/run-an-instance.md b/docs/run-an-instance.md
new file mode 100644
index 0000000000000000000000000000000000000000..9aa7c9091658802d16aadc19f5ee1a12470a45c8
--- /dev/null
+++ b/docs/run-an-instance.md
@@ -0,0 +1,60 @@
+# how to run a cobalt instance
+this tutorial will help you run your own cobalt processing instance. if your instance is public-facing, we highly recommend that you also [protect it from abuse](/docs/protect-an-instance.md) using turnstile or api keys or both.
+
+## using docker compose and package from github (recommended)
+to run the cobalt docker package, you need to have `docker` and `docker-compose` installed and configured.
+
+if you need help with installing docker, follow *only the first step* of these tutorials by digitalocean:
+- [how to install docker](https://www.digitalocean.com/community/tutorial-collections/how-to-install-and-use-docker)
+- [how to install docker compose](https://www.digitalocean.com/community/tutorial-collections/how-to-install-docker-compose)
+
+## how to run a cobalt docker package:
+1. create a folder for cobalt config file, something like this:
+ ```sh
+ mkdir cobalt
+ ```
+
+2. go to cobalt folder, and create a docker compose config file:
+ ```sh
+ cd cobalt && nano docker-compose.yml
+ ```
+ i'm using `nano` in this example, it may not be available in your distro. you can use any other text editor.
+
+3. copy and paste the [sample config from here](examples/docker-compose.example.yml) and edit it to your needs.
+ make sure to replace default URLs with your own or cobalt won't work correctly.
+
+4. finally, start the cobalt container (from cobalt directory):
+ ```sh
+ docker compose up -d
+ ```
+
+if you want your instance to support services that require authentication to view public content, create `cookies.json` file in the same directory as `docker-compose.yml`. example cookies file [can be found here](examples/cookies.example.json).
+
+cobalt package will update automatically thanks to watchtower.
+
+it's highly recommended to use a reverse proxy (such as nginx) if you want your instance to face the public internet. look up tutorials online.
+
+## run cobalt api outside of docker (useful for local development)
+requirements:
+- node.js >= 18
+- git
+- pnpm
+
+1. clone the repo: `git clone https://github.com/imputnet/cobalt`.
+2. go to api/src directory: `cd cobalt/api/src`.
+3. install dependencies: `pnpm install`.
+4. create `.env` file in the same directory.
+5. add needed environment variables to `.env` file. only `API_URL` is required to run cobalt.
+ - if you don't know what api url to use for local development, use `http://localhost:9000/`.
+6. run cobalt: `pnpm start`.
+
+### ubuntu 22.04 workaround
+`nscd` needs to be installed and running so that the `ffmpeg-static` binary can resolve DNS ([#101](https://github.com/imputnet/cobalt/issues/101#issuecomment-1494822258)):
+
+```bash
+sudo apt install nscd
+sudo service nscd start
+```
+
+## list of environment variables
+[this section has moved](/docs/api-env-variables.md) to a dedicated document that is way easier to understand and maintain. go check it out!
diff --git a/packages/api-client/package.json b/packages/api-client/package.json
index 676d22bb75f6847cd6418fd1d7d39970ef107ff2..560d1af9089a8b4de6de5c74f49636b7dc1f7730 100644
--- a/packages/api-client/package.json
+++ b/packages/api-client/package.json
@@ -9,7 +9,7 @@
"license": "MIT",
"devDependencies": {
"prettier": "3.3.3",
- "tsup": "^8.2.4",
+ "tsup": "^8.3.0",
"typescript": "^5.4.5"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 220a2cf33bee0b5bf06a0e9320ff0d265acd940d..76584fb13c34bc6ab2e3faf534dfd87d571b0500 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,6 +10,12 @@ importers:
api:
dependencies:
+ '@datastructures-js/priority-queue':
+ specifier: ^6.3.1
+ version: 6.3.1
+ '@imput/psl':
+ specifier: ^2.0.4
+ version: 2.0.4
'@imput/version-info':
specifier: workspace:^
version: link:../packages/version-info
@@ -22,15 +28,12 @@ importers:
dotenv:
specifier: ^16.0.1
version: 16.4.5
- esbuild:
- specifier: ^0.14.51
- version: 0.14.54
express:
- specifier: ^4.18.1
- version: 4.19.2
+ specifier: ^4.21.2
+ version: 4.21.2
express-rate-limit:
- specifier: ^6.3.0
- version: 6.11.2(express@4.19.2)
+ specifier: ^7.4.1
+ version: 7.4.1(express@4.21.2)
ffmpeg-static:
specifier: ^5.1.0
version: 5.2.0
@@ -38,17 +41,11 @@ importers:
specifier: ^0.10.7
version: 0.10.9
ipaddr.js:
- specifier: 2.1.0
- version: 2.1.0
+ specifier: 2.2.0
+ version: 2.2.0
nanoid:
- specifier: ^4.0.2
- version: 4.0.2
- node-cache:
- specifier: ^5.1.2
- version: 5.1.2
- psl:
- specifier: 1.9.0
- version: 1.9.0
+ specifier: ^5.0.9
+ version: 5.0.9
set-cookie-parser:
specifier: 2.6.0
version: 2.6.0
@@ -59,8 +56,8 @@ importers:
specifier: 1.0.3
version: 1.0.3
youtubei.js:
- specifier: ^10.3.0
- version: 10.3.0
+ specifier: ^13.3.0
+ version: 13.3.0
zod:
specifier: ^3.23.8
version: 3.23.8
@@ -68,6 +65,12 @@ importers:
freebind:
specifier: ^0.2.2
version: 0.2.2
+ rate-limit-redis:
+ specifier: ^4.2.0
+ version: 4.2.0(express-rate-limit@7.4.1(express@4.21.2))
+ redis:
+ specifier: ^4.7.0
+ version: 4.7.0
packages/api-client:
devDependencies:
@@ -75,8 +78,8 @@ importers:
specifier: 3.3.3
version: 3.3.3
tsup:
- specifier: ^8.2.4
- version: 8.2.4(postcss@8.4.40)(typescript@5.5.4)
+ specifier: ^8.3.0
+ version: 8.3.0(postcss@8.4.47)(typescript@5.5.4)
typescript:
specifier: ^5.4.5
version: 5.5.4
@@ -104,17 +107,17 @@ importers:
specifier: workspace:^
version: link:../packages/version-info
'@sveltejs/adapter-static':
- specifier: ^3.0.2
- version: 3.0.2(@sveltejs/kit@2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))
+ specifier: ^3.0.6
+ version: 3.0.6(@sveltejs/kit@2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))
'@sveltejs/kit':
- specifier: ^2.0.0
- version: 2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))
+ specifier: ^2.9.1
+ version: 2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))
'@sveltejs/vite-plugin-svelte':
specifier: ^3.0.0
- version: 3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))
+ version: 3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))
'@tabler/icons-svelte':
specifier: 3.6.0
- version: 3.6.0(svelte@4.2.18)
+ version: 3.6.0(svelte@4.2.19)
'@types/eslint__js':
specifier: ^8.42.3
version: 8.42.3
@@ -126,7 +129,7 @@ importers:
version: 20.14.14
'@vitejs/plugin-basic-ssl':
specifier: ^1.1.0
- version: 1.1.0(vite@5.3.5(@types/node@20.14.14))
+ version: 1.1.0(vite@5.4.8(@types/node@20.14.14))
compare-versions:
specifier: ^6.1.0
version: 6.1.1
@@ -134,29 +137,32 @@ importers:
specifier: ^16.0.1
version: 16.4.5
eslint:
- specifier: ^8.57.0
- version: 8.57.0
+ specifier: ^9.16.0
+ version: 9.16.0
glob:
- specifier: ^10.4.5
- version: 10.4.5
+ specifier: ^11.0.0
+ version: 11.0.0
mdsvex:
specifier: ^0.11.2
- version: 0.11.2(svelte@4.2.18)
+ version: 0.11.2(svelte@4.2.19)
mime:
specifier: ^4.0.4
version: 4.0.4
svelte:
- specifier: ^4.2.7
- version: 4.2.18
+ specifier: ^4.2.19
+ version: 4.2.19
svelte-check:
specifier: ^3.6.0
- version: 3.8.5(postcss@8.4.40)(svelte@4.2.18)
+ version: 3.8.5(postcss@8.4.47)(svelte@4.2.19)
svelte-preprocess:
specifier: ^6.0.2
- version: 6.0.2(postcss@8.4.40)(svelte@4.2.18)(typescript@5.5.4)
+ version: 6.0.2(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4)
+ svelte-sitemap:
+ specifier: 2.6.0
+ version: 2.6.0
sveltekit-i18n:
specifier: ^2.4.2
- version: 2.4.2(svelte@4.2.18)
+ version: 2.4.2(svelte@4.2.19)
ts-deepmerge:
specifier: ^7.0.1
version: 7.0.1
@@ -170,11 +176,11 @@ importers:
specifier: ^5.4.5
version: 5.5.4
typescript-eslint:
- specifier: ^7.13.1
- version: 7.18.0(eslint@8.57.0)(typescript@5.5.4)
+ specifier: ^8.18.0
+ version: 8.18.0(eslint@9.16.0)(typescript@5.5.4)
vite:
- specifier: ^5.0.3
- version: 5.3.5(@types/node@20.14.14)
+ specifier: ^5.3.6
+ version: 5.4.8(@types/node@20.14.14)
packages:
@@ -182,6 +188,15 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
+ '@bufbuild/protobuf@2.2.5':
+ resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==}
+
+ '@datastructures-js/heap@4.3.3':
+ resolution: {integrity: sha512-UcUu/DLh/aM4W3C8zZfwxxm6/6FIZUlm3mcAXuNOCa6Aj4iizNvNXQyb8DjZQH2jKSQbMRyNlngP6TPimuGjpQ==}
+
+ '@datastructures-js/priority-queue@6.3.1':
+ resolution: {integrity: sha512-eoxkWql/j0VJ0UFMFTpnyJz4KbEEVQ6aZ/JuJUgenu0Im4tYKylAycNGsYCHGXiVNEd7OKGVwfx1Ac3oYkuu7A==}
+
'@derhuerst/http-basic@8.2.4':
resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
engines: {node: '>=6.0.0'}
@@ -318,12 +333,6 @@ packages:
cpu: [ia32]
os: [linux]
- '@esbuild/linux-loong64@0.14.54':
- resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==}
- engines: {node: '>=12'}
- cpu: [loong64]
- os: [linux]
-
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
engines: {node: '>=12'}
@@ -480,22 +489,38 @@ packages:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
- '@eslint-community/regexpp@4.11.0':
- resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==}
+ '@eslint-community/regexpp@4.12.1':
+ resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
- '@eslint/eslintrc@2.1.4':
- resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ '@eslint/config-array@0.19.1':
+ resolution: {integrity: sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/js@8.57.0':
- resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ '@eslint/core@0.9.1':
+ resolution: {integrity: sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.2.0':
+ resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.16.0':
+ resolution: {integrity: sha512-tw2HxzQkrbeuvyj1tG2Yqq+0H9wGoI2IMk4EOsQeX+vmd75FtJAzf+gTA69WF+baUKRYQ3x2kbLE08js5OsTVg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.8.0':
resolution: {integrity: sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/object-schema@2.1.5':
+ resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.2.4':
+ resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
@@ -509,22 +534,32 @@ packages:
'@fontsource/redaction-10@5.0.2':
resolution: {integrity: sha512-PODxYvb06YrNxdUBGcygiMibpgcZihzmvkmlX/TQAA2F7BUU/anfSKQi/VnLdJ/8LIK81/bUY+i7L/GP27FkVw==}
- '@humanwhocodes/config-array@0.11.14':
- resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
- engines: {node: '>=10.10.0'}
- deprecated: Use @eslint/config-array instead
+ '@humanfs/core@0.19.1':
+ resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.6':
+ resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==}
+ engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
- '@humanwhocodes/object-schema@2.0.3':
- resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
- deprecated: Use @eslint/object-schema instead
+ '@humanwhocodes/retry@0.3.1':
+ resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
+ engines: {node: '>=18.18'}
+
+ '@humanwhocodes/retry@0.4.1':
+ resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
+ engines: {node: '>=18.18'}
'@imput/libav.js-remux-cli@5.5.6':
resolution: {integrity: sha512-XdAab90EZKf6ULtD/x9Y2bnlmNJodXSO6w8aWrn97+N2IRuOS8zv3tAFPRC69SWKa8Utjeu5YTYuTolnX3QprQ==}
+ '@imput/psl@2.0.4':
+ resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==}
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -559,6 +594,22 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
+ '@oozcitak/dom@1.15.10':
+ resolution: {integrity: sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==}
+ engines: {node: '>=8.0'}
+
+ '@oozcitak/infra@1.0.8':
+ resolution: {integrity: sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==}
+ engines: {node: '>=6.0'}
+
+ '@oozcitak/url@1.0.4':
+ resolution: {integrity: sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==}
+ engines: {node: '>=8.0'}
+
+ '@oozcitak/util@8.3.8':
+ resolution: {integrity: sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==}
+ engines: {node: '>=8.0'}
+
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -566,99 +617,128 @@ packages:
'@polka/url@1.0.0-next.25':
resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
- '@rollup/rollup-android-arm-eabi@4.19.2':
- resolution: {integrity: sha512-OHflWINKtoCFSpm/WmuQaWW4jeX+3Qt3XQDepkkiFTsoxFc5BpF3Z5aDxFZgBqRjO6ATP5+b1iilp4kGIZVWlA==}
+ '@redis/bloom@1.2.0':
+ resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/client@1.6.0':
+ resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==}
+ engines: {node: '>=14'}
+
+ '@redis/graph@1.1.1':
+ resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/json@1.0.7':
+ resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/search@1.2.0':
+ resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@redis/time-series@1.1.0':
+ resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
+ peerDependencies:
+ '@redis/client': ^1.0.0
+
+ '@rollup/rollup-android-arm-eabi@4.24.0':
+ resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==}
cpu: [arm]
os: [android]
- '@rollup/rollup-android-arm64@4.19.2':
- resolution: {integrity: sha512-k0OC/b14rNzMLDOE6QMBCjDRm3fQOHAL8Ldc9bxEWvMo4Ty9RY6rWmGetNTWhPo+/+FNd1lsQYRd0/1OSix36A==}
+ '@rollup/rollup-android-arm64@4.24.0':
+ resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.19.2':
- resolution: {integrity: sha512-IIARRgWCNWMTeQH+kr/gFTHJccKzwEaI0YSvtqkEBPj7AshElFq89TyreKNFAGh5frLfDCbodnq+Ye3dqGKPBw==}
+ '@rollup/rollup-darwin-arm64@4.24.0':
+ resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.19.2':
- resolution: {integrity: sha512-52udDMFDv54BTAdnw+KXNF45QCvcJOcYGl3vQkp4vARyrcdI/cXH8VXTEv/8QWfd6Fru8QQuw1b2uNersXOL0g==}
+ '@rollup/rollup-darwin-x64@4.24.0':
+ resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-linux-arm-gnueabihf@4.19.2':
- resolution: {integrity: sha512-r+SI2t8srMPYZeoa1w0o/AfoVt9akI1ihgazGYPQGRilVAkuzMGiTtexNZkrPkQsyFrvqq/ni8f3zOnHw4hUbA==}
+ '@rollup/rollup-linux-arm-gnueabihf@4.24.0':
+ resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.19.2':
- resolution: {integrity: sha512-+tYiL4QVjtI3KliKBGtUU7yhw0GMcJJuB9mLTCEauHEsqfk49gtUBXGtGP3h1LW8MbaTY6rSFIQV1XOBps1gBA==}
+ '@rollup/rollup-linux-arm-musleabihf@4.24.0':
+ resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm64-gnu@4.19.2':
- resolution: {integrity: sha512-OR5DcvZiYN75mXDNQQxlQPTv4D+uNCUsmSCSY2FolLf9W5I4DSoJyg7z9Ea3TjKfhPSGgMJiey1aWvlWuBzMtg==}
+ '@rollup/rollup-linux-arm64-gnu@4.24.0':
+ resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-arm64-musl@4.19.2':
- resolution: {integrity: sha512-Hw3jSfWdUSauEYFBSFIte6I8m6jOj+3vifLg8EU3lreWulAUpch4JBjDMtlKosrBzkr0kwKgL9iCfjA8L3geoA==}
+ '@rollup/rollup-linux-arm64-musl@4.24.0':
+ resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-powerpc64le-gnu@4.19.2':
- resolution: {integrity: sha512-rhjvoPBhBwVnJRq/+hi2Q3EMiVF538/o9dBuj9TVLclo9DuONqt5xfWSaE6MYiFKpo/lFPJ/iSI72rYWw5Hc7w==}
+ '@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
+ resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==}
cpu: [ppc64]
os: [linux]
- '@rollup/rollup-linux-riscv64-gnu@4.19.2':
- resolution: {integrity: sha512-EAz6vjPwHHs2qOCnpQkw4xs14XJq84I81sDRGPEjKPFVPBw7fwvtwhVjcZR6SLydCv8zNK8YGFblKWd/vRmP8g==}
+ '@rollup/rollup-linux-riscv64-gnu@4.24.0':
+ resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-s390x-gnu@4.19.2':
- resolution: {integrity: sha512-IJSUX1xb8k/zN9j2I7B5Re6B0NNJDJ1+soezjNojhT8DEVeDNptq2jgycCOpRhyGj0+xBn7Cq+PK7Q+nd2hxLA==}
+ '@rollup/rollup-linux-s390x-gnu@4.24.0':
+ resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==}
cpu: [s390x]
os: [linux]
- '@rollup/rollup-linux-x64-gnu@4.19.2':
- resolution: {integrity: sha512-OgaToJ8jSxTpgGkZSkwKE+JQGihdcaqnyHEFOSAU45utQ+yLruE1dkonB2SDI8t375wOKgNn8pQvaWY9kPzxDQ==}
+ '@rollup/rollup-linux-x64-gnu@4.24.0':
+ resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-linux-x64-musl@4.19.2':
- resolution: {integrity: sha512-5V3mPpWkB066XZZBgSd1lwozBk7tmOkKtquyCJ6T4LN3mzKENXyBwWNQn8d0Ci81hvlBw5RoFgleVpL6aScLYg==}
+ '@rollup/rollup-linux-x64-musl@4.24.0':
+ resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-win32-arm64-msvc@4.19.2':
- resolution: {integrity: sha512-ayVstadfLeeXI9zUPiKRVT8qF55hm7hKa+0N1V6Vj+OTNFfKSoUxyZvzVvgtBxqSb5URQ8sK6fhwxr9/MLmxdA==}
+ '@rollup/rollup-win32-arm64-msvc@4.24.0':
+ resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.19.2':
- resolution: {integrity: sha512-Mda7iG4fOLHNsPqjWSjANvNZYoW034yxgrndof0DwCy0D3FvTjeNo+HGE6oGWgvcLZNLlcp0hLEFcRs+UGsMLg==}
+ '@rollup/rollup-win32-ia32-msvc@4.24.0':
+ resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==}
cpu: [ia32]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.19.2':
- resolution: {integrity: sha512-DPi0ubYhSow/00YqmG1jWm3qt1F8aXziHc/UNy8bo9cpCacqhuWu+iSq/fp2SyEQK7iYTZ60fBU9cat3MXTjIQ==}
+ '@rollup/rollup-win32-x64-msvc@4.24.0':
+ resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==}
cpu: [x64]
os: [win32]
- '@sveltejs/adapter-static@3.0.2':
- resolution: {integrity: sha512-/EBFydZDwfwFfFEuF1vzUseBoRziwKP7AoHAwv+Ot3M084sE/HTVBHf9mCmXfdM9ijprY5YEugZjleflncX5fQ==}
+ '@sveltejs/adapter-static@3.0.6':
+ resolution: {integrity: sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==}
peerDependencies:
'@sveltejs/kit': ^2.0.0
- '@sveltejs/kit@2.5.19':
- resolution: {integrity: sha512-r/lah3nnYEZX1btlvpSy+Exkt1aWhmOP5pnCt+BBro+tZrh2Zci+26Xnm1fCBLLMeM5q7gHvWiS8c/UtrWjdvQ==}
+ '@sveltejs/kit@2.9.1':
+ resolution: {integrity: sha512-D+yH3DTvvkjXdl3Xv7akKmolrArDZRtsFv3nlxJPjlIKsZEpkkInnomKJuAql2TrNGJ2dJMGBO1YYgVn2ILmag==}
engines: {node: '>=18.13'}
hasBin: true
peerDependencies:
- '@sveltejs/vite-plugin-svelte': ^3.0.0
+ '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0
svelte: ^4.0.0 || ^5.0.0-next.0
- vite: ^5.0.3
+ vite: ^5.0.3 || ^6.0.0
'@sveltejs/vite-plugin-svelte-inspector@2.1.0':
resolution: {integrity: sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==}
@@ -703,6 +783,9 @@ packages:
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
+ '@types/estree@1.0.6':
+ resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
+
'@types/fluent-ffmpeg@2.1.25':
resolution: {integrity: sha512-a9/Jtv/RVaCG4lUwWIcuClWE5eXJFoFS/oHOecOv/RS8n+lQdJzcJVmDlxA8Xbk4B82YpO88Dijcoljb6sYTcA==}
@@ -721,66 +804,52 @@ packages:
'@types/unist@2.0.10':
resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==}
- '@typescript-eslint/eslint-plugin@7.18.0':
- resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ '@typescript-eslint/eslint-plugin@8.18.0':
+ resolution: {integrity: sha512-NR2yS7qUqCL7AIxdJUQf2MKKNDVNaig/dEB0GBLU7D+ZdHgK1NoH/3wsgO3OnPVipn51tG3MAwaODEGil70WEw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- '@typescript-eslint/parser': ^7.0.0
- eslint: ^8.56.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/parser@7.18.0':
- resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ '@typescript-eslint/parser@8.18.0':
+ resolution: {integrity: sha512-hgUZ3kTEpVzKaK3uNibExUYm6SKKOmTU2BOxBSvOYwtJEPdVQ70kZJpPjstlnhCHcuc2WGfSbpKlb/69ttyN5Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.56.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/scope-manager@7.18.0':
- resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ '@typescript-eslint/scope-manager@8.18.0':
+ resolution: {integrity: sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/type-utils@7.18.0':
- resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ '@typescript-eslint/type-utils@8.18.0':
+ resolution: {integrity: sha512-er224jRepVAVLnMF2Q7MZJCq5CsdH2oqjP4dT7K6ij09Kyd+R21r7UVJrF0buMVdZS5QRhDzpvzAxHxabQadow==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.56.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/types@7.18.0':
- resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ '@typescript-eslint/types@8.18.0':
+ resolution: {integrity: sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@7.18.0':
- resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ '@typescript-eslint/typescript-estree@8.18.0':
+ resolution: {integrity: sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/utils@7.18.0':
- resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ '@typescript-eslint/utils@8.18.0':
+ resolution: {integrity: sha512-p6GLdY383i7h5b0Qrfbix3Vc3+J2k6QWw6UMUeY5JGfm3C5LbZ4QIZzJNoNOfgyRe0uuYKjvVOsO/jD4SJO+xg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.56.0
-
- '@typescript-eslint/visitor-keys@7.18.0':
- resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.8.0'
- '@ungap/structured-clone@1.2.0':
- resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+ '@typescript-eslint/visitor-keys@8.18.0':
+ resolution: {integrity: sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-basic-ssl@1.1.0':
resolution: {integrity: sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==}
@@ -802,6 +871,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.14.0:
+ resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -832,6 +906,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ argparse@1.0.10:
+ resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -841,10 +918,6 @@ packages:
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
- array-union@2.1.0:
- resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
- engines: {node: '>=8'}
-
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -856,8 +929,8 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
- body-parser@1.20.2:
- resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==}
+ body-parser@1.20.3:
+ resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
brace-expansion@1.1.11:
@@ -910,9 +983,9 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
- clone@2.1.2:
- resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
- engines: {node: '>=0.8'}
+ cluster-key-slot@1.1.2:
+ resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
+ engines: {node: '>=0.10.0'}
code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
@@ -960,6 +1033,10 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
+ cookie@0.7.1:
+ resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
+ engines: {node: '>= 0.6'}
+
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@@ -968,6 +1045,10 @@ packages:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@@ -1016,16 +1097,8 @@ packages:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
- devalue@5.0.0:
- resolution: {integrity: sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==}
-
- dir-glob@3.0.1:
- resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
- engines: {node: '>=8'}
-
- doctrine@3.0.0:
- resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
- engines: {node: '>=6.0.0'}
+ devalue@5.1.1:
+ resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==}
dotenv@16.4.5:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
@@ -1047,6 +1120,10 @@ packages:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -1062,131 +1139,6 @@ packages:
es6-promise@3.3.1:
resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==}
- esbuild-android-64@0.14.54:
- resolution: {integrity: sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [android]
-
- esbuild-android-arm64@0.14.54:
- resolution: {integrity: sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==}
- engines: {node: '>=12'}
- cpu: [arm64]
- os: [android]
-
- esbuild-darwin-64@0.14.54:
- resolution: {integrity: sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [darwin]
-
- esbuild-darwin-arm64@0.14.54:
- resolution: {integrity: sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==}
- engines: {node: '>=12'}
- cpu: [arm64]
- os: [darwin]
-
- esbuild-freebsd-64@0.14.54:
- resolution: {integrity: sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [freebsd]
-
- esbuild-freebsd-arm64@0.14.54:
- resolution: {integrity: sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==}
- engines: {node: '>=12'}
- cpu: [arm64]
- os: [freebsd]
-
- esbuild-linux-32@0.14.54:
- resolution: {integrity: sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==}
- engines: {node: '>=12'}
- cpu: [ia32]
- os: [linux]
-
- esbuild-linux-64@0.14.54:
- resolution: {integrity: sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [linux]
-
- esbuild-linux-arm64@0.14.54:
- resolution: {integrity: sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==}
- engines: {node: '>=12'}
- cpu: [arm64]
- os: [linux]
-
- esbuild-linux-arm@0.14.54:
- resolution: {integrity: sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==}
- engines: {node: '>=12'}
- cpu: [arm]
- os: [linux]
-
- esbuild-linux-mips64le@0.14.54:
- resolution: {integrity: sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==}
- engines: {node: '>=12'}
- cpu: [mips64el]
- os: [linux]
-
- esbuild-linux-ppc64le@0.14.54:
- resolution: {integrity: sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==}
- engines: {node: '>=12'}
- cpu: [ppc64]
- os: [linux]
-
- esbuild-linux-riscv64@0.14.54:
- resolution: {integrity: sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==}
- engines: {node: '>=12'}
- cpu: [riscv64]
- os: [linux]
-
- esbuild-linux-s390x@0.14.54:
- resolution: {integrity: sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==}
- engines: {node: '>=12'}
- cpu: [s390x]
- os: [linux]
-
- esbuild-netbsd-64@0.14.54:
- resolution: {integrity: sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [netbsd]
-
- esbuild-openbsd-64@0.14.54:
- resolution: {integrity: sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [openbsd]
-
- esbuild-sunos-64@0.14.54:
- resolution: {integrity: sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [sunos]
-
- esbuild-windows-32@0.14.54:
- resolution: {integrity: sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==}
- engines: {node: '>=12'}
- cpu: [ia32]
- os: [win32]
-
- esbuild-windows-64@0.14.54:
- resolution: {integrity: sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==}
- engines: {node: '>=12'}
- cpu: [x64]
- os: [win32]
-
- esbuild-windows-arm64@0.14.54:
- resolution: {integrity: sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==}
- engines: {node: '>=12'}
- cpu: [arm64]
- os: [win32]
-
- esbuild@0.14.54:
- resolution: {integrity: sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==}
- engines: {node: '>=12'}
- hasBin: true
-
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -1204,25 +1156,39 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
- eslint-scope@7.2.2:
- resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ eslint-scope@8.2.0:
+ resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
- eslint@8.57.0:
- resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ eslint-visitor-keys@4.2.0:
+ resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint@9.16.0:
+ resolution: {integrity: sha512-whp8mSQI4C8VXd+fLgSM0lh3UlmcFtVwUQjyKCFfsp+2ItAIYhlq/hqGahGqHE6cv9unM41VlqKk2VtKYR2TaA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
- esm-env@1.0.0:
- resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==}
+ esm-env@1.2.1:
+ resolution: {integrity: sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==}
- espree@9.6.1:
- resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
- engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ espree@10.3.0:
+ resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esprima@4.0.1:
+ resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+ engines: {node: '>=4'}
+ hasBin: true
esquery@1.6.0:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
@@ -1251,14 +1217,14 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
- express-rate-limit@6.11.2:
- resolution: {integrity: sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==}
- engines: {node: '>= 14'}
+ express-rate-limit@7.4.1:
+ resolution: {integrity: sha512-KS3efpnpIDVIXopMc65EMbWbUht7qvTCdtCR2dD/IZmi9MIkopYESwyRqLgv8Pfu589+KqDqOdzJWW7AHoACeg==}
+ engines: {node: '>= 16'}
peerDependencies:
- express: ^4 || ^5
+ express: 4 || 5 || ^5.0.0-beta.1
- express@4.19.2:
- resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==}
+ express@4.21.2:
+ resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
engines: {node: '>= 0.10.0'}
fast-deep-equal@3.1.3:
@@ -1277,29 +1243,37 @@ packages:
fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
+ fdir@6.4.0:
+ resolution: {integrity: sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
ffmpeg-static@5.2.0:
resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==}
engines: {node: '>=16'}
- file-entry-cache@6.0.1:
- resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
- finalhandler@1.2.0:
- resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==}
+ finalhandler@1.3.1:
+ resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
engines: {node: '>= 0.8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
- flat-cache@3.2.0:
- resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
- engines: {node: ^10.12.0 || >=12.0.0}
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
flatted@3.3.1:
resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
@@ -1330,6 +1304,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ generic-pool@3.9.0:
+ resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
+ engines: {node: '>= 4'}
+
get-intrinsic@1.2.4:
resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==}
engines: {node: '>= 0.4'}
@@ -1350,21 +1328,22 @@ packages:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true
+ glob@11.0.0:
+ resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==}
+ engines: {node: 20 || >=22}
+ hasBin: true
+
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
- globals@13.24.0:
- resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
- engines: {node: '>=8'}
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
globalyzer@0.1.0:
resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==}
- globby@11.1.0:
- resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
- engines: {node: '>=10'}
-
globrex@0.1.2:
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
@@ -1448,6 +1427,10 @@ packages:
resolution: {integrity: sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==}
engines: {node: '>= 10'}
+ ipaddr.js@2.2.0:
+ resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
+ engines: {node: '>= 10'}
+
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1468,10 +1451,6 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
- is-path-inside@3.0.3:
- resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
- engines: {node: '>=8'}
-
is-reference@3.0.2:
resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==}
@@ -1485,13 +1464,21 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
- jintr@2.1.1:
- resolution: {integrity: sha512-89cwX4ouogeDGOBsEVsVYsnWWvWjchmwXBB4kiBhmjOKw19FiOKhNhMhpxhTlK2ctl7DS+d/ethfmuBpzoNNgA==}
+ jackspeak@4.0.2:
+ resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==}
+ engines: {node: 20 || >=22}
+
+ jintr@3.3.0:
+ resolution: {integrity: sha512-ZsaajJ4Hr5XR0tSPhOZOTjFhxA0qscKNSOs41NRjx7ZOGwpfdp8NKIBEUtvUPbA37JXyv1sJlgeOOZHjr3h76Q==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
+ js-yaml@3.14.1:
+ resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
+ hasBin: true
+
js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
@@ -1543,6 +1530,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+ lru-cache@11.0.2:
+ resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==}
+ engines: {node: 20 || >=22}
+
magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
@@ -1558,8 +1549,8 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
- merge-descriptors@1.0.1:
- resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
+ merge-descriptors@1.0.3:
+ resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -1602,6 +1593,10 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
+ minimatch@10.0.1:
+ resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==}
+ engines: {node: 20 || >=22}
+
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -1645,9 +1640,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- nanoid@4.0.2:
- resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
- engines: {node: ^14 || ^16 || >=18}
+ nanoid@5.0.9:
+ resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==}
+ engines: {node: ^18 || >=20}
hasBin: true
natural-compare@1.4.0:
@@ -1657,10 +1652,6 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
- node-cache@5.1.2:
- resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==}
- engines: {node: '>= 8.0.0'}
-
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -1730,12 +1721,12 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
- path-to-regexp@0.1.7:
- resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==}
+ path-scurry@2.0.0:
+ resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==}
+ engines: {node: 20 || >=22}
- path-type@4.0.0:
- resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
- engines: {node: '>=8'}
+ path-to-regexp@0.1.12:
+ resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
@@ -1743,10 +1734,17 @@ packages:
picocolors@1.0.1:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
+ picocolors@1.1.0:
+ resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
+
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
+ picomatch@4.0.2:
+ resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
+ engines: {node: '>=12'}
+
pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -1769,8 +1767,8 @@ packages:
yaml:
optional: true
- postcss@8.4.40:
- resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==}
+ postcss@8.4.47:
+ resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -1797,15 +1795,12 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
- psl@1.9.0:
- resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
-
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- qs@6.11.0:
- resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
+ qs@6.13.0:
+ resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -1815,6 +1810,12 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
+ rate-limit-redis@4.2.0:
+ resolution: {integrity: sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express-rate-limit: '>= 6'
+
raw-body@2.5.2:
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
engines: {node: '>= 0.8'}
@@ -1827,6 +1828,9 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
+ redis@4.7.0:
+ resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1844,13 +1848,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
- rimraf@3.0.2:
- resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
- deprecated: Rimraf versions prior to v4 are no longer supported
- hasBin: true
-
- rollup@4.19.2:
- resolution: {integrity: sha512-6/jgnN1svF9PjNYJ4ya3l+cqutg49vOZ4rVgsDKxdl+5gpGPnByFXWGyfH9YGx9i3nfBwSu1Iyu6vGwFFA0BdQ==}
+ rollup@4.24.0:
+ resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -1875,12 +1874,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
- send@0.18.0:
- resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
+ send@0.19.0:
+ resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'}
- serve-static@1.15.0:
- resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==}
+ serve-static@1.16.2:
+ resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
set-cookie-parser@2.6.0:
@@ -1912,13 +1911,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
- sirv@2.0.4:
- resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
- engines: {node: '>= 10'}
-
- slash@3.0.0:
- resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
- engines: {node: '>=8'}
+ sirv@3.0.0:
+ resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==}
+ engines: {node: '>=18'}
sorcery@0.11.1:
resolution: {integrity: sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==}
@@ -1928,10 +1923,17 @@ packages:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
+ sprintf-js@1.0.3:
+ resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -2062,8 +2064,13 @@ packages:
typescript:
optional: true
- svelte@4.2.18:
- resolution: {integrity: sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==}
+ svelte-sitemap@2.6.0:
+ resolution: {integrity: sha512-WcwsuIeo8iJFG9a5cgvXwXEGoyjk6Zowb6JmL5BbwfnFXMzakGa1+mQjthw5Ni3UV/gGbE0PgJvc7Ygir3LmFg==}
+ engines: {node: '>= 14.17.0'}
+ hasBin: true
+
+ svelte@4.2.19:
+ resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==}
engines: {node: '>=16'}
sveltekit-i18n@2.4.2:
@@ -2074,9 +2081,6 @@ packages:
syscall-napi@0.0.6:
resolution: {integrity: sha512-qHbwjyFXAAekKUXxl70lhDiBYJ3e7XM7kQwu7LV3F0pHMenKox+VcZPZkRkhdmL/wNJD3NmrMGnL7161kdecUQ==}
- text-table@0.2.0:
- resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
-
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -2087,6 +2091,10 @@ packages:
tiny-glob@0.2.9:
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
+ tinyglobby@0.2.9:
+ resolution: {integrity: sha512-8or1+BGEdk1Zkkw2ii16qSS7uVrQJPre5A9o/XkWPATkk23FZh/15BKFxPnlTy6vkljZxLqYCzzBMj30ZrSvjw==}
+ engines: {node: '>=12.0.0'}
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -2122,8 +2130,8 @@ packages:
tslib@2.6.3:
resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==}
- tsup@8.2.4:
- resolution: {integrity: sha512-akpCPePnBnC/CXgRrcy72ZSntgIEUa1jN0oJbbvpALWKNOz1B7aM+UVDWGRGIO/T/PZugAESWDJUAb5FD48o8Q==}
+ tsup@8.3.0:
+ resolution: {integrity: sha512-ALscEeyS03IomcuNdFdc0YWGVIkwH1Ws7nfTbAPuoILvEV2hpGQAY72LIOjglGo4ShWpZfpBqP/jpQVCzqYQag==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -2148,10 +2156,6 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
- type-fest@0.20.2:
- resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
- engines: {node: '>=10'}
-
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@@ -2159,15 +2163,12 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
- typescript-eslint@7.18.0:
- resolution: {integrity: sha512-PonBkP603E3tt05lDkbOMyaxJjvKqQrXsnow72sVeOFINDE/qNmnnd+f9b4N+U7W6MXnnYyrhtmF2t08QWwUbA==}
- engines: {node: ^18.18.0 || >=20.0.0}
+ typescript-eslint@8.18.0:
+ resolution: {integrity: sha512-Xq2rRjn6tzVpAyHr3+nmSg1/9k9aIHnJ2iZeOH7cfGOWqTkXTm3kwpQglEuLGdNrYvPF+2gtAs+/KF5rjVo+WQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
- eslint: ^8.56.0
- typescript: '*'
- peerDependenciesMeta:
- typescript:
- optional: true
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <5.8.0'
typescript@5.5.4:
resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==}
@@ -2209,8 +2210,8 @@ packages:
vfile-message@2.0.4:
resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==}
- vite@5.3.5:
- resolution: {integrity: sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==}
+ vite@5.4.8:
+ resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -2218,6 +2219,7 @@ packages:
less: '*'
lightningcss: ^1.21.0
sass: '*'
+ sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
@@ -2230,6 +2232,8 @@ packages:
optional: true
sass:
optional: true
+ sass-embedded:
+ optional: true
stylus:
optional: true
sugarss:
@@ -2271,12 +2275,19 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ xmlbuilder2@3.1.1:
+ resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==}
+ engines: {node: '>=12.0'}
+
+ yallist@4.0.0:
+ resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- youtubei.js@10.3.0:
- resolution: {integrity: sha512-tLmeJCECK2xF2hZZtF2nEqirdKVNLFSDpa0LhTaXY3tngtL7doQXyy7M2CLueramDTlmCnFaW+rctHirTPFaRQ==}
+ youtubei.js@13.3.0:
+ resolution: {integrity: sha512-tbl7rxltpgKoSsmfGUe9JqWUAzv6HFLqrOn0N85EbTn5DLt24EXrjClnXdxyr3PBARMJ3LC4vbll100a0ABsYw==}
zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
@@ -2288,6 +2299,14 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
+ '@bufbuild/protobuf@2.2.5': {}
+
+ '@datastructures-js/heap@4.3.3': {}
+
+ '@datastructures-js/priority-queue@6.3.1':
+ dependencies:
+ '@datastructures-js/heap': 4.3.3
+
'@derhuerst/http-basic@8.2.4':
dependencies:
caseless: 0.12.0
@@ -2361,9 +2380,6 @@ snapshots:
'@esbuild/linux-ia32@0.23.0':
optional: true
- '@esbuild/linux-loong64@0.14.54':
- optional: true
-
'@esbuild/linux-loong64@0.21.5':
optional: true
@@ -2439,19 +2455,31 @@ snapshots:
'@esbuild/win32-x64@0.23.0':
optional: true
- '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)':
+ '@eslint-community/eslint-utils@4.4.0(eslint@9.16.0)':
dependencies:
- eslint: 8.57.0
+ eslint: 9.16.0
eslint-visitor-keys: 3.4.3
- '@eslint-community/regexpp@4.11.0': {}
+ '@eslint-community/regexpp@4.12.1': {}
- '@eslint/eslintrc@2.1.4':
+ '@eslint/config-array@0.19.1':
+ dependencies:
+ '@eslint/object-schema': 2.1.5
+ debug: 4.3.6
+ minimatch: 3.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/core@0.9.1':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.2.0':
dependencies:
ajv: 6.12.6
debug: 4.3.6
- espree: 9.6.1
- globals: 13.24.0
+ espree: 10.3.0
+ globals: 14.0.0
ignore: 5.3.1
import-fresh: 3.3.0
js-yaml: 4.1.0
@@ -2460,10 +2488,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@eslint/js@8.57.0': {}
+ '@eslint/js@9.16.0': {}
'@eslint/js@9.8.0': {}
+ '@eslint/object-schema@2.1.5': {}
+
+ '@eslint/plugin-kit@0.2.4':
+ dependencies:
+ levn: 0.4.1
+
'@fastify/busboy@2.1.1': {}
'@fontsource-variable/noto-sans-mono@5.0.20': {}
@@ -2472,20 +2506,25 @@ snapshots:
'@fontsource/redaction-10@5.0.2': {}
- '@humanwhocodes/config-array@0.11.14':
+ '@humanfs/core@0.19.1': {}
+
+ '@humanfs/node@0.16.6':
dependencies:
- '@humanwhocodes/object-schema': 2.0.3
- debug: 4.3.6
- minimatch: 3.1.2
- transitivePeerDependencies:
- - supports-color
+ '@humanfs/core': 0.19.1
+ '@humanwhocodes/retry': 0.3.1
'@humanwhocodes/module-importer@1.0.1': {}
- '@humanwhocodes/object-schema@2.0.3': {}
+ '@humanwhocodes/retry@0.3.1': {}
+
+ '@humanwhocodes/retry@0.4.1': {}
'@imput/libav.js-remux-cli@5.5.6': {}
+ '@imput/psl@2.0.4':
+ dependencies:
+ punycode: 2.3.1
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -2524,114 +2563,163 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.17.1
+ '@oozcitak/dom@1.15.10':
+ dependencies:
+ '@oozcitak/infra': 1.0.8
+ '@oozcitak/url': 1.0.4
+ '@oozcitak/util': 8.3.8
+
+ '@oozcitak/infra@1.0.8':
+ dependencies:
+ '@oozcitak/util': 8.3.8
+
+ '@oozcitak/url@1.0.4':
+ dependencies:
+ '@oozcitak/infra': 1.0.8
+ '@oozcitak/util': 8.3.8
+
+ '@oozcitak/util@8.3.8': {}
+
'@pkgjs/parseargs@0.11.0':
optional: true
'@polka/url@1.0.0-next.25': {}
- '@rollup/rollup-android-arm-eabi@4.19.2':
+ '@redis/bloom@1.2.0(@redis/client@1.6.0)':
+ dependencies:
+ '@redis/client': 1.6.0
+ optional: true
+
+ '@redis/client@1.6.0':
+ dependencies:
+ cluster-key-slot: 1.1.2
+ generic-pool: 3.9.0
+ yallist: 4.0.0
+ optional: true
+
+ '@redis/graph@1.1.1(@redis/client@1.6.0)':
+ dependencies:
+ '@redis/client': 1.6.0
+ optional: true
+
+ '@redis/json@1.0.7(@redis/client@1.6.0)':
+ dependencies:
+ '@redis/client': 1.6.0
+ optional: true
+
+ '@redis/search@1.2.0(@redis/client@1.6.0)':
+ dependencies:
+ '@redis/client': 1.6.0
+ optional: true
+
+ '@redis/time-series@1.1.0(@redis/client@1.6.0)':
+ dependencies:
+ '@redis/client': 1.6.0
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.24.0':
optional: true
- '@rollup/rollup-android-arm64@4.19.2':
+ '@rollup/rollup-android-arm64@4.24.0':
optional: true
- '@rollup/rollup-darwin-arm64@4.19.2':
+ '@rollup/rollup-darwin-arm64@4.24.0':
optional: true
- '@rollup/rollup-darwin-x64@4.19.2':
+ '@rollup/rollup-darwin-x64@4.24.0':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.19.2':
+ '@rollup/rollup-linux-arm-gnueabihf@4.24.0':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.19.2':
+ '@rollup/rollup-linux-arm-musleabihf@4.24.0':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.19.2':
+ '@rollup/rollup-linux-arm64-gnu@4.24.0':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.19.2':
+ '@rollup/rollup-linux-arm64-musl@4.24.0':
optional: true
- '@rollup/rollup-linux-powerpc64le-gnu@4.19.2':
+ '@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.19.2':
+ '@rollup/rollup-linux-riscv64-gnu@4.24.0':
optional: true
- '@rollup/rollup-linux-s390x-gnu@4.19.2':
+ '@rollup/rollup-linux-s390x-gnu@4.24.0':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.19.2':
+ '@rollup/rollup-linux-x64-gnu@4.24.0':
optional: true
- '@rollup/rollup-linux-x64-musl@4.19.2':
+ '@rollup/rollup-linux-x64-musl@4.24.0':
optional: true
- '@rollup/rollup-win32-arm64-msvc@4.19.2':
+ '@rollup/rollup-win32-arm64-msvc@4.24.0':
optional: true
- '@rollup/rollup-win32-ia32-msvc@4.19.2':
+ '@rollup/rollup-win32-ia32-msvc@4.24.0':
optional: true
- '@rollup/rollup-win32-x64-msvc@4.19.2':
+ '@rollup/rollup-win32-x64-msvc@4.24.0':
optional: true
- '@sveltejs/adapter-static@3.0.2(@sveltejs/kit@2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))':
+ '@sveltejs/adapter-static@3.0.6(@sveltejs/kit@2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))':
dependencies:
- '@sveltejs/kit': 2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))
+ '@sveltejs/kit': 2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))
- '@sveltejs/kit@2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))':
+ '@sveltejs/kit@2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))':
dependencies:
- '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))
+ '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))
'@types/cookie': 0.6.0
cookie: 0.6.0
- devalue: 5.0.0
- esm-env: 1.0.0
+ devalue: 5.1.1
+ esm-env: 1.2.1
import-meta-resolve: 4.1.0
kleur: 4.1.5
magic-string: 0.30.11
mrmime: 2.0.0
sade: 1.8.1
set-cookie-parser: 2.6.0
- sirv: 2.0.4
- svelte: 4.2.18
+ sirv: 3.0.0
+ svelte: 4.2.19
tiny-glob: 0.2.9
- vite: 5.3.5(@types/node@20.14.14)
+ vite: 5.4.8(@types/node@20.14.14)
- '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))':
+ '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))':
dependencies:
- '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))
+ '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))
debug: 4.3.6
- svelte: 4.2.18
- vite: 5.3.5(@types/node@20.14.14)
+ svelte: 4.2.19
+ vite: 5.4.8(@types/node@20.14.14)
transitivePeerDependencies:
- supports-color
- '@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))':
+ '@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))':
dependencies:
- '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))
+ '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))
debug: 4.3.6
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.11
- svelte: 4.2.18
- svelte-hmr: 0.16.0(svelte@4.2.18)
- vite: 5.3.5(@types/node@20.14.14)
- vitefu: 0.2.5(vite@5.3.5(@types/node@20.14.14))
+ svelte: 4.2.19
+ svelte-hmr: 0.16.0(svelte@4.2.19)
+ vite: 5.4.8(@types/node@20.14.14)
+ vitefu: 0.2.5(vite@5.4.8(@types/node@20.14.14))
transitivePeerDependencies:
- supports-color
- '@sveltekit-i18n/base@1.3.7(svelte@4.2.18)':
+ '@sveltekit-i18n/base@1.3.7(svelte@4.2.19)':
dependencies:
- svelte: 4.2.18
+ svelte: 4.2.19
'@sveltekit-i18n/parser-default@1.1.1': {}
- '@tabler/icons-svelte@3.6.0(svelte@4.2.18)':
+ '@tabler/icons-svelte@3.6.0(svelte@4.2.19)':
dependencies:
'@tabler/icons': 3.6.0
- svelte: 4.2.18
+ svelte: 4.2.19
'@tabler/icons@3.6.0': {}
@@ -2648,6 +2736,8 @@ snapshots:
'@types/estree@1.0.5': {}
+ '@types/estree@1.0.6': {}
+
'@types/fluent-ffmpeg@2.1.25':
dependencies:
'@types/node': 20.14.14
@@ -2664,104 +2754,100 @@ snapshots:
'@types/unist@2.0.10': {}
- '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)':
+ '@typescript-eslint/eslint-plugin@8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.16.0)(typescript@5.5.4))(eslint@9.16.0)(typescript@5.5.4)':
dependencies:
- '@eslint-community/regexpp': 4.11.0
- '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.5.4)
- '@typescript-eslint/scope-manager': 7.18.0
- '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4)
- '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4)
- '@typescript-eslint/visitor-keys': 7.18.0
- eslint: 8.57.0
+ '@eslint-community/regexpp': 4.12.1
+ '@typescript-eslint/parser': 8.18.0(eslint@9.16.0)(typescript@5.5.4)
+ '@typescript-eslint/scope-manager': 8.18.0
+ '@typescript-eslint/type-utils': 8.18.0(eslint@9.16.0)(typescript@5.5.4)
+ '@typescript-eslint/utils': 8.18.0(eslint@9.16.0)(typescript@5.5.4)
+ '@typescript-eslint/visitor-keys': 8.18.0
+ eslint: 9.16.0
graphemer: 1.4.0
ignore: 5.3.1
natural-compare: 1.4.0
ts-api-utils: 1.3.0(typescript@5.5.4)
- optionalDependencies:
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4)':
+ '@typescript-eslint/parser@8.18.0(eslint@9.16.0)(typescript@5.5.4)':
dependencies:
- '@typescript-eslint/scope-manager': 7.18.0
- '@typescript-eslint/types': 7.18.0
- '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4)
- '@typescript-eslint/visitor-keys': 7.18.0
+ '@typescript-eslint/scope-manager': 8.18.0
+ '@typescript-eslint/types': 8.18.0
+ '@typescript-eslint/typescript-estree': 8.18.0(typescript@5.5.4)
+ '@typescript-eslint/visitor-keys': 8.18.0
debug: 4.3.6
- eslint: 8.57.0
- optionalDependencies:
+ eslint: 9.16.0
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@7.18.0':
+ '@typescript-eslint/scope-manager@8.18.0':
dependencies:
- '@typescript-eslint/types': 7.18.0
- '@typescript-eslint/visitor-keys': 7.18.0
+ '@typescript-eslint/types': 8.18.0
+ '@typescript-eslint/visitor-keys': 8.18.0
- '@typescript-eslint/type-utils@7.18.0(eslint@8.57.0)(typescript@5.5.4)':
+ '@typescript-eslint/type-utils@8.18.0(eslint@9.16.0)(typescript@5.5.4)':
dependencies:
- '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4)
- '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4)
+ '@typescript-eslint/typescript-estree': 8.18.0(typescript@5.5.4)
+ '@typescript-eslint/utils': 8.18.0(eslint@9.16.0)(typescript@5.5.4)
debug: 4.3.6
- eslint: 8.57.0
+ eslint: 9.16.0
ts-api-utils: 1.3.0(typescript@5.5.4)
- optionalDependencies:
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@7.18.0': {}
+ '@typescript-eslint/types@8.18.0': {}
- '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)':
+ '@typescript-eslint/typescript-estree@8.18.0(typescript@5.5.4)':
dependencies:
- '@typescript-eslint/types': 7.18.0
- '@typescript-eslint/visitor-keys': 7.18.0
+ '@typescript-eslint/types': 8.18.0
+ '@typescript-eslint/visitor-keys': 8.18.0
debug: 4.3.6
- globby: 11.1.0
+ fast-glob: 3.3.2
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.6.3
ts-api-utils: 1.3.0(typescript@5.5.4)
- optionalDependencies:
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@7.18.0(eslint@8.57.0)(typescript@5.5.4)':
+ '@typescript-eslint/utils@8.18.0(eslint@9.16.0)(typescript@5.5.4)':
dependencies:
- '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0)
- '@typescript-eslint/scope-manager': 7.18.0
- '@typescript-eslint/types': 7.18.0
- '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4)
- eslint: 8.57.0
+ '@eslint-community/eslint-utils': 4.4.0(eslint@9.16.0)
+ '@typescript-eslint/scope-manager': 8.18.0
+ '@typescript-eslint/types': 8.18.0
+ '@typescript-eslint/typescript-estree': 8.18.0(typescript@5.5.4)
+ eslint: 9.16.0
+ typescript: 5.5.4
transitivePeerDependencies:
- supports-color
- - typescript
- '@typescript-eslint/visitor-keys@7.18.0':
+ '@typescript-eslint/visitor-keys@8.18.0':
dependencies:
- '@typescript-eslint/types': 7.18.0
- eslint-visitor-keys: 3.4.3
+ '@typescript-eslint/types': 8.18.0
+ eslint-visitor-keys: 4.2.0
- '@ungap/structured-clone@1.2.0': {}
-
- '@vitejs/plugin-basic-ssl@1.1.0(vite@5.3.5(@types/node@20.14.14))':
+ '@vitejs/plugin-basic-ssl@1.1.0(vite@5.4.8(@types/node@20.14.14))':
dependencies:
- vite: 5.3.5(@types/node@20.14.14)
+ vite: 5.4.8(@types/node@20.14.14)
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
- acorn-jsx@5.3.2(acorn@8.12.1):
+ acorn-jsx@5.3.2(acorn@8.14.0):
dependencies:
- acorn: 8.12.1
+ acorn: 8.14.0
acorn@8.12.1: {}
+ acorn@8.14.0: {}
+
agent-base@6.0.2:
dependencies:
debug: 4.3.6
@@ -2792,6 +2878,10 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
+ argparse@1.0.10:
+ dependencies:
+ sprintf-js: 1.0.3
+
argparse@2.0.1: {}
aria-query@5.3.0:
@@ -2800,15 +2890,13 @@ snapshots:
array-flatten@1.1.1: {}
- array-union@2.1.0: {}
-
axobject-query@4.1.0: {}
balanced-match@1.0.2: {}
binary-extensions@2.3.0: {}
- body-parser@1.20.2:
+ body-parser@1.20.3:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
@@ -2818,7 +2906,7 @@ snapshots:
http-errors: 2.0.0
iconv-lite: 0.4.24
on-finished: 2.4.1
- qs: 6.11.0
+ qs: 6.13.0
raw-body: 2.5.2
type-is: 1.6.18
unpipe: 1.0.0
@@ -2880,7 +2968,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
- clone@2.1.2: {}
+ cluster-key-slot@1.1.2:
+ optional: true
code-red@1.0.4:
dependencies:
@@ -2923,6 +3012,8 @@ snapshots:
cookie@0.6.0: {}
+ cookie@0.7.1: {}
+
cors@2.8.5:
dependencies:
object-assign: 4.1.1
@@ -2934,6 +3025,12 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
css-tree@2.3.1:
dependencies:
mdn-data: 2.0.30
@@ -2965,15 +3062,7 @@ snapshots:
detect-indent@6.1.0: {}
- devalue@5.0.0: {}
-
- dir-glob@3.0.1:
- dependencies:
- path-type: 4.0.0
-
- doctrine@3.0.0:
- dependencies:
- esutils: 2.0.3
+ devalue@5.1.1: {}
dotenv@16.4.5: {}
@@ -2987,6 +3076,8 @@ snapshots:
encodeurl@1.0.2: {}
+ encodeurl@2.0.0: {}
+
env-paths@2.2.1: {}
es-define-property@1.0.0:
@@ -2997,90 +3088,6 @@ snapshots:
es6-promise@3.3.1: {}
- esbuild-android-64@0.14.54:
- optional: true
-
- esbuild-android-arm64@0.14.54:
- optional: true
-
- esbuild-darwin-64@0.14.54:
- optional: true
-
- esbuild-darwin-arm64@0.14.54:
- optional: true
-
- esbuild-freebsd-64@0.14.54:
- optional: true
-
- esbuild-freebsd-arm64@0.14.54:
- optional: true
-
- esbuild-linux-32@0.14.54:
- optional: true
-
- esbuild-linux-64@0.14.54:
- optional: true
-
- esbuild-linux-arm64@0.14.54:
- optional: true
-
- esbuild-linux-arm@0.14.54:
- optional: true
-
- esbuild-linux-mips64le@0.14.54:
- optional: true
-
- esbuild-linux-ppc64le@0.14.54:
- optional: true
-
- esbuild-linux-riscv64@0.14.54:
- optional: true
-
- esbuild-linux-s390x@0.14.54:
- optional: true
-
- esbuild-netbsd-64@0.14.54:
- optional: true
-
- esbuild-openbsd-64@0.14.54:
- optional: true
-
- esbuild-sunos-64@0.14.54:
- optional: true
-
- esbuild-windows-32@0.14.54:
- optional: true
-
- esbuild-windows-64@0.14.54:
- optional: true
-
- esbuild-windows-arm64@0.14.54:
- optional: true
-
- esbuild@0.14.54:
- optionalDependencies:
- '@esbuild/linux-loong64': 0.14.54
- esbuild-android-64: 0.14.54
- esbuild-android-arm64: 0.14.54
- esbuild-darwin-64: 0.14.54
- esbuild-darwin-arm64: 0.14.54
- esbuild-freebsd-64: 0.14.54
- esbuild-freebsd-arm64: 0.14.54
- esbuild-linux-32: 0.14.54
- esbuild-linux-64: 0.14.54
- esbuild-linux-arm: 0.14.54
- esbuild-linux-arm64: 0.14.54
- esbuild-linux-mips64le: 0.14.54
- esbuild-linux-ppc64le: 0.14.54
- esbuild-linux-riscv64: 0.14.54
- esbuild-linux-s390x: 0.14.54
- esbuild-netbsd-64: 0.14.54
- esbuild-openbsd-64: 0.14.54
- esbuild-sunos-64: 0.14.54
- esbuild-windows-32: 0.14.54
- esbuild-windows-64: 0.14.54
- esbuild-windows-arm64: 0.14.54
-
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -3138,63 +3145,63 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-scope@7.2.2:
+ eslint-scope@8.2.0:
dependencies:
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
- eslint@8.57.0:
+ eslint-visitor-keys@4.2.0: {}
+
+ eslint@9.16.0:
dependencies:
- '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0)
- '@eslint-community/regexpp': 4.11.0
- '@eslint/eslintrc': 2.1.4
- '@eslint/js': 8.57.0
- '@humanwhocodes/config-array': 0.11.14
+ '@eslint-community/eslint-utils': 4.4.0(eslint@9.16.0)
+ '@eslint-community/regexpp': 4.12.1
+ '@eslint/config-array': 0.19.1
+ '@eslint/core': 0.9.1
+ '@eslint/eslintrc': 3.2.0
+ '@eslint/js': 9.16.0
+ '@eslint/plugin-kit': 0.2.4
+ '@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
- '@nodelib/fs.walk': 1.2.8
- '@ungap/structured-clone': 1.2.0
+ '@humanwhocodes/retry': 0.4.1
+ '@types/estree': 1.0.6
+ '@types/json-schema': 7.0.15
ajv: 6.12.6
chalk: 4.1.2
- cross-spawn: 7.0.3
+ cross-spawn: 7.0.6
debug: 4.3.6
- doctrine: 3.0.0
escape-string-regexp: 4.0.0
- eslint-scope: 7.2.2
- eslint-visitor-keys: 3.4.3
- espree: 9.6.1
+ eslint-scope: 8.2.0
+ eslint-visitor-keys: 4.2.0
+ espree: 10.3.0
esquery: 1.6.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
- file-entry-cache: 6.0.1
+ file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
- globals: 13.24.0
- graphemer: 1.4.0
ignore: 5.3.1
imurmurhash: 0.1.4
is-glob: 4.0.3
- is-path-inside: 3.0.3
- js-yaml: 4.1.0
json-stable-stringify-without-jsonify: 1.0.1
- levn: 0.4.1
lodash.merge: 4.6.2
minimatch: 3.1.2
natural-compare: 1.4.0
optionator: 0.9.4
- strip-ansi: 6.0.1
- text-table: 0.2.0
transitivePeerDependencies:
- supports-color
- esm-env@1.0.0: {}
+ esm-env@1.2.1: {}
- espree@9.6.1:
+ espree@10.3.0:
dependencies:
- acorn: 8.12.1
- acorn-jsx: 5.3.2(acorn@8.12.1)
- eslint-visitor-keys: 3.4.3
+ acorn: 8.14.0
+ acorn-jsx: 5.3.2(acorn@8.14.0)
+ eslint-visitor-keys: 4.2.0
+
+ esprima@4.0.1: {}
esquery@1.6.0:
dependencies:
@@ -3226,38 +3233,38 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
- express-rate-limit@6.11.2(express@4.19.2):
+ express-rate-limit@7.4.1(express@4.21.2):
dependencies:
- express: 4.19.2
+ express: 4.21.2
- express@4.19.2:
+ express@4.21.2:
dependencies:
accepts: 1.3.8
array-flatten: 1.1.1
- body-parser: 1.20.2
+ body-parser: 1.20.3
content-disposition: 0.5.4
content-type: 1.0.5
- cookie: 0.6.0
+ cookie: 0.7.1
cookie-signature: 1.0.6
debug: 2.6.9
depd: 2.0.0
- encodeurl: 1.0.2
+ encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
- finalhandler: 1.2.0
+ finalhandler: 1.3.1
fresh: 0.5.2
http-errors: 2.0.0
- merge-descriptors: 1.0.1
+ merge-descriptors: 1.0.3
methods: 1.1.2
on-finished: 2.4.1
parseurl: 1.3.3
- path-to-regexp: 0.1.7
+ path-to-regexp: 0.1.12
proxy-addr: 2.0.7
- qs: 6.11.0
+ qs: 6.13.0
range-parser: 1.2.1
safe-buffer: 5.2.1
- send: 0.18.0
- serve-static: 1.15.0
+ send: 0.19.0
+ serve-static: 1.16.2
setprototypeof: 1.2.0
statuses: 2.0.1
type-is: 1.6.18
@@ -3284,6 +3291,10 @@ snapshots:
dependencies:
reusify: 1.0.4
+ fdir@6.4.0(picomatch@4.0.2):
+ optionalDependencies:
+ picomatch: 4.0.2
+
ffmpeg-static@5.2.0:
dependencies:
'@derhuerst/http-basic': 8.2.4
@@ -3293,18 +3304,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
- file-entry-cache@6.0.1:
+ file-entry-cache@8.0.0:
dependencies:
- flat-cache: 3.2.0
+ flat-cache: 4.0.1
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
- finalhandler@1.2.0:
+ finalhandler@1.3.1:
dependencies:
debug: 2.6.9
- encodeurl: 1.0.2
+ encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
@@ -3318,11 +3329,10 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
- flat-cache@3.2.0:
+ flat-cache@4.0.1:
dependencies:
flatted: 3.3.1
keyv: 4.5.4
- rimraf: 3.0.2
flatted@3.3.1: {}
@@ -3349,6 +3359,9 @@ snapshots:
function-bind@1.1.2: {}
+ generic-pool@3.9.0:
+ optional: true
+
get-intrinsic@1.2.4:
dependencies:
es-errors: 1.3.0
@@ -3376,6 +3389,15 @@ snapshots:
package-json-from-dist: 1.0.0
path-scurry: 1.11.1
+ glob@11.0.0:
+ dependencies:
+ foreground-child: 3.3.0
+ jackspeak: 4.0.2
+ minimatch: 10.0.1
+ minipass: 7.1.2
+ package-json-from-dist: 1.0.0
+ path-scurry: 2.0.0
+
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@@ -3385,21 +3407,10 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
- globals@13.24.0:
- dependencies:
- type-fest: 0.20.2
+ globals@14.0.0: {}
globalyzer@0.1.0: {}
- globby@11.1.0:
- dependencies:
- array-union: 2.1.0
- dir-glob: 3.0.1
- fast-glob: 3.3.2
- ignore: 5.3.1
- merge2: 1.4.1
- slash: 3.0.0
-
globrex@0.1.2: {}
gopd@1.0.1:
@@ -3471,7 +3482,10 @@ snapshots:
ipaddr.js@1.9.1: {}
- ipaddr.js@2.1.0: {}
+ ipaddr.js@2.1.0:
+ optional: true
+
+ ipaddr.js@2.2.0: {}
is-binary-path@2.1.0:
dependencies:
@@ -3487,8 +3501,6 @@ snapshots:
is-number@7.0.0: {}
- is-path-inside@3.0.3: {}
-
is-reference@3.0.2:
dependencies:
'@types/estree': 1.0.5
@@ -3503,12 +3515,21 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
- jintr@2.1.1:
+ jackspeak@4.0.2:
dependencies:
- acorn: 8.12.1
+ '@isaacs/cliui': 8.0.2
+
+ jintr@3.3.0:
+ dependencies:
+ acorn: 8.14.0
joycon@3.1.1: {}
+ js-yaml@3.14.1:
+ dependencies:
+ argparse: 1.0.10
+ esprima: 4.0.1
+
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
@@ -3548,23 +3569,25 @@ snapshots:
lru-cache@10.4.3: {}
+ lru-cache@11.0.2: {}
+
magic-string@0.30.11:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
mdn-data@2.0.30: {}
- mdsvex@0.11.2(svelte@4.2.18):
+ mdsvex@0.11.2(svelte@4.2.19):
dependencies:
'@types/unist': 2.0.10
prism-svelte: 0.4.7
prismjs: 1.29.0
- svelte: 4.2.18
+ svelte: 4.2.19
vfile-message: 2.0.4
media-typer@0.3.0: {}
- merge-descriptors@1.0.1: {}
+ merge-descriptors@1.0.3: {}
merge-stream@2.0.0: {}
@@ -3591,6 +3614,10 @@ snapshots:
min-indent@1.0.1: {}
+ minimatch@10.0.1:
+ dependencies:
+ brace-expansion: 2.0.1
+
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.11
@@ -3625,16 +3652,12 @@ snapshots:
nanoid@3.3.7: {}
- nanoid@4.0.2: {}
+ nanoid@5.0.9: {}
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
- node-cache@5.1.2:
- dependencies:
- clone: 2.1.2
-
normalize-path@3.0.0: {}
npm-run-path@4.0.1:
@@ -3695,9 +3718,12 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
- path-to-regexp@0.1.7: {}
+ path-scurry@2.0.0:
+ dependencies:
+ lru-cache: 11.0.2
+ minipass: 7.1.2
- path-type@4.0.0: {}
+ path-to-regexp@0.1.12: {}
periscopic@3.1.0:
dependencies:
@@ -3707,21 +3733,25 @@ snapshots:
picocolors@1.0.1: {}
+ picocolors@1.1.0: {}
+
picomatch@2.3.1: {}
+ picomatch@4.0.2: {}
+
pirates@4.0.6: {}
- postcss-load-config@6.0.1(postcss@8.4.40):
+ postcss-load-config@6.0.1(postcss@8.4.47):
dependencies:
lilconfig: 3.1.2
optionalDependencies:
- postcss: 8.4.40
+ postcss: 8.4.47
- postcss@8.4.40:
+ postcss@8.4.47:
dependencies:
nanoid: 3.3.7
- picocolors: 1.0.1
- source-map-js: 1.2.0
+ picocolors: 1.1.0
+ source-map-js: 1.2.1
prelude-ls@1.2.1: {}
@@ -3738,11 +3768,9 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
- psl@1.9.0: {}
-
punycode@2.3.1: {}
- qs@6.11.0:
+ qs@6.13.0:
dependencies:
side-channel: 1.0.6
@@ -3750,6 +3778,11 @@ snapshots:
range-parser@1.2.1: {}
+ rate-limit-redis@4.2.0(express-rate-limit@7.4.1(express@4.21.2)):
+ dependencies:
+ express-rate-limit: 7.4.1(express@4.21.2)
+ optional: true
+
raw-body@2.5.2:
dependencies:
bytes: 3.1.2
@@ -3767,6 +3800,16 @@ snapshots:
dependencies:
picomatch: 2.3.1
+ redis@4.7.0:
+ dependencies:
+ '@redis/bloom': 1.2.0(@redis/client@1.6.0)
+ '@redis/client': 1.6.0
+ '@redis/graph': 1.1.1(@redis/client@1.6.0)
+ '@redis/json': 1.0.7(@redis/client@1.6.0)
+ '@redis/search': 1.2.0(@redis/client@1.6.0)
+ '@redis/time-series': 1.1.0(@redis/client@1.6.0)
+ optional: true
+
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -3777,30 +3820,26 @@ snapshots:
dependencies:
glob: 7.2.3
- rimraf@3.0.2:
- dependencies:
- glob: 7.2.3
-
- rollup@4.19.2:
+ rollup@4.24.0:
dependencies:
- '@types/estree': 1.0.5
+ '@types/estree': 1.0.6
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.19.2
- '@rollup/rollup-android-arm64': 4.19.2
- '@rollup/rollup-darwin-arm64': 4.19.2
- '@rollup/rollup-darwin-x64': 4.19.2
- '@rollup/rollup-linux-arm-gnueabihf': 4.19.2
- '@rollup/rollup-linux-arm-musleabihf': 4.19.2
- '@rollup/rollup-linux-arm64-gnu': 4.19.2
- '@rollup/rollup-linux-arm64-musl': 4.19.2
- '@rollup/rollup-linux-powerpc64le-gnu': 4.19.2
- '@rollup/rollup-linux-riscv64-gnu': 4.19.2
- '@rollup/rollup-linux-s390x-gnu': 4.19.2
- '@rollup/rollup-linux-x64-gnu': 4.19.2
- '@rollup/rollup-linux-x64-musl': 4.19.2
- '@rollup/rollup-win32-arm64-msvc': 4.19.2
- '@rollup/rollup-win32-ia32-msvc': 4.19.2
- '@rollup/rollup-win32-x64-msvc': 4.19.2
+ '@rollup/rollup-android-arm-eabi': 4.24.0
+ '@rollup/rollup-android-arm64': 4.24.0
+ '@rollup/rollup-darwin-arm64': 4.24.0
+ '@rollup/rollup-darwin-x64': 4.24.0
+ '@rollup/rollup-linux-arm-gnueabihf': 4.24.0
+ '@rollup/rollup-linux-arm-musleabihf': 4.24.0
+ '@rollup/rollup-linux-arm64-gnu': 4.24.0
+ '@rollup/rollup-linux-arm64-musl': 4.24.0
+ '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0
+ '@rollup/rollup-linux-riscv64-gnu': 4.24.0
+ '@rollup/rollup-linux-s390x-gnu': 4.24.0
+ '@rollup/rollup-linux-x64-gnu': 4.24.0
+ '@rollup/rollup-linux-x64-musl': 4.24.0
+ '@rollup/rollup-win32-arm64-msvc': 4.24.0
+ '@rollup/rollup-win32-ia32-msvc': 4.24.0
+ '@rollup/rollup-win32-x64-msvc': 4.24.0
fsevents: 2.3.3
run-parallel@1.2.0:
@@ -3824,7 +3863,7 @@ snapshots:
semver@7.6.3: {}
- send@0.18.0:
+ send@0.19.0:
dependencies:
debug: 2.6.9
depd: 2.0.0
@@ -3842,12 +3881,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- serve-static@1.15.0:
+ serve-static@1.16.2:
dependencies:
- encodeurl: 1.0.2
+ encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
- send: 0.18.0
+ send: 0.19.0
transitivePeerDependencies:
- supports-color
@@ -3881,14 +3920,12 @@ snapshots:
signal-exit@4.1.0: {}
- sirv@2.0.4:
+ sirv@3.0.0:
dependencies:
'@polka/url': 1.0.0-next.25
mrmime: 2.0.0
totalist: 3.0.1
- slash@3.0.0: {}
-
sorcery@0.11.1:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@@ -3898,10 +3935,14 @@ snapshots:
source-map-js@1.2.0: {}
+ source-map-js@1.2.1: {}
+
source-map@0.8.0-beta.0:
dependencies:
whatwg-url: 7.1.0
+ sprintf-js@1.0.3: {}
+
statuses@2.0.1: {}
string-width@4.2.3:
@@ -3950,14 +3991,14 @@ snapshots:
dependencies:
has-flag: 4.0.0
- svelte-check@3.8.5(postcss@8.4.40)(svelte@4.2.18):
+ svelte-check@3.8.5(postcss@8.4.47)(svelte@4.2.19):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
chokidar: 3.6.0
picocolors: 1.0.1
sade: 1.8.1
- svelte: 4.2.18
- svelte-preprocess: 5.1.4(postcss@8.4.40)(svelte@4.2.18)(typescript@5.5.4)
+ svelte: 4.2.19
+ svelte-preprocess: 5.1.4(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4)
typescript: 5.5.4
transitivePeerDependencies:
- '@babel/core'
@@ -3970,30 +4011,36 @@ snapshots:
- stylus
- sugarss
- svelte-hmr@0.16.0(svelte@4.2.18):
+ svelte-hmr@0.16.0(svelte@4.2.19):
dependencies:
- svelte: 4.2.18
+ svelte: 4.2.19
- svelte-preprocess@5.1.4(postcss@8.4.40)(svelte@4.2.18)(typescript@5.5.4):
+ svelte-preprocess@5.1.4(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4):
dependencies:
'@types/pug': 2.0.10
detect-indent: 6.1.0
magic-string: 0.30.11
sorcery: 0.11.1
strip-indent: 3.0.0
- svelte: 4.2.18
+ svelte: 4.2.19
optionalDependencies:
- postcss: 8.4.40
+ postcss: 8.4.47
typescript: 5.5.4
- svelte-preprocess@6.0.2(postcss@8.4.40)(svelte@4.2.18)(typescript@5.5.4):
+ svelte-preprocess@6.0.2(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4):
dependencies:
- svelte: 4.2.18
+ svelte: 4.2.19
optionalDependencies:
- postcss: 8.4.40
+ postcss: 8.4.47
typescript: 5.5.4
- svelte@4.2.18:
+ svelte-sitemap@2.6.0:
+ dependencies:
+ fast-glob: 3.3.2
+ minimist: 1.2.8
+ xmlbuilder2: 3.1.1
+
+ svelte@4.2.19:
dependencies:
'@ampproject/remapping': 2.3.0
'@jridgewell/sourcemap-codec': 1.5.0
@@ -4010,17 +4057,15 @@ snapshots:
magic-string: 0.30.11
periscopic: 3.1.0
- sveltekit-i18n@2.4.2(svelte@4.2.18):
+ sveltekit-i18n@2.4.2(svelte@4.2.19):
dependencies:
- '@sveltekit-i18n/base': 1.3.7(svelte@4.2.18)
+ '@sveltekit-i18n/base': 1.3.7(svelte@4.2.19)
'@sveltekit-i18n/parser-default': 1.1.1
- svelte: 4.2.18
+ svelte: 4.2.19
syscall-napi@0.0.6:
optional: true
- text-table@0.2.0: {}
-
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -4034,6 +4079,11 @@ snapshots:
globalyzer: 0.1.0
globrex: 0.1.2
+ tinyglobby@0.2.9:
+ dependencies:
+ fdir: 6.4.0(picomatch@4.0.2)
+ picomatch: 4.0.2
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -4058,7 +4108,7 @@ snapshots:
tslib@2.6.3: {}
- tsup@8.2.4(postcss@8.4.40)(typescript@5.5.4):
+ tsup@8.3.0(postcss@8.4.47)(typescript@5.5.4):
dependencies:
bundle-require: 5.0.0(esbuild@0.23.0)
cac: 6.7.14
@@ -4067,17 +4117,17 @@ snapshots:
debug: 4.3.6
esbuild: 0.23.0
execa: 5.1.1
- globby: 11.1.0
joycon: 3.1.1
- picocolors: 1.0.1
- postcss-load-config: 6.0.1(postcss@8.4.40)
+ picocolors: 1.1.0
+ postcss-load-config: 6.0.1(postcss@8.4.47)
resolve-from: 5.0.0
- rollup: 4.19.2
+ rollup: 4.24.0
source-map: 0.8.0-beta.0
sucrase: 3.35.0
+ tinyglobby: 0.2.9
tree-kill: 1.2.2
optionalDependencies:
- postcss: 8.4.40
+ postcss: 8.4.47
typescript: 5.5.4
transitivePeerDependencies:
- jiti
@@ -4091,8 +4141,6 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
- type-fest@0.20.2: {}
-
type-is@1.6.18:
dependencies:
media-typer: 0.3.0
@@ -4100,13 +4148,12 @@ snapshots:
typedarray@0.0.6: {}
- typescript-eslint@7.18.0(eslint@8.57.0)(typescript@5.5.4):
+ typescript-eslint@8.18.0(eslint@9.16.0)(typescript@5.5.4):
dependencies:
- '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)
- '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.5.4)
- '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4)
- eslint: 8.57.0
- optionalDependencies:
+ '@typescript-eslint/eslint-plugin': 8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.16.0)(typescript@5.5.4))(eslint@9.16.0)(typescript@5.5.4)
+ '@typescript-eslint/parser': 8.18.0(eslint@9.16.0)(typescript@5.5.4)
+ '@typescript-eslint/utils': 8.18.0(eslint@9.16.0)(typescript@5.5.4)
+ eslint: 9.16.0
typescript: 5.5.4
transitivePeerDependencies:
- supports-color
@@ -4142,18 +4189,18 @@ snapshots:
'@types/unist': 2.0.10
unist-util-stringify-position: 2.0.3
- vite@5.3.5(@types/node@20.14.14):
+ vite@5.4.8(@types/node@20.14.14):
dependencies:
esbuild: 0.21.5
- postcss: 8.4.40
- rollup: 4.19.2
+ postcss: 8.4.47
+ rollup: 4.24.0
optionalDependencies:
'@types/node': 20.14.14
fsevents: 2.3.3
- vitefu@0.2.5(vite@5.3.5(@types/node@20.14.14)):
+ vitefu@0.2.5(vite@5.4.8(@types/node@20.14.14)):
optionalDependencies:
- vite: 5.3.5(@types/node@20.14.14)
+ vite: 5.4.8(@types/node@20.14.14)
webidl-conversions@4.0.2: {}
@@ -4183,11 +4230,22 @@ snapshots:
wrappy@1.0.2: {}
+ xmlbuilder2@3.1.1:
+ dependencies:
+ '@oozcitak/dom': 1.15.10
+ '@oozcitak/infra': 1.0.8
+ '@oozcitak/util': 8.3.8
+ js-yaml: 3.14.1
+
+ yallist@4.0.0:
+ optional: true
+
yocto-queue@0.1.0: {}
- youtubei.js@10.3.0:
+ youtubei.js@13.3.0:
dependencies:
- jintr: 2.1.1
+ '@bufbuild/protobuf': 2.2.5
+ jintr: 3.3.0
tslib: 2.6.3
undici: 5.28.4
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..52516b88080283fafae72019ced5f3f9ccd41752
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,8 @@
+# builds
+/build
+/.svelte-kit
+/package
+
+# vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/web/.npmrc b/web/.npmrc
new file mode 100644
index 0000000000000000000000000000000000000000..b6f27f135954640c8cc5bfd7b8c9922ca6eb2aad
--- /dev/null
+++ b/web/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/web/LICENSE b/web/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..bfef380bf7d9cb74ec9ba533b37c3fbeef3bdc09
--- /dev/null
+++ b/web/LICENSE
@@ -0,0 +1,437 @@
+Attribution-NonCommercial-ShareAlike 4.0 International
+
+=======================================================================
+
+Creative Commons Corporation ("Creative Commons") is not a law firm and
+does not provide legal services or legal advice. Distribution of
+Creative Commons public licenses does not create a lawyer-client or
+other relationship. Creative Commons makes its licenses and related
+information available on an "as-is" basis. Creative Commons gives no
+warranties regarding its licenses, any material licensed under their
+terms and conditions, or any related information. Creative Commons
+disclaims all liability for damages resulting from their use to the
+fullest extent possible.
+
+Using Creative Commons Public Licenses
+
+Creative Commons public licenses provide a standard set of terms and
+conditions that creators and other rights holders may use to share
+original works of authorship and other material subject to copyright
+and certain other rights specified in the public license below. The
+following considerations are for informational purposes only, are not
+exhaustive, and do not form part of our licenses.
+
+ Considerations for licensors: Our public licenses are
+ intended for use by those authorized to give the public
+ permission to use material in ways otherwise restricted by
+ copyright and certain other rights. Our licenses are
+ irrevocable. Licensors should read and understand the terms
+ and conditions of the license they choose before applying it.
+ Licensors should also secure all rights necessary before
+ applying our licenses so that the public can reuse the
+ material as expected. Licensors should clearly mark any
+ material not subject to the license. This includes other CC-
+ licensed material, or material used under an exception or
+ limitation to copyright. More considerations for licensors:
+ wiki.creativecommons.org/Considerations_for_licensors
+
+ Considerations for the public: By using one of our public
+ licenses, a licensor grants the public permission to use the
+ licensed material under specified terms and conditions. If
+ the licensor's permission is not necessary for any reason--for
+ example, because of any applicable exception or limitation to
+ copyright--then that use is not regulated by the license. Our
+ licenses grant only permissions under copyright and certain
+ other rights that a licensor has authority to grant. Use of
+ the licensed material may still be restricted for other
+ reasons, including because others have copyright or other
+ rights in the material. A licensor may make special requests,
+ such as asking that all changes be marked or described.
+ Although not required by our licenses, you are encouraged to
+ respect those requests where reasonable. More considerations
+ for the public:
+ wiki.creativecommons.org/Considerations_for_licensees
+
+=======================================================================
+
+Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
+Public License
+
+By exercising the Licensed Rights (defined below), You accept and agree
+to be bound by the terms and conditions of this Creative Commons
+Attribution-NonCommercial-ShareAlike 4.0 International Public License
+("Public License"). To the extent this Public License may be
+interpreted as a contract, You are granted the Licensed Rights in
+consideration of Your acceptance of these terms and conditions, and the
+Licensor grants You such rights in consideration of benefits the
+Licensor receives from making the Licensed Material available under
+these terms and conditions.
+
+
+Section 1 -- Definitions.
+
+ a. Adapted Material means material subject to Copyright and Similar
+ Rights that is derived from or based upon the Licensed Material
+ and in which the Licensed Material is translated, altered,
+ arranged, transformed, or otherwise modified in a manner requiring
+ permission under the Copyright and Similar Rights held by the
+ Licensor. For purposes of this Public License, where the Licensed
+ Material is a musical work, performance, or sound recording,
+ Adapted Material is always produced where the Licensed Material is
+ synched in timed relation with a moving image.
+
+ b. Adapter's License means the license You apply to Your Copyright
+ and Similar Rights in Your contributions to Adapted Material in
+ accordance with the terms and conditions of this Public License.
+
+ c. BY-NC-SA Compatible License means a license listed at
+ creativecommons.org/compatiblelicenses, approved by Creative
+ Commons as essentially the equivalent of this Public License.
+
+ d. Copyright and Similar Rights means copyright and/or similar rights
+ closely related to copyright including, without limitation,
+ performance, broadcast, sound recording, and Sui Generis Database
+ Rights, without regard to how the rights are labeled or
+ categorized. For purposes of this Public License, the rights
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
+ Rights.
+
+ e. Effective Technological Measures means those measures that, in the
+ absence of proper authority, may not be circumvented under laws
+ fulfilling obligations under Article 11 of the WIPO Copyright
+ Treaty adopted on December 20, 1996, and/or similar international
+ agreements.
+
+ f. Exceptions and Limitations means fair use, fair dealing, and/or
+ any other exception or limitation to Copyright and Similar Rights
+ that applies to Your use of the Licensed Material.
+
+ g. License Elements means the license attributes listed in the name
+ of a Creative Commons Public License. The License Elements of this
+ Public License are Attribution, NonCommercial, and ShareAlike.
+
+ h. Licensed Material means the artistic or literary work, database,
+ or other material to which the Licensor applied this Public
+ License.
+
+ i. Licensed Rights means the rights granted to You subject to the
+ terms and conditions of this Public License, which are limited to
+ all Copyright and Similar Rights that apply to Your use of the
+ Licensed Material and that the Licensor has authority to license.
+
+ j. Licensor means the individual(s) or entity(ies) granting rights
+ under this Public License.
+
+ k. NonCommercial means not primarily intended for or directed towards
+ commercial advantage or monetary compensation. For purposes of
+ this Public License, the exchange of the Licensed Material for
+ other material subject to Copyright and Similar Rights by digital
+ file-sharing or similar means is NonCommercial provided there is
+ no payment of monetary compensation in connection with the
+ exchange.
+
+ l. Share means to provide material to the public by any means or
+ process that requires permission under the Licensed Rights, such
+ as reproduction, public display, public performance, distribution,
+ dissemination, communication, or importation, and to make material
+ available to the public including in ways that members of the
+ public may access the material from a place and at a time
+ individually chosen by them.
+
+ m. Sui Generis Database Rights means rights other than copyright
+ resulting from Directive 96/9/EC of the European Parliament and of
+ the Council of 11 March 1996 on the legal protection of databases,
+ as amended and/or succeeded, as well as other essentially
+ equivalent rights anywhere in the world.
+
+ n. You means the individual or entity exercising the Licensed Rights
+ under this Public License. Your has a corresponding meaning.
+
+
+Section 2 -- Scope.
+
+ a. License grant.
+
+ 1. Subject to the terms and conditions of this Public License,
+ the Licensor hereby grants You a worldwide, royalty-free,
+ non-sublicensable, non-exclusive, irrevocable license to
+ exercise the Licensed Rights in the Licensed Material to:
+
+ a. reproduce and Share the Licensed Material, in whole or
+ in part, for NonCommercial purposes only; and
+
+ b. produce, reproduce, and Share Adapted Material for
+ NonCommercial purposes only.
+
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
+ Exceptions and Limitations apply to Your use, this Public
+ License does not apply, and You do not need to comply with
+ its terms and conditions.
+
+ 3. Term. The term of this Public License is specified in Section
+ 6(a).
+
+ 4. Media and formats; technical modifications allowed. The
+ Licensor authorizes You to exercise the Licensed Rights in
+ all media and formats whether now known or hereafter created,
+ and to make technical modifications necessary to do so. The
+ Licensor waives and/or agrees not to assert any right or
+ authority to forbid You from making technical modifications
+ necessary to exercise the Licensed Rights, including
+ technical modifications necessary to circumvent Effective
+ Technological Measures. For purposes of this Public License,
+ simply making modifications authorized by this Section 2(a)
+ (4) never produces Adapted Material.
+
+ 5. Downstream recipients.
+
+ a. Offer from the Licensor -- Licensed Material. Every
+ recipient of the Licensed Material automatically
+ receives an offer from the Licensor to exercise the
+ Licensed Rights under the terms and conditions of this
+ Public License.
+
+ b. Additional offer from the Licensor -- Adapted Material.
+ Every recipient of Adapted Material from You
+ automatically receives an offer from the Licensor to
+ exercise the Licensed Rights in the Adapted Material
+ under the conditions of the Adapter's License You apply.
+
+ c. No downstream restrictions. You may not offer or impose
+ any additional or different terms or conditions on, or
+ apply any Effective Technological Measures to, the
+ Licensed Material if doing so restricts exercise of the
+ Licensed Rights by any recipient of the Licensed
+ Material.
+
+ 6. No endorsement. Nothing in this Public License constitutes or
+ may be construed as permission to assert or imply that You
+ are, or that Your use of the Licensed Material is, connected
+ with, or sponsored, endorsed, or granted official status by,
+ the Licensor or others designated to receive attribution as
+ provided in Section 3(a)(1)(A)(i).
+
+ b. Other rights.
+
+ 1. Moral rights, such as the right of integrity, are not
+ licensed under this Public License, nor are publicity,
+ privacy, and/or other similar personality rights; however, to
+ the extent possible, the Licensor waives and/or agrees not to
+ assert any such rights held by the Licensor to the limited
+ extent necessary to allow You to exercise the Licensed
+ Rights, but not otherwise.
+
+ 2. Patent and trademark rights are not licensed under this
+ Public License.
+
+ 3. To the extent possible, the Licensor waives any right to
+ collect royalties from You for the exercise of the Licensed
+ Rights, whether directly or through a collecting society
+ under any voluntary or waivable statutory or compulsory
+ licensing scheme. In all other cases the Licensor expressly
+ reserves any right to collect such royalties, including when
+ the Licensed Material is used other than for NonCommercial
+ purposes.
+
+
+Section 3 -- License Conditions.
+
+Your exercise of the Licensed Rights is expressly made subject to the
+following conditions.
+
+ a. Attribution.
+
+ 1. If You Share the Licensed Material (including in modified
+ form), You must:
+
+ a. retain the following if it is supplied by the Licensor
+ with the Licensed Material:
+
+ i. identification of the creator(s) of the Licensed
+ Material and any others designated to receive
+ attribution, in any reasonable manner requested by
+ the Licensor (including by pseudonym if
+ designated);
+
+ ii. a copyright notice;
+
+ iii. a notice that refers to this Public License;
+
+ iv. a notice that refers to the disclaimer of
+ warranties;
+
+ v. a URI or hyperlink to the Licensed Material to the
+ extent reasonably practicable;
+
+ b. indicate if You modified the Licensed Material and
+ retain an indication of any previous modifications; and
+
+ c. indicate the Licensed Material is licensed under this
+ Public License, and include the text of, or the URI or
+ hyperlink to, this Public License.
+
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
+ reasonable manner based on the medium, means, and context in
+ which You Share the Licensed Material. For example, it may be
+ reasonable to satisfy the conditions by providing a URI or
+ hyperlink to a resource that includes the required
+ information.
+ 3. If requested by the Licensor, You must remove any of the
+ information required by Section 3(a)(1)(A) to the extent
+ reasonably practicable.
+
+ b. ShareAlike.
+
+ In addition to the conditions in Section 3(a), if You Share
+ Adapted Material You produce, the following conditions also apply.
+
+ 1. The Adapter's License You apply must be a Creative Commons
+ license with the same License Elements, this version or
+ later, or a BY-NC-SA Compatible License.
+
+ 2. You must include the text of, or the URI or hyperlink to, the
+ Adapter's License You apply. You may satisfy this condition
+ in any reasonable manner based on the medium, means, and
+ context in which You Share Adapted Material.
+
+ 3. You may not offer or impose any additional or different terms
+ or conditions on, or apply any Effective Technological
+ Measures to, Adapted Material that restrict exercise of the
+ rights granted under the Adapter's License You apply.
+
+
+Section 4 -- Sui Generis Database Rights.
+
+Where the Licensed Rights include Sui Generis Database Rights that
+apply to Your use of the Licensed Material:
+
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
+ to extract, reuse, reproduce, and Share all or a substantial
+ portion of the contents of the database for NonCommercial purposes
+ only;
+
+ b. if You include all or a substantial portion of the database
+ contents in a database in which You have Sui Generis Database
+ Rights, then the database in which You have Sui Generis Database
+ Rights (but not its individual contents) is Adapted Material,
+ including for purposes of Section 3(b); and
+
+ c. You must comply with the conditions in Section 3(a) if You Share
+ all or a substantial portion of the contents of the database.
+
+For the avoidance of doubt, this Section 4 supplements and does not
+replace Your obligations under this Public License where the Licensed
+Rights include other Copyright and Similar Rights.
+
+
+Section 5 -- Disclaimer of Warranties and Limitation of Liability.
+
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
+
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
+
+ c. The disclaimer of warranties and limitation of liability provided
+ above shall be interpreted in a manner that, to the extent
+ possible, most closely approximates an absolute disclaimer and
+ waiver of all liability.
+
+
+Section 6 -- Term and Termination.
+
+ a. This Public License applies for the term of the Copyright and
+ Similar Rights licensed here. However, if You fail to comply with
+ this Public License, then Your rights under this Public License
+ terminate automatically.
+
+ b. Where Your right to use the Licensed Material has terminated under
+ Section 6(a), it reinstates:
+
+ 1. automatically as of the date the violation is cured, provided
+ it is cured within 30 days of Your discovery of the
+ violation; or
+
+ 2. upon express reinstatement by the Licensor.
+
+ For the avoidance of doubt, this Section 6(b) does not affect any
+ right the Licensor may have to seek remedies for Your violations
+ of this Public License.
+
+ c. For the avoidance of doubt, the Licensor may also offer the
+ Licensed Material under separate terms or conditions or stop
+ distributing the Licensed Material at any time; however, doing so
+ will not terminate this Public License.
+
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
+ License.
+
+
+Section 7 -- Other Terms and Conditions.
+
+ a. The Licensor shall not be bound by any additional or different
+ terms or conditions communicated by You unless expressly agreed.
+
+ b. Any arrangements, understandings, or agreements regarding the
+ Licensed Material not stated herein are separate from and
+ independent of the terms and conditions of this Public License.
+
+
+Section 8 -- Interpretation.
+
+ a. For the avoidance of doubt, this Public License does not, and
+ shall not be interpreted to, reduce, limit, restrict, or impose
+ conditions on any use of the Licensed Material that could lawfully
+ be made without permission under this Public License.
+
+ b. To the extent possible, if any provision of this Public License is
+ deemed unenforceable, it shall be automatically reformed to the
+ minimum extent necessary to make it enforceable. If the provision
+ cannot be reformed, it shall be severed from this Public License
+ without affecting the enforceability of the remaining terms and
+ conditions.
+
+ c. No term or condition of this Public License will be waived and no
+ failure to comply consented to unless expressly agreed to by the
+ Licensor.
+
+ d. Nothing in this Public License constitutes or may be interpreted
+ as a limitation upon, or waiver of, any privileges and immunities
+ that apply to the Licensor or You, including from the legal
+ processes of any jurisdiction or authority.
+
+=======================================================================
+
+Creative Commons is not a party to its public
+licenses. Notwithstanding, Creative Commons may elect to apply one of
+its public licenses to material it publishes and in those instances
+will be considered the “Licensor.” The text of the Creative Commons
+public licenses is dedicated to the public domain under the CC0 Public
+Domain Dedication. Except for the limited purpose of indicating that
+material is shared under a Creative Commons public license or as
+otherwise permitted by the Creative Commons policies published at
+creativecommons.org/policies, Creative Commons does not authorize the
+use of the trademark "Creative Commons" or any other trademark or logo
+of Creative Commons without its prior written consent including,
+without limitation, in connection with any unauthorized modifications
+to any of its public licenses or any other arrangements,
+understandings, or agreements concerning use of licensed material. For
+the avoidance of doubt, this paragraph does not form part of the
+public licenses.
+
+Creative Commons may be contacted at creativecommons.org.
\ No newline at end of file
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c528f6e5a79f01366788e3401f7ad331352e2fd8
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,44 @@
+# cobalt web
+the cobalt frontend is a static web app built with
+[sveltekit](https://kit.svelte.dev/) + [vite](https://vitejs.dev/).
+
+## configuring
+- to run a dev environment, run `pnpm run dev`.
+- to make a release build of the frontend, run `pnpm run build`.
+
+## environment variables
+the frontend has several build-time environment variables for configuring various features. to use
+them, you must specify them when building the frontend (or running a vite server for development).
+
+| name | example | description |
+|:---------------------|:----------------------------|:---------------------------------------------------------------------------------------------------------|
+| `WEB_HOST` | `cobalt.tools` | domain on which the frontend will be running. used for meta tags and configuring plausible. |
+| `WEB_PLAUSIBLE_HOST` | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. |
+| `WEB_DEFAULT_API` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. |
+
+\* don't use plausible.io as receiver backend unless you paid for their cloud service.
+ use your own domain when hosting community edition of plausible. refer to their [docs](https://plausible.io/docs) when needed.
+
+## license
+cobalt web code is licensed under [CC-BY-NC-SA-4.0](LICENSE).
+
+this license allows you to:
+- copy and redistribute the code in any medium or format, and
+- remix, transform, use and build upon the code
+
+as long as you:
+- give appropriate credit to the original repo,
+- provide a link to the license and indicate if changes to the code were made,
+- release the code under the **same license**, and
+- **don't use the code for any commercial purposes**.
+
+cobalt branding, mascots, and other related assets included in the repo are ***copyrighted*** and not covered by the license. you ***cannot*** use them under same terms.
+
+you are allowed to host an ***unmodified*** instance of cobalt with branding for **non-commercial purposes**, but this ***does not*** give you permission to use the branding anywhere else, or make derivatives of it in any way.
+
+when making an alternative version of the project, please replace or remove all branding (including the name).
+
+## 3rd party licenses
+- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji) (used in cobalt) is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.
+- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) fonts (used in cobalt) are licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.
+- many update banners were taken from [tenor.com](https://tenor.com/).
diff --git a/web/changelogs/10.0.md b/web/changelogs/10.0.md
new file mode 100644
index 0000000000000000000000000000000000000000..0e5add7132fefc326cb7895f83246e3043a98c72
--- /dev/null
+++ b/web/changelogs/10.0.md
@@ -0,0 +1,56 @@
+---
+title: "cobalt, reborn"
+date: "9 Sept, 2024"
+banner:
+ file: "cobalt10.webp"
+ alt: "meowth plush staring into a screen with cobalt 10 ui shown."
+---
+
+everything is new! this update marks the start of the latest chapter for cobalt. we spent the entire summer working hard to deliver the best experience ever, and we really hope you enjoy the rebirth of cobalt.
+
+before we list the new features, we also added support for a couple of new services: facebook, bluesky, loom, and snapchat.
+
+cobalt already supports downloading videos from bluesky and will continue to do so after they're officially released. we also added support for saving images from all supported services (such as twitter).
+
+now, here's the gist of what's new in the web app:
+
+- the web app was rebuilt from the ground up with usability & accessibility in mind. everyone will enjoy using the new web app, even if they rely on screen readers or other accessibility tools.
+- [on-device remuxing (beta)](/remux). this feature solves all compatibility issues with old software. just drop the file into the remux page, and it'll fix it up to make it work with your favorite apps! vegas pro, logic pro, fl studio, you name it.
+- [all-new settings page](/settings). settings are now way easier to understand and use. everything is appropriately labeled, categorized, and described.
+- [custom audio bitrate](/settings/audio#audio-bitrate). you can now choose what bitrate to use when processing audio.
+- [community instances (beta)](/settings/instances#community), right in the main web app. just go to instance settings and use a custom processing server if you wish. make sure to read the important safety message.
+- [tunnel all files (beta)](/settings/privacy#tunnel). this feature will hide your ip address, browser info, and bypass local network restrictions. all downloaded files will also have pretty filenames.
+- new localization system. this allowed us to implement a [language picker](/settings/appearance#language) right into cobalt. expect more languages in the future!
+- more granular error messages with proper context. no more grouped errors such as "this happened or this or that idk lol guess".
+- [settings data management](/settings/advanced#data). you can now export, import, or wipe settings.
+- [new donate page](/donate). the donation page has been completely reimagined, with more ways to support us than ever (via stripe and liberapay), and via sharing cobalt with a friend.
+- [comprehensive about page](/about). the new about page includes more info than ever before, and we will be progressively adding more content there to make sure there is no more confusion, period.
+- convenient updates page (*you're here*). the new updates page is comfortable to read and navigate. we have also moved to using markdown so that we can do *this* and **this** and ~~that~~. it also includes more changelogs than ever before.
+- tab key navigation across the entire web app.
+- new navigation system with proper routing, history, and all other benefits. it is now persistent and always stays on the screen.
+- new dialog system. dialogs now use native html elements, meaning that they work as you'd expect. no more finnicky navigation.
+- new picker dialog. pretty, easy to use, and works beautifully on all devices.
+
+...and this is just the tip of the iceberg. we couldn't possibly list all changes. just go and take a look around, don't be scared to press all buttons you see.
+
+and for nerds, we have a giant list of backend changes (that we are also excited about):
+- completely restructured API schema and endpoints.
+- API now has error codes instead of messages that used to contain HTML, and the error responses also include a separated error context that is much easier to parse.
+- server info endpoint returns a lot more contextual information about each instance.
+- API and web codebases have been completely separated.
+- support for OAuth2 tokens for youtube.
+- implemented JWT sessions, which are generated based on a Turnstile challenge or in the future by an API key.
+- streams are now tunnels.
+- API now returns the filename in the response instead of just as content-disposition for tunnels.
+- range requests are now supported for direct tunnels, which means these requests are also pauseable and resumable.
+- a ton of refactoring in continuous effort to make the codebase readable for everyone.
+
+this update allows us to actually innovate and develop new & exciting features. we are no longer held back by the legacy codebase. first feature of such kind is on-device remuxing. go check it out!
+
+oh yeah, we now have over 2 million monthly users. kind of insane.
+
+we hope you enjoy this update as much as we enjoyed making it. it was a really fun summer project for both of us.
+
+have a lovely day :D
+
+~ your friends at imput
diff --git a/web/changelogs/10.1.md b/web/changelogs/10.1.md
new file mode 100644
index 0000000000000000000000000000000000000000..885018d25c5de8b2fe09ccfc86f17f7842ef5e74
--- /dev/null
+++ b/web/changelogs/10.1.md
@@ -0,0 +1,55 @@
+---
+title: "squashing bugs, improving security and ux"
+date: "1 Oct, 2024"
+banner:
+ file: "meowth101hammer.webp"
+ alt: "meowth plush getting squished with a hammer."
+---
+
+this update enhances the cobalt experience all around, here's everything that we added or changed since 10.0:
+
+### saving improvements:
+- youtube videos encoded in av1 are now downloaded in the webm container. they also include opus audio for the best quality all around.
+- fixed various bugs related to the download process on older devices/browsers. cobalt should work everywhere within sane limits.
+- fixed downloading of twitch clips.
+- fixed a bug where cobalt wouldn't download bluesky videos that are in a post with a quote.
+- fixed a bug that caused some youtube music videos to fail to download due to differently formatted metadata.
+- cobalt will no longer unexpectedly open video files on iOS. instead, a dialog with other options will be shown. this had to be done due to missing "download" button in safari's video player. you can override this by enabling [forced tunneling](/settings/privacy#tunnel).
+- fixed a bug in filename generation where certain information was added to the filename even if cobalt didn't have it (such as youtube video format).
+
+### general ui/ux improvements:
+- added a button to quickly copy a link to the section in settings or about page.
+- added `(remux)` to filenames of remuxed videos to distinguish them from the original file.
+- improved the look & behavior of the sidebar.
+- fixed cursor appearance to update correctly when using the sidebar or subpage navigation.
+- added a stepped scroller to the donation options card [on the donate page](/donate).
+- tweaked the [donate page](/donate) layout to be cleaner and more flexible.
+- fixed tab navigation for donation option buttons.
+- updated the [10.0 changelog banner](/updates#10.0) to be less boring.
+- fixed a bug that caused some changelog dates to be displayed a day later than intended.
+- changelog banner can now be saved with a right click.
+- cobalt version now gently fades in on the [settings page](/settings).
+- fixed the position of the notch easter egg on iPhone XR, 11, 16 Pro, and 16 Pro Max.
+- cobalt will let you paste the link even if the anti-bot check isn't completed yet. if anything goes wrong regarding anti-bot checks, cobalt will let you know.
+- fixed a bunch of typos and minor grammatical errors.
+- other minor changes.
+
+### about page improvements:
+- added motivation section to the [general about page](/about/general).
+- added a list of beta testers to the [credits page](/about/credits).
+- rephrased some about sections to improve clarity and readability.
+- made about page body narrower to be easier to read.
+- added extra padding between sections on about page to increase readability.
+
+### internal improvements:
+- cobalt now preloads server info for quicker access to supported services & loading turnstile on demand.
+- converted all elements and the about page to be translatable in preparations for community-sourced translations *(coming soon!)*.
+- added `content-security-policy` header to restrict and better prevent XSS attacks.
+- moved the turnstile bot check key to the server, making it load the script on the client only if necessary.
+- fixed a bug in the api that allowed for making requests without a valid `Accept` header if authentication wasn't enabled on an instance.
+
+you can also check [all commits since the 10.0 release on github](https://github.com/imputnet/cobalt/compare/08bc5022...f461b02f).
+
+we hope you enjoy this stable update and have a wonderful day!
+
+\~ your friends at imput ❤️
diff --git a/web/changelogs/10.3.md b/web/changelogs/10.3.md
new file mode 100644
index 0000000000000000000000000000000000000000..9adfc6e3e27a68ff02840d4103619899dee3bb15
--- /dev/null
+++ b/web/changelogs/10.3.md
@@ -0,0 +1,101 @@
+---
+title: "fastest cobalt yet, new youtube features, translation platform, and a lot more"
+date: "4 Nov, 2024"
+banner:
+ file: "meowbalt_very_fast.webp"
+ alt: "meowbalt absolutely zooming through space and time (only meowbalt and his speed trail are pictured)."
+---
+
+## oh-so-fast
+starting from this update, cobalt can run several instances in parallel, reducing load on individual instances and making it much faster.
+previously cobalt ran on *only one thread*, and it's honestly impressive that it lasted this long.
+
+we tested cobalt under peak traffic load & same network conditions:
+- initial request processing is now **~14 times faster than before**.
+- starting a tunnel is now **~32 times faster**.
+
+
+
+these tests weren't really scientific as we based them on screen recordings,
+but the point still stands: cobalt no longer slows down and runs as fast as it can.
+
+## youtube improvements
+- added a [new hls option](/settings/video#youtube-hls) that allows for downloading *more formats* of youtube videos.
+- fixed an issue that caused long youtube videos to get abruptly cut off. if you still experience this issue, try enabling the [new hls option](/settings/video#youtube-hls) in settings!
+- added an option to [pick any audio track language](/settings/audio#youtube-dub) for youtube videos in settings. all languages that youtube supports are listed, cobalt will fall back to default if preferred language isn't available.
+- if a [youtube codec](/settings/video#youtube-codec) isn't available, cobalt will now fall back to the next best one.
+
+## meet weblate, a place where you can translate cobalt
+we're finally ready to invite you to translate cobalt to any language you like! your translation contributions are linked to your github account, so you'll show up in cobalt's contributors list.
+
+you can start translating cobalt at [i18n.imput.net](https://i18n.imput.net/) right now!
+
+thank you for showing such an overwhelming amount of interest in making cobalt more accessible around the world, we really appreciate it!
+
+## other service improvements
+- added support for bookmark links from twitter.
+- fixed parsing of some mobile tiktok links.
+- fixed twitter gifs having an incorrect extension in the content picker.
+- fixed a bug that broke downloading older (shorter) links from streamable.
+- fixed video downloading from odnoklassniki (ok.ru).
+
+## ui/ux improvements
+- [always-on file tunneling](/settings/privacy#tunnel) is out of beta! feel free to use it if your isp tracks or filters your internet traffic.
+- redesigned the [community & support page](/about/community), added bluesky and removed support email.
+- improved the debug page: added a button to copy data, added current states, fixed padding. if you're curious, it can be enabled in [advanced settings](/settings/advanced#debug).
+- reduced timeouts on action buttons in security warning popups as they were very annoying before.
+- added a message about cobalt not being fully usable without javascript when the page is loaded without it.
+- improved contrast of all emoji icons on the home/save page.
+- improved contrast of the toggle button.
+- fixed the color of text selection, it's no longer hideous.
+- audio bitrate section now gets greyed out when it's not applicable.
+- fixed cursor state (pointer, arrow, etc) on various buttons.
+- fixed a bug when iphone landscape mode optimizations were applied incorrectly (fix for a bug in ios firefox).
+- various text/phrasing improvements across ui.
+- small padding improvements across ui.
+- other small improvements.
+
+## documentation improvements
+- all [documentation on github](https://github.com/imputnet/cobalt) was majorly improved. all projects and docs are now listed in the main readme. all docs are now easier to read and follow.
+- added a new document outlining all [instance protection methods](https://github.com/imputnet/cobalt/blob/main/docs/protect-an-instance.md) along with step-by-step tutorials on how to configure them.
+- added a tutorial for [configuring a cobalt instance for youtube downloading](https://github.com/imputnet/cobalt/blob/main/docs/configure-for-youtube.md).
+- updated [contribution guidelines](https://github.com/imputnet/cobalt/blob/main/CONTRIBUTING.md).
+- updated [examples](https://github.com/imputnet/cobalt/tree/main/docs/examples) for cookie & docker compose files. we now recommend running cobalt api as **read only** image, as it ensures that it wasn't tampered with. we do it on our servers, too.
+
+## internal improvements for nerds
+- added support for api keys, api instance hosters are now able to limit access to a set of people. you can see [how to configure them on github](https://github.com/imputnet/cobalt/blob/main/docs/protect-an-instance.md#configure-api-keys).
+- cobalt api docker image is now running alpine & node 23. it's also much smaller than before.
+- instances now log whether they were able to load cookies or api keys. no more guessing if your config works or not.
+- updated the console error when cobalt api is configured incorrectly.
+- majorly refactored the youtube module.
+- lots of general api code refactoring.
+- improved settings schema migration on frontend.
+- removed outdated api functions, util scripts, and docs.
+
+## fixed a XSS vulnerability that wasn't exploited
+a malicious cobalt instance could serve links with the javascript: protocol, resulting in XSS when the user tries to download an item from a picker.
+
+as far as we know, this vulnerability was never found and exploited in the wild, but we still urge all frontend instance hosters to **update their instances asap**. cobalt.tools and all other instances that configured CSP correctly weren't affected by this vulnerability.
+
+this issue was fully fixed in [c4be1d3](https://github.com/imputnet/cobalt/commit/c4be1d3a37b0deb6b6087ec7a815262ac942daf1) and [an advisory with CVE was posted on github](https://github.com/imputnet/cobalt/security/advisories/GHSA-cm4c-v4cm-3735).
+
+if you ever discover a security vulnerability in cobalt, please report it responsibly [on github](https://github.com/imputnet/cobalt/security/advisories/new). we'll make sure to fix it as soon as possible!
+
+## where's 10.2?
+we were very excited to release the first part of changes, so we bumped the version early. then, we decided to make cobalt faster, so now we're at 10.3!
+
+*we also silently released changes in prod before the announcement, teehee :3c*
+
+## all changes are on github
+as always, you can check [all commits since the 10.1 release on github](https://github.com/imputnet/cobalt/compare/f461b02...c021293) for even more details.
+
+we hope you enjoy this update as much as you enjoy fresh air, because it really feels like one!
+
+\~ your friends at imput ❤️
\ No newline at end of file
diff --git a/web/changelogs/10.5.md b/web/changelogs/10.5.md
new file mode 100644
index 0000000000000000000000000000000000000000..10776b58b176a163d9b3ca8b7f82f7414ec053e9
--- /dev/null
+++ b/web/changelogs/10.5.md
@@ -0,0 +1,91 @@
+---
+title: "merry christmas and happy new year!"
+date: "23 Dec, 2024"
+banner:
+ file: "newyear2025.webp"
+ alt: "meowth plush in a christmas hat sitting in front of a shiny christmas tree."
+---
+
+## where the elves at?
+we are back once again with another cobalt update, whether you like it or not! just like santa, we come when you least expect us.
+
+we're back to the battlefield against youtube's scraper flattener, but we're winning so far! we even managed to squeeze in a ton of improvements that range from performance bumps to ui overhauls to brand new features. make sure to read further or you might end up on the naughty list...
+
+## even more youtube improvements
+- countless infrastructure improvements and developments that allowed us to keep youtube support available during the worst times.
+- improved youtube codec fallback. now cobalt goes through all codecs to find you the best one!
+- improved youtube video quality selection & fallback.
+
+## improvements for other services
+- added support for loom's video embed links.
+- added support for facebook's mobile subdomain links.
+- fixed a bug in the instagram module where it wouldn't use the graphql api on failure, due to which cobalt was unable to load slightly more posts successfully. now the majority of posts are accessible!
+- removed support for vine because 𝕏, "The Everything App", broke the vine archive.
+- increased performance of downloads from bluesky by using the video cdn directly.
+- error messages from bluesky module are now more descriptive.
+- rewrote the vk video extraction module to use the general api as the web app extraction was broken by a vk update.
+- added support for new vk video links.
+- cobalt now shows an appropriate error if:
+ - soundcloud track is region locked or paywalled.
+ - tiktok post is age restricted or otherwise unavailable.
+ - rutube video is region locked.
+ - vk video is region locked.
+
+*~ still reading? that's impressive. ~*
+
+## web app (and ui/ux) improvements
+- added support for [instance access keys](/settings/instances#access-key)! now you can access private cobalt instances with no turnstile, directly from the web app.
+- redesigned the [remux page](/remux) to indicate better what remuxing does and what it's for.
+- majorly improved the reliability of turnstile. it no longer gets stuck in the background, and cobalt always keeps track of its state and displays it in the omnibox.
+- rewrote almost all error messages in an effort to make them easier to understand at a glance.
+- added more error messages to describe processing issues even better whenever possible.
+- added animations to omnibox icons that make them more lively and cute.
+- improved the toggle animation, made it stretchy and jumpy just like the rest of the ui.
+- made the cobalt web app fully compatible with RTL languages (such as arabic).
+- added an automatically generated sitemap, making the web app easier to index by search engine crawlers.
+- made it way easier to override the selfhosted processing instance in a selfhosted web app.
+- removed an extra security warning in the selfhosted web app which appeared when the processing instance didn't match the default one.
+- added the "community instance" label to the web app that appears on instances different from the official one, making it easier to differentiate them from one another.
+- updated cobalt embed description to be less corny.
+- fixed a bug that caused settings to be exported improperly on ios in PWA mode. now they're extracted via the share api, just like all other files!
+- fixed the weird focus borders in chromium browsers that appeared after a recent browser update.
+- optimized rendering of the _supported services_ popover & updated its animation.
+- improved accessibility of the web app all around.
+- other tiny but mighty changes.
+
+*~ 🦆🔜 ~*
+
+## processing instance improvements
+*(mostly nerd talk)*
+- added support for one more way of youtube authentication: web cookies with poToken & visitorData, so that everyone can access youtube on their instances again!
+- significantly refactored the cookie system for better error resistance.
+- added success and error console messages to indicate whether cookies/keys were loaded successfully.
+- cobalt now warns if it was unable to save updated cookies back to the file.
+- majorly refactored the youtube module and removed unnecessary extra loops.
+- cobalt no longer loads unnecessary data from youtube when not needed.
+- fixed a bug where cobalt tried to proxy URLs on local network when global proxy was configured.
+- fixed a bug that caused some HLS videos to be impossible to download in the "mute" download mode.
+- fixed a bug where cobalt stacked HLS streams several times within itself which caused heavily reduced performance.
+- fixed a bug where cobalt did not use a dispatcher on a HLS stream's chunks, sometimes causing it to access content from an incorrect IP address.
+- refactored automatic testing CI, made service tests easier to manage.
+- reduced docker container privileges to a regular user.
+- improved rich filename & metadata support. all metadata is now added to the file "as-is" with no modifications at all. filenames are now compatible with all operating systems and files should never appear as "tunnel", even in some rare cases.
+
+## more details
+as always, you can check [all commits since the last release on github](https://github.com/imputnet/cobalt/compare/c021293...41430ff) for *literally all changes!*
+
+## thank you!
+our [github repo](https://github.com/imputnet/cobalt) reached over 20k stars recently, and around the same time the cobalt web app reached over 150k daily visitors. both of these numbers are insane to think about, thank you so much for your support!
+
+this is the last big update of 2024, the most transformative and exciting year for cobalt yet.
+we're already working on new cool features that'll come out next year :3
+
+we hope you have amazing holidays and 2025!
+
+\~ your jolly friends at imput 🎄
+
+## donate to imput
+plz [donate](/donate), we as elves work all day and night
+
+
+
diff --git a/web/changelogs/2.0.md b/web/changelogs/2.0.md
new file mode 100644
index 0000000000000000000000000000000000000000..0e778003482dfdab10501dfac43564e6873b6ac4
--- /dev/null
+++ b/web/changelogs/2.0.md
@@ -0,0 +1,20 @@
+---
+title: "everything is new!"
+date: "Jun 28, 2022"
+---
+
+- added support for: bilibili.com, youtube, youtube music, reddit, vk;
+- remade the way downloads are handled;
+- added proper website branding;
+- added settings, donations, and changelog menu;
+- added manual theme picker;
+- added format picker for youtube;
+- added quality picker for youtube and vk downloads (bilibili and twitter later);
+- improved usability;
+- upgraded the download button to be adaptive depending on current status;
+- popups are now adaptive, too;
+- better scalability;
+- took out trash;
+- moved from commonjs to ems;
+- overall revamp of backend and frontend;
+- fixed various issues that were present in older version.
\ No newline at end of file
diff --git a/web/changelogs/2.2.5.md b/web/changelogs/2.2.5.md
new file mode 100644
index 0000000000000000000000000000000000000000..11fde1c03ff61f6b709f4ee3e41e7f06820cffab
--- /dev/null
+++ b/web/changelogs/2.2.5.md
@@ -0,0 +1,17 @@
+---
+title: "remade localization system once again"
+date: "Jul 24, 2022"
+---
+
+- new localization system: fast, dynamic, way more organized
+- localization strings are WAY more descriptive
+- it's now easier to add support for other languages (just one loc file instead of five)
+- localization now falls back to english if localized string isnt available
+- got rid of all static language selectors (probably)
+- slightly updated english and russian strings
+- miscellaneous settings items have been bundled together and moved to the bottom, cause they're used the least
+- bottom links should no longer touch the popup border on overflow
+- rearranged popup order in the rendered page
+- bumped version up to 2.2.5
+
+if you see strings that are like this: !!EXAMPLE!! or withoutspace please file an issue on github
diff --git a/web/changelogs/2.2.6.md b/web/changelogs/2.2.6.md
new file mode 100644
index 0000000000000000000000000000000000000000..02ce1a2993d55dfd3195cf51611d74ab5b452a59
--- /dev/null
+++ b/web/changelogs/2.2.6.md
@@ -0,0 +1,10 @@
+---
+title: "tiktok is back!"
+date: "Jul 28, 2022"
+---
+
+- added support for tiktok (images won't work, they're only accessible through the app)
+- hopefully main input bar is now not rounded on ios, i fucking hate apple
+- if service is not supported, a correlating error will appear, not generic one
+- removed duplicates from config that are present in package json already
+- tiny bit of clean up
\ No newline at end of file
diff --git a/web/changelogs/2.2.8.md b/web/changelogs/2.2.8.md
new file mode 100644
index 0000000000000000000000000000000000000000..ba054adc295fabdfe3ce8112b286ae02ec878a46
--- /dev/null
+++ b/web/changelogs/2.2.8.md
@@ -0,0 +1,7 @@
+---
+title: "faster and more accessible"
+date: "Jul 30, 2022"
+---
+
+- spanish localization by @adrigoomy
+- cobalt should load even faster cause all loaded files are now way smaller (esbuild implementation)
\ No newline at end of file
diff --git a/web/changelogs/2.2.9.md b/web/changelogs/2.2.9.md
new file mode 100644
index 0000000000000000000000000000000000000000..ef54654c3206ec82c7525a3403ac79518ef0fce4
--- /dev/null
+++ b/web/changelogs/2.2.9.md
@@ -0,0 +1,10 @@
+---
+title: "fixes"
+date: "Aug 6, 2022"
+---
+
+- fixed neighbor quality picking for youtube videos
+- webm is now default for youtube downloads for all platforms except for ios
+- even more readme changes
+- a tiny bit of clean up
+- preparing stuff for next major update
\ No newline at end of file
diff --git a/web/changelogs/2.2.md b/web/changelogs/2.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..768094e684bb8113eadd5cdb50419ac71d06c32d
--- /dev/null
+++ b/web/changelogs/2.2.md
@@ -0,0 +1,16 @@
+---
+title: "beginning of 2.2"
+date: "Jul 13, 2022"
+---
+
+- added download popup to solve the issue with downloads on ios
+- merged big and small popups into one
+- made buttons in donation menu act like buttons
+- began to clean up localisation
+- added ability to embed repo url into localisation strings
+- moved ffmpeg args to config for more flexibility (and hopefully future changes)
+- removed error response in stream that could result in a crash
+- removed notice for ios users from about cause it's no longer relevant
+- made error popup look and act like the rest
+- a tiny bit of clean up
+- changelog is now made out of latest commit (and doesn't break)
\ No newline at end of file
diff --git a/web/changelogs/3.0.md b/web/changelogs/3.0.md
new file mode 100644
index 0000000000000000000000000000000000000000..37c2d5e8fdb9f049dcdc872da0aba7c0a40c1733
--- /dev/null
+++ b/web/changelogs/3.0.md
@@ -0,0 +1,46 @@
+---
+title: "everything what you've been waiting for. welcome to cobalt 3.0 :)"
+date: "Aug 12, 2022"
+---
+
+follow cobalt's twitter account for polls, updates, and more: [@justusecobalt](https://twitter.com/justusecobalt)
+
+stuff that you can notice:
+
+- you can now download audio from any supported service, in any format that you set in settings (+). yes, that includes mp3, which you all have been waiting for :D
+- it's now easier to switch between download modes (just a single toggle on the bottom).
+- your youtube download format has been reset, sorry, but that was required to implement all audio downloads.
+- default download format for youtube videos on all platforms is now webm. except for ios.
+
+- cobalt now has emoji, just to spice up the black and white ui. all of them have been tuned to look the best in both themes. isn't it cool?
+- about, changelog, and donation popups have been merged into just one, for covnenience.
+- changelog got a huge upgrade (as you can see), and now there are both major changes and latest commit info, just so commits can finally go back to being batshit insane.
+- changelog popup appears on every major update, but you can disable it in settings, if you want to.
+- changelog now opens by default when pressing "?" button. i don't think anyone reads "about" as often.
+- settings (+) have been split into three tabs, also for convenience and ease of use.
+
+- added support for donation links. you can now donate through boosty, not only via crypto :D
+- donate popup has been rearranged and tuned just a tiny bit.
+
+- you can now click away from any popup by pressing the void behind it.
+- you can also press "escape" key on keyboard to close any popup.
+
+- switchers and buttons are now way easier on eye. white border is gone from where it's unneeded.
+- buttons are now very satisfying to press.
+- switchers are scrollable if there's not enough space to fit all contents on screen.
+- scaling is now even better than before.
+
+internal stuff:
+
+- frontend won't send video related stuff if audio mode is on.
+- matching has, yet again, gone through mitosis, and is now probably the cleanest it can get.
+- page rendering is now modular, something like what frameworks have but way lighter. this makes adding new features WAY easier.
+- removed some stuff that didn't make sense (like storing language of stream request).
+- cleaned up insides of cobalt, of course.
+- almost all links now open in new tab, just like they should have from the very beginning.
+
+known issues:
+- impossible to download audio from vk. i'll try to fix it in the next update.
+- headers are not sticky in tabbed popups. maybe this is a good thing, i'll think about it.
+
+if you ever notice any issues, make sure to report them on github. your report doesn't have to sound professional, just do your best to describe the issue.
\ No newline at end of file
diff --git a/web/changelogs/3.1.md b/web/changelogs/3.1.md
new file mode 100644
index 0000000000000000000000000000000000000000..8b4bff75bd5fa031e5bfe6560989ce577631730f
--- /dev/null
+++ b/web/changelogs/3.1.md
@@ -0,0 +1,11 @@
+---
+title: small quality of life improvements
+date: "Aug 16, 2022"
+---
+
+- tiktok videos can now be downloaded without watermark, you just have to enable it in video settings (+)!
+- you now can pass "u" query to main website to fill out the input area right away (co.wukko.me?u=your_link_here).
+- added ability to select text in certain areas of website.
+- some internal stuff has been cleaned up.
+
+follow cobalt's twitter account for polls, updates, and more: [@justusecobalt](https://twitter.com/justusecobalt)
\ No newline at end of file
diff --git a/web/changelogs/3.2.md b/web/changelogs/3.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..5896eb0591c05ac7d180fa53f4dfa72b22436802
--- /dev/null
+++ b/web/changelogs/3.2.md
@@ -0,0 +1,12 @@
+---
+title: ukrainian localization and new error popup
+date: "Aug 19, 2022"
+---
+
+- added ukrainian localization (thanks to löffel).
+- new error popup! it's now prettier, more compact, and has an easily accessible close button.
+- russian localization has been patched up a bit
+- cleaned up css a bit
+- added github contributors to made with love message.
+- emojis have been tuned to have the same shade of yellow.
+- updated translation guidelines in readme a bit.
\ No newline at end of file
diff --git a/web/changelogs/3.4.md b/web/changelogs/3.4.md
new file mode 100644
index 0000000000000000000000000000000000000000..c781af17fabcafb9c141b4681e596cee7c2bcbf0
--- /dev/null
+++ b/web/changelogs/3.4.md
@@ -0,0 +1,15 @@
+---
+title: tiktok images and better localization
+date: "Sep 3, 2022"
+---
+
+- added ability to save images from tiktok conveniently, and without watermarks.
+- it's now way easier to contribute translations to cobalt. read more on how to do it [on github](https://github.com/imputnet/cobalt#how-to-contribute-translations). in short, you don't need to fork the repo anymore, everything is handled through crowdin :D
+- updated readme in github repo to make it easier to read and understand.
+- began to add more descriptive errors, more to come soon.
+
+internal stuff:
+- remade entirety of tiktok module and merged it with douyin one. now both (basically identical) platforms have perfect parity of download features.
+- cleaned up the twitter module, now it's way more compact and easy to read.
+- moved changelog out of english localization.
+- other small improvements and fixes.
\ No newline at end of file
diff --git a/web/changelogs/3.5.2.md b/web/changelogs/3.5.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..22e8f91651be3171a64a6ba79a128cd17d46184d
--- /dev/null
+++ b/web/changelogs/3.5.2.md
@@ -0,0 +1,17 @@
+---
+title: "vk clips support, improved changelog system, and less bugs"
+date: "Sep 11, 2022"
+---
+new features:
+- added support for vk clips. cobalt now lets you download even more cringy videos!
+- added update history right to the changelog menu. it's not loaded by default to minimize page load time, but can be loaded upon pressing a button. probably someone will enjoy this.
+- as you've just read, cobalt now has on-demand blocks. they're rendered on server upon request and exist to prevent any unnecessary clutter by default. the first feature to use on-demand rendering is history of updates in changelog tab.
+
+changes:
+- moved twitter entry to about tab and made it localized.
+- added clarity to what services exactly are supported in about tab.
+
+bug fixes:
+- cobalt should no longer crash to firefox users if they love to play around with user-agent switching.
+- vk videos of any resolution and aspect ratio should now be downloadable.
+- vk quality picking has been fixed after vk broke it for parsers on their side.
\ No newline at end of file
diff --git a/web/changelogs/3.5.4.md b/web/changelogs/3.5.4.md
new file mode 100644
index 0000000000000000000000000000000000000000..a0e863b04106f7982b1befba570f06bb8cf8fcea
--- /dev/null
+++ b/web/changelogs/3.5.4.md
@@ -0,0 +1,6 @@
+---
+title: "tiktok support is back :D"
+date: "Sep 21, 2022"
+---
+you can download videos, sounds, and images from tiktok again!
+huge thank you to [@minzique](https://github.com/minzique) for finding another api endpoint that works.
\ No newline at end of file
diff --git a/web/changelogs/3.5.md b/web/changelogs/3.5.md
new file mode 100644
index 0000000000000000000000000000000000000000..834d7a046340d177fc3ed6f2e2dbcde241618a71
--- /dev/null
+++ b/web/changelogs/3.5.md
@@ -0,0 +1,20 @@
+---
+title: "ui revamp and usability improvements"
+date: "Sep 8, 2022"
+---
+new features:
+- cobalt now lets you paste the link in your clipboard and download the file in a single press of a button.if your clipboard's latest content isn't a valid url, cobalt won't process or paste it. you can also hide the clipboard button in settings if you want to.
+unfortunately, the clipboard feature is not available to firefox users because mozilla didn't add proper support for clipboard api.
+- there's now a button to quickly clean the input area, right next to download button. it's really useful in case when you want to quickly save a bunch of videos and don't want to bother selecting text.
+- keyboard shortcuts! you love them, i love them, and now we can use them to perform quick actions in cobalt. use ctrl+v combo to paste the link without focusing the input area; press escape key to close the active popup or clean the input area; and if you didn't know, you can also press enter to download content from the link.
+
+new looks:
+- main box has been revamped. it has lost its border, thick padding, and now feels light and fresh.
+- download button is now prettier, and has been tuned to make >> look just like the logo.
+- buttons on the bottom now actually look like buttons and are way more descriptive. no more #@+?$ bullshit. it's way easier to see and understand what each of them does.
+- bottom buttons are prettier and easier to use on a phone. they're bigger and stretch out to sides, making them easier to press.
+
+fixes:
+- it's now impossible to overlap multiple popups at once. no more mess if you decide to explore popups while waiting for request to process.
+- popup tabs have been slightly moved down to prevent popup content overlapping.
+- ui scalability has been improved.
\ No newline at end of file
diff --git a/web/changelogs/3.6.3.md b/web/changelogs/3.6.3.md
new file mode 100644
index 0000000000000000000000000000000000000000..80150494c7e6e5496fb46441663f85a8768aad4f
--- /dev/null
+++ b/web/changelogs/3.6.3.md
@@ -0,0 +1,15 @@
+---
+title: "less disturbance"
+date: "Oct 5, 2022"
+---
+changelog popup no longer annoys you after a major update! this action has been replaced with a notification dot. if you see a red dot, then there's something new.
+
+your old setting that disabled the changelog popup now applies to notifications.
+
+new users will see a notification dot instead of an about popup, too. this was mostly done to prevent complications if your browser is set up to clean local storage when you close it.
+
+other changes:
+- popups are now a bit wider, just so more content fits at once.
+- better interface scaling.
+- code is a bit cleaner now.
+- changed twitter api endpoint. there should no longer be any rate limits.
\ No newline at end of file
diff --git a/web/changelogs/3.6.md b/web/changelogs/3.6.md
new file mode 100644
index 0000000000000000000000000000000000000000..cc494e7fa3974c60a48412b3f1452f38b2dc5e08
--- /dev/null
+++ b/web/changelogs/3.6.md
@@ -0,0 +1,11 @@
+---
+title: "improvements all around!"
+date: "Sep 28, 2022"
+---
+- download mode switcher is moving places, it's now right next to link input area.
+- smart mode has been renamed to auto mode, because this name is easier to understand.
+- all spacings in ui have been evened out. no more eye strain.
+- added support for twitter /video/1 links
+- clipboard button exception has been redone to prepare for adoption of readtext clipboard api in firefox.
+- cobalt is now using different tiktok api endpoint, because previous one got killed, just like the one before.
+- "other" settings tab has been cleaned up.
\ No newline at end of file
diff --git a/web/changelogs/3.7.md b/web/changelogs/3.7.md
new file mode 100644
index 0000000000000000000000000000000000000000..ada689d729262ee2803a3e3c7fad233daf2d6203
--- /dev/null
+++ b/web/changelogs/3.7.md
@@ -0,0 +1,19 @@
+---
+title: "support for multi media tweets is here!"
+date: "Oct 9, 2022"
+---
+cobalt now lets you save any of the videos or gifs in a tweet. even if there are many of them.
+
+simply paste a link like you'd usually do and cobalt will ask what exactly you want to save.
+
+FIREFOX USERS: if you have strict tracking protection on, you might wanna turn it off for cobalt, or else twitter video previews won't load. firefox filters out twitter image cdn as if it was a tracker, which it's not. it's a false-positive.
+
+however, you can leave it on if you're fine with blank squares and video numbers. i have thought of that in prior, you're welcome.
+
+other changes:
+- repurposed ex tiktok-only image picker to be dynamic and adapt depending on content to pick. that's exactly how twitter multi media downloads work.
+- cobalt is now properly viewable on phones with tiny screens, such as first gen iphone se.
+- scrollbars now should be visible only where they're needed.
+- brought back proper twitter api, because other one doesn't have multi media stuff (at least yet).
+- cleaned up some internal files, including main frontend js file.
+- reorganized some files in project directory, now you won't get lost when contributing or just looking through cobalt's code.
\ No newline at end of file
diff --git a/web/changelogs/4.0.md b/web/changelogs/4.0.md
new file mode 100644
index 0000000000000000000000000000000000000000..cad320814f86a3f842de7856115b9fc96b809b2b
--- /dev/null
+++ b/web/changelogs/4.0.md
@@ -0,0 +1,23 @@
+---
+title: "better and faster than ever"
+date: "Oct 24, 2022"
+---
+this update has a ton of improvements and new features.
+
+changes you probably care about:
+- cobalt now has support for recorded twitter spaces! download the previous conversation no matter how long it was.
+- download speeds from youtube are at least 10 times better now. you're welcome.
+- both video and audio length limits have been extended to 2 hours.
+- audio downloads from youtube, youtube music, twitter spaces, and soundcloud now have metadata! most often it's just title and artist, but when cobalt is able to get more info, it adds that metadata too.
+- tiktok downloads have been fixed, yet again, and if they ever break in the future, cobalt will fall back to downloading a less annoyingly watermarked video.
+- soundcloud downloads have been fixed, too.
+
+less notable changes:
+- currently experimenting with using mp3 as default audio format. if you set something other than mp3 before, it'll be set to mp3. you can always change it back in settings. let me know what you think about this.
+- "download audio" button from image picker no longer stays on the screen after popup was closed.
+- clipboard button now shows up depending on your browser's support for it.
+- you can no longer manually hide the clipboard button, 'cause it's unnecessary.
+- small internal improvements such as separation of changelog version and title.
+- fair bit of internal clean up.
+
+if you want to help me implement covers for downloaded audios, [you can do it on github](https://github.com/imputnet/cobalt).
\ No newline at end of file
diff --git a/web/changelogs/4.1.md b/web/changelogs/4.1.md
new file mode 100644
index 0000000000000000000000000000000000000000..3d6e2af946df5cfd63e6eafb65052cc58f1bd2ae
--- /dev/null
+++ b/web/changelogs/4.1.md
@@ -0,0 +1,11 @@
+---
+title: "better tiktok image downloads"
+date: "Oct 27, 2022"
+---
+here's what's up:
+- tiktok images are saved as .jpeg instead of .webp (finally, i know).
+- added support for image downloads from douyin.
+- fixed tiktok audio downloads from the image picker.
+- emoji in about button now changes on special occasions. be it halloween or christmas, cobalt will change just a tiny bit to fit in :D
+
+if you're not caught up with new stuff in cobalt 4.x yet, check out the previous changelog down below. there's a ton of stuff to like.
\ No newline at end of file
diff --git a/web/changelogs/4.2.md b/web/changelogs/4.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..f9dd460975c448949bfe2c128542f0ff9ebffcec
--- /dev/null
+++ b/web/changelogs/4.2.md
@@ -0,0 +1,16 @@
+---
+title: "optimized quality picking and 8k video support"
+date: "Nov 4, 2022"
+---
+- this update fixes quality picking that was accidentally broken in 4.0 update.
+- you now can download videos in 8k from youtube. why would you that? no idea. but i'm more than happy to give you this option.
+- default video quality for downloads from pc is now 1440p, and 720p for phones.
+- default video format is now mp4 for everyone.
+- default audio format is now mp3 for everyone.
+
+you can always change new defaults back to whatever you prefer in settings.
+
+other changes:
+- added more clarity to quality picker description.
+- youtube video codecs are now right in the picker.
+- setup script is now easier to understand.
\ No newline at end of file
diff --git a/web/changelogs/4.3.2.md b/web/changelogs/4.3.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..07e8a6a529500c910dfe88feb924dd67842bb25a
--- /dev/null
+++ b/web/changelogs/4.3.2.md
@@ -0,0 +1,14 @@
+---
+title: "twitter improvements & changelog overhaul"
+date: "Nov 15, 2022"
+---
+- you can download explicit content from twitter.
+- direct video links from twitter are properly supported (video/1, video/2, etc.).
+- changelog history got support for banners.
+- changelog categories are not messy anymore.
+- cobalt version in changelogs is now highlighted.
+- changelog history got separators to make text easier to read.
+- changelog history can be collapsed after loading.
+- download button takes less time to change back to pressable state.
+
+if you're a developer and would like to play around with cobalt's api, then read more about it in older changelogs below!
\ No newline at end of file
diff --git a/web/changelogs/4.3.md b/web/changelogs/4.3.md
new file mode 100644
index 0000000000000000000000000000000000000000..0d66468cefecd63b64e3665efce41d9cbd3452a7
--- /dev/null
+++ b/web/changelogs/4.3.md
@@ -0,0 +1,28 @@
+---
+title: "developers, developers, developers, developers"
+date: "Nov 12, 2022"
+banner:
+ file: "developers.webp"
+ alt: "steve ballmer going \"developers, developers, developers\""
+---
+this update features a TON of improvements.
+
+[developers](https://www.youtube.com/watch?v=SaVTHG-Ev4k), you now can rely on cobalt for getting content from social media. the api has been revamped and [documentation](https://github.com/imputnet/cobalt/tree/main/docs/api.md) is now available. you can read more about API changes down below. go crazy, and have fun :D
+
+if you're not a developer, here's a list of changes that you probably care about:
+- rate limit is now approximately 8 times bigger. no more waiting, even if you want to download entirety of your tiktok "for you" page.
+- some updates will now have expressive banners, just like this one.
+- fixed what was causing an error when a youtube video had no description.
+- mp4 format button text should now be displayed properly, no matter if you touched the switcher or not.
+
+next, the star of this update — improved api!
+- main endpoint now uses POST method instead of GET.
+- internal variables for preferences have been updated to be consistent and easier to understand.
+- ip address is now hashed right upon request, not somewhere deep inside the code.
+- global stream salt variable is no longer unnecessarily passed over a billion functions.
+- url and picker keys are now separate in the json response.
+- cobalt web app now correctly processes responses with "success" status.
+
+if you currently have a siri shortcut or some other script that uses the GET method, make sure to update it soon. this method is deprecated, limited, and will be removed entirely in coming updates.
+
+if you ever make something using cobalt's api, make sure to mention [@justusecobalt](https://twitter.com/justusecobalt) on twitter, i would absolutely love to see what you made.
\ No newline at end of file
diff --git a/web/changelogs/4.4.md b/web/changelogs/4.4.md
new file mode 100644
index 0000000000000000000000000000000000000000..06e001985523bacee562f259154653905d9930cb
--- /dev/null
+++ b/web/changelogs/4.4.md
@@ -0,0 +1,13 @@
+---
+title: "over 1 million monthly requests. thank you."
+date: "Nov 20, 2022"
+banner:
+ file: "onemillionr.webp"
+ alt: "cobalt logo and a confetti emoji"
+---
+this is a huge milestone for me, i cannot express enough how grateful i am for each and every one of you.
+thank you for using cobalt, and thank you for showing that people love the web that's friendly and bullshit-free. i'm hoping to never disappoint you in the future and keep up the good work.
+
+thank you <3
+
+if you want to thank ME, check out the renovated donations tab, which now is also linked alongside bottom action buttons.
\ No newline at end of file
diff --git a/web/changelogs/4.5.md b/web/changelogs/4.5.md
new file mode 100644
index 0000000000000000000000000000000000000000..a15c10da75bd5deaba42431f425dbc155d459af0
--- /dev/null
+++ b/web/changelogs/4.5.md
@@ -0,0 +1,34 @@
+---
+title: "better, faster, stronger, stable"
+date: "Dec 6, 2022"
+banner:
+ file: "meowthstrong.webp"
+ alt: "meowth stretching"
+---
+your favorite social media downloader just got even better! this update includes a ton of improvements and fixes.
+
+in fact, there are so many changes, i had to split them in sections.
+
+service-related improvements:
+- vimeo module has been revamped, all sorts of videos should now be supported.
+- vimeo audio downloads! you now can download audios from more recent videos.
+- cobalt now supports all sorts of tumblr links. (even those scary ones from the mobile app)
+- vk clips support has been fixed. they rolled back the separation of videos and clips, so i had to do the same.
+- youtube videos with community warnings should now be possible to download.
+user interface improvements:
+- list of supported services is now MUCH easier to read.
+- banners in changelog history should no longer overlap each other.
+- bullet points! they have a bit of extra padding, so it makes them stand out of the rest of text.
+internal improvements:
+- cobalt will now match the link to regex when using ?u= query for autopasting it into input area.
+- better rate limiting: limiting now is done per minute, not per 20 minutes. this ensures less waiting and less attack area for request spammers.
+- moved to my own fork of ytdl-core, cause main project seems to have been abandoned. go check it out on [github](https://github.com/wukko/better-ytdl-core) or [npm](https://www.npmjs.com/package/better-ytdl-core)!
+- ALL user inputs are now properly sanitized on the server. that includes variables for POST api method, too.
+- "got" package has been (mostly) replaced by native fetch api. this should greatly reduce ram usage.
+- all unnecessary duplications of module imports have been gotten rid of. no more error passing strings from inside of service modules. you don't make mistakes only if you don't do anything, right?
+- other code optimizations. there's less clutter overall.
+huge update, right? seems like everything's fixed now?
+
+nope, one issue still persists: sometimes youtube server drops packets for an audio file while cobalt's rendering the video for you. this results in abrupt cuts of audio. if you want to help solving this issue, [please feel free to do it on github!](https://github.com/imputnet/cobalt/issues/62)
+
+thank you for reading this, and thank you for sticking with cobalt and me.
\ No newline at end of file
diff --git a/web/changelogs/4.6.md b/web/changelogs/4.6.md
new file mode 100644
index 0000000000000000000000000000000000000000..89a16380c39fb7d106bf70546ecf2e5dbef5ef65
--- /dev/null
+++ b/web/changelogs/4.6.md
@@ -0,0 +1,26 @@
+---
+title: "mute videos and proper soundcloud support"
+date: "Dec 17, 2022"
+banner:
+ file: "shutup.webp"
+ alt: "a cat yawning, with a crossed out loudspeaker icon next to it"
+---
+i've been longing to implement both of these things, and here they finally are.
+
+service-related improvements:
+- you now can download videos with no audio! simply enable the "mute audio" option in settings > audio.
+- soundcloud module has been updated, and downloads should no longer break after some time.
+visual improvements:
+- moved some things around in settings popup, and added separators where separation is needed.
+- updated some texts in english and russian.
+- version and commit hash have been joined together, now they're a single unit.
+internal improvements:
+- updated api documentation to include isAudioMuted.
+- simplified the startup message.
+- created render elements for separator and explanation due to high duplication of them in the page.
+- fully deprecated GET method for API requests.
+- fixed some code quirks.
+here's how soundcloud downloads got fixed:
+
+previously, client_id was (stupidly) hardcoded. that means cobalt wasn't able to fetch song data if soundcloud web app got updated.
+now, cobalt tries to find the up-to-date client_id, caches it in memory, and checks if web app version has changed to update the id accordingly. you can see this change for yourself on github.
\ No newline at end of file
diff --git a/web/changelogs/4.7.md b/web/changelogs/4.7.md
new file mode 100644
index 0000000000000000000000000000000000000000..089b135802d2eefd3b28d709fe8e390f91ec31bb
--- /dev/null
+++ b/web/changelogs/4.7.md
@@ -0,0 +1,39 @@
+---
+title: "we're better together! thank you for bug reports."
+date: "Jan 13, 2023"
+banner:
+ file: "bettertogether.webp"
+ alt: "various different pokémon jumping in happiness"
+---
+this update includes a bunch of improvements, many of which were made thanks to the community :D
+
+service-related improvements:
+- private soundcloud links are now supported (#68);
+- tiktok usernames with dots in them no longer confuse cobalt (#71);
+- .ogg files no longer wrongfully include a video channel (#67);
+- fixed an issue that caused cobalt to freak out when user attempted to download an audio from audio-only service with "mute video" option enabled.
+
+ui improvements:
+- popup padding has been evened out. popups are now able to fit in more information on scroll, especially on mobile;
+- all buttons are now of even size and are displayed without any padding issues across all modern browsers and devices;
+- checkbox is no longer crippled on ios;
+- many explanation texts have been simplified to get rid of unnecessary bloat (no bullshit, remember?);
+- moved tiktok section in video settings higher due to higher priority;
+- fixed unexpectedly displayed scrollbars on switch rows in firefox.
+
+stability improvements:
+- ffmpeg process now should end upon finishing the render;
+- ffmpeg should also quit when download is abruptly cut off;
+- fixed a memory leak that was caused by misconfigured stream information caching (#63).
+
+internal improvements:
+- requested streams are now stored in cache for 2 minutes instead of 1000 hours (yes, 1000 hours, i fucked up);
+- cached data is now reused if user requests same content within 2 minutes;
+- page render module is now even cleaner than before;
+- proper support for bullet-points in loc strings.
+
+you can suggest features or report bugs on [github](https://github.com/imputnet/cobalt) or [twitter](https://twitter.com/justusecobalt). both work just fine, use whichever you're more comfortable with.
+
+thank you for using cobalt, and thank you for reading this changelog.
+
+you're amazing, keep it up :)
\ No newline at end of file
diff --git a/web/changelogs/4.8.md b/web/changelogs/4.8.md
new file mode 100644
index 0000000000000000000000000000000000000000..ae0118dc7f0591108e6dc6b153215ece2ad4c1a9
--- /dev/null
+++ b/web/changelogs/4.8.md
@@ -0,0 +1,31 @@
+---
+title: "prettier than ever"
+date: "Jan 29, 2023"
+banner:
+ file: "catmakeup.webp"
+ alt: "a cat being brushed with a powder makeup brush"
+---
+this version brings many visual improvements and a completely revamped "about" tab.
+
+what's new in "about" tab:
+- all information is now split into collapsible sections, making it easier to navigate.
+- added privacy policy to further prove that none of your data is collected.
+- added emoji to the page title to make it look consistent with other pages.
+- added mastodon account handle and link.
+- there are now short notes at the end of each section.
+- other changes that are too small to describe. just go check it out!
+
+visual improvements:
+- less wasted space: paddings and margins have been reduced and optimized for usability, consistency, and overall beauty.
+- all [links](https://youtu.be/dQw4w9WgXcQ) are now in italic. it's much easier to tell them apart from regular highlights.
+- error popup no longer looks broken and out of place.
+- download popup now has a proper close button, not something from 2.x era.
+- emoji are no longer selectable or draggable.
+- better scalability: desktop layout for home screen is shown if device viewport is wide enough to fit in three action buttons.
+- page shouldn't look broken on phones in landscape mode (i still highly recommend using cobalt in portrait mode).
+- removed bulletpoint padding. it was unnecessary.
+- updated some service names.
+
+as always, you can suggest features or report bugs on any platform listed in the "support" section of about tab.
+
+thank you for using cobalt. i hope you have a good day :)
\ No newline at end of file
diff --git a/web/changelogs/5.0.md b/web/changelogs/5.0.md
new file mode 100644
index 0000000000000000000000000000000000000000..b25992110ec58d77e9c4af625c64f28f30e1fd0c
--- /dev/null
+++ b/web/changelogs/5.0.md
@@ -0,0 +1,42 @@
+---
+title: "it's all about attention to detail!"
+date: "Feb 13, 2023"
+banner:
+ file: "valentines.webp"
+ alt: "relaxed meowth with sakura petals falling in front of them"
+---
+happy valentine's day! i have an update for you, as a gift :D
+
+tl;dr: added support for reddit gifs, fixed douyin downloads, fixed vimeo quality picking, revamped entirety of codebase, and many other fixes.
+
+here's more info:
+
+this update is mostly about cleaning up and polishing the codebase, but it also has some new features. here's what's up:
+
+service-related improvements:
+- you now can download gifs from reddit!
+- attempting to download a video from douyin no longer throws an error (bytedance changed the api endpoint, yet again).
+- fixed quality picking for vimeo downloads.
+- fixed length limit check in vimeo module.
+- fixed support for "user view" vk clips links.
+- various twitter errors are now displayed correctly instead of falling back to the default error.
+- state of all services is now tested on each commit.
+
+ui improvements:
+- cobalt social links no longer disappear if you have an aggressive ad blocking extension installed.
+- various localization improvements for both english and russian.
+- changed some service aliases to display full list of supported downloads.
+- added current branch information to version text (in settings).
+- fixed typos in older changelogs.
+
+internal improvements:
+- everything has been sanitized, improved, and refactored. code is now much easier to read and maintain.
+- rewrote and/or optimized all modules that were messy or inefficient.
+- all git interaction functions now store info in cache instead of fetching it every time the function is called.
+- added a test script that checks functionality of all supported services.
+- updated deepsource config. checks are more accurate now.
+- requests from internet explorer are now dropped entirely instead of redirecting people stuck in 90s to a proper browser download page. this was done to avoid (my) personal bias towards browsers.
+
+i put a ton of effort into this version, and i hope you like it as much as i do.
+
+thank you for using cobalt. there's so much more to come :)
\ No newline at end of file
diff --git a/web/changelogs/5.1.md b/web/changelogs/5.1.md
new file mode 100644
index 0000000000000000000000000000000000000000..27b0c37be13645354c1d3cbdaf5a6750e8ec4d80
--- /dev/null
+++ b/web/changelogs/5.1.md
@@ -0,0 +1,47 @@
+---
+title: "the evil has been defeated"
+date: "Feb 26, 2023"
+banner:
+ file: "happymeowth.webp"
+ alt: "meowth jumping up into the sky very excitedly"
+---
+hey, ever wanted to download a youtube video without a hassle? cobalt is here to help. this update fixes all issues related to youtube downloads.
+not only that, but it also introduces features never before seen in a downloader, such as youtube dub downloads! read below to see what's up :)
+
+tl;dr:
+
+- audio in youtube videos FINALLY no longer gets cut off.
+- you now can pick any video resolution you want (from 360p to 8k) and any possible youtube video codec (h264/av1/vp9).
+- you now can download youtube videos with dubs in your native language. just check settings > audio.
+- youtube processing has been vastly sped up.
+
+ok, now onto the nerdy part of changelog. this update is pretty huge and includes improvements across the board.
+
+service improvements:
+- all youtube functionality has been reworked. cobalt now relies on innertube apis, not web scraping.
+- random audio cut off issue has been fixed, let me know if it ever occurs again. (closes #62, #66, #75, #88).
+- added support for youtube dubs. currently it's using your browser's default language when enabled, but i have plans on making a picker. i'll ask people on twitter and mastodon if this feature is needed, and add a picker in next updates.
+- instead of adding more quality presets, i added granular quality options. pick whatever you like, from 360p up to 4320p (for all services, not just youtube).
+- replaced a format picker with codec picker for youtube. you can pick h264, av1, or vp9. all of them should work as expected (closes #88).
+- youtube audio files are now properly matched to corresponding video files.
+- it's now always possible to download pristine h264 720p/360p videos from youtube. these videos will work ANYWHERE, so they're default for mobile.
+- youtube requests are no longer permanently cached, ram usage should drop even further.
+- youtube video and audio file names now include codec and dub language when applicable.
+- max video and audio duration limits have been bumped up to 3 hours.
+- general performance of entire youtube download process has been greatly improved.
+- vk module has been reworked to be more compact and not make use of outdated technique of quality picking. should also be way more reliable.
+
+internal improvements:
+- cleaned up services config, all constants have been moved directly to modules for quicker access.
+- matching module has been slightly cleaned up.
+
+interface improvements:
+- many descriptions and error messages have been slightly tuned to be less wordy.
+- unnecessary title duplications in settings have been merged into one.
+- added more clarity to quality and codec descriptions.
+
+if you use cobalt api, please note that you have to update your creation to support new features.
+
+this is the second batch of 5.x improvements, there's way more to come. thank you for being here, i really appreciate your support.
+
+if you want to thank me (the developer), there's a nice tab under this changelog that has "donations" text on it. anything helps me continue developing and hosting the friendliest media downloader :D
\ No newline at end of file
diff --git a/web/changelogs/5.2.md b/web/changelogs/5.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..36fd9ea175fc58328de9a1ba078d78b5687dd652
--- /dev/null
+++ b/web/changelogs/5.2.md
@@ -0,0 +1,54 @@
+---
+title: "fastest one in the game"
+date: "Mar 24, 2023"
+banner:
+ file: "catspeed.webp"
+ alt: "a cat running very fast in an exercise wheel"
+---
+hey, notice anything different? well, at very least the page loaded way faster! this update includes many improvements and fixes, but also some new features.
+
+tl;dr:
+
+- twitter retweet links are now supported.
+- all vimeo videos should now be possible to download.
+- you now can download audio from vimeo.
+- it's now possible to pick between preferred vimeo download method in settings.
+- fixed issues related to tiktok, twitter, twitter spaces, and vimeo downloads.
+- overall cobalt performance should be MUCH better.
+
+service improvements:
+- added support for twitter retweet links. now all kinds of tweet links are supported.
+- fixed the issue related to periods in tiktok usernames (#96).
+- fixed twitter spaces downloads.
+- added support for audio downloads from vimeo.
+- added ability to choose between "progressive" and "dash" vimeo downloads. go to settings > video to pick your preference.
+- fixed the issue related to vimeo quality picking.
+- fixed the issue when vimeo module wouldn't show appropriate errors and instead would fallback to default ones.
+- improved audio only downloads for some edge cases.
+- (hopefully) better youtube reliability.
+- temporarily disabled douyin support due to api endpoint cut off.
+
+interface improvements:
+- merged clipboard and mode switcher rows into one for mobile view.
+- added left-handed layout toggle for those who prefer to have the clipboard button on left.
+- new custom-made clipboard icon. now it clearly indicates what it does.
+- improved english and russian localization. both are way more direct and less bloaty.
+- frontend page is now rendered once and is cached on disk instead of being rendered every time someone requests a page. this greatly improves page loading speeds and further reduces strain put on the server.
+- frontend page is now minimized just like js and css files. this should minimize traffic wasted on loading the page, along with minor loading speed improvement.
+- added proper checkbox icon for better clarity.
+- checkboxes are now stretched edge-to-edge on phone to be easier to manage for right-handed people.
+- removed button hover highlights on phones.
+- fixed button press animations for safari on ios.
+- fixed text selection on ios. previously you could select text or images anywhere, but now they're selectable in limited places, just like on other platforms.
+- frontend platform is now marked in settings: p is for pc; m is for mobile; i is for ios. this is done for possible future debugging and issue-solving.
+- better error messaging.
+
+internal improvements:
+- better rate limiting, there should be way less cases of accidental limits.
+- added support for m3u8 playlists. this will be useful for future additions, and is currently used by vimeo module.
+- added support for "chop" stream format for vimeo downloads.
+- fixed vk user id extraction. i assumed the - in url was a separator, but it's actually a part of id.
+- completely reworked the vimeo module. it's much cleaner and better performant now.
+- minor clean ups across the board.
+
+not really related to this update, but thank you for 50k monthly users! i really appreciate that you're still here, because that means i'm doing some things right :D
\ No newline at end of file
diff --git a/web/changelogs/5.3.md b/web/changelogs/5.3.md
new file mode 100644
index 0000000000000000000000000000000000000000..1aa2adb97cbc8e6059dc21c621a827d2bcb1a8c9
--- /dev/null
+++ b/web/changelogs/5.3.md
@@ -0,0 +1,21 @@
+---
+title: "better looks, better feel"
+date: "Apr 3, 2023"
+banner:
+ file: "cattired.webp"
+ alt: "a cat laying on a sofa face down, wiggling its tail"
+---
+this update isn't as big as previous ones, but it still greatly enhances the cobalt experience.
+
+here's what's up:
+- new mode switcher! elegant and 100% clear. should no longer cause any confusion. let me know if you like it better this way :D
+- wide paste button on mobile is back, but now it's even closer to your finger.
+- removed the weird grey chin on changelog banners.
+- removed left-handed layout toggle since it is no longer needed.
+- fixed input area display in chromium 112+.
+- centered the main action box.
+- cleaned up css of main action box to get rid of tricks and ensure correct display on all devices.
+- fixed a bug that'd cause notifications dots to disappear when an unrelated checkbox was checked.
+
+hopefully from now on i'll focus on adding support for more services.
+thank you for using cobalt. stay cool :)
\ No newline at end of file
diff --git a/web/changelogs/5.4.md b/web/changelogs/5.4.md
new file mode 100644
index 0000000000000000000000000000000000000000..d5e08241eba30df2736c65f1928c53db4e8e0c41
--- /dev/null
+++ b/web/changelogs/5.4.md
@@ -0,0 +1,36 @@
+---
+title: "instagram support, docker, and more!"
+date: "Apr 24, 2023"
+banner:
+ file: "catphonestand.webp"
+ alt: "a cat holding a phone under its chin while a person plays clash of clans on it"
+---
+something many of you've been waiting for is finally here! try it out and let me know what you think :)
+
+tl;dr:
+
+- added experimental instagram support! download any reels or videos you like, and make sure to report any issues you encounter. yes, you can convert either to audio.
+- fixed support for on.soundcloud links.
+- added share button to "how to save?" popup.
+- added docker support.
+
+service improvements:
+- added experimental support for videos from instagram. currently only reels and post videos are downloadable, but i'm looking into ways to save high resolution photos too. if you experience any issues, please report them on either of support platforms.
+- fixed support for on.soundcloud share links. should work just as well as other versions!
+- fixed an issue that made some youtube videos impossible to download.
+
+interface improvements:
+- new css-only checkmark! yes, i can't stop tinkering with it because slight flashing on svg load annoyed me. now it loads instantly (and also looks slightly better).
+- fixed copy animation.
+- minor localization improvements.
+- fixed the embed logo that i broke somewhere in between 5.3 and 5.4.
+
+internal improvements:
+- now using nanoid for live render stream ids.
+- added support for docker. it's kind of clumsy because of how i get .git folder inside the container, but if you know how to do it better, feel free to make a pr.
+- cobalt now checks only for existence of environment variables, not exactly the .env file.
+- changed the way user ip address is retrieved for instances using cloudflare.
+- added ability to disable cors, both to setup script and environment variables.
+
+i can't believe how diverse and widespread cobalt has become. it's used in all fields: music production, education, content creation, and even game development. thank you. this is absolutely nuts.
+if you don't mind sharing, please tell me about your use case. i'd really love to hear how you use cobalt and how i could make it even more useful for you.
\ No newline at end of file
diff --git a/web/changelogs/6.0.md b/web/changelogs/6.0.md
new file mode 100644
index 0000000000000000000000000000000000000000..3d9d1c1978da57c2caafbed7a9b55250608de332
--- /dev/null
+++ b/web/changelogs/6.0.md
@@ -0,0 +1,69 @@
+---
+title: "better reliability, new infrastructure, pinterest support, and way more!"
+date: "June 7, 2023"
+banner:
+ file: "catswitchboxes.webp"
+ alt: "a cat climbing into two empty boxes of asahi beer"
+---
+hey! long time no see, hopefully over 40 changes will make up for it :)
+
+cobalt now has an official community discord server. you can go there for news, support, or just to chat. [go check it out!](https://discord.gg/pQPt8HBUPu)
+
+tl;dr
+
+- new infra, new hosting structure, new main instance api url. developers, [get it here](https://github.com/imputnet/cobalt/blob/main/docs/api.md).
+- added support for pinterest, vine archive, tumblr audio, youtube vr videos.
+- better web app performance and look.
+- better stability thanks to load balancing.
+- (hopefully) no more random video/audio download drops.
+
+service improvements:
+- added support for pinterest videos and stories (pr by [@Snazzah](https://github.com/imputnet/cobalt/commit/40291c4d24cb5f441cdddfd26104f149bc4ee27c)).
+- added support for tumblr audio. sorry, tumblr.
+- added support for youtube vr videos. please note that they're in youtube's proprietary ratio.
+- added support for vine archive.
+- added support for ancient vk videos in 240p.
+- fixed an issue related to muted video downloads from tumblr.
+- moved to twitter v2 api.
+- soundcloud share links are now processed without errors.
+
+ui improvements:
+- lazy image loading. should significantly speed up the page load.
+- fixed checkbox width on mobile devices.
+- addition of a temporary urgent notice.
+- added hover border to all buttons.
+- less annoying donation button highlight.
+- more consistent color scheme.
+- added link to a discord server into about popup.
+- remember celebratory emoji changes? they've been fixed, and are now dynamically loaded!
+- changelog history now lets you try to load it again if first attempt failed for whatever reason.
+- padding (everywhere) has been slightly reduced to fit in more content and be consistent across ui.
+- added more info to the "how to save" popup for ios devices.
+- crypto wallet press-to-copy buttons now look like buttons.
+- improved ui layout for smallest screens (iphone 5, 5s, se, etc).
+- removed partial translations for sake of clarity and consistency.
+
+internal improvements:
+- separated web and api servers. they're now completely independent and therefore more stress-resistant.
+- added a dedicated script for building the web app if you don't want to reload the frontend server.
+- web app building improvements.
+- async localization preloading.
+- consistent server start time reporting.
+- dynamic stream and ip hashing salt generation.
+
+infrastructure improvements:
+- load balancing: your api requests are now sent to the least busy server. yes, there are now several of them with more to come in the future.
+- when possible, server in closest region is used instead of a far-away one. this should help with download speeds.
+- currently there are multiple servers in europe. i will let you know when (and if) i manage to get an american one.
+
+updates for developers and instance hosters:
+- server info api endpoint: you can now check up on the api server of choice. it reports all the basic info you may need. [check the api docs](https://github.com/imputnet/cobalt/blob/main/docs/api.md#get-apiserverinfo) for more info.
+- api names: each and every api instance should have a distinctive name. this will be useful in the future :)
+- added docker compose sample config.
+- updated and more granular setup script.
+- better api scalability and faster server start up thanks to web and api separation.
+- added ability to specify ffmpeg threads. simply add ffmpegThreads to your environment variables!
+
+i'm still in awe from how popular cobalt has become. there are now over 200k of unique users monthly, and that number only keeps growing. i even had to come up with something to accommodate for larger traffic, it's absolutely insane.
+
+love you all, have a great day :D
\ No newline at end of file
diff --git a/web/changelogs/6.2.md b/web/changelogs/6.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..5b3ef6f8dfdc9c780bfa9027658e38542f4fe660
--- /dev/null
+++ b/web/changelogs/6.2.md
@@ -0,0 +1,23 @@
+---
+title: "all network issues have been fixed!"
+date: "June 27 2023"
+banner:
+ file: "meowthhammer.webp"
+ alt: "meowth plush holding a hammer in real life"
+---
+hey! there have been some hiccups in cobalt's stability lately, i was going through finals while trying to scale up the infrastructure, and that didn't really work out, lol.
+BUT i'm happy to announce that i've optimized all nodes! there should no longer be any networking issues.
+
+enjoy stable experience while i work in background to make cobalt even better :)
+
+here's what's new in this update:
+- better button contrast in both themes.
+- button highlight in light theme now actually looks like a highlight.
+- removed ip gate for streamables and updated privacy policy to reflect this change.
+- streamable links now last for 20 seconds instead of 2 minutes.
+- cleaned up stream verification algorithm. now the same function doesn't run 4 times in a row.
+- removed deprecated way of hosting a cobalt instance.
+
+thank you for sticking with cobalt, and i hope you have a great day :D
+
+banner photo is by [@halftroller](https://twitter.com/halftroller) on twitter, thank you so much!
\ No newline at end of file
diff --git a/web/changelogs/7.0.md b/web/changelogs/7.0.md
new file mode 100644
index 0000000000000000000000000000000000000000..6b65b9312df7972b353610a1e65bf3aca9620798
--- /dev/null
+++ b/web/changelogs/7.0.md
@@ -0,0 +1,84 @@
+---
+title: "biggest ui refresh yet!"
+date: "August 15, 2023"
+banner:
+ file: "meowthcooking.webp"
+ alt: "meowth handling orders in a restaurant"
+---
+hey! this update is huge and mostly aimed to refresh the ui, but there are also some other nice fixes/additions. read below for more info :)
+
+tl;dr:
+
+- entirety of web app has been refreshed. it's more prettier and optimized than ever, both on phone and desktop.
+- if you're on ios, try adding cobalt to home screen! it'll look and act like a native app.
+- all soundcloud links are now supported and audio quality is higher than before.
+- all x (previously twitter) links are now supported and work properly.
+- newer reddit videos are downloadable now.
+- added some sort of eula, list of keyboard shortcuts, updated privacy policy for more clarity. check it all in refreshed about tab!
+- cobalt now lets you know if your browser doesn't support clipboard pasting and helps you fix it.
+
+accessibility notice:
+this update includes animations and transparency, if you'd like to disable any or all of them, head to settings > other > accessibility.
+
+[full changelog]
+
+service improvements:
+- fixed unexpected 502 errors when downloading newer reddit videos.
+- newer reddit videos (with audio) are downloadable now.
+- upgraded soundcloud downloads to use higher audio quality than before.
+- all soundcloud links are now supported.
+- added support for x.com urls.
+- changed twitter api once again. now everything works, again.
+
+web improvements:
+- all-new matte glass aesthetic, applied to revamped popup headers, tab selectors, and also small popups.
+- rounded corners everywhere! cobalt is now safe for everyone who can't handle sharp objects.
+- paddings everywhere are smaller, more content fits on the screen at once.
+- optimized installed web app to look and act like a native app, especially on ios.
+- added update release dates to changelogs.
+- cobalt now lets you know if your browser doesn't support clipboard api and helps you fix it.
+- refreshed all popups: less padding, more content.
+- completely remade error and download popups, they're consistent with the rest of refreshed design.
+- refreshed the look of entire changelog tab: separated title and version/commit, made title bigger, evened out all paddings.
+- replaced close button with back button, moved it to left.
+- added interaction animations.
+- added more keyboard shorcuts.
+- added a list of keyboard shortcuts to about tab.
+- added eula to about tab. check it out.
+- added more accessibility options, put them all into one category. you can disable animations and transparency if you want to.
+- added a link to self-troubleshooting guide to about tab.
+- renamed 2160p and 4320p to 4k and 8k respectfully for better clarity.
+- popups now work without any weird workarounds, especially on mobile. they're clean and nice.
+- home screen now also works without any weird workarounds. it is also clean and nice.
+- optimized css of almost all ui elements. should be even more consistent across platforms now.
+- added ability to translate "cobalt" more in-depth localization. for example, in russian "cobalt" is now "кобальт", that's the style i'll be going with from now on.
+- updated many localization strings for more clarity.
+- removed ability to change the app name dynamically in all locations. cobalt is a sustained app name.
+- updated donation and privacy policy texts for more clarity in both english and russian.
+- home screen now smoothly fades in instead of popping in.
+- proper banner loading. no more jumping text!
+- proper banner error handling. if banner wasn't loaded, it'll simply go grey instead of disappearing.
+- links are no longer italic and are instead underlined.
+- collapsible lists now have corresponding emoji.
+- donate button is now highlighted with magenta instead of white.
+- proper dropdown arrow.
+- removed 6.0 api fallback.
+- fixed celebrations emoji. again.
+- cleaned up all related frontend modules, especially page.js.
+- urgent notice is now a js element, not a static piece of text. can be updated easily.
+
+api improvements:
+- now catching all json api related errors.
+- moved on demand blocks to web server, now changelog can be updated independently from preferred api server.
+- now sending standard rate limiting headers.
+- better readability in source.
+
+other improvements:
+- renamed docker-compose.yml.example to docker-compose.example.yml for linting in code editors.
+- added a wiki with wip troubleshooting guide on github. more guides are coming soon!
+
+that's a ton of changes! i really hope you like this update as much as i do.
+
+if you experience any issues, feel free to contact me on any platform listed in about tab! i'd love to hear back from you.
+
+thank you for sticking with me and cobalt, i hope you have THE best day :D
\ No newline at end of file
diff --git a/web/changelogs/7.1.md b/web/changelogs/7.1.md
new file mode 100644
index 0000000000000000000000000000000000000000..18814c8afacf65347da79b392a9e98a01e845a9d
--- /dev/null
+++ b/web/changelogs/7.1.md
@@ -0,0 +1,28 @@
+---
+title: "instagram, streamable, video metadata, and more!"
+date: "August 20, 2023"
+banner:
+ file: "meowthproductions.webp"
+ alt: "meowth roaring in a fancy circle, à la MGM studios intro"
+---
+service improvements:
+- extended instagram support: high quality photos, videos, reels. everything should work without any issues, enjoy! :)
+- added support for streamable.com (thanks to [#179](https://github.com/imputnet/cobalt/pull/179))
+- added video metadata to youtube videos.
+- fixed vk video downloads.
+- vxtwitter links are now supported.
+- fixed support for youtube audio dubs.
+
+ui improvements:
+- fixed picker popup: it's now scrollable in all cases and clickable areas don't overlap each other.
+
+backend improvements:
+- cobalt will now let you know if something goes wrong during video download instead of nuking the stream.
+- added support for cookies (thanks to [#177](https://github.com/imputnet/cobalt/pull/177))
+- replaced got with undici (thanks to [#182](https://github.com/imputnet/cobalt/pull/182)). downloads should be slightly faster and clean of garbage in headers.
+
+internal improvements:
+- moved host overrides into its own module.
+- minor clean ups.
+
+even more cool stuff is coming in future updates! thank you for using cobalt :D
\ No newline at end of file
diff --git a/web/changelogs/7.11.md b/web/changelogs/7.11.md
new file mode 100644
index 0000000000000000000000000000000000000000..67919050525a68aae050f64829f069046e3191fc
--- /dev/null
+++ b/web/changelogs/7.11.md
@@ -0,0 +1,45 @@
+---
+title: "cache encryption, meowbalt, dailymotion, bilibili, and much more!"
+date: "March 6, 2024"
+banner:
+ file: "meowth7eleven.webp"
+ alt: "meowth plush in front of 7-eleven store"
+---
+cobalt may not have as many groceries as 7-eleven, but it sure does have lots of big changes in this update!
+
+- all cached stream info is now encrypted and can only be decrypted with a link you get from cobalt.
+- new popup style featuring meowbalt, cobalt's speedy mascot. you will see him more often from now on!
+- added support for dailymotion (including short links).
+- added support for bilibili.tv, fixed support for bilibili.com, and added support for all related short links.
+- added support for unlisted vimeo links.
+- added support for tumblr audio and revamped the entire module.
+- added support for embed ok.ru links.
+
+we also updated the privacy policy to reflect the addition of data encryption, go check it out.
+
+for people with iphones:
+- clearer ios saving tutorial.
+- added "save to files" ios shortcut.
+- updated save to photos shortcut.
+
+make sure to save both shortcuts and read the updated tutorial!
+
+for people who host a cobalt instance:
+- updated all environment variables TO_BE_LIKE_THIS. time to update your configs! for now cobalt is backwards compatible with old variable names, but it won't last forever.
+- added a list of all environment variables and their descriptions to [run-an-instance doc](https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md#list-of-all-environment-variables).
+- updated [cookie file example](https://github.com/imputnet/cobalt/blob/main/docs/examples/cookies.example.json) with more services and improved examples.
+- updated [docker compose example](https://github.com/imputnet/cobalt/blob/main/docs/examples/docker-compose.example.yml) with better explanations and up-to-date env variable samples.
+- updated some packages to get rid of all unnecessary messages in console.
+
+want to host an instance? [learn how to do it here](https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md).
+
+frontend changes:
+- removed migration popup.
+- corners across ui are even more round now.
+- bottom glass bkg in popups is no longer rounded on top right.
+- small popup no longer stretches like gum, it's fixed in size on desktop.
+- small popup animation no longer lags on mobile.
+- better ui scaling across resolutions.
+- updated donation text.
+
+thank you for using cobalt, all 750k of you. hope you like this update as much as we enjoyed making it :D
\ No newline at end of file
diff --git a/web/changelogs/7.13.md b/web/changelogs/7.13.md
new file mode 100644
index 0000000000000000000000000000000000000000..fa78c53677a399c512d1396774f4966fdc3f5e35
--- /dev/null
+++ b/web/changelogs/7.13.md
@@ -0,0 +1,59 @@
+---
+title: "better ux, improvements for youtube, twitter, tiktok, instagram, and more!"
+date: "May 5, 2024"
+banner:
+ file: "meowthbusinessman.webp"
+ alt: "photo of a businessman holding hands together (merkel-raute pose) with meowth plush head."
+---
+long time no see! well, actually, you've been using the latest version for some time now. we've moved to a rolling release scheme, allowing for speedy update rollouts :)
+
+since 7.11, there has been a ton of changes. here are the most notable of them:
+- youtube downloads are now faster and more reliable than ever.
+- all posts from twitter are now downloadable, including sensitive ones.
+- you now can download tiktok videos in 1080p h265! just enable h265 support in settings > video.
+- added support for sharing links directly to the cobalt web app on android.
+- added 240p and 144p quality options to the quality picker in settings (for some reason, many of you wanted this).
+- pasting a link with additional text around it will now work; cobalt will extract the link for you (works only via the paste button).
+- added anonymous traffic analytics by plausible. we're using a selfhosted instance and don't collect any identifiable information about you. you can learn more in about > privacy policy. you can also opt out of anonymous analytics in settings > other.
+
+service support improvements:
+- implemented internal streams functionality, allowing for more fine-grained file streaming and therefore proper youtube support.
+- added fallback to m4a if opus isn't available for youtube.
+- added a total of 7 ways to get instagram post info, including mobile api, embed, and graphql api. absolute torture.
+- added support for reddit user posts.
+- updated the way tiktok downloads are handled for better reliability and 1080p support.
+- added tiktok author's username to filename.
+- added support for rutube shorts and yappy videos.
+- added support for m.soundcloud.com links.
+- added support for new post and reel links from instagram.
+- added support for photo twitter links, only used for gifs.
+- added support for m.bilibili.com links.
+- added support for new type of vimeo links.
+- added support for ddinstagram.com links.
+- updated youtube codec info in settings to display the fact that av1 is a better choice now.
+- updated best audio picking for tiktok and soundcloud.
+- changed the youtube client to web, since android client no longer works.
+- removed the vimeo download type switcher, as it should've always been automatic instead.
+- removed an ability to enable the tiktok watermark, as it no longer includes the author's username.
+
+ui & ux improvements:
+- youtube audio dub switcher is now a toggle with a much easier to understand description.
+- meowbalt now sticks out on the left side of download popup on desktop.
+- updated "made with love" text to include the research & dev team behind cobalt, imput.
+- fixed grammar of russian localization.
+- rounded corners are now correctly rendered across all browsers.
+- various minor improvements, including smaller button padding.
+- removed the notification (red dot) functionality as the most recent changelog is already always on screen.
+- removed settings migration from the old domain.
+
+other changes:
+- various docs updates in github repo, making sure they're functional across branches and forks.
+- major codebase cleanup.
+
+thank you for using cobalt, and thank you for being one of our 900k friends! i hope you like this update as much as we liked making it.
+
+we're committed to keeping cobalt the best way to save what you love without ads or invasion of your privacy. there's a ton of cool stuff to come soon; stay tuned and have an amazing rest of your day <3
+
+if you want to help our goal of a better internet for everyone, just share cobalt with a friend!
+
+(original photo of a man in a suit by benzoix on freepik)
\ No newline at end of file
diff --git a/web/changelogs/7.14.md b/web/changelogs/7.14.md
new file mode 100644
index 0000000000000000000000000000000000000000..b41ec08f0b9f0535dba4ad565873ede4740dedaf
--- /dev/null
+++ b/web/changelogs/7.14.md
@@ -0,0 +1,43 @@
+---
+title: "now helping over 1 million people monthly"
+date: "May 17, 2024"
+banner:
+ file: "millionusers.webp"
+ alt: "collage of two photos, side by side. left photo: brown cake with 7 lit candles forming 1000000 and one ferrero rocher candy in the middle with cobalt (double greater than symbol) logo on it. right photo: chocolate cake with 7 lit candles forming 1000000 and cobalt logo formed with whipped cream on the cake. two plushes of meowth and pompompurin in party hats are seen behind the cake."
+---
+yesterday, cobalt hit 1 million users around the world! it's an absolutely insane milestone for us and we're incredibly grateful to everyone saving and creating what they love with help of cobalt. thank you for being our friends.
+
+in anticipation of 7 figure user count, we've revamped the cobalt codebase and infrastructure to be faster and more reliable than ever. a combination of many changes has resulted into incredible download speeds (up to 30 MB/s, as tested by both developers in europe).
+
+note: there's no backend instance in asia just yet, so if you're there, you might experience average speeds *for now*. you can help us afford a dedicated server in asia by donating to cobalt in the "donate" menu.
+
+changes since the last major update
+
+service improvements:
+- youtube music support on the main instance is back!
+- added support for pinterest images and gifs.
+- cobalt will now use original soundcloud mp3 file when available.
+- fixed a youtube bug that prevented some videos from downloading.
+
+ui/ux improvements:
+- cobalt web app is now fully optimized for ipad. you can add it to home screen from share menu to make it act like a native app!
+- majorly reduced vertical padding when viewing cobalt in mobile web browser, allowing for more content at once. most noticeable on smaller screens.
+- status bar color is now dynamic in the web browser on ios and web app on android.
+- web app on android feels way more native than before.
+- filename style icons are no longer blurry in safari.
+- changelog notification no longer overlaps with dynamic island on newer iphones when cobalt is installed as a web app.
+- fixed safe area padding.
+
+other changes:
+- added support for [freebind](https://github.com/imputnet/freebind.js), made by one of the cobalt developers.
+- rate limit and max video length limits are now customizable through [environment variables](https://github.com/imputnet/cobalt/blob/main/docs/run-an-instance.md#variables-for-api).
+- cobalt api now returns rate limit headers at all times.
+- majorly cleaned up the codebase: removed unnecessary functions, rewrote those that were cryptic and confusing. it's way more comprehensible and contribution-friendly than ever before.
+- moved the [cobalt repo](https://github.com/imputnet/cobalt) to our organization on github. everything stayed the same and all old links link back to it.
+
+note for instance hosters:
+along with cobalt repo, the docker image also moved! please update the url for it in your config along with watchtower args to include restarting containers (just in case) as seen in [updated docker compose example](https://github.com/imputnet/cobalt/blob/main/docs/examples/docker-compose.example.yml). we're mirroring packages to old url for now, but it won't last forever.
+
+that's it for now! hope you have an amazing day and share the 1 million celebration with us :)
+
+join our [discord server](https://discord.gg/pQPt8HBUPu) to discuss everything cobalt there
\ No newline at end of file
diff --git a/web/changelogs/7.3.md b/web/changelogs/7.3.md
new file mode 100644
index 0000000000000000000000000000000000000000..c89738dbe225a7c33d6f30f2ca1de6910fefa729
--- /dev/null
+++ b/web/changelogs/7.3.md
@@ -0,0 +1,30 @@
+---
+title: "extended video length limit, metadata toggle, ui improvements, and more!"
+date: "September 6, 2023"
+banner:
+ file: "meowthsnap.webp"
+ alt: "cartoon meowth pointing paw dramatically and saying something"
+---
+this update gives cobalt a sharp look in chromium browsers and makes it even more useful than before. check out the full changelog below!
+
+service improvements:
+- increased video length limit from 3 hours to 5 hours. feel free to download lectures you need :)
+- you can now disable file metadata in settings.
+- fixed a bug which previously caused some downloads to end up being 0 bytes.
+
+ui improvements:
+- fixed clickable area for urgent notice (text on top).
+- fixed blurry header in chrome.
+- fixed blurry tab bar in chrome.
+- fixed blurry switches in chrome.
+- fixed weirdly rounded corners in popups.
+- fixed 1px gap on edges of various elements in popup in chrome.
+- fixed overscrolling in other settings tab on ios.
+- fixed unexpected button highlight effect on phones.
+- removed outdated fixes for tiny screens.
+
+other improvements:
+- cobalt web & api start faster than before, additional preparation functions aren't unexpectedly run anymore.
+- cobalt is now available as a docker package. check it out on [github](https://github.com/imputnet/cobalt/pkgs/container/cobalt).
+
+thank you for being here. i hope you have a great day :D
\ No newline at end of file
diff --git a/web/changelogs/7.4.md b/web/changelogs/7.4.md
new file mode 100644
index 0000000000000000000000000000000000000000..a61af8e4563f9fdbd97b0842da22e73611e75f9a
--- /dev/null
+++ b/web/changelogs/7.4.md
@@ -0,0 +1,45 @@
+---
+title: "new domain, what's coming in future, bug fixes, and more!"
+date: "September 9, 2023"
+banner:
+ file: "newdomain.webp"
+ alt: "text: new domain, same cobalt"
+---
+cobalt is finally moving to its own domain! many of you have been anticipating this, and many kept forgetting the link due to how cryptic it was.
+
+well, worry no more - cobalt.tools is here.
+
+if you haven't yet, open [co.wukko.me](https://co.wukko.me) to transfer your settings here! no additional action from you is required. just open the old link and cobalt will do everything for you :)
+
+make sure to update your bookmarks and reinstall the web app!
+
+here's what domain change means:
+- still no ads, same owner, same features, same reliability. just a way more rememberable link (it's literally two words).
+- cobalt.tools makes it clear that cobalt is a tool and that it's "cobalt", not "wukko".
+- i can host various versions of cobalt on subdomains without links looking awkward.
+- i can host cobalt-related websites without polluting my personal domain's dns (such as crowdin).
+- i stand by same privacy policies (and in fact am using the same exact server as before).
+
+the domain change is required for the future of cobalt.
+
+here's what's coming soon:
+- support for many top-requested sites, such as (but not limited to) twitch and niconico.
+- education version of cobalt, as often requested by students and educators.
+- major localization system upgrade, allowing for simpler community contributions.
+- region-specific versions with 100% translations and tweaks.
+- native clients for desktop and mobile (not sure about this one, i'm no superman).
+- ...and more!
+
+now, here's what's new in 7.4:
+- tabs in popups now scroll to top on tab bar tap.
+- padding across web app was tuned.
+- (obviously) a migration agent. soon will be used for importing and exporting settings.
+- some minor clean ups in codebase.
+
+if you want to help cobalt achieve goals listed above, consider donating! donations are the only way i can keep cobalt ad-less, powerful, (basically) limitless, and also 100% free.
+
+in fact, donations have helped me grow cobalt more than i've ever anticipated. just imagine how much better it will be in a year.
+
+go to donations down below to find ways to donate!
+
+thank you for reading through all of this. i hope you enjoy this update and have a great day :D
\ No newline at end of file
diff --git a/web/changelogs/7.5.md b/web/changelogs/7.5.md
new file mode 100644
index 0000000000000000000000000000000000000000..420beeb1f4c7c55ceae23ef70383ed2cb22a2517
--- /dev/null
+++ b/web/changelogs/7.5.md
@@ -0,0 +1,28 @@
+---
+title: "support for twitch clips and rutube!"
+date: "September 16, 2023"
+banner:
+ file: "twitchupdate.webp"
+ alt: "meowth plush staring into the camera, laptop with generic purple service in the background"
+---
+hey! this update (finally) adds support for twitch clips and rutube, among other smaller changes.
+
+service improvements:
+- added support for twitch clips. no vods, they're unnecessary. just clip whatever you want to download!
+- added support for rutube in case you ever wanted to download something russian.
+
+interface improvements:
+- added a note about cobalt not being affiliated with any supported services.
+- added a note about meta (the company) in russian.
+- better russian localization. will keep improving it to make it sound not so robotic over time.
+
+other improvements:
+- all official servers are now using the docker package. and so should you!
+- moved the load balancer to poland. requests should be slightly faster now.
+- minor codebase clean up.
+
+if you're confused about the new domain, read the older changelog! just scroll lower and press "expand".
+
+i hope you find this update useful and have a wonderful day :)
+
+btw, cobalt has a pretty active community server on discord. go to about > support & source code to join!
\ No newline at end of file
diff --git a/web/changelogs/7.6.md b/web/changelogs/7.6.md
new file mode 100644
index 0000000000000000000000000000000000000000..02cb3b18e8c2ab5c74eb20ad0a5d1883bd50fc5d
--- /dev/null
+++ b/web/changelogs/7.6.md
@@ -0,0 +1,33 @@
+---
+title: "customizable file names, instagram stories, and first cobalt sponsor!"
+date: "October 15, 2023"
+banner:
+ file: "meowthcenter.webp"
+ alt: "meowth plush in a datacenter wearing a hardhat, wielding a hammer"
+---
+as many have (very) often requested, cobalt now lets you pick between several file name format styles!
+go to settings > other and change it to whichever you like! there's a preview of each style, so you know how exactly files are gonna look like.
+
+if you liked file names the way they were before, don't worry: classic style is still the default :)
+
+on a different but not any less important note: cobalt is now sponsored by [royalehosting.net](https://royalehosting.net/)!
+overall service performance and stability is gonna be better, but also more content will be possible to download thanks to geniuine server locations. and yes, still no ads or trackers.
+
+this update also includes a bunch of other changes, check them out:
+
+service improvements:
+- added support for instagram stories thanks to [#194](https://github.com/imputnet/cobalt/pull/194).
+- fixed reddit support thanks to [#221](https://github.com/imputnet/cobalt/pull/221).
+- added support for rich file names for youtube, vimeo, soundcloud, rutube, and vk.
+- numbers and emoji no longer disappear from file name and metadata.
+- mute and audio dub file name tags don't appear together anymore.
+- youtube: dub file name tag doesn't appear anymore if audio track is default.
+
+interface improvements:
+- added a list of sponsors to about tab. if you host an instance, it's disabled by default, but can be enabled with showSponsors env variable.
+- about button now opens about tab when no new changelog is available.
+- fixed download button thickness on ios.
+
+you now can reach out to cobalt via email for support! it's located in the about tab along with other socials, such as discord.
+
+i hope you enjoy this long-awaited update and have a blissful day :D
\ No newline at end of file
diff --git a/web/changelogs/7.7.md b/web/changelogs/7.7.md
new file mode 100644
index 0000000000000000000000000000000000000000..220d2a799be1e61e9d713d9574c09fd08b7b01ea
--- /dev/null
+++ b/web/changelogs/7.7.md
@@ -0,0 +1,30 @@
+---
+title: "bugfixes and better downloads!"
+date: "December 2, 2023"
+banner:
+ file: "meowthpolishegg.webp"
+ alt: "meowth polishing a togepi egg"
+---
+this update fixes various issues with supported services. no new features yet, but twitter fix is surely something good to have in the meantime!
+
+service improvements:
+- broken twitter videos are now automatically fixed by cobalt.
+- all vimeo videos and audios should now be possible to download.
+- vimeo: fixed short resolution displayed in "basic" and "pretty" filename styles.
+
+interface improvements:
+- streamables are now easier to save on ios.
+
+internal improvements:
+- port env variable is now not strictly necessary for cobalt to run.
+- minor clean up.
+
+changes since 7.6:
+- fix for an issue related to youtube dubs.
+- fixed a memory leak related to live renders.
+- handling all errors related to twitter downloads.
+- fixed support for reddit links in various languages.
+- added rich filenames support for twitch clips.
+- updated support and donation lists.
+
+stay tuned for future updates and have a great day :D
\ No newline at end of file
diff --git a/web/changelogs/7.8.md b/web/changelogs/7.8.md
new file mode 100644
index 0000000000000000000000000000000000000000..16a9fcfef34e1d2ee286fc171739661909eeb585
--- /dev/null
+++ b/web/changelogs/7.8.md
@@ -0,0 +1,35 @@
+---
+title: "new years clean up! bug fixes and fresh look for the home page"
+date: "December 25, 2023"
+banner:
+ file: "catroomba.webp"
+ alt: "a cat riding a roomba vacuum"
+---
+merry christmas and happy new year! this update fixes several (very annoying) bugs to help you enjoy your holidays better.
+
+you might have already noticed, but we've refreshed the home page on desktop and mobile! less space wasted, more pleasant to look at. let us know if you like it or not :D
+
+service improvements:
+- [#264](https://github.com/imputnet/cobalt/issues/264) anything that includes a period in the url should be possible to download (including instagram stories).
+- [#273](https://github.com/imputnet/cobalt/issues/273) soundcloud: falling back to mp3 instead of refusing to download the song at all.
+- [#275](https://github.com/imputnet/cobalt/issues/275) youtube: query parameters are parsed and handled correctly, all links should be supported, no matter where v query is located.
+- tlds are parsed and validated correctly (e.g. "pinterest.co.uk" works now).
+- fixvx.com links are now supported.
+
+interface improvements:
+- cleaner and more consistent home page layout.
+- cleaned up support section in "about". also includes a link to the status page.
+
+internal improvements:
+- urls, subdomains, and tlds are properly validated.
+- minor clean up.
+
+changes since 7.7:
+- made terms and ethics more descriptive.
+- fix only affected twitter videos.
+- fixed quick ⌘+V pasting on mac.
+- now catching even more youtube-related errors.
+
+this might not seem like a lot, but even smaller changes make a difference!
+
+enjoy this update and the rest of your day :D
\ No newline at end of file
diff --git a/web/changelogs/7.9.md b/web/changelogs/7.9.md
new file mode 100644
index 0000000000000000000000000000000000000000..4a79172e841316e4ec9bf691382de49688d4da53
--- /dev/null
+++ b/web/changelogs/7.9.md
@@ -0,0 +1,33 @@
+---
+title: "twitter gifs, pinterest, ok.ru, and more!"
+date: "January 17, 2024"
+banner:
+ file: "meowthball.webp"
+ alt: "meowth rolling on a big catnip ball"
+---
+yes, you read that right. cobalt now lets you convert any twitter gif to an actual .gif file! (finally)
+just go to settings and enable this feature :)
+
+service improvements:
+- added an option to [convert gifs from twitter](https://github.com/imputnet/cobalt/issues/250) into actual .gif format. files will be bigger and lower quality, but maybe you want that.
+- pinterest support has been completely redone, now all videos ([and even pin.it links](https://github.com/imputnet/cobalt/issues/160)) are supported.
+- added [support for ok.ru](https://github.com/imputnet/cobalt/issues/322) in case you're a russian grandma.
+- now processing [all reddit links](https://github.com/imputnet/cobalt/issues/318) (including old.reddit.com).
+- [instagram live vods](https://github.com/imputnet/cobalt/issues/316) are now supported.
+- fixed a [rare vimeo bug](https://github.com/imputnet/cobalt/issues/289) related to 1440p videos.
+
+other improvements:
+- ui fade in animation is no longer present if you've disabled animations.
+- all images now have alt descriptions.
+- cobalt html is now [biblically correct](https://github.com/imputnet/cobalt/issues/317) and follows the html spec.
+- lots of cleaning up.
+
+patches since 7.8:
+- shift+key [shortcuts are now ignored](https://github.com/imputnet/cobalt/issues/288) if url bar is focused.
+- longer soundcloud links are now supported, also catching more tiktok-related errors.
+- removed mastodon from support links as that account is no longer active.
+- added ability to download a specific video from multi media tweets and support for /mediaViewer links.
+- fixed [modal blurriness](https://github.com/imputnet/cobalt/issues/309) in chromium.
+- minor html changes (road to biblically correct one).
+
+lots of long-awaited updates (especially twitter gifs), hope you enjoy them and have a great day :D
\ No newline at end of file
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..7d683070aec9088b5204fe461c47d9d1d12cbe05
--- /dev/null
+++ b/web/eslint.config.js
@@ -0,0 +1,9 @@
+// @ts-check
+
+import eslint from '@eslint/js';
+import tseslint from 'typescript-eslint';
+
+export default tseslint.config(
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+);
diff --git a/web/i18n/en/a11y/dialog.json b/web/i18n/en/a11y/dialog.json
new file mode 100644
index 0000000000000000000000000000000000000000..84df736b1f3bd2f86dd5c1d1645cbabb3a596bd2
--- /dev/null
+++ b/web/i18n/en/a11y/dialog.json
@@ -0,0 +1,5 @@
+{
+ "picker.item.photo": "photo thumbnail",
+ "picker.item.video": "video thumbnail",
+ "picker.item.gif": "gif thumbnail"
+}
diff --git a/web/i18n/en/a11y/donate.json b/web/i18n/en/a11y/donate.json
new file mode 100644
index 0000000000000000000000000000000000000000..625eee916d92451051ed7169f82fe85b4ced27f3
--- /dev/null
+++ b/web/i18n/en/a11y/donate.json
@@ -0,0 +1,4 @@
+{
+ "share.qr.expand": "qr code. press to expand.",
+ "share.qr.collapse": "expanded qr code. press to collapse."
+}
diff --git a/web/i18n/en/a11y/general.json b/web/i18n/en/a11y/general.json
new file mode 100644
index 0000000000000000000000000000000000000000..30c862e137062a3b12f1eb14e2a1c6ead24bb072
--- /dev/null
+++ b/web/i18n/en/a11y/general.json
@@ -0,0 +1,3 @@
+{
+ "back": "go back"
+}
diff --git a/web/i18n/en/a11y/save.json b/web/i18n/en/a11y/save.json
new file mode 100644
index 0000000000000000000000000000000000000000..2dc85154bb4aef4665ddd79f21b4146ad7268fac
--- /dev/null
+++ b/web/i18n/en/a11y/save.json
@@ -0,0 +1,13 @@
+{
+ "link_area": "link input area",
+ "link_area.turnstile": "link input area. checking if you're not a robot.",
+ "clear_input": "clear input",
+ "download": "download",
+ "download.think": "processing the link...",
+ "download.check": "verifying download...",
+ "download.done": "downloading done",
+ "download.error": "downloading error",
+
+ "tutorial.shortcut.photos": "add photos shortcut",
+ "tutorial.shortcut.files": "add files shortcut"
+}
diff --git a/web/i18n/en/a11y/tabs.json b/web/i18n/en/a11y/tabs.json
new file mode 100644
index 0000000000000000000000000000000000000000..7eafb56f93a52fe634d50622afadd6f7148d9922
--- /dev/null
+++ b/web/i18n/en/a11y/tabs.json
@@ -0,0 +1,3 @@
+{
+ "tab_panel": "tabs panel"
+}
diff --git a/web/i18n/en/about.json b/web/i18n/en/about.json
new file mode 100644
index 0000000000000000000000000000000000000000..72b7fe40ebf505fd3d6baaff491ab329353d8454
--- /dev/null
+++ b/web/i18n/en/about.json
@@ -0,0 +1,35 @@
+{
+ "page.general": "what's cobalt?",
+ "page.faq": "FAQ",
+
+ "page.community": "community & support",
+
+ "page.privacy": "privacy policy",
+ "page.terms": "terms and ethics",
+ "page.credits": "thanks & licenses",
+
+ "heading.general": "general terms",
+ "heading.licenses": "licenses",
+ "heading.summary": "best way to save what you love",
+ "heading.privacy": "leading privacy",
+ "heading.community": "open community",
+ "heading.local": "on-device processing",
+ "heading.saving": "saving",
+ "heading.encryption": "encryption",
+ "heading.plausible": "anonymous traffic analytics",
+ "heading.cloudflare": "web privacy & security",
+ "heading.responsibility": "user responsibilities",
+ "heading.abuse": "reporting abuse",
+ "heading.motivation": "motivation",
+ "heading.testers": "beta testers",
+
+ "support.github": "check out cobalt's source code, contribute changes, or report issues",
+ "support.discord": "chat with the community and developers about cobalt or ask for help",
+ "support.twitter": "follow cobalt's updates and development on your twitter timeline",
+ "support.telegram": "stay up to date with latest cobalt updates via a telegram channel",
+ "support.bluesky": "follow cobalt's updates and development on your bluesky feed",
+
+ "support.description.issue": "if you want to report a bug or some other recurring issue, please do it on github.",
+ "support.description.help": "use discord for any other questions. describe the issue properly in #cobalt-support or else no one will be able help you.",
+ "support.description.best-effort": "all support is best effort and not guaranteed, a reply might take some time."
+}
diff --git a/web/i18n/en/about/credits.md b/web/i18n/en/about/credits.md
new file mode 100644
index 0000000000000000000000000000000000000000..812f3394bf83cce7340f4197cec3a028c9ac72d5
--- /dev/null
+++ b/web/i18n/en/about/credits.md
@@ -0,0 +1,63 @@
+
+
+
+
+
+cobalt is made with love and care by the [imput](https://imput.net/) research and development team.
+
+you can support us on the [donate page](/donate)!
+
+
+
+
+
+huge shoutout to our thing breakers for testing updates early and making sure they're stable.
+they also helped us ship cobalt 10!
+
+
+all links are external and lead to their personal websites or social media.
+
+
+
+
+
+meowbalt is cobalt's speedy mascot. he is an extremely expressive cat that loves fast internet.
+
+all amazing drawings of meowbalt that you see in cobalt were made by [GlitchyPSI](https://glitchypsi.xyz/).
+he is also the original designer of the character.
+
+you cannot use or modify GlitchyPSI's artworks of meowbalt without his explicit permission.
+
+you cannot use or modify the meowbalt character design commercially or in any form that isn't fan art.
+
+
+
+
+
+cobalt processing server is open source and licensed under [AGPL-3.0]({docs.apiLicense}).
+
+cobalt frontend is [source first](https://sourcefirst.com/) and licensed under [CC-BY-NC-SA 4.0]({docs.webLicense}).
+we decided to use this license to stop grifters from profiting off our work
+& from creating malicious clones that deceive people and hurt our public identity.
+
+we rely on many open source libraries, create & distribute our own.
+you can see the full list of dependencies on [github]({contacts.github}).
+
diff --git a/web/i18n/en/about/general.md b/web/i18n/en/about/general.md
new file mode 100644
index 0000000000000000000000000000000000000000..1f5e53fd736870fc7c2fc6f2ffa6d43db92adfdc
--- /dev/null
+++ b/web/i18n/en/about/general.md
@@ -0,0 +1,78 @@
+
+
+
+
+
+cobalt helps you save anything from your favorite websites: video, audio, photos or gifs. just paste the link and you're ready to rock!
+
+no ads, trackers, paywalls, or other nonsense. just a convenient web app that works anywhere, whenever you need it.
+
+
+
+
+
+cobalt was created for public benefit, to protect people from ads and malware pushed by its alternatives.
+we believe that the best software is safe, open, and accessible.
+
+a part of our infrastructure is provided by our long-standing partner, [royalehosting.net]({partners.royalehosting})!
+
+
+
+
+
+all requests to the backend are anonymous and all information about tunnels is encrypted.
+we have a strict zero log policy and don't track *anything* about individual people.
+
+when a request needs additional processing, cobalt processes files on-the-fly.
+it's done by tunneling processed parts directly to the client, without ever saving anything to disk.
+for example, this method is used when the source service provides video and audio channels as separate files.
+
+additionally, you can [enable forced tunneling](/settings/privacy#tunnel) to protect your privacy.
+when enabled, cobalt will tunnel all downloaded files.
+no one will know where you download something from, even your network provider.
+all they'll see is that you're using a cobalt instance.
+
+
+
+
+
+cobalt is used by countless artists, educators, and content creators to do what they love.
+we're always on the line with our community and work together to make cobalt even more useful.
+feel free to [join the conversation](/about/community)!
+
+we believe that the future of the internet is open, which is why cobalt is
+[source first](https://sourcefirst.com/) and [easily self-hostable]({docs.instanceHosting}).
+
+if your friend hosts a processing instance, just ask them for a domain and [add it in instance settings](/settings/instances#community).
+
+you can check the source code and contribute [on github]({contacts.github}) at any time.
+we welcome all contributions and suggestions!
+
+
+
+
+
+newest features, such as [remuxing](/remux), work locally on your device.
+on-device processing is efficient and never sends anything over the internet.
+it perfectly aligns with our future goal of moving as much processing as possible to the client.
+
diff --git a/web/i18n/en/about/privacy.md b/web/i18n/en/about/privacy.md
new file mode 100644
index 0000000000000000000000000000000000000000..7291aff490e090507f9d69716bbd0b67423b4816
--- /dev/null
+++ b/web/i18n/en/about/privacy.md
@@ -0,0 +1,76 @@
+
+
+
+
+
+cobalt's privacy policy is simple: we don't collect or store anything about you. what you do is solely your business, not ours or anyone else's.
+
+these terms are applicable only when using the official cobalt instance. in other cases, you may need to contact the hoster for accurate info.
+
+
+
+
+
+tools that use on-device processing work offline, locally, and never send any data anywhere. they are explicitly marked as such whenever applicable.
+
+
+
+
+
+when using saving functionality, in some cases cobalt will encrypt & temporarily store information needed for tunneling. it's stored in processing server's RAM for 90 seconds and irreversibly purged afterwards. no one has access to it, even instance owners, as long as they don't modify the official cobalt image.
+
+processed/tunneled files are never cached anywhere. everything is tunneled live. cobalt's saving functionality is essentially a fancy proxy service.
+
+
+
+
+
+temporarily stored tunnel data is encrypted using the AES-256 standard. decryption keys are only included in the access link and never logged/cached/stored anywhere. only the end user has access to the link & encryption keys. keys are generated uniquely for each requested tunnel.
+
+
+{#if env.PLAUSIBLE_ENABLED}
+
+
+
+for sake of privacy, we use [plausible's anonymous traffic analytics](https://plausible.io/) to get an approximate number of active cobalt users. no identifiable information about you or your requests is ever stored. all data is anonymized and aggregated. the plausible instance we use is hosted & managed by us.
+
+plausible doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.
+
+[learn more about plausible's dedication to privacy.](https://plausible.io/privacy-focused-web-analytics)
+
+if you wish to opt out of anonymous analytics, you can do it in [privacy settings](/settings/privacy#analytics).
+
+{/if}
+
+
+
+
+we use cloudflare services for ddos & bot protection. we also use cloudflare pages for deploying & hosting the static web app. all of these are required to provide the best experience for everyone. it's the most private & reliable provider that we know of.
+
+cloudflare is fully compliant with GDPR and HIPAA.
+
+[learn more about cloudflare's dedication to privacy.](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/)
+
diff --git a/web/i18n/en/about/terms.md b/web/i18n/en/about/terms.md
new file mode 100644
index 0000000000000000000000000000000000000000..634e7502e42f82b14dbc78939150359210f8ef14
--- /dev/null
+++ b/web/i18n/en/about/terms.md
@@ -0,0 +1,57 @@
+
+
+
+
+
+these terms are applicable only when using the official cobalt instance.
+in other cases, you may need to contact the hoster for accurate info.
+
+
+
+
+
+saving functionality simplifies downloading content from the internet and takes zero liability for what the saved content is used for.
+processing servers work like advanced proxies and don't ever write any content to disk.
+everything is handled in RAM and permanently purged once the tunnel is done.
+we have no downloading logs and can't identify anyone.
+
+[you can read more about how tunnels work in our privacy policy.](/about/privacy)
+
+
+
+
+
+you (end user) are responsible for what you do with our tools, how you use and distribute resulting content.
+please be mindful when using content of others and always credit original creators.
+make sure you don't violate any terms or licenses.
+
+when used in educational purposes, always cite sources and credit original creators.
+
+fair use and credits benefit everyone.
+
+
+
+
+
+we have no way of detecting abusive behavior automatically because cobalt is 100% anonymous.
+however, you can report such activities to us via email and we'll do our best to comply manually: abuse[at]imput.net
+
+**this email is not intended for user support, you will not get a response if your concern is not related to abuse.**
+
+if you're experiencing issues, contact us via any preferred method on [the support page](/about/community).
+
diff --git a/web/i18n/en/button.json b/web/i18n/en/button.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ea7fb41d2ff908573296e4fba00eb1c48a4f477
--- /dev/null
+++ b/web/i18n/en/button.json
@@ -0,0 +1,20 @@
+{
+ "gotit": "got it",
+ "cancel": "cancel",
+ "reset": "reset",
+ "done": "done",
+ "download.audio": "download audio",
+ "download": "download",
+ "share": "share",
+ "copy": "copy",
+ "copy.section": "copy the section link",
+ "copied": "copied",
+ "import": "import",
+ "continue": "continue",
+ "star": "star",
+ "follow": "follow",
+ "save": "save",
+ "export": "export",
+ "yes": "yes",
+ "no": "no"
+}
diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json
new file mode 100644
index 0000000000000000000000000000000000000000..3e6f5dece6ca5da5c2ecda973c50e736039dd54e
--- /dev/null
+++ b/web/i18n/en/dialog.json
@@ -0,0 +1,25 @@
+{
+ "reset.title": "reset all data?",
+ "reset.body": "are you sure you want to reset all data? this action is immediate and irreversible.",
+
+ "picker.title": "select what to save",
+ "picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.",
+ "picker.description.phone": "press an item to save it. images can also be saved with a long press.",
+ "picker.description.ios": "press an item to save it with a shortcut. images can also be saved with a long press.",
+
+ "saving.title": "choose how to save",
+ "saving.blocked": "cobalt tried opening the file in a new tab, but your browser blocked it. you can allow pop-ups for cobalt to prevent this from happening next time.",
+ "saving.timeout": "cobalt tried saving the file automatically, but your browser stopped it. you have to select a preferred method manually.",
+
+ "safety.title": "important safety notice",
+
+ "import.body": "importing unknown or corrupted files may unexpectedly alter or break cobalt functionality. only import files that you've personally exported and haven't modified. if you were asked to import this file by someone - don't do it.\n\nwe are not responsible for any harm caused by importing unknown setting files.",
+
+ "api.override.title": "processing instance override",
+ "api.override.body": "{{ value }} is now the processing instance. if you don't trust it, press \"cancel\" and it'll be ignored.\n\nyou can change your choice later in processing settings.",
+
+ "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.",
+
+ "processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?",
+ "processing.title.ongoing": "processing will be cancelled"
+}
diff --git a/web/i18n/en/donate.json b/web/i18n/en/donate.json
new file mode 100644
index 0000000000000000000000000000000000000000..6907e4c4610b69d92263bc504b794f5e2eb36268
--- /dev/null
+++ b/web/i18n/en/donate.json
@@ -0,0 +1,37 @@
+{
+ "banner.title": "Support a safe\nand open Internet",
+ "banner.subtitle": "donate to imput or share the\njoy of cobalt with a friend",
+
+ "body.motivation": "cobalt helps producers, educators, video makers, and many others to do what they love. it's a different kind of service that is made with love, not for profit.",
+ "body.no_bullshit": "we believe that the internet doesn't have to be scary, which is why cobalt will never have ads or other kinds of malicious content. it's a promise that we firmly stand by. everything we do is built with privacy, accessibility, and ease of use in mind, making cobalt available for everyone.",
+ "body.keep_going": "if you found cobalt useful, please consider supporting our work! you can help us by making a donation or sharing cobalt with a friend. every donation is highly appreciated and helps us keep working on cobalt and other projects.",
+
+ "card.once": "one-time donation",
+ "card.recurring": "recurring donation",
+ "card.custom": "custom amount (from $2)",
+
+ "card.processor": "via {{value}}",
+
+ "card.option.5": "cup of coffee",
+ "card.option.10": "full size pizza",
+ "card.option.15": "full lunch",
+ "card.option.30": "lunch for two",
+ "card.option.50": "10kg of cat food",
+ "card.option.100": "one year of domains",
+ "card.option.200": "air fryer",
+ "card.option.500": "fancy office chair",
+ "card.option.1599": "base macbook pro",
+ "card.option.4900": "10,000 apples",
+ "card.option.7398": "maxed out macbook pro",
+ "card.option.8629": "a small plot of land",
+ "card.option.9433": "luxury hot tub",
+
+ "card.custom.submit": "donate custom amount",
+
+ "share.title": "share cobalt with a friend",
+
+ "alternative.title": "alternative ways to donate",
+
+ "alt.copy": "{{ value }}. crypto wallet address. press to copy.",
+ "alt.open": "{{ value }}. press to open."
+}
diff --git a/web/i18n/en/error.json b/web/i18n/en/error.json
new file mode 100644
index 0000000000000000000000000000000000000000..2c347951f68605a9d85f8216d1654b605875bc19
--- /dev/null
+++ b/web/i18n/en/error.json
@@ -0,0 +1,73 @@
+{
+ "import.no_data": "there are no settings to load from this file. are you sure it's the right one?",
+ "import.invalid": "this file doesn't have valid cobalt settings to import. are you sure it's the right one?",
+ "import.unknown": "couldn't load data from the file. it may be corrupted or of wrong format. here's the error i got:\n\n{{ value }}",
+
+ "remux.corrupted": "couldn't read the metadata from this file, it may be corrupted.",
+ "remux.out_of_resources": "cobalt ran out of resources and can't continue with on-device processing. this is caused by your browser's limitations. refresh or reopen the app and try again!",
+
+ "tunnel.probe": "couldn't test this tunnel. your browser or network configuration may be blocking access to one of cobalt servers. are you sure you don't have any weird browser extensions?",
+
+ "captcha_ongoing": "cloudflare turnstile is still checking if you're not a bot. if it takes too long, you can try: disabling weird browser extensions, changing networks, using a different browser, or checking your device for malware.",
+
+ "api.auth.jwt.missing": "couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!",
+ "api.auth.jwt.invalid": "couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!",
+ "api.auth.turnstile.missing": "couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!",
+ "api.auth.turnstile.invalid": "couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!",
+
+ "api.auth.key.missing": "an access key is required to use this processing instance but it's missing. add it in instance settings!",
+ "api.auth.key.not_api_key": "an access key is required to use this processing instance but it's missing. add it in instance settings!",
+
+ "api.auth.key.invalid": "the access key is invalid. reset it in instance settings and use a proper one!",
+ "api.auth.key.not_found": "the access key you used couldn't be found. are you sure this instance has your key?",
+ "api.auth.key.invalid_ip": "your ip address couldn't be parsed. something went very wrong. report this issue!",
+ "api.auth.key.ip_not_allowed": "your ip address is not allowed to use this access key. use a different instance or network!",
+ "api.auth.key.ua_not_allowed": "your user agent is not allowed to use this access key. use a different client or device!",
+
+ "api.unreachable": "couldn't connect to the processing instance. check your internet connection and try again!",
+ "api.timed_out": "the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!",
+ "api.rate_exceeded": "you're making too many requests. try again in {{ limit }} seconds.",
+ "api.capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!",
+
+ "api.generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!",
+ "api.unknown_response": "couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
+ "api.invalid_body": "couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
+
+ "api.service.unsupported": "this service is not supported yet. have you pasted the right link?",
+ "api.service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!",
+ "api.service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!",
+
+ "api.link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?",
+ "api.link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?",
+
+ "api.fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!",
+ "api.fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!",
+ "api.fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?",
+ "api.fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!",
+ "api.fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!",
+
+ "api.content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!",
+
+ "api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!",
+ "api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!",
+ "api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!",
+ "api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try a different link!",
+ "api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!",
+
+ "api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!",
+ "api.content.paid": "this content requires purchase. cobalt can't download paid content. try a different link!",
+
+ "api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!",
+ "api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!",
+ "api.content.post.age": "this post is age-restricted and isn't available without logging in. try a different link!",
+
+ "api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!",
+ "api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!",
+ "api.youtube.login": "couldn't get this video because youtube asked the instance to log in. this is potentially caused by the processing instance not having any active account tokens or youtube updating something about their api. try again in a few seconds, but if it still doesn't work, please report this issue!",
+ "api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
+ "api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
+ "api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
+ "api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
+ "api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
+ "api.youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
+}
diff --git a/web/i18n/en/general.json b/web/i18n/en/general.json
new file mode 100644
index 0000000000000000000000000000000000000000..a50db46b147b4fedfd23803f9bafdfca524be861
--- /dev/null
+++ b/web/i18n/en/general.json
@@ -0,0 +1,7 @@
+{
+ "cobalt": "cobalt",
+ "meowbalt": "meowbalt",
+ "beta": "beta",
+
+ "embed.description": "cobalt lets you save what you love without ads, tracking, paywalls or other nonsense. just paste the link and you're ready to rock!"
+}
diff --git a/web/i18n/en/notification.json b/web/i18n/en/notification.json
new file mode 100644
index 0000000000000000000000000000000000000000..a9b321394519beab5e0ecf6a0b0c229b42065f47
--- /dev/null
+++ b/web/i18n/en/notification.json
@@ -0,0 +1,4 @@
+{
+ "update.title": "update is available!",
+ "update.subtext": "press to reload"
+}
diff --git a/web/i18n/en/receiver.json b/web/i18n/en/receiver.json
new file mode 100644
index 0000000000000000000000000000000000000000..567e569fb7b8a2d20947a23b2c644d82861d16bc
--- /dev/null
+++ b/web/i18n/en/receiver.json
@@ -0,0 +1,5 @@
+{
+ "title": "drag or select a file",
+ "title.drop": "drop the file here!",
+ "accept": "supported formats: {{ formats }}."
+}
diff --git a/web/i18n/en/remux.json b/web/i18n/en/remux.json
new file mode 100644
index 0000000000000000000000000000000000000000..d8b031c34a1509849f336264d27a92ba4d5fdb15
--- /dev/null
+++ b/web/i18n/en/remux.json
@@ -0,0 +1,8 @@
+{
+ "bullet.purpose.title": "what does remux do?",
+ "bullet.purpose.description": "remux fixes any issues with the file container, such as missing time info. it helps increase compatibility with old software, such as vegas pro and windows media player.",
+ "bullet.explainer.title": "how does it work?",
+ "bullet.explainer.description": "remuxing takes existing codec data and copies it over to a new media container. it's lossless, media data doesn't get re-encoded.",
+ "bullet.privacy.title": "on-device processing",
+ "bullet.privacy.description": "cobalt remuxes files locally. files never leave your device, so processing is nearly instant."
+}
diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json
new file mode 100644
index 0000000000000000000000000000000000000000..e6edc0de6519a2eaef936623ebbe4d4577dc3bc0
--- /dev/null
+++ b/web/i18n/en/save.json
@@ -0,0 +1,25 @@
+{
+ "paste": "paste",
+ "paste.long": "paste and download",
+ "auto": "auto",
+ "audio": "audio",
+ "mute": "mute",
+ "input.placeholder": "paste the link here",
+ "terms.note.agreement": "by continuing, you agree to",
+ "terms.note.link": "terms and ethics of use",
+ "services.title": "supported services",
+ "services.title_show": "show supported services",
+ "services.title_hide": "hide supported services",
+ "services.disclaimer": "cobalt is not affiliated with any of the services listed above.",
+
+ "tutorial.title": "how to save on ios?",
+ "tutorial.intro": "to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.",
+ "tutorial.step.1": "add companion siri shortcuts:",
+ "tutorial.step.2": "press the \"share\" button in cobalt's saving dialog.",
+ "tutorial.step.3": "select the respective shortcut in the share sheet.",
+ "tutorial.outro": "these shortcuts will work only from the cobalt app, sharing links from other apps will not work.",
+ "tutorial.shortcut.photos": "to photos",
+ "tutorial.shortcut.files": "to files",
+
+ "label.community_instance": "community instance"
+}
diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..418410bf13f4cff3e3dd2318f8ad6aef8ba73a3f
--- /dev/null
+++ b/web/i18n/en/settings.json
@@ -0,0 +1,126 @@
+{
+ "page.appearance": "appearance",
+ "page.privacy": "privacy",
+ "page.video": "video",
+ "page.audio": "audio",
+ "page.download": "downloading",
+ "page.advanced": "advanced",
+ "page.debug": "info for nerds",
+ "page.instances": "instances",
+
+ "section.general": "general",
+ "section.save": "save",
+
+ "theme": "theme",
+ "theme.auto": "auto",
+ "theme.light": "light",
+ "theme.dark": "dark",
+ "theme.description": "auto theme switches between light and dark themes depending on your device's display mode.",
+
+ "video.quality": "video quality",
+ "video.quality.max": "8k+",
+ "video.quality.2160": "4k",
+ "video.quality.1440": "1440p",
+ "video.quality.1080": "1080p",
+ "video.quality.720": "720p",
+ "video.quality.480": "480p",
+ "video.quality.360": "360p",
+ "video.quality.240": "240p",
+ "video.quality.144": "144p",
+ "video.quality.description": "if preferred video quality isn't available, next best is picked instead.",
+
+ "video.youtube.codec": "youtube codec and container",
+ "video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't as widely supported as h264. if av1 or vp9 isn't available, h264 is used instead.",
+
+ "video.youtube.hls": "youtube hls formats",
+ "video.youtube.hls.title": "prefer hls for video & audio",
+ "video.youtube.hls.description": "files download faster and are less prone to errors or getting abruptly cut off. only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\n\nthis option is experimental, it may go away or change in the future.",
+
+ "video.twitter.gif": "twitter/x",
+ "video.twitter.gif.title": "convert looping videos to GIF",
+ "video.twitter.gif.description": "GIF conversion is inefficient, converted file may be obnoxiously big and low quality.",
+
+ "video.h265": "high efficiency video codec",
+ "video.h265.title": "allow h265 for videos",
+ "video.h265.description": "allows downloading videos from platforms like tiktok and xiaohongshu in higher quality at cost of compatibility.",
+
+ "audio.format": "audio format",
+ "audio.format.best": "best",
+ "audio.format.mp3": "mp3",
+ "audio.format.ogg": "ogg",
+ "audio.format.wav": "wav",
+ "audio.format.opus": "opus",
+ "audio.format.description": "all formats but \"best\" are converted from the source format, there will be some quality loss. when \"best\" format is selected, the audio is kept in its original format whenever possible.",
+
+ "audio.bitrate": "audio bitrate",
+ "audio.bitrate.kbps": "kb/s",
+ "audio.bitrate.description": "bitrate is applied only when converting audio to a lossy format. cobalt can't improve the source audio quality, so choosing a bitrate over 128kbps may inflate the file size with no audible difference. perceived quality may vary by format.",
+
+ "audio.youtube.dub": "youtube audio track",
+ "audio.youtube.dub.title": "preferred dub language",
+ "audio.youtube.dub.description": "cobalt will use a dubbed audio track for selected language if it's available. if not, original will be used instead.",
+ "youtube.dub.original": "original",
+
+ "audio.tiktok.original": "tiktok",
+ "audio.tiktok.original.title": "download original sound",
+ "audio.tiktok.original.description": "cobalt will download the sound from the video without any changes by the post's author.",
+
+ "metadata.filename": "filename style",
+ "metadata.filename.classic": "classic",
+ "metadata.filename.basic": "basic",
+ "metadata.filename.pretty": "pretty",
+ "metadata.filename.nerdy": "nerdy",
+ "metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.",
+
+ "metadata.filename.preview.video": "Video Title",
+ "metadata.filename.preview.audio": "Audio Title - Audio Author",
+
+ "metadata.file": "file metadata",
+ "metadata.disable.title": "disable file metadata",
+ "metadata.disable.description": "title, artist, and other info will not be added to the file.",
+
+ "saving.title": "saving method",
+ "saving.ask": "ask",
+ "saving.download": "download",
+ "saving.share": "share",
+ "saving.copy": "copy",
+ "saving.description": "preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.",
+
+ "accessibility": "accessibility",
+ "accessibility.transparency.title": "reduce visual transparency",
+ "accessibility.transparency.description": "reduces transparency of surfaces and disables blur effects. may also improve ui performance on low performance devices.",
+ "accessibility.motion.title": "reduce motion",
+ "accessibility.motion.description": "disables animations and transitions whenever possible.",
+
+ "language": "language",
+ "language.auto.title": "automatic selection",
+ "language.auto.description": "cobalt will use your browser's default language if translation is available. if not, english will be used instead.",
+ "language.preferred.title": "preferred language",
+ "language.preferred.description": "this language will be used when automatic selection is disabled. any text that isn't translated will be displayed in english.\n\nsome languages use community-sourced translations, they may be inaccurate or incomplete.",
+
+ "privacy.analytics": "anonymous traffic analytics",
+ "privacy.analytics.title": "don't contribute to analytics",
+ "privacy.analytics.description": "anonymous traffic analytics are needed to get an approximate number of active cobalt users. no identifiable information about you is ever stored. all processed data is anonymized and aggregated.\n\nwe use a self-hosted plausible instance that doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.",
+ "privacy.analytics.learnmore": "learn more about plausible's dedication to privacy.",
+
+ "privacy.tunnel": "tunneling",
+ "privacy.tunnel.title": "always tunnel files",
+ "privacy.tunnel.description": "cobalt will hide your ip address, browser info, and bypass local network restrictions. when enabled, files will also have readable filenames that otherwise would be gibberish.",
+
+ "advanced.debug": "debug",
+ "advanced.debug.title": "enable features for nerds",
+ "advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.",
+
+ "advanced.data": "data management",
+
+ "processing.community": "community instances",
+ "processing.enable_custom.title": "use a custom processing server",
+ "processing.enable_custom.description": "cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.",
+
+ "processing.access_key": "instance access key",
+ "processing.access_key.title": "use an instance access key",
+ "processing.access_key.description": "cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!",
+
+ "processing.custom_instance.input.alt_text": "custom instance domain",
+ "processing.access_key.input.alt_text": "u-u-i-d access key"
+}
diff --git a/web/i18n/en/tabs.json b/web/i18n/en/tabs.json
new file mode 100644
index 0000000000000000000000000000000000000000..3cab9cc06a4f2bb958a97671aeef5706297184ca
--- /dev/null
+++ b/web/i18n/en/tabs.json
@@ -0,0 +1,8 @@
+{
+ "save": "save",
+ "settings": "settings",
+ "updates": "updates",
+ "donate": "donate",
+ "about": "about",
+ "remux": "remux"
+}
diff --git a/web/i18n/en/updates.json b/web/i18n/en/updates.json
new file mode 100644
index 0000000000000000000000000000000000000000..3ffeb8b4b78d10a60cf833c83f25bb3fee629107
--- /dev/null
+++ b/web/i18n/en/updates.json
@@ -0,0 +1,4 @@
+{
+ "button.next": "go to older changelog ({{ value }})",
+ "button.previous": "go to newer changelog ({{ value }})"
+}
diff --git a/web/i18n/languages.json b/web/i18n/languages.json
new file mode 100644
index 0000000000000000000000000000000000000000..0760fe1d33d754ee351308e79f7289ad41803eac
--- /dev/null
+++ b/web/i18n/languages.json
@@ -0,0 +1,4 @@
+{
+ "en": "english",
+ "ru": "русский"
+}
diff --git a/web/i18n/ru/a11y/general.json b/web/i18n/ru/a11y/general.json
new file mode 100644
index 0000000000000000000000000000000000000000..64053ecec9c0f1e58b93575ff0d35f8d477dc41b
--- /dev/null
+++ b/web/i18n/ru/a11y/general.json
@@ -0,0 +1,3 @@
+{
+ "back": "назад"
+}
diff --git a/web/i18n/ru/a11y/save.json b/web/i18n/ru/a11y/save.json
new file mode 100644
index 0000000000000000000000000000000000000000..d6def5e5cfe31a9b0ac23376cf913a28ed59023f
--- /dev/null
+++ b/web/i18n/ru/a11y/save.json
@@ -0,0 +1,9 @@
+{
+ "link_area": "зона вставки ссылки",
+ "clear_input": "clear input",
+ "download": "скачать",
+ "download.think": "обрабатываю ссылку...",
+ "download.check": "проверяю загрузку...",
+ "download.done": "загрузка завершена!",
+ "download.error": "ошибка загрузки"
+}
diff --git a/web/i18n/ru/a11y/tabs.json b/web/i18n/ru/a11y/tabs.json
new file mode 100644
index 0000000000000000000000000000000000000000..73f0ffee18cf40fba079f0a3caf6421d4e85ede6
--- /dev/null
+++ b/web/i18n/ru/a11y/tabs.json
@@ -0,0 +1,3 @@
+{
+ "tab_panel": "панель вкладок"
+}
diff --git a/web/i18n/ru/general.json b/web/i18n/ru/general.json
new file mode 100644
index 0000000000000000000000000000000000000000..90cbfef58ae9872b8d4621508d275ce6be4fb6e1
--- /dev/null
+++ b/web/i18n/ru/general.json
@@ -0,0 +1,7 @@
+{
+ "cobalt": "кобальт",
+ "meowbalt": "мяубальт",
+ "beta": "бета",
+
+ "embed.description": "сохраняй то, что любишь: без рекламы, трекеров и прочей чепухи. кобальт создан с любовью, а не с целью заработать."
+}
diff --git a/web/i18n/ru/save.json b/web/i18n/ru/save.json
new file mode 100644
index 0000000000000000000000000000000000000000..ce64917a9f62e3b057ebdaa4926a6c21ed86d95c
--- /dev/null
+++ b/web/i18n/ru/save.json
@@ -0,0 +1,14 @@
+{
+ "paste": "вставить",
+ "paste.long": "вставить и скачать",
+ "auto": "авто",
+ "audio": "аудио",
+ "mute": "без звука",
+ "input.placeholder": "вставь ссылку сюда",
+ "terms.note.agreement": "продолжая, ты соглашаешься с",
+ "terms.note.link": "условиями и этикой использования",
+ "services.title": "поддерживаемые сервисы",
+ "services.title_show": "показать поддерживаемые сервисы",
+ "services.title_hide": "скрыть поддерживаемые сервисы",
+ "services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской."
+}
diff --git a/web/i18n/ru/tabs.json b/web/i18n/ru/tabs.json
new file mode 100644
index 0000000000000000000000000000000000000000..0b93cc7f4dbf4f3e180066e70873d898ab37a5b0
--- /dev/null
+++ b/web/i18n/ru/tabs.json
@@ -0,0 +1,8 @@
+{
+ "save": "скачать",
+ "settings": "настройки",
+ "updates": "новости",
+ "donate": "донаты",
+ "about": "инфа",
+ "remux": "ремукс"
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..96900d0cb248b6dd801cb553db7d513b65b4dd40
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@imput/cobalt-web",
+ "version": "10.9",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "license": "CC-BY-NC-SA-4.0",
+ "engines": {
+ "node": ">=20",
+ "pnpm": ">=9"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/imputnet/cobalt.git"
+ },
+ "bugs": {
+ "url": "https://github.com/imputnet/cobalt/issues"
+ },
+ "homepage": "https://cobalt.tools/",
+ "devDependencies": {
+ "@eslint/js": "^9.5.0",
+ "@fontsource-variable/noto-sans-mono": "^5.0.20",
+ "@fontsource/ibm-plex-mono": "^5.0.13",
+ "@fontsource/redaction-10": "^5.0.2",
+ "@imput/libav.js-remux-cli": "^5.5.6",
+ "@imput/version-info": "workspace:^",
+ "@sveltejs/adapter-static": "^3.0.6",
+ "@sveltejs/kit": "^2.9.1",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
+ "@tabler/icons-svelte": "3.6.0",
+ "@types/eslint__js": "^8.42.3",
+ "@types/fluent-ffmpeg": "^2.1.25",
+ "@types/node": "^20.14.10",
+ "@vitejs/plugin-basic-ssl": "^1.1.0",
+ "compare-versions": "^6.1.0",
+ "dotenv": "^16.0.1",
+ "eslint": "^9.16.0",
+ "glob": "^11.0.0",
+ "mdsvex": "^0.11.2",
+ "mime": "^4.0.4",
+ "svelte": "^4.2.19",
+ "svelte-check": "^3.6.0",
+ "svelte-preprocess": "^6.0.2",
+ "svelte-sitemap": "2.6.0",
+ "sveltekit-i18n": "^2.4.2",
+ "ts-deepmerge": "^7.0.1",
+ "tslib": "^2.4.1",
+ "turnstile-types": "^1.2.2",
+ "typescript": "^5.4.5",
+ "typescript-eslint": "^8.18.0",
+ "vite": "^5.3.6"
+ }
+}
diff --git a/web/src/app.d.ts b/web/src/app.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b30667dec96f99e993f4a962b6e706dbaddd2db9
--- /dev/null
+++ b/web/src/app.d.ts
@@ -0,0 +1,24 @@
+// needed so that changelog files are appropriately
+// typed as svelte components
+declare module '*.md' {
+ import type { SvelteComponentDev } from 'svelte/internal';
+
+ export default class Comp extends SvelteComponentDev {
+ $$prop_def: {};
+ }
+ export const metadata: Record;
+}
+
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/web/src/app.html b/web/src/app.html
new file mode 100644
index 0000000000000000000000000000000000000000..b60acb3c1896a040465e435dbb13758b6f2d2a41
--- /dev/null
+++ b/web/src/app.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+