diff --git a/api/README.md b/api/README.md index 5c281246..d740fb21 100644 --- a/api/README.md +++ b/api/README.md @@ -1,4 +1,66 @@ # 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). + +> [!WARNING] +> the v7 public api (/api/json) will be shut down on **november 11th, 2024**. +> you can access documentation for it [here](https://github.com/imputnet/cobalt/blob/7/docs/api.md). + +## supported services +this list is not final and keeps expanding over time. if support for a service you want is missing, create an 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 | ✅ | ❌ | ✅ | ✅ | ✅ | +| youtube | ✅ | ✅ | ✅ | ✅ | ✅ | + +| emoji | meaning | +| :-----: | :---------------------- | +| ✅ | supported | +| ➖ | impossible/unreasonable | +| ❌ | 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 +71,38 @@ 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. +## acknowledgements +### ffmpeg +cobalt heavily relies on ffmpeg for converting and merging media files. it's an absolutely amazing piece of software offered for anyone for free, yet doesn't receive as much credit as it should. -## 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)! -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 +#### ffmpeg-static +we use [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static) to get binaries for ffmpeg depending on the platform. + +you can support the developer via various methods listed on their github page! (linked above) + +### youtube.js +cobalt relies on [youtube.js](https://github.com/LuanRT/YouTube.js) for interacting with the innertube api, it wouldn't have been possible without it. + +you can support the developer via various methods listed on their github page! (linked above) + +### many others +cobalt 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. +- [esbuild](https://www.npmjs.com/package/esbuild) to minify the frontend files. +- [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. +- [hls-parser](https://www.npmjs.com/package/hls-parser) to parse `m3u8` playlists for certain services. +- [ipaddr.js](https://www.npmjs.com/package/ipaddr.js) to parse ip addresses (for rate limiting). +- [nanoid](https://www.npmjs.com/package/nanoid) to generate unique (temporary) identifiers for each requested stream. +- [node-cache](https://www.npmjs.com/package/node-cache) to cache stream info in server ram for a limited amount of time. +- [psl](https://www.npmjs.com/package/psl) as the domain name parser. +- [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. + +...and many other packages that these packages rely on. diff --git a/api/package.json b/api/package.json index e37c839d..0498fe23 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.4.3", "author": "imput", "exports": "./src/cobalt.js", "type": "module", @@ -10,9 +10,9 @@ }, "scripts": { "start": "node src/cobalt", - "setup": "node src/util/setup", "test": "node src/util/test", - "token:youtube": "node src/util/generate-youtube-tokens" + "token:youtube": "node src/util/generate-youtube-tokens", + "token:jwt": "node src/util/generate-jwt-secret" }, "repository": { "type": "git", @@ -24,26 +24,29 @@ }, "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.1", + "express-rate-limit": "^7.4.1", "ffmpeg-static": "^5.1.0", "hls-parser": "^0.10.7", - "ipaddr.js": "2.1.0", + "ipaddr.js": "2.2.0", "nanoid": "^4.0.2", "node-cache": "^5.1.2", - "psl": "1.9.0", "set-cookie-parser": "2.6.0", "undici": "^5.19.1", "url-pattern": "1.0.3", - "youtubei.js": "^10.3.0", + "youtubei.js": "^11.0.1", "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 c548e792..5cac208d 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 1f00231e..191e8441 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -1,5 +1,6 @@ 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 +14,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, @@ -43,12 +45,35 @@ 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, } 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'); +} + export { env, genericUserAgent, diff --git a/api/src/core/api.js b/api/src/core/api.js index e7eadd78..153f2ca6 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,16 +8,18 @@ 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"; const git = { branch: await getBranch(), @@ -28,7 +31,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 +41,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,35 +59,46 @@ 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: 60000, + limit: 10, + 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) } }) @@ -103,9 +116,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 +127,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 +164,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] - ); - - if (!verifyJwt) { + const [ type, token, ...rest ] = authorization.split(" "); + if (!token || type.toLowerCase() !== 'bearer' || rest.length) { return fail(res, "error.api.auth.jwt.invalid"); } - req.authorized = true; + if (!jwt.verify(token)) { + return fail(res, "error.api.auth.jwt.invalid"); + } + + 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 +198,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") } @@ -187,16 +227,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,8 +263,7 @@ export const runAPI = (express, app, __dirname) => { } }) - app.get('/tunnel', (req, res) => { - console.log("come to tunnel===========================================>"); + 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); @@ -248,7 +282,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(); } @@ -260,7 +294,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); } @@ -280,7 +314,9 @@ export const runAPI = (express, app, __dirname) => { ]); return stream(res, { type: 'internal', ...streamInfo }); - }) + }; + + app.get('/itunnel', itunnelHandler); app.get('/', (_, res) => { res.type('json'); @@ -311,20 +347,48 @@ 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" + + 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" + + "~~~~~~\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" - ) - }) + 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 (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 00000000..56664d15 --- /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 014584ae..8df8fcc6 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 Red(tt) { - return t("\x1b[31m", tt) + +export function Bright(text) { + return wrap('bright', text); } -export function Green(tt) { - return t("\x1b[32m", tt) + +export function Red(text) { + return wrap('red', text); } -export function Cyan(tt) { - return t("\x1b[36m", tt) + +export function Green(text) { + return wrap('green', text); +} + +export function Cyan(text) { + return wrap('cyan', text); +} + +export function Yellow(text) { + return wrap('yellow', text); } diff --git a/api/src/misc/crypto.js b/api/src/misc/crypto.js index 3a520156..e0f8858b 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 10d19aef..21d97d04 100644 --- a/api/src/misc/run-test.js +++ b/api/src/misc/run-test.js @@ -41,4 +41,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 34666d1c..fd497d18 100644 --- a/api/src/misc/utils.js +++ b/api/src/misc/utils.js @@ -1,50 +1,3 @@ -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]]}`) - } - 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 -} - export function getRedirectingURL(url) { return fetch(url, { redirect: 'manual' }).then((r) => { if ([301, 302, 303].includes(r.status) && r.headers.has('location')) diff --git a/api/src/processing/cookie/cookie.js b/api/src/processing/cookie/cookie.js index 6dd95fc3..1d9636d5 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 25bf9c90..c2b37801 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_oauth' +]); +const invalidCookies = {}; let cookies = {}, dirty = false, intervalId; -const setup = async () => { - try { - if (!cookiePath) return; - - cookies = await readFile(cookiePath, 'utf8'); - cookies = JSON.parse(cookies); - intervalId = setInterval(writeChanges, WRITE_INTERVAL) - } catch { /* no cookies for you */ } -} - -setup(); - -function writeChanges() { +function writeChanges(cookiePath) { if (!dirty) return; dirty = false; - writeFile(cookiePath, JSON.stringify(cookies, null, 4)).catch(() => { - clearInterval(intervalId) + 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; }) } -export function getCookie(service) { - if (!cookies[service] || !cookies[service].length) return; +const setupMain = async (cookiePath) => { + try { + cookies = await readFile(cookiePath, 'utf8'); + cookies = JSON.parse(cookies); + 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; - let n; - if (cookies[service][COUNTER] === undefined) { - n = cookies[service][COUNTER] = 0 - } else { - ++cookies[service][COUNTER] - n = (cookies[service][COUNTER] %= cookies[service].length) + 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); + } +} + +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(); } - const cookie = cookies[service][n]; - if (typeof cookie === 'string') cookies[service][n] = Cookie.fromString(cookie); + dirty = false; +} - return cookies[service][n] +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; + + const idx = Math.floor(Math.random() * cookies[service].length); + + const cookie = cookies[service][idx]; + if (typeof cookie === 'string') { + cookies[service][idx] = Cookie.fromString(cookie); + } + + cookies[service][idx].meta = { service, idx }; + return cookies[service][idx]; +} + +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 -} diff --git a/api/src/processing/create-filename.js b/api/src/processing/create-filename.js index 216b15a4..911b5603 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/match-action.js b/api/src/processing/match-action.js index 4fdb24f6..8d2c1e38 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -9,7 +9,7 @@ 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 ? @@ -24,7 +24,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") { @@ -54,20 +54,21 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab 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 } if (host === "reddit" && r.typeId === "redirect") { responseType = "redirect"; @@ -92,12 +93,12 @@ 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, }) @@ -137,13 +138,13 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab } break; + case "ok": case "vk": case "tiktok": params = { type: "proxy" }; break; case "facebook": - case "vine": case "instagram": case "tumblr": case "pinterest": @@ -159,7 +160,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 +184,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 ffb92c23..57f04b36 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"; @@ -78,8 +77,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,13 +97,14 @@ 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) { @@ -127,7 +128,7 @@ export default async function({ host, patternMatch, params }) { 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, @@ -239,7 +234,8 @@ export default async function({ host, patternMatch, params }) { case "bsky": r = await bluesky({ ...patternMatch, - alwaysProxy: params.alwaysProxy + alwaysProxy: params.alwaysProxy, + dispatcher }); break; diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 4287267c..d512bfe5 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; diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index 172d480c..48d8b058 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 8d8bf4ac..81afaf39 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,7 +30,7 @@ export const services = { "reel/:id", "share/:shareType/:id" ], - subdomains: ["web"], + subdomains: ["web", "m"], altDomains: ["fb.watch"], }, instagram: { @@ -46,7 +46,7 @@ export const services = { altDomains: ["ddinstagram.com"], }, loom: { - patterns: ["share/:id"], + patterns: ["share/:id", "embed/:id"], }, ok: { patterns: [ @@ -111,10 +111,10 @@ export const services = { tiktok: { patterns: [ ":user/video/:postId", - ":id", - "t/:id", + ":shortLink", + "t/:shortLink", ":user/photo/:postId", - "v/:id.html" + "v/:postId.html" ], subdomains: ["vt", "vm", "m"], }, @@ -143,10 +143,6 @@ export const services = { subdomains: ["mobile"], altDomains: ["x.com", "vxtwitter.com", "fixvx.com"], }, - vine: { - patterns: ["v/:id"], - tld: "co", - }, vimeo: { patterns: [ ":id", @@ -158,11 +154,17 @@ 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"], }, youtube: { patterns: [ diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index 2105a563..e8c46639 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -36,10 +36,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 +55,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, diff --git a/api/src/processing/services/bluesky.js b/api/src/processing/services/bluesky.js index 5f5cbcec..bc887437 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,7 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => { return { picker }; } -export default async function ({ user, post, alwaysProxy }) { +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 +80,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,7 +95,7 @@ export default async function ({ user, post, alwaysProxy }) { case "InvalidRequest": return { error: "link.unsupported" }; default: - return { error: "fetch.empty" }; + return { error: "content.post.unavailable" }; } } diff --git a/api/src/processing/services/dailymotion.js b/api/src/processing/services/dailymotion.js index a403a16b..a30a8bc7 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/instagram.js b/api/src/processing/services/instagram.js index 17e78ec4..d9a646aa 100644 --- a/api/src/processing/services/instagram.js +++ b/api/src/processing/services/instagram.js @@ -177,7 +177,7 @@ export default function(obj) { if (alwaysProxy) proxyFile = createStream({ service: "instagram", type: "proxy", - u: url, + url, filename: `instagram_${id}_${i + 1}.${itemExt}` }); @@ -189,7 +189,7 @@ 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` }) } @@ -230,7 +230,7 @@ export default function(obj) { if (alwaysProxy) proxyFile = createStream({ service: "instagram", type: "proxy", - u: url, + url, filename: `instagram_${id}_${i + 1}.${itemExt}` }); @@ -242,7 +242,7 @@ export default function(obj) { thumb: createStream({ service: "instagram", type: "proxy", - u: imageUrl, + url: imageUrl, filename: `instagram_${id}_${i + 1}.jpg` }) } @@ -266,6 +266,7 @@ export default function(obj) { } async function getPost(id, alwaysProxy) { + const hasData = (data) => data && data.gql_data !== null; let data, result; try { const cookie = getCookie('instagram'); @@ -282,16 +283,16 @@ 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" }; diff --git a/api/src/processing/services/ok.js b/api/src/processing/services/ok.js index 2fb6082d..10fb785b 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/rutube.js b/api/src/processing/services/rutube.js index 4305241a..5b502452 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 acb6813a..4c62a5ff 100644 --- a/api/src/processing/services/snapchat.js +++ b/api/src/processing/services/snapchat.js @@ -73,7 +73,7 @@ async function getStory(username, storyId, alwaysProxy) { const proxy = createStream({ service: "snapchat", type: "proxy", - u: snapUrl, + url: snapUrl, filename: `snapchat_${username}_${snap.timestampInSec.value}.${snapExt}`, }); @@ -81,7 +81,7 @@ async function getStory(username, storyId, alwaysProxy) { if (snapType === "video") thumbProxy = createStream({ service: "snapchat", type: "proxy", - u: snap.snapUrls.mediaPreviewUrl.value, + url: snap.snapUrls.mediaPreviewUrl.value, }); if (alwaysProxy) snapUrl = proxy; diff --git a/api/src/processing/services/soundcloud.js b/api/src/processing/services/soundcloud.js index 394f7dfe..ad535479 100644 --- a/api/src/processing/services/soundcloud.js +++ b/api/src/processing/services/soundcloud.js @@ -1,5 +1,4 @@ import { env } from "../../config.js"; -import { cleanString } from "../../misc/utils.js"; const cachedID = { version: '', @@ -63,7 +62,17 @@ export default async function(obj) { if (!json) return { error: "fetch.fail" }; - if (!json.media.transcodings) return { error: "fetch.empty" }; + if (json?.policy === "BLOCK") { + return { error: "content.region" }; + } + + if (json?.policy === "SNIP") { + return { error: "content.paid" }; + } + + if (!json?.media?.transcodings || !json?.media?.transcodings.length === 0) { + return { error: "fetch.empty" }; + } let bestAudio = "opus", selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"), @@ -75,6 +84,10 @@ export default async function(obj) { bestAudio = "mp3" } + if (!selectedStream) { + return { error: "fetch.empty" }; + } + let fileUrlBase = selectedStream.url; let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`; @@ -91,8 +104,8 @@ export default async function(obj) { if (!file) return { error: "fetch.empty" }; let fileMetadata = { - title: cleanString(json.title.trim()), - artist: cleanString(json.user.username.trim()), + title: json.title.trim(), + artist: json.user.username.trim(), } return { diff --git a/api/src/processing/services/tiktok.js b/api/src/processing/services/tiktok.js index 3c70e033..6978e071 100644 --- a/api/src/processing/services/tiktok.js +++ b/api/src/processing/services/tiktok.js @@ -12,7 +12,7 @@ export default async function(obj) { let postId = obj.postId; if (!postId) { - let html = await fetch(`${shortDomain}${obj.id}`, { + let html = await fetch(`${shortDomain}${obj.shortLink}`, { redirect: "manual", headers: { "user-agent": genericUserAgent.split(' Chrome/1')[0] @@ -24,7 +24,7 @@ export default async function(obj) { if (html.startsWith('')[1] - .split('')[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 b361b98c..2b8aa4ce 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 ac85fbcf..4b9d4551 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 18866b49..b4a1d557 100644 --- a/api/src/processing/services/twitter.js +++ b/api/src/processing/services/twitter.js @@ -159,10 +159,10 @@ 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) { @@ -208,7 +208,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 +217,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 23e84191..8d704771 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 e3c18e47..33224d69 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}`, { - headers: { - "user-agent": genericUserAgent - } - }) - .then(r => r.arrayBuffer()) - .catch(() => {}); +const clientId = "51552953"; +const clientSecret = "qgr0yWwXCrsxA1jnRtRX"; - if (!html) return { error: "fetch.fail" }; +// 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"; - // decode cyrillic from windows-1251 because vk still uses apis from prehistoric times - let decoder = new TextDecoder('windows-1251'); - html = decoder.decode(html); +const cachedToken = { + token: "", + expiry: 0, + device_id: "", +}; - if (!html.includes(`{"lang":`)) return { error: "fetch.empty" }; - - let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]); - - if (Number(js.mvData.is_active_live) !== 0) { - return { error: "content.video.live" }; +const getToken = async () => { + if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) { + return cachedToken.token; } - if (js.mvData.duration > env.durationLimit) { + 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": 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 => { + if (r.status === 200) { + return r.json(); + } + }); + + return video; +} + +export default async function ({ ownerId, videoId, accessKey, quality }) { + const token = await getToken(); + if (!token) return { error: "fetch.fail" }; + + const videoGet = await getVideo(ownerId, videoId, accessKey); + + if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) { + return { error: "fetch.empty" }; + } + + const video = videoGet.response.items[0]; + + 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 (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}`]; - let fileMetadata = { - title: cleanString(js.player.params[0].md_title.trim()), - author: cleanString(js.player.params[0].md_author.trim()), + if (!url) return { error: "fetch.fail" }; + + 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/youtube.js b/api/src/processing/services/youtube.js index f08489ed..3af6ba94 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -1,16 +1,16 @@ -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"; const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms let innertube, lastRefreshedAt; -const codecMatch = { +const codecList = { h264: { videoCodec: "avc1", audioCodec: "mp4a", @@ -28,6 +28,21 @@ const codecMatch = { } } +const hlsCodecList = { + h264: { + videoCodec: "avc1", + audioCodec: "mp4a", + container: "mp4" + }, + vp9: { + videoCodec: "vp09", + audioCodec: "mp4a", + container: "webm" + } +} + +const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320]; + const transformSessionData = (cookie) => { if (!cookie) return; @@ -71,19 +86,9 @@ const cloneInnertube = async (customFetch) => { const cookie = getCookie('youtube_oauth'); const oauthData = transformSessionData(cookie); - - if (!session.logged_in && oauthData) { - const tokensMod = { - ...oauthData, // 复制 oauthData 中的所有属性 - client: { - client_id: oauthData.client_id, - client_secret: oauthData.client_secret - } - }; - delete tokensMod.client_id; - delete tokensMod.client_secret; - await session.oauth.init(tokensMod); + if (!session.logged_in && oauthData) { + await session.oauth.init(oauthData); session.logged_in = true; } @@ -118,7 +123,7 @@ export default async function(o) { dispatcher: o.dispatcher }) ); - } catch(e) { + } catch (e) { if (e.message?.endsWith("decipher algorithm")) { return { error: "youtube.decipher" } } else if (e.message?.includes("refresh access token")) { @@ -126,29 +131,33 @@ export default async function(o) { } else throw e; } - const quality = o.quality === "max" ? "9000" : o.quality; + let useHLS = o.youtubeHLS; - let info, isDubbed, - format = o.format || "h264"; - - function qual(i) { - if (!i.quality_label) { - return; - } - - return i.quality_label.split('p')[0].split('s')[0] + // HLS playlists don't contain the av1 video format, at least with the iOS client + if (useHLS && o.format === "av1") { + useHLS = false; } + let info; 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") { - return { error: "content.video.unavailable" }; - } else { - return { error: "fetch.fail" }; + info = await yt.getBasicInfo(o.id, useHLS ? 'IOS' : 'ANDROID'); + } catch (e) { + if (e?.info) { + const errorInfo = JSON.parse(e?.info); + + 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" }; + } } + + if (e?.message === "This video is unavailable") { + return { error: "content.video.unavailable" }; + } + + return { error: "fetch.fail" }; } if (!info) return { error: "fetch.fail" }; @@ -156,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")) { + 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) { @@ -196,64 +215,200 @@ 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 quality = o.quality === "max" ? 9000 : Number(o.quality); - let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); - - if (adaptive_formats.length === 0 && format === "vp9") { - format = "h264" - adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats) + const normalizeQuality = res => { + const shortestSide = res.height > res.width ? res.width : res.height; + return videoQualities.find(qual => qual >= shortestSide); } - let bestQuality; + let video, audio, dubbedLanguage, + codec = o.format || "h264"; - const bestVideo = adaptive_formats.find(i => i.has_video && i.content_length); - const hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length); + if (useHLS) { + const hlsManifest = info.streaming_data.hls_manifest_url; - if (bestVideo) bestQuality = qual(bestVideo); + if (!hlsManifest) { + return { error: "youtube.no_hls_streams" }; + } - if ((!bestQuality && !o.isAudioOnly) || !hasAudio) - return { error: "youtube.codec" }; + 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 (basicInfo.duration > env.durationLimit) - return { error: "content.too_long" }; + if (!fetchedHlsManifest) { + return { error: "youtube.no_hls_streams" }; + } - const checkBestAudio = (i) => (i.has_audio && !i.has_video); + const variants = HLS.parse(fetchedHlsManifest).variants.sort( + (a, b) => Number(b.bandwidth) - Number(a.bandwidth) + ); - let audio = adaptive_formats.find(i => - checkBestAudio(i) && i.is_original - ); + if (!variants || variants.length === 0) { + return { error: "youtube.no_hls_streams" }; + } - if (o.dubLang) { - let dubbedAudio = adaptive_formats.find(i => - checkBestAudio(i) - && i.language === o.dubLang - && i.audio_track - ) + const matchHlsCodec = codecs => ( + codecs.includes(hlsCodecList[codec].videoCodec) + ); - if (dubbedAudio && !dubbedAudio?.audio_track?.audio_is_default) { - audio = dubbedAudio; - isDubbed = true; + 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 sorted = sorted_formats[yCodec]; + const goodFormat = checkFormat(format, yCodec); + if (!goodFormat) return; + + if (format.has_video) { + sorted.video.push(format); + if (!sorted.bestVideo) sorted.bestVideo = format; + } + if (format.has_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 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, + }) + } + + 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)); - } - - 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]; @@ -263,61 +418,70 @@ 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" - } + if (audio && o.isAudioOnly) { + let bestAudio = codec === "h264" ? "m4a" : "opus"; + let urls = audio.url; - 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 (useHLS) { + bestAudio = "mp3"; + urls = audio.uri; + } - let match, type, urls; - - // 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); - } - - const video = adaptive_formats.find(checkRender); - - if (!match && video && audio) { - match = video; - type = "merge"; - urls = [ - video.decipher(yt.session.player), - audio.decipher(yt.session.player) - ] - } - - if (match) { - filenameAttributes.qualityLabel = match.quality_label; - filenameAttributes.resolution = `${match.width}x${match.height}`; - filenameAttributes.extension = codecMatch[format].container; - filenameAttributes.youtubeFormat = format; return { - type, + type: "audio", + isAudioOnly: true, urls, filenameAttributes, - fileMetadata + fileMetadata, + bestAudio, + isHLS: useHLS, } } - return { error: "fetch.fail" } + if (video && audio) { + let resolution; + + if (useHLS) { + resolution = normalizeQuality(video.resolution); + filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`; + filenameAttributes.extension = hlsCodecList[codec].container; + + video = video.uri; + audio = audio.uri; + } else { + resolution = normalizeQuality({ + width: video.width, + height: video.height, + }); + filenameAttributes.resolution = `${video.width}x${video.height}`; + filenameAttributes.extension = codecList[codec].container; + + video = video.url; + audio = audio.url; + } + + filenameAttributes.qualityLabel = `${resolution}p`; + filenameAttributes.youtubeFormat = codec; + + return { + type: "merge", + urls: [ + video, + audio, + ], + filenameAttributes, + fileMetadata, + isHLS: useHLS, + } + } + + return { error: "youtube.no_matching_format" }; } diff --git a/api/src/processing/url.js b/api/src/processing/url.js index 034a5d73..64517099 100644 --- a/api/src/processing/url.js +++ b/api/src/processing/url.js @@ -1,4 +1,4 @@ -import psl from "psl"; +import psl from "@imput/psl"; import { strict as assert } from "node:assert"; import { env } from "../config.js"; @@ -42,7 +42,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,6 +85,13 @@ function aliasURL(url) { url.hostname = 'instagram.com'; } break; + + case "vk": + case "vkvideo": + if (services.vk.altDomains.includes(url.hostname)) { + url.hostname = 'vk.com'; + } + break; } return url diff --git a/api/src/security/api-keys.js b/api/src/security/api-keys.js new file mode 100644 index 00000000..d534999c --- /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/secrets.js b/api/src/security/secrets.js new file mode 100644 index 00000000..fff24f84 --- /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 00000000..c2a59ff8 --- /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 00000000..100a0e09 --- /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 00000000..64d11e5e --- /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 00000000..0b359526 --- /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 00000000..e268d88d --- /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 07fcebde..83deb440 100644 --- a/api/src/stream/internal-hls.js +++ b/api/src/stream/internal-hls.js @@ -53,7 +53,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 51552d4c..7d8bf4c9 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -1,7 +1,7 @@ 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; @@ -83,7 +83,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 +93,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 +123,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 e25f4434..79b5c1db 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,26 @@ 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"; // 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 +38,18 @@ export function createStream(obj) { audioBitrate: obj.audioBitrate, audioCopy: !!obj.audioCopy, audioFormat: obj.audioFormat, + + isHLS: obj.isHLS || false, }; + // 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 +75,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 +98,11 @@ export function createInternalStream(url, obj = {}) { service: obj.service, headers, controller, - dispatcher + dispatcher, + isHLS: obj.isHLS, }); - 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 = () => { @@ -146,10 +145,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 91e1ac2f..65af03f0 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/types.js b/api/src/stream/types.js index 184af873..98c3b04e 100644 --- a/api/src/stream/types.js +++ b/api/src/stream/types.js @@ -4,7 +4,6 @@ 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`) @@ -101,12 +123,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 +264,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 00000000..8db6e230 --- /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 34afde7e..2ba555ed 100644 --- a/api/src/util/test.js +++ b/api/src/util/test.js @@ -1,84 +1,129 @@ -import "dotenv/config"; +import path from "node:path"; + +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 { randomizeCiphers } from "../misc/randomize-ciphers.js"; 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"; -env.apiURL = 'http://localhost:9000' -let tests = loadJSON('./src/util/tests.json'); +const getTestPath = service => path.join('./src/util/tests/', `./${service}.json`); +const getTests = (service) => loadJSON(getTestPath(service)); -let noTest = []; -let failed = []; -let success = 0; +// services that are known to frequently fail due to external +// factors (e.g. rate limiting) +const finnicky = new Set(['bilibili', 'instagram', 'facebook', 'youtube', 'vk', 'twitter']); -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]; +const runTestsFor = async (service) => { + const tests = getTests(service); + let softFails = 0, fails = 0; - 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}` - } - - 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", {}) - } - } - console.log("\n\n") - } else { - console.warn(`No tests found for ${i}.`); - noTest.push(i) + 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); + }); + } + } + + return { fails, softFails }; } -console.log(`✅ ${success} tests succeeded.`); -console.log(`❌ ${failed.length} tests failed.`); -console.log(`❔ ${noTest.length} services weren't tested.`); - -if (failed.length > 0) { - console.log(`\nFailed tests:`); - console.log(failed) +const printHeader = (service, padLen) => { + const padding = padLen - service.length; + service = service.padEnd(1 + service.length + padding, ' '); + console.log(service + '='.repeat(50)); } -if (noTest.length > 0) { - console.log(`\nMissing tests:`); - console.log(noTest) +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": + env.streamLifespan = 10000; + env.apiURL = 'http://x/'; + randomizeCiphers(); + + 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 = {}; + + env.streamLifespan = 10000; + env.apiURL = 'http://x/'; + randomizeCiphers(); + + 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 00000000..61d60134 --- /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/lbMyOI9", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "bilibili.tv link", + "url": "https://www.bilibili.tv/en/video/4789599404426256", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/bsky.json b/api/src/util/tests/bsky.json new file mode 100644 index 00000000..840f1169 --- /dev/null +++ b/api/src/util/tests/bsky.json @@ -0,0 +1,78 @@ +[ + { + "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": "redirect" + } + }, + { + "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 00000000..4de9302c --- /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 00000000..876ac7fe --- /dev/null +++ b/api/src/util/tests/facebook.json @@ -0,0 +1,67 @@ +[ + { + "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/", + "canFail": true, + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "reel video", + "url": "https://web.facebook.com/reel/730293269054758", + "canFail": true, + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "shared video link", + "url": "https://www.facebook.com/share/v/NEf87jbPTvFE8LsL/", + "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" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/instagram.json b/api/src/util/tests/instagram.json new file mode 100644 index 00000000..2ee42194 --- /dev/null +++ b/api/src/util/tests/instagram.json @@ -0,0 +1,123 @@ +[ + { + "name": "single photo post", + "url": "https://www.instagram.com/p/CwIgW8Yu5-I/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "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/CoEBV3eM4QR/", + "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/CoEBV3eM4QR/", + "params": { + "downloadMode": "audio" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "reel (isAudioMuted)", + "url": "https://www.instagram.com/reel/CoEBV3eM4QR/", + "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" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/loom.json b/api/src/util/tests/loom.json new file mode 100644 index 00000000..1849a000 --- /dev/null +++ b/api/src/util/tests/loom.json @@ -0,0 +1,33 @@ +[ + { + "name": "1080p video", + "url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "1080p video (muted)", + "url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761", + "params": { + "downloadMode": "mute" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "1080p video (audio only)", + "url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761", + "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 00000000..8eb103eb --- /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 00000000..2f15fb0b --- /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": "redirect" + } + }, + { + "name": "regular picture (.ca TLD)", + "url": "https://www.pinterest.ca/pin/412994228343400946/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular gif", + "url": "https://www.pinterest.com/pin/814447913881127862/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "regular gif (.ca TLD)", + "url": "https://www.pinterest.ca/pin/814447913881127862/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + } +] \ 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 00000000..3afc6126 --- /dev/null +++ b/api/src/util/tests/reddit.json @@ -0,0 +1,60 @@ +[ + { + "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" + } + } +] \ 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 00000000..2eaf69bf --- /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 00000000..44f764ce --- /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" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/soundcloud.json b/api/src/util/tests/soundcloud.json new file mode 100644 index 00000000..04ed8632 --- /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 00000000..bf03c228 --- /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 00000000..c8dbce8c --- /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 00000000..87352255 --- /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 00000000..fd6b84af --- /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 00000000..0024d097 --- /dev/null +++ b/api/src/util/tests/twitter.json @@ -0,0 +1,213 @@ +[ + { + "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/uwukko/status/1696901469633421344", + "params": {}, + "canFail": true, + "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": "redirect" + } + }, + { + "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": "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=1837430141179289876", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + } +] \ No newline at end of file diff --git a/api/src/util/tests/vimeo.json b/api/src/util/tests/vimeo.json new file mode 100644 index 00000000..6c44a47d --- /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 00000000..71720af5 --- /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/youtube.json b/api/src/util/tests/youtube.json new file mode 100644 index 00000000..0655e683 --- /dev/null +++ b/api/src/util/tests/youtube.json @@ -0,0 +1,240 @@ +[ + { + "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", + "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", + "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", + "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", + "params": { + "downloadMode": "audio", + "youtubeHLS": true, + "audioFormat": "best" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + } +] \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 220a2cf3..ec6b0095 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 @@ -26,11 +32,11 @@ importers: specifier: ^0.14.51 version: 0.14.54 express: - specifier: ^4.18.1 - version: 4.19.2 + specifier: ^4.21.1 + 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 +44,14 @@ 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 set-cookie-parser: specifier: 2.6.0 version: 2.6.0 @@ -59,8 +62,8 @@ importers: specifier: 1.0.3 version: 1.0.3 youtubei.js: - specifier: ^10.3.0 - version: 10.3.0 + specifier: ^11.0.1 + version: 11.0.1 zod: specifier: ^3.23.8 version: 3.23.8 @@ -68,6 +71,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: @@ -182,6 +191,15 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@bufbuild/protobuf@2.2.3': + resolution: {integrity: sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==} + + '@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'} @@ -525,6 +543,9 @@ packages: '@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'} @@ -566,6 +587,35 @@ packages: '@polka/url@1.0.0-next.25': resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} + '@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.19.2': resolution: {integrity: sha512-OHflWINKtoCFSpm/WmuQaWW4jeX+3Qt3XQDepkkiFTsoxFc5BpF3Z5aDxFZgBqRjO6ATP5+b1iilp4kGIZVWlA==} cpu: [arm] @@ -856,8 +906,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: @@ -914,6 +964,10 @@ packages: 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 +1014,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'} @@ -1047,6 +1105,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'} @@ -1251,14 +1313,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: @@ -1289,8 +1351,8 @@ packages: 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: @@ -1330,6 +1392,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'} @@ -1448,6 +1514,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'} @@ -1485,8 +1555,8 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jintr@2.1.1: - resolution: {integrity: sha512-89cwX4ouogeDGOBsEVsVYsnWWvWjchmwXBB4kiBhmjOKw19FiOKhNhMhpxhTlK2ctl7DS+d/ethfmuBpzoNNgA==} + jintr@3.1.0: + resolution: {integrity: sha512-azhCHApkRfBH8INpiUCwKBYaNCdB5G+x3NApsI2MxQXSlgFAx7rap3YwE3JAkN08GO8f3ilZsGB0Yvc+412ntQ==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -1558,8 +1628,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==} @@ -1730,8 +1800,8 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@0.1.7: - resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -1797,15 +1867,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 +1882,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 +1900,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'} @@ -1875,12 +1951,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: @@ -2271,12 +2347,15 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + 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@11.0.1: + resolution: {integrity: sha512-ZsbOd+5XF2Ofi3FrLMfYd+f9g9H8xswlouFhjhOqbwT68dMJtX6CRGsHNj5VTFCR/+L/865x1lnUlllB2dDDTA==} zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -2288,6 +2367,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@bufbuild/protobuf@2.2.3': {} + + '@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 @@ -2486,6 +2573,10 @@ snapshots: '@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 @@ -2529,6 +2620,38 @@ snapshots: '@polka/url@1.0.0-next.25': {} + '@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.19.2': optional: true @@ -2808,7 +2931,7 @@ snapshots: 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 +2941,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 @@ -2882,6 +3005,9 @@ snapshots: clone@2.1.2: {} + cluster-key-slot@1.1.2: + optional: true + code-red@1.0.4: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -2923,6 +3049,8 @@ snapshots: cookie@0.6.0: {} + cookie@0.7.1: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -2987,6 +3115,8 @@ snapshots: encodeurl@1.0.2: {} + encodeurl@2.0.0: {} + env-paths@2.2.1: {} es-define-property@1.0.0: @@ -3226,38 +3356,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 @@ -3301,10 +3431,10 @@ snapshots: 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 @@ -3349,6 +3479,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 @@ -3471,7 +3604,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: @@ -3503,7 +3639,7 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jintr@2.1.1: + jintr@3.1.0: dependencies: acorn: 8.12.1 @@ -3564,7 +3700,7 @@ snapshots: media-typer@0.3.0: {} - merge-descriptors@1.0.1: {} + merge-descriptors@1.0.3: {} merge-stream@2.0.0: {} @@ -3695,7 +3831,7 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@0.1.7: {} + path-to-regexp@0.1.12: {} path-type@4.0.0: {} @@ -3738,11 +3874,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 +3884,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 +3906,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: {} @@ -3824,7 +3973,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 +3991,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 @@ -4183,11 +4332,15 @@ snapshots: wrappy@1.0.2: {} + yallist@4.0.0: + optional: true + yocto-queue@0.1.0: {} - youtubei.js@10.3.0: + youtubei.js@11.0.1: dependencies: - jintr: 2.1.1 + '@bufbuild/protobuf': 2.2.3 + jintr: 3.1.0 tslib: 2.6.3 undici: 5.28.4 diff --git a/web/.firebaserc b/web/.firebaserc new file mode 100644 index 00000000..789f3e66 --- /dev/null +++ b/web/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "cobalt-bamboo" + } +} diff --git a/web/firebase.json b/web/firebase.json new file mode 100644 index 00000000..340ed5b7 --- /dev/null +++ b/web/firebase.json @@ -0,0 +1,16 @@ +{ + "hosting": { + "public": "build", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } +} diff --git a/web/i18n/en/about.json b/web/i18n/en/about.json index cfe9129f..81487166 100644 --- a/web/i18n/en/about.json +++ b/web/i18n/en/about.json @@ -1,5 +1,5 @@ { - "page.general": "what's cobalt?", + "page.general": "what's bamboo download?", "page.faq": "FAQ", "page.community": "community & support", diff --git a/web/i18n/en/about/general.md b/web/i18n/en/about/general.md index 2334ab8e..8a48bc5c 100644 --- a/web/i18n/en/about/general.md +++ b/web/i18n/en/about/general.md @@ -11,22 +11,12 @@ sectionId="summary" /> -cobalt helps you save anything from your favorite websites: video, audio, photos or gifs. just paste the link and you're ready to rock! +bamboo download 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. - -it's possible to keep the main instances up thanks to our long-standing infrastructure partner, [royalehosting.net]({partners.royalehosting})! -
-
- - -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! -
-these terms are applicable only when using the official cobalt instance. +these terms are applicable only when using the official freesavevideo instance. in other cases, you may need to contact the hoster for accurate info.
@@ -48,8 +48,8 @@ fair use and credits benefit everyone. sectionId="abuse" /> -we have no way of detecting abusive behavior automatically, as cobalt is 100% anonymous. -however, you can report such activities to us and we will do our best to comply manually: [safety@imput.net](mailto:safety@imput.net) +we have no way of detecting abusive behavior automatically, as freesavevideo is 100% anonymous. + please note that this email is not intended for user support. if you're experiencing issues, contact us via any preferred method on [the support page](/about/community). diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index a2688f6d..0f7a1283 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -8,18 +8,18 @@ "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.", + "saving.blocked": "bamboo download tried opening the file in a new tab, but your browser blocked it. you can allow pop-ups for bamboo download to prevent this from happening next time.", + "saving.timeout": "bamboo download 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.", + "import.body": "importing unknown or corrupted files may unexpectedly alter or break bamboo download 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.", + "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from bamboo download 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.ongoing": "bamboo download 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 index 6907e4c4..01380b67 100644 --- a/web/i18n/en/donate.json +++ b/web/i18n/en/donate.json @@ -1,5 +1,5 @@ { - "banner.title": "Support a safe\nand open Internet", + "banner.title": "This website is great, a little appreciation!", "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.", diff --git a/web/i18n/en/general.json b/web/i18n/en/general.json index c3667f26..02b454ee 100644 --- a/web/i18n/en/general.json +++ b/web/i18n/en/general.json @@ -1,7 +1,12 @@ { - "cobalt": "cobalt", - "meowbalt": "meowbalt", + "cobalt": "bamboo download, Download Videos for Free - YouTube, TikTok, Bilibili, Instagram, Facebook, Twitter", + "meowbalt": "bamboo download", "beta": "beta", + "embed.description": "Download videos for free from YouTube, TikTok, Bilibili, Instagram, Facebook, and Twitter. No registration needed. Easy and fast video downloads.", + "guide": { + "title": "Freesavevideo.online Online Video Downloader Guide", + "description1": "Use Freesavevideo.online, the leading online video downloader to download videos and music. No extra software is required; you can save your favorite media directly from the web. Our intuitive platform makes video downloading simple and efficient.", + "description2": "Easily access and download various content, from popular movies and TV shows to exciting sports clips. Simply paste the video URL into the designated field and click the download button." + } - "embed.description": "save what you love without ads, tracking, paywalls or other nonsense. cobalt is a truly open web app, built with love and care by imput." } diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json index 79a65dfb..751500ce 100644 --- a/web/i18n/en/save.json +++ b/web/i18n/en/save.json @@ -10,7 +10,7 @@ "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.", + "services.disclaimer": "freesavevideo 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.", diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index fba788e2..bdbefb43 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -50,22 +50,22 @@ "audio.bitrate": "audio bitrate", "audio.bitrate.kbps": "kb/s", - "audio.bitrate.description": "bitrate applies only to audio conversion. 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.bitrate.description": "bitrate applies only to audio conversion. freesavevideo 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.youtube.dub.title": "use browser language for dubbed videos", - "audio.youtube.dub.description": "works even if cobalt isn't translated to your language.", + "audio.youtube.dub.description": "works even if freesavevideo isn't translated to your language.", "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.", + "audio.tiktok.original.description": "freesavevideo 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.description": "filename style will only be used for files tunneled by freesavevideo. some services don't support filename styles other than classic.", "metadata.filename.preview.video": "Video Title", "metadata.filename.preview.audio": "Audio Title - Audio Author", @@ -79,7 +79,7 @@ "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.", + "saving.description": "preferred way of saving the file or link from freesavevideo. if preferred method is unavailable or something goes wrong, freesavevideo will ask you what to do next.", "accessibility": "accessibility", "accessibility.transparency.title": "reduce visual transparency", @@ -89,18 +89,18 @@ "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.auto.description": "freesavevideo 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\nwe use community-sourced translations for languages other than english, russian, and czech. 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.description": "anonymous traffic analytics are needed to get an approximate number of active freesavevideo 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.", + "privacy.tunnel.description": "freesavevideo 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 debug features", @@ -115,7 +115,7 @@ "processing.community": "community instances", "processing.enable_custom.title": "use a custom processing server", - "processing.enable_custom.description": "cobalt will use a custom processing server 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.enable_custom.description": "freesavevideo will use a custom processing server if you choose to. even though freesavevideo 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.custom.placeholder": "custom instance domain" } diff --git a/web/i18n/en/tabs.json b/web/i18n/en/tabs.json index 3cab9cc0..c0c17cc6 100644 --- a/web/i18n/en/tabs.json +++ b/web/i18n/en/tabs.json @@ -1,8 +1,9 @@ { - "save": "save", + "save": "index", "settings": "settings", "updates": "updates", "donate": "donate", "about": "about", - "remux": "remux" + "remux": "remux", + "faq": "FAQ" } diff --git a/web/i18n/languages.json b/web/i18n/languages.json index 0760fe1d..a8154eac 100644 --- a/web/i18n/languages.json +++ b/web/i18n/languages.json @@ -1,4 +1,6 @@ { "en": "english", - "ru": "русский" + "ru": "русский", + "zh": "中文", + "th": "ภาษาไทย" } diff --git a/web/i18n/ru/tabs.json b/web/i18n/ru/tabs.json index 0b93cc7f..6137071c 100644 --- a/web/i18n/ru/tabs.json +++ b/web/i18n/ru/tabs.json @@ -1,5 +1,5 @@ { - "save": "скачать", + "save": "первая страница", "settings": "настройки", "updates": "новости", "donate": "донаты", diff --git a/web/i18n/th/a11y/dialog.json b/web/i18n/th/a11y/dialog.json new file mode 100644 index 00000000..84df736b --- /dev/null +++ b/web/i18n/th/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/th/a11y/donate.json b/web/i18n/th/a11y/donate.json new file mode 100644 index 00000000..625eee91 --- /dev/null +++ b/web/i18n/th/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/th/a11y/general.json b/web/i18n/th/a11y/general.json new file mode 100644 index 00000000..30c862e1 --- /dev/null +++ b/web/i18n/th/a11y/general.json @@ -0,0 +1,3 @@ +{ + "back": "go back" +} diff --git a/web/i18n/th/a11y/save.json b/web/i18n/th/a11y/save.json new file mode 100644 index 00000000..2dc85154 --- /dev/null +++ b/web/i18n/th/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/th/a11y/tabs.json b/web/i18n/th/a11y/tabs.json new file mode 100644 index 00000000..7eafb56f --- /dev/null +++ b/web/i18n/th/a11y/tabs.json @@ -0,0 +1,3 @@ +{ + "tab_panel": "tabs panel" +} diff --git a/web/i18n/th/about.json b/web/i18n/th/about.json new file mode 100644 index 00000000..20b6c200 --- /dev/null +++ b/web/i18n/th/about.json @@ -0,0 +1,31 @@ +{ + "page.general": "ดาวน์โหลด Bamboo คืออะไร?", + "page.faq": "คำถามที่พบบ่อย", + + "page.community": "ชุมชนและการสนับสนุน", + + "page.privacy": "นโยบายความเป็นส่วนตัว", + "page.terms": "ข้อกำหนดและจริยธรรม", + "page.credits": "ขอบคุณและใบอนุญาต", + + "community.discord": "เซิร์ฟเวอร์ Discord ชุมชน", + "community.twitter": "บัญชีข่าวบน Twitter", + "community.github": "ที่เก็บ GitHub", + "community.email": "อีเมลสนับสนุน", + "community.telegram": "ช่องข่าวบน Telegram", + + "heading.general": "ข้อกำหนดทั่วไป", + "heading.licenses": "ใบอนุญาต", + "heading.summary": "วิธีที่ดีที่สุดในการบันทึกสิ่งที่คุณรัก", + "heading.privacy": "ความเป็นส่วนตัวชั้นนำ", + "heading.community": "ชุมชนแบบเปิด", + "heading.local": "การประมวลผลบนอุปกรณ์", + "heading.saving": "การบันทึก", + "heading.encryption": "การเข้ารหัส", + "heading.plausible": "การวิเคราะห์การเข้าชมแบบไม่ระบุตัวตน", + "heading.cloudflare": "ความเป็นส่วนตัวและความปลอดภัยบนเว็บ", + "heading.responsibility": "ความรับผิดชอบของผู้ใช้", + "heading.abuse": "รายงานการละเมิด", + "heading.motivation": "แรงจูงใจ", + "heading.testers": "ผู้ทดสอบเบต้า" +} diff --git a/web/i18n/th/about/credits.md b/web/i18n/th/about/credits.md new file mode 100644 index 00000000..6c001f58 --- /dev/null +++ b/web/i18n/th/about/credits.md @@ -0,0 +1,52 @@ + + +
+ + +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/th/about/general.md b/web/i18n/th/about/general.md new file mode 100644 index 00000000..56b8e1ba --- /dev/null +++ b/web/i18n/th/about/general.md @@ -0,0 +1,49 @@ + + +
+ + +bamboo download 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. +
+ + +
+ + +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, bamboo download 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, bamboo download 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 bamboo download instance. +
+ + +
+ + +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/th/about/privacy.md b/web/i18n/th/about/privacy.md new file mode 100644 index 00000000..b19ca762 --- /dev/null +++ b/web/i18n/th/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. +
+{/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/th/about/terms.md b/web/i18n/th/about/terms.md new file mode 100644 index 00000000..ff08471a --- /dev/null +++ b/web/i18n/th/about/terms.md @@ -0,0 +1,56 @@ + + +
+ + +these terms are applicable only when using the official freesavevideo 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, as freesavevideo is 100% anonymous. + + +please note that this email is not intended for user support. +if you're experiencing issues, contact us via any preferred method on [the support page](/about/community). +
diff --git a/web/i18n/th/button.json b/web/i18n/th/button.json new file mode 100644 index 00000000..e32cd901 --- /dev/null +++ b/web/i18n/th/button.json @@ -0,0 +1,20 @@ +{ + "gotit": "เข้าใจแล้ว", + "cancel": "ยกเลิก", + "reset": "รีเซ็ต", + "done": "เสร็จสิ้น", + "download.audio": "ดาวน์โหลดเสียง", + "download": "ดาวน์โหลด", + "share": "แชร์", + "copy": "คัดลอก", + "copy.section": "คัดลอกลิงก์ส่วน", + "copied": "คัดลอกแล้ว", + "import": "นำเข้า", + "continue": "ดำเนินการต่อ", + "star": "ดาว", + "follow": "ติดตาม", + "save": "บันทึก", + "export": "ส่งออก", + "yes": "ใช่", + "no": "ไม่" +} diff --git a/web/i18n/th/dialog.json b/web/i18n/th/dialog.json new file mode 100644 index 00000000..b7e61296 --- /dev/null +++ b/web/i18n/th/dialog.json @@ -0,0 +1,25 @@ +{ + "reset.title": "รีเซ็ตการตั้งค่าทั้งหมด?", + "reset.body": "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตการตั้งค่าทั้งหมด? การกระทำนี้จะเกิดขึ้นทันทีและไม่สามารถย้อนกลับได้", + + "picker.title": "เลือกสิ่งที่ต้องการบันทึก", + "picker.description.desktop": "คลิกที่รายการเพื่อบันทึก รูปภาพสามารถบันทึกได้ผ่านเมนูคลิกขวา", + "picker.description.phone": "กดที่รายการเพื่อบันทึก รูปภาพสามารถบันทึกได้ด้วยการกดค้าง", + "picker.description.ios": "กดที่รายการเพื่อบันทึกด้วยทางลัด รูปภาพสามารถบันทึกได้ด้วยการกดค้าง", + + "saving.title": "เลือกวิธีการบันทึก", + "saving.blocked": "Cobalt พยายามเปิดไฟล์ในแท็บใหม่ แต่เบราว์เซอร์ของคุณบล็อกไว้ คุณสามารถอนุญาตให้มีป๊อปอัพสำหรับ Cobalt เพื่อป้องกันไม่ให้เกิดเหตุการณ์นี้ในครั้งหน้า", + "saving.timeout": "Cobalt พยายามบันทึกไฟล์โดยอัตโนมัติ แต่เบราว์เซอร์ของคุณหยุดมันไว้ คุณต้องเลือกวิธีที่ต้องการด้วยตัวเอง", + + "safety.title": "ประกาศความปลอดภัยที่สำคัญ", + + "import.body": "การนำเข้าไฟล์ที่ไม่รู้จักหรือเสียหายอาจทำให้การทำงานของ Cobalt เปลี่ยนแปลงหรือเสียหายได้โดยไม่คาดคิด โปรดนำเข้าเฉพาะไฟล์ที่คุณได้ส่งออกด้วยตัวเองและยังไม่ได้ทำการแก้ไข หากมีใครขอให้คุณนำเข้าไฟล์นี้ - อย่าทำ\n\nเราไม่รับผิดชอบต่อความเสียหายใดๆ ที่เกิดจากการนำเข้าไฟล์การตั้งค่าที่ไม่รู้จัก", + + "api.override.title": "การแทนที่อินสแตนซ์การประมวลผล", + "api.override.body": "{{ value }} เป็นอินสแตนซ์การประมวลผลใหม่ หากคุณไม่ไว้วางใจ กด \"ยกเลิก\" และมันจะถูกละเว้น\n\nคุณสามารถเปลี่ยนแปลงตัวเลือกนี้ในภายหลังในการตั้งค่าการประมวลผล", + + "safety.custom_instance.body": "อินสแตนซ์ที่กำหนดเองอาจก่อให้เกิดความเสี่ยงด้านความเป็นส่วนตัวและความปลอดภัย\n\nอินสแตนซ์ที่ไม่ดีอาจ:\n1. เปลี่ยนเส้นทางคุณออกจาก Cobalt และพยายามหลอกลวง\n2. บันทึกข้อมูลทั้งหมดเกี่ยวกับคำขอของคุณ เก็บข้อมูลนั้นไว้ตลอดไป และใช้เพื่อติดตามคุณ\n3. ให้บริการไฟล์ที่เป็นอันตราย (เช่น มัลแวร์)\n4. บังคับให้คุณดูโฆษณาหรือเรียกเก็บเงินสำหรับการดาวน์โหลด\n\nหลังจากจุดนี้ เราไม่สามารถปกป้องคุณได้ โปรดระมัดระวังในการใช้อินสแตนซ์ที่เลือก และเชื่อสัญชาตญาณของคุณ หากคุณรู้สึกว่ามีอะไรผิดปกติ ให้กลับมาที่หน้านี้ รีเซ็ตอินสแตนซ์ที่กำหนดเอง และรายงานให้เราทราบผ่าน GitHub", + + "processing.ongoing": "Cobalt กำลังประมวลผลสื่อในแท็บนี้ หากคุณออกไปจะเป็นการยกเลิกการประมวลผล คุณแน่ใจหรือว่าต้องการทำสิ่งนี้?", + "processing.title.ongoing": "การประมวลผลจะถูกยกเลิก" +} diff --git a/web/i18n/th/donate.json b/web/i18n/th/donate.json new file mode 100644 index 00000000..a6571660 --- /dev/null +++ b/web/i18n/th/donate.json @@ -0,0 +1,37 @@ +{ + "banner.title": "เว็บไซต์นี้ดีมาก ขอยกนิ้วให้!", + "banner.subtitle": "บริจาคให้กับ imput หรือแบ่งปัน\nความสุขของ cobalt กับเพื่อนของคุณ", + + "body.motivation": "cobalt ช่วยผู้ผลิต นักการศึกษา นักทำวิดีโอ และอีกหลายๆ คนในการทำสิ่งที่พวกเขารัก นี่เป็นบริการประเภทที่แตกต่างซึ่งสร้างขึ้นด้วยความรัก ไม่ใช่เพื่อแสวงหากำไร", + "body.no_bullshit": "เราเชื่อว่าอินเทอร์เน็ตไม่จำเป็นต้องน่ากลัว นั่นคือเหตุผลที่ cobalt จะไม่มีโฆษณาหรือเนื้อหาที่เป็นอันตรายใดๆ นี่คือคำสัญญาที่เรายึดมั่น ทุกสิ่งที่เราทำถูกสร้างขึ้นโดยคำนึงถึงความเป็นส่วนตัว การเข้าถึงได้ง่าย และใช้งานสะดวก ทำให้ cobalt สามารถเข้าถึงได้สำหรับทุกคน", + "body.keep_going": "หากคุณพบว่า cobalt มีประโยชน์ โปรดพิจารณาสนับสนุนงานของเรา! คุณสามารถช่วยเราได้โดยการบริจาคหรือแบ่งปัน cobalt กับเพื่อน ทุกการบริจาคมีค่ามากและช่วยให้เราสามารถทำงานใน cobalt และโครงการอื่นๆ ต่อไปได้", + + "card.once": "บริจาคครั้งเดียว", + "card.recurring": "บริจาคซ้ำ", + "card.custom": "จำนวนเงินที่กำหนดเอง (เริ่มต้นที่ $2)", + + "card.processor": "ผ่าน {{value}}", + + "card.option.5": "กาแฟหนึ่งแก้ว", + "card.option.10": "พิซซ่าขนาดใหญ่", + "card.option.15": "มื้ออาหารเต็มรูปแบบ", + "card.option.30": "มื้ออาหารสำหรับสองคน", + "card.option.50": "อาหารแมว 10 กิโลกรัม", + "card.option.100": "โดเมนหนึ่งปี", + "card.option.200": "หม้อทอดไร้น้ำมัน", + "card.option.500": "เก้าอี้สำนักงานสุดหรู", + "card.option.1599": "MacBook Pro รุ่นพื้นฐาน", + "card.option.4900": "แอปเปิ้ล 10,000 ลูก", + "card.option.7398": "MacBook Pro รุ่นสูงสุด", + "card.option.8629": "ที่ดินแปลงเล็กๆ", + "card.option.9433": "อ่างน้ำร้อนสุดหรู", + + "card.custom.submit": "บริจาคจำนวนเงินที่กำหนดเอง", + + "share.title": "แบ่งปัน cobalt กับเพื่อนของคุณ", + + "alternative.title": "วิธีการบริจาคอื่นๆ", + + "alt.copy": "{{ value }}. ที่อยู่กระเป๋าเงินคริปโต กดเพื่อคัดลอก", + "alt.open": "{{ value }}. กดเพื่อเปิด" +} diff --git a/web/i18n/th/error.json b/web/i18n/th/error.json new file mode 100644 index 00000000..2f29f10d --- /dev/null +++ b/web/i18n/th/error.json @@ -0,0 +1,54 @@ +{ + "import.no_data": "ไม่มีข้อมูลที่จะโหลดจากไฟล์ คุณแน่ใจหรือว่านี่เป็นไฟล์ที่ถูกต้อง?", + "import.invalid": "ไฟล์ของคุณไม่มีการตั้งค่า Cobalt ที่ถูกต้องสำหรับการนำเข้า คุณแน่ใจหรือว่านี่เป็นไฟล์ที่ถูกต้อง?", + "import.unknown": "ไม่สามารถโหลดข้อมูลจากไฟล์ได้ ไฟล์อาจเสียหายหรือมีรูปแบบผิด นี่คือข้อผิดพลาดที่ได้รับ:\n\n{{ value }}", + + "remux.corrupted": "ไม่สามารถอ่านข้อมูลเมตาจากไฟล์นี้ได้ ไฟล์อาจเสียหาย", + "remux.out_of_resources": "Cobalt หมดทรัพยากรและไม่สามารถดำเนินการประมวลผลบนอุปกรณ์ต่อได้ ซึ่งเกี่ยวข้องกับข้อจำกัดของเบราว์เซอร์ของคุณ ลองรีเฟรชหรือเปิดแอปใหม่แล้วลองอีกครั้ง อุปกรณ์บางเครื่องสามารถประมวลผลได้เฉพาะไฟล์ขนาดเล็กเท่านั้น", + + "tunnel.probe": "ไม่สามารถตรวจสอบได้ว่าคุณสามารถดาวน์โหลดไฟล์นี้ได้ ลองใหม่อีกครั้งในไม่กี่วินาที!", + + "captcha_ongoing": "กำลังตรวจสอบว่าคุณไม่ใช่บอท รอให้ตัวหมุนหายไปแล้วลองอีกครั้ง\n\nหากใช้เวลานานเกินไป โปรดแจ้งให้เราทราบ! เราใช้ Cloudflare Turnstile สำหรับการป้องกันบอท และบางครั้งมันบล็อกคนโดยไม่มีเหตุผล", + + "api.auth.jwt.missing": "ไม่สามารถยืนยันได้ว่าคุณไม่ใช่บอทเนื่องจากเซิร์ฟเวอร์ประมวลผลไม่ได้รับโทเค็นการเข้าถึงของมนุษย์ ลองอีกครั้งในไม่กี่วินาทีหรือลองโหลดหน้าใหม่!", + "api.auth.jwt.invalid": "ไม่สามารถยืนยันได้ว่าคุณไม่ใช่บอทเนื่องจากโทเค็นการเข้าถึงของมนุษย์ของคุณหมดอายุและไม่ได้ต่ออายุ ลองอีกครั้งในไม่กี่วินาทีหรือลองโหลดหน้าใหม่!", + "api.auth.turnstile.missing": "ไม่สามารถยืนยันได้ว่าคุณไม่ใช่บอทเนื่องจากเซิร์ฟเวอร์ประมวลผลไม่ได้รับโทเค็นการเข้าถึงของมนุษย์ ลองอีกครั้งในไม่กี่วินาทีหรือลองโหลดหน้าใหม่!", + "api.auth.turnstile.invalid": "ไม่สามารถยืนยันได้ว่าคุณไม่ใช่บอทเนื่องจากโทเค็นการเข้าถึงของมนุษย์ของคุณหมดอายุและไม่ได้ต่ออายุ ลองอีกครั้งในไม่กี่วินาทีหรือลองโหลดหน้าใหม่!", + + "api.unreachable": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ประมวลผลได้ ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณแล้วลองอีกครั้ง", + "api.timed_out": "เซิร์ฟเวอร์ประมวลผลใช้เวลานานเกินไปในการตอบสนอง อาจมีการทำงานเกินกำลังในขณะนี้ ลองใหม่อีกครั้งในไม่กี่วินาที!", + "api.rate_exceeded": "คุณทำการร้องขอมากเกินไป ลองใหม่อีกครั้งใน {{ limit }} วินาที!", + "api.capacity": "Cobalt ทำงานเต็มความสามารถแล้วและไม่สามารถประมวลผลคำขอของคุณได้ในขณะนี้ ลองใหม่อีกครั้งในไม่กี่วินาที หากยังไม่ได้ผล โปรดแจ้งให้เราทราบแล้วเราจะพยายามช่วยเหลือ!", + + "api.generic": "มีบางอย่างผิดพลาดและฉันไม่สามารถหาข้อมูลให้คุณได้ ลองใหม่อีกครั้งในไม่กี่วินาที แต่หากปัญหายังคงอยู่ โปรดแจ้งให้เราทราบแล้วเราจะพยายามช่วยเหลือ!", + "api.unknown_response": "ไม่สามารถแยกวิเคราะห์การตอบสนองจากเซิร์ฟเวอร์ได้ อาจเกิดจากความไม่ตรงกันของเวอร์ชัน คุณแน่ใจหรือว่ากำลังใช้เวอร์ชันล่าสุดของ Cobalt?", + + "api.service.unsupported": "บริการนี้ยังไม่รองรับ คุณได้วางลิงก์ที่ถูกต้องแล้วหรือไม่?", + "api.service.disabled": "บริการนี้รองรับโดย Cobalt แต่ถูกปิดการใช้งานในอินสแตนซ์นี้ ลองใช้ลิงก์จากบริการอื่น!", + + "api.link.invalid": "ลิงก์ของคุณไม่ถูกต้องหรือบริการนี้ยังไม่รองรับ คุณได้วางลิงก์ที่ถูกต้องแล้วหรือไม่?", + "api.link.unsupported": "{{ service }} รองรับแล้ว แต่ฉันไม่สามารถจดจำลิงก์ของคุณได้ คุณได้วางลิงก์ที่ถูกต้องแล้วหรือไม่?", + + "api.fetch.fail": "มีบางอย่างผิดพลาดเมื่อดึงข้อมูลจาก {{ service }} และฉันไม่สามารถหาข้อมูลให้คุณได้ ลิงก์ของคุณใช้งานได้หรือไม่? หากใช่และคุณยังคงเห็นข้อผิดพลาดนี้ โปรดแจ้งให้เราทราบแล้วเราจะพยายามช่วยเหลือ!", + "api.fetch.critical": "โมดูล {{ service }} คืนค่าข้อผิดพลาดที่ฉันไม่รู้จัก ลองใหม่อีกครั้งในไม่กี่วินาที แต่หากปัญหายังคงอยู่ โปรดแจ้งให้เราทราบ!", + "api.fetch.empty": "ไม่พบสื่อใดๆ ที่ฉันสามารถดาวน์โหลดให้คุณได้ คุณได้วางลิงก์ที่ถูกต้องแล้วหรือไม่?", + "api.fetch.rate": "เซิร์ฟเวอร์ประมวลผล Cobalt ถูกจำกัดอัตราการขอจาก API ของ {{ service }} ลองใหม่อีกครั้งในไม่กี่วินาที!", + "api.fetch.short_link": "ไม่สามารถรับข้อมูลลิงก์จากลิงก์สั้นได้ คุณแน่ใจว่ามันใช้งานได้หรือไม่? หากใช่และคุณยังได้รับข้อผิดพลาดนี้ โปรดแจ้งให้เราทราบ แล้วเราจะพยายามช่วยเหลือ!", + + "api.content.too_long": "สื่อที่คุณร้องขอยาวเกินไป ขีดจำกัดระยะเวลาปัจจุบันคือ {{ limit }} นาที ลองสื่อที่สั้นกว่านี้แทน!", + + "api.content.video.unavailable": "ฉันไม่สามารถเข้าถึงวิดีโอนี้ได้ อาจมีการจำกัดโดยฝั่ง {{ service }} คุณได้วางลิงก์ที่ถูกต้องแล้วหรือไม่?", + "api.content.video.live": "วิดีโอนี้กำลังถ่ายทอดสด ดังนั้นฉันยังไม่สามารถดาวน์โหลดได้ รอให้การถ่ายทอดสดสิ้นสุดก่อน แล้วลองใหม่อีกครั้ง!", + "api.content.video.private": "วิดีโอนี้เป็นแบบส่วนตัว ดังนั้นฉันไม่สามารถเข้าถึงได้ เปลี่ยนการมองเห็นของมันหรือทดลองวิดีโออื่น!", + "api.content.video.age": "วิดีโอนี้มีการจำกัดอายุ ดังนั้นฉันไม่สามารถเข้าถึงได้โดยไม่ระบุตัวตน ลองวิดีโออื่น!", + "api.content.video.region": "วิดีโอนี้ถูกจำกัดตามภูมิภาค และเซิร์ฟเวอร์ประมวลผลอยู่ในตำแหน่งที่แตกต่างกัน ลองวิดีโออื่น!", + + "api.content.post.unavailable": "ไม่พบข้อมูลใดๆ เกี่ยวกับโพสต์นี้ อาจมีการจำกัดการมองเห็นหรืออาจไม่มีอยู่จริง ตรวจสอบให้แน่ใจว่าลิงก์ของคุณใช้งานได้แล้วลองใหม่อีกครั้งในไม่กี่วินาที!", + "api.content.post.private": "โพสต์นี้มาจากบัญชีส่วนตัว ดังนั้นฉันไม่สามารถเข้าถึงได้ คุณได้วางลิงก์ที่ถูกต้องแล้วหรือไม่?", + "api.content.post.age": "โพสต์นี้มีการจำกัดอายุ ดังนั้นฉันไม่สามารถเข้าถึงได้โดยไม่ระบุตัวตน คุณได้วางลิงก์ที่ถูกต้องแล้วหรือไม่?", + + "api.youtube.codec": "YouTube ไม่ส่งคืนสิ่งใดๆ ที่มีรหัสวิดีโอที่คุณต้องการ ลองอันอื่นในการตั้งค่า!", + "api.youtube.decipher": "YouTube อัปเดตอัลกอริทึมถอดรหัสของพวกเขา และฉันไม่สามารถดึงข้อมูลเกี่ยวกับวิดีโอได้\n\nลองใหม่อีกครั้งในไม่กี่วินาที แต่หากปัญหายังคงอยู่ โปรดติดต่อเราสำหรับการสนับสนุน", + "api.youtube.login": "ไม่สามารถรับวิดีโอนี้ได้เนื่องจาก YouTube ระบุว่าฉันเป็นบอท ซึ่งอาจเกิดจากอินสแตนซ์การประมวลผลที่ไม่มีโทเค็นบัญชีที่ใช้งานอยู่ ลองใหม่อีกครั้งในไม่กี่วินาที แต่หากยังไม่ได้ผล โปรดแจ้งเจ้าของอินสแตนซ์เกี่ยวกับข้อผิดพลาดนี้!", + "api.youtube.token_expired": "ไม่สามารถรับวิดีโอนี้ได้เนื่องจากโทเค็น YouTube หมดอายุและฉันไม่สามารถรีเฟรชมันได้ ลองใหม่อีกครั้งในไม่กี่วินาที แต่หากยังไม่ได้ผล โปรดแจ้งเจ้าของอินสแตนซ์เกี่ยวกับข้อผิดพลาดนี้!" +} diff --git a/web/i18n/th/general.json b/web/i18n/th/general.json new file mode 100644 index 00000000..379be931 --- /dev/null +++ b/web/i18n/th/general.json @@ -0,0 +1,11 @@ +{ + "cobalt": "bamboo download,ดาวน์โหลดวิดีโอฟรี - YouTube, TikTok, Bilibili, Instagram, Facebook, Twitter", + "meowbalt": "meowbalt", + "beta": "เบต้า", + "embed.description": "ดาวน์โหลดวิดีโอฟรีจาก YouTube, TikTok, Bilibili, Instagram, Facebook และ Twitter ไม่จำเป็นต้องลงทะเบียน ดาวน์โหลดวิดีโอได้ง่ายและรวดเร็ว", + "guide": { + "title": "คู่มือการดาวน์โหลดวิดีโอออนไลน์ Freesavevideo.online", + "description1": "ใช้ Freesavevideo.online ซึ่งเป็นเว็บไซต์ดาวน์โหลดวิดีโอออนไลน์ชั้นนำ เพื่อดาวน์โหลดวิดีโอและเพลง ไม่ต้องติดตั้งซอฟต์แวร์เพิ่มเติม คุณสามารถบันทึกสื่อโปรดของคุณได้โดยตรงจากเว็บไซต์ แพลตฟอร์มที่ใช้งานง่ายของเราทำให้การดาวน์โหลดวิดีโอเป็นเรื่องง่ายและมีประสิทธิภาพ", + "description2": "เข้าถึงและดาวน์โหลดเนื้อหาต่างๆ ได้อย่างง่ายดาย ตั้งแต่ภาพยนตร์และรายการทีวียอดนิยมไปจนถึงคลิปกีฬาที่น่าตื่นเต้น เพียงวาง URL ของวิดีโอลงในช่องที่กำหนดแล้วคลิกปุ่มดาวน์โหลด" + } +} diff --git a/web/i18n/th/notification.json b/web/i18n/th/notification.json new file mode 100644 index 00000000..a9b32139 --- /dev/null +++ b/web/i18n/th/notification.json @@ -0,0 +1,4 @@ +{ + "update.title": "update is available!", + "update.subtext": "press to reload" +} diff --git a/web/i18n/th/receiver.json b/web/i18n/th/receiver.json new file mode 100644 index 00000000..bcddfb73 --- /dev/null +++ b/web/i18n/th/receiver.json @@ -0,0 +1,5 @@ +{ + "title": "ลากหรือเลือกไฟล์", + "title.drop": "วางไฟล์ที่นี่!", + "accept": "รูปแบบที่รองรับ: {{ formats }}." +} diff --git a/web/i18n/th/remux.json b/web/i18n/th/remux.json new file mode 100644 index 00000000..66ccd280 --- /dev/null +++ b/web/i18n/th/remux.json @@ -0,0 +1,3 @@ +{ + "description": "การรีมักซ์มักจะแก้ปัญหาความเข้ากันได้กับซอฟต์แวร์เก่าได้ มันรวดเร็ว ไม่สูญเสียคุณภาพ และทุกอย่างจะถูกประมวลผลบนอุปกรณ์ของคุณ" +} diff --git a/web/i18n/th/save.json b/web/i18n/th/save.json new file mode 100644 index 00000000..9db0ccb4 --- /dev/null +++ b/web/i18n/th/save.json @@ -0,0 +1,23 @@ +{ + "paste": "วาง", + "paste.long": "วางและดาวน์โหลด", + "auto": "อัตโนมัติ", + "audio": "เสียง", + "mute": "ปิดเสียง", + "input.placeholder": "วางลิงก์ที่นี่", + "terms.note.agreement": "เมื่อดำเนินการต่อ แสดงว่าคุณยอมรับ", + "terms.note.link": "ข้อกำหนดและจริยธรรมในการใช้งาน", + "services.title": "บริการที่รองรับ", + "services.title_show": "แสดงบริการที่รองรับ", + "services.title_hide": "ซ่อนบริการที่รองรับ", + "services.disclaimer": "freesavevideo ไม่ได้เป็นพันธมิตรกับบริการใด ๆ ที่กล่าวถึงข้างต้น", + + "tutorial.title": "จะบันทึกบน iOS ได้อย่างไร?", + "tutorial.intro": "ในการบันทึกสื่ออย่างสะดวกบน iOS คุณจะต้องใช้ทางลัด Siri ร่วมจากเมนูแชร์", + "tutorial.step.1": "เพิ่มทางลัด Siri ร่วม:", + "tutorial.step.2": "กดปุ่ม \"แชร์\" ในกล่องโต้ตอบการบันทึกของ Cobalt", + "tutorial.step.3": "เลือกทางลัดที่เกี่ยวข้องในเมนูแชร์", + "tutorial.outro": "ทางลัดเหล่านี้จะทำงานเฉพาะจากแอป Cobalt เท่านั้น การแชร์ลิงก์จากแอปอื่นจะไม่ทำงาน", + "tutorial.shortcut.photos": "ไปยังรูปภาพ", + "tutorial.shortcut.files": "ไปยังไฟล์" +} diff --git a/web/i18n/th/settings.json b/web/i18n/th/settings.json new file mode 100644 index 00000000..7c1da50a --- /dev/null +++ b/web/i18n/th/settings.json @@ -0,0 +1,121 @@ +{ + "page.appearance": "การแสดงผล", + "page.privacy": "ความเป็นส่วนตัว", + "page.video": "วิดีโอ", + "page.audio": "เสียง", + "page.download": "การดาวน์โหลด", + "page.advanced": "ขั้นสูง", + "page.debug": "ข้อมูลการดีบัก", + "page.instances": "อินสแตนซ์", + + "section.general": "ทั่วไป", + "section.save": "บันทึก", + + "theme": "ธีม", + "theme.auto": "อัตโนมัติ", + "theme.light": "สว่าง", + "theme.dark": "มืด", + "theme.description": "ธีมอัตโนมัติจะสลับระหว่างธีมสว่างและมืด ขึ้นอยู่กับโหมดการแสดงผลของอุปกรณ์ของคุณ", + + "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": "หากคุณภาพวิดีโอที่ต้องการไม่พร้อมใช้งาน จะเลือกคุณภาพที่ดีที่สุดถัดไปแทน", + + "video.youtube.codec": "ตัวเข้ารหัสวิดีโอและคอนเทนเนอร์ของ YouTube", + "video.youtube.codec.description": "h264: เข้ากันได้ดีที่สุด บิตเรทปานกลาง คุณภาพสูงสุดคือ 1080p\nav1: คุณภาพดีที่สุด ประสิทธิภาพและบิตเรท รองรับ 8k และ HDR\nvp9: คุณภาพและบิตเรทเดียวกับ av1 แต่ไฟล์มีขนาดใหญ่กว่าสองเท่า รองรับ 4k และ HDR\n\nav1 และ vp9 ไม่ได้รับการสนับสนุนอย่างแพร่หลายเท่ากับ h264", + + "video.twitter.gif": "twitter/x", + "video.twitter.gif.title": "แปลงวิดีโอวนซ้ำเป็น GIF", + "video.twitter.gif.description": "การแปลง GIF ไม่มีประสิทธิภาพ ไฟล์ที่แปลงอาจมีขนาดใหญ่และคุณภาพต่ำ", + + "video.tiktok.h265": "tiktok", + "video.tiktok.h265.title": "เลือกฟอร์แมต HEVC/H265", + "video.tiktok.h265.description": "อนุญาตให้ดาวน์โหลดวิดีโอใน 1080p แต่ลดความเข้ากันได้ลง", + + "audio.format": "ฟอร์แมตเสียง", + "audio.format.best": "ดีที่สุด", + "audio.format.mp3": "mp3", + "audio.format.ogg": "ogg", + "audio.format.wav": "wav", + "audio.format.opus": "opus", + "audio.format.description": "ฟอร์แมตทั้งหมดนอกเหนือจาก \"ดีที่สุด\" จะถูกแปลง ซึ่งอาจทำให้คุณภาพลดลง เสียงจะไม่ถูกเข้ารหัสใหม่เมื่อเลือกฟอร์แมต \"ดีที่สุด\"", + + "audio.bitrate": "บิตเรทเสียง", + "audio.bitrate.kbps": "kb/s", + "audio.bitrate.description": "บิตเรทจะใช้กับการแปลงเสียงเท่านั้น freesavevideo ไม่สามารถปรับปรุงคุณภาพเสียงต้นฉบับได้ ดังนั้นการเลือกบิตเรทมากกว่า 128kbps อาจทำให้ขนาดไฟล์ใหญ่ขึ้นโดยไม่มีความแตกต่างของเสียงที่ได้ยิน คุณภาพที่รับรู้ได้อาจแตกต่างกันไปตามฟอร์แมต", + + "audio.youtube.dub": "youtube", + "audio.youtube.dub.title": "ใช้ภาษาของเบราว์เซอร์สำหรับวิดีโอที่พากย์เสียง", + "audio.youtube.dub.description": "ทำงานได้แม้ว่า freesavevideo จะไม่ได้แปลเป็นภาษาของคุณ", + + "audio.tiktok.original": "tiktok", + "audio.tiktok.original.title": "ดาวน์โหลดเสียงต้นฉบับ", + "audio.tiktok.original.description": "freesavevideo จะดาวน์โหลดเสียงจากวิดีโอโดยไม่มีการเปลี่ยนแปลงจากผู้โพสต์", + + "metadata.filename": "สไตล์ชื่อไฟล์", + "metadata.filename.classic": "คลาสสิก", + "metadata.filename.basic": "พื้นฐาน", + "metadata.filename.pretty": "สวยงาม", + "metadata.filename.nerdy": "เนิร์ด", + "metadata.filename.description": "สไตล์ชื่อไฟล์จะใช้เฉพาะกับไฟล์ที่จัดการโดย freesavevideo บริการบางอย่างไม่รองรับสไตล์ชื่อไฟล์ที่ไม่ใช่คลาสสิก", + + "metadata.filename.preview.video": "ชื่อวิดีโอ", + "metadata.filename.preview.audio": "ชื่อเสียง - ผู้แต่งเสียง", + + "metadata.file": "ข้อมูลเมตาไฟล์", + "metadata.disable.title": "ปิดข้อมูลเมตาไฟล์", + "metadata.disable.description": "ชื่อเรื่อง ศิลปิน และข้อมูลอื่นๆ จะไม่ถูกเพิ่มลงในไฟล์", + + "saving.title": "วิธีการบันทึก", + "saving.ask": "ถาม", + "saving.download": "ดาวน์โหลด", + "saving.share": "แชร์", + "saving.copy": "คัดลอก", + "saving.description": "วิธีที่ต้องการสำหรับการบันทึกไฟล์หรือลิงก์จาก freesavevideo หากวิธีที่ต้องการไม่พร้อมใช้งานหรือล้มเหลว freesavevideo จะถามคุณว่าต้องการทำอะไรต่อไป", + + "accessibility": "การเข้าถึง", + "accessibility.transparency.title": "ลดความโปร่งใสของภาพ", + "accessibility.transparency.description": "ลดความโปร่งใสของพื้นผิวและปิดเอฟเฟ็กต์เบลอ", + "accessibility.motion.title": "ลดการเคลื่อนไหว", + "accessibility.motion.description": "ปิดการใช้งานแอนิเมชั่นและการเปลี่ยนแปลงเมื่อใดก็ตามที่ทำได้", + + "language": "ภาษา", + "language.auto.title": "การเลือกอัตโนมัติ", + "language.auto.description": "freesavevideo จะใช้ภาษาของเบราว์เซอร์ของคุณหากมีการแปล หากไม่ การใช้งานจะเป็นภาษาอังกฤษแทน", + "language.preferred.title": "ภาษาที่ต้องการ", + "language.preferred.description": "ภาษานี้จะถูกใช้เมื่อการเลือกอัตโนมัติถูกปิด ข้อความใดๆ ที่ไม่ได้แปลจะถูกแสดงเป็นภาษาอังกฤษ\n\nเราใช้การแปลจากชุมชนสำหรับภาษาที่ไม่ใช่ภาษาอังกฤษ รัสเซีย และเช็ก ซึ่งอาจไม่ถูกต้องหรือไม่สมบูรณ์", + + "privacy.analytics": "การวิเคราะห์การเข้าชมแบบไม่ระบุตัวตน", + "privacy.analytics.title": "ไม่ร่วมในการวิเคราะห์", + "privacy.analytics.description": "การวิเคราะห์การเข้าชมแบบไม่ระบุตัวตนเป็นสิ่งจำเป็นในการประมาณจำนวนผู้ใช้ freesavevideo ที่ใช้งานอยู่ ไม่มีข้อมูลที่ระบุตัวตนเกี่ยวกับคุณที่ถูกจัดเก็บ ข้อมูลที่ประมวลผลทั้งหมดจะไม่ระบุตัวตนและรวมเข้าด้วยกัน\n\nเราใช้แพลตฟอร์ม plausible ที่โฮสต์เองซึ่งไม่ใช้คุกกี้และสอดคล้องกับ GDPR, CCPA และ PECR อย่างเต็มที่", + "privacy.analytics.learnmore": "เรียนรู้เพิ่มเติมเกี่ยวกับความทุ่มเทของ plausible ต่อความเป็นส่วนตัว", + + "privacy.tunnel": "การทำงานแบบอุโมงค์", + "privacy.tunnel.title": "ทำงานแบบอุโมงค์ไฟล์เสมอ", + "privacy.tunnel.description": "freesavevideo จะซ่อนที่อยู่ IP ข้อมูลเบราว์เซอร์ของคุณ และหลีกเลี่ยงข้อจำกัดของเครือข่ายท้องถิ่น เมื่อเปิดใช้งาน ไฟล์จะมีชื่อไฟล์ที่อ่านได้ ซึ่งถ้าไม่เปิดใช้งานจะเป็นตัวอักษรสุ่ม", + + "advanced.debug": "ดีบัก", + "advanced.debug.title": "เปิดใช้งานฟีเจอร์ดีบัก", + "advanced.debug.description": "ให้คุณเข้าถึงหน้าที่มีข้อมูลต่างๆ ที่อาจมีประโยชน์ในการดีบัก", + + "advanced.data": "ข้อมูลการตั้งค่า", + + "processing.override": "การแทนที่อินสแตนซ์เริ่มต้น", + "processing.override.title": "ใช้เซิร์ฟเวอร์ประมวลผลที่อินสแตนซ์จัดให้", + "processing.override.description": "หากเว็บอินสแตนซ์ให้เซิร์ฟเวอร์ประมวลผลเริ่มต้น คุณสามารถเลือกใช้แทนเซิร์ฟเวอร์หลักได้ ตรวจสอบให้แน่ใจว่าเป็นเซิร์ฟเวอร์ของคนที่คุณไว้วางใจ", + + "processing.community": "อินสแตนซ์ของชุมชน", + + "processing.enable_custom.title": "ใช้เซิร์ฟเวอร์ประมวลผลที่กำหนดเอง", + "processing.enable_custom.description": "freesavevideo จะใช้เซิร์ฟเวอร์ประมวลผลที่กำหนดเองหากคุณเลือก แม้ว่าจะมีมาตรการรักษาความปลอดภัยบางอย่าง เราไม่รับผิดชอบต่อความเสียหายใดๆ ที่เกิดขึ้นจากอินสแตนซ์ชุมชน เนื่องจากเราไม่สามารถควบคุมได้\n\nโปรดระมัดระวังในการใช้อินสแตนซ์ที่คุณเลือกและตรวจสอบให้แน่ใจว่ามีคนที่คุณไว้วางใจเป็นผู้โฮสต์", + + "processing.custom.placeholder": "โดเมนอินสแตนซ์ที่กำหนดเอง" +} diff --git a/web/i18n/th/tabs.json b/web/i18n/th/tabs.json new file mode 100644 index 00000000..bff5fa73 --- /dev/null +++ b/web/i18n/th/tabs.json @@ -0,0 +1,9 @@ +{ + "save": "หน้าแรก", + "settings": "การตั้งค่า", + "updates": "การอัปเดต", + "donate": "บริจาค", + "about": "เกี่ยวกับ", + "remux": "รีมักซ์", + "faq": "FAQ" +} diff --git a/web/i18n/th/updates.json b/web/i18n/th/updates.json new file mode 100644 index 00000000..c10f6897 --- /dev/null +++ b/web/i18n/th/updates.json @@ -0,0 +1,4 @@ +{ + "button.next": "ไปที่บันทึกการเปลี่ยนแปลงเก่า ({{ value }})", + "button.previous": "ไปที่บันทึกการเปลี่ยนแปลงใหม่ ({{ value }})" +} diff --git a/web/i18n/zh/a11y/dialog.json b/web/i18n/zh/a11y/dialog.json new file mode 100644 index 00000000..84df736b --- /dev/null +++ b/web/i18n/zh/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/zh/a11y/donate.json b/web/i18n/zh/a11y/donate.json new file mode 100644 index 00000000..625eee91 --- /dev/null +++ b/web/i18n/zh/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/zh/a11y/general.json b/web/i18n/zh/a11y/general.json new file mode 100644 index 00000000..30c862e1 --- /dev/null +++ b/web/i18n/zh/a11y/general.json @@ -0,0 +1,3 @@ +{ + "back": "go back" +} diff --git a/web/i18n/zh/a11y/save.json b/web/i18n/zh/a11y/save.json new file mode 100644 index 00000000..2dc85154 --- /dev/null +++ b/web/i18n/zh/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/zh/a11y/tabs.json b/web/i18n/zh/a11y/tabs.json new file mode 100644 index 00000000..7eafb56f --- /dev/null +++ b/web/i18n/zh/a11y/tabs.json @@ -0,0 +1,3 @@ +{ + "tab_panel": "tabs panel" +} diff --git a/web/i18n/zh/about.json b/web/i18n/zh/about.json new file mode 100644 index 00000000..34ff800d --- /dev/null +++ b/web/i18n/zh/about.json @@ -0,0 +1,27 @@ +{ + "page.general": "什么是 竹子下载?", + "page.faq": "常见问题", + "page.community": "社区与支持", + "page.privacy": "隐私政策", + "page.terms": "条款与伦理", + "page.credits": "致谢与许可证", + "community.discord": "社区 Discord 服务器", + "community.twitter": "Twitter 新闻账号", + "community.github": "GitHub 仓库", + "community.email": "支持邮箱", + "community.telegram": "Telegram 新闻频道", + "heading.general": "通用条款", + "heading.licenses": "许可证", + "heading.summary": "保存你喜爱的最佳方式", + "heading.privacy": "领先的隐私保护", + "heading.community": "开放社区", + "heading.local": "本地设备处理", + "heading.saving": "保存", + "heading.encryption": "加密", + "heading.plausible": "匿名流量分析", + "heading.cloudflare": "网络隐私与安全", + "heading.responsibility": "用户责任", + "heading.abuse": "滥用报告", + "heading.motivation": "动机", + "heading.testers": "测试者" +} diff --git a/web/i18n/zh/about/credits.md b/web/i18n/zh/about/credits.md new file mode 100644 index 00000000..6c001f58 --- /dev/null +++ b/web/i18n/zh/about/credits.md @@ -0,0 +1,52 @@ + + +
+ + +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/zh/about/general.md b/web/i18n/zh/about/general.md new file mode 100644 index 00000000..04494d8d --- /dev/null +++ b/web/i18n/zh/about/general.md @@ -0,0 +1,51 @@ + + +
+ + +bamboo download 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. +
+ + + +
+ + +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, bamboo download 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, bamboo download 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 bamboo download instance. +
+ + + +
+ + +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/zh/about/privacy.md b/web/i18n/zh/about/privacy.md new file mode 100644 index 00000000..b19ca762 --- /dev/null +++ b/web/i18n/zh/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. +
+{/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/zh/about/terms.md b/web/i18n/zh/about/terms.md new file mode 100644 index 00000000..ff08471a --- /dev/null +++ b/web/i18n/zh/about/terms.md @@ -0,0 +1,56 @@ + + +
+ + +these terms are applicable only when using the official freesavevideo 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, as freesavevideo is 100% anonymous. + + +please note that this email is not intended for user support. +if you're experiencing issues, contact us via any preferred method on [the support page](/about/community). +
diff --git a/web/i18n/zh/button.json b/web/i18n/zh/button.json new file mode 100644 index 00000000..3c98e7cb --- /dev/null +++ b/web/i18n/zh/button.json @@ -0,0 +1,20 @@ +{ + "gotit": "知道了", + "cancel": "取消", + "reset": "重置", + "done": "完成", + "download.audio": "下载音频", + "download": "下载", + "share": "分享", + "copy": "复制", + "copy.section": "复制部分链接", + "copied": "已复制", + "import": "导入", + "continue": "继续", + "star": "收藏", + "follow": "关注", + "save": "保存", + "export": "导出", + "yes": "是", + "no": "否" +} diff --git a/web/i18n/zh/dialog.json b/web/i18n/zh/dialog.json new file mode 100644 index 00000000..2ebc818b --- /dev/null +++ b/web/i18n/zh/dialog.json @@ -0,0 +1,18 @@ +{ + "reset.title": "重置所有设置?", + "reset.body": "您确定要重置所有设置吗?此操作不可撤销。", + "picker.title": "选择要保存的内容", + "picker.description.desktop": "点击项目以保存。图片也可以通过右键菜单保存。", + "picker.description.phone": "长按项目以保存。图片也可以通过长按保存。", + "picker.description.ios": "按住项目以通过快捷方式保存。图片也可以通过长按保存。", + "saving.title": "选择保存方式", + "saving.blocked": "Cobalt尝试在新标签中打开文件,但您的浏览器阻止了它。您可以为Cobalt启用弹出窗口以防止下次发生。", + "saving.timeout": "Cobalt尝试自动保存文件,但您的浏览器阻止了它。您需要手动选择首选的保存方式。", + "safety.title": "重要的安全提示", + "import.body": "导入未知或损坏的文件可能会意外更改或破坏Cobalt的功能。仅导入您自己导出的且未修改的文件。如果有人要求您导入此文件,请不要这样做。我们不对导入未知设置文件造成的任何损害负责。", + "api.override.title": "处理实例覆盖", + "api.override.body": "{{ value }} 现在是处理实例。如果您不信任它,请按“取消”,它将被忽略。稍后您可以在处理设置中更改您的选择。", + "safety.custom_instance.body": "自定义实例可能会带来隐私和安全风险。恶意实例可能会:1. 将您重定向离开Cobalt并试图诈骗您。2. 记录您请求的所有信息,永久存储,并用来跟踪您。3. 向您提供恶意文件(如恶意软件)。4. 强制您观看广告,或让您付费下载。此后,我们无法保护您。请谨慎选择使用哪些实例,并始终相信自己的直觉。如果感觉不对劲,请回到此页面,重置自定义实例,并在GitHub上向我们报告。", + "processing.ongoing": "Cobalt当前正在此标签页处理媒体。离开将中止处理。您确定要执行此操作吗?", + "processing.title.ongoing": "处理将被取消" +} diff --git a/web/i18n/zh/donate.json b/web/i18n/zh/donate.json new file mode 100644 index 00000000..51340d36 --- /dev/null +++ b/web/i18n/zh/donate.json @@ -0,0 +1,29 @@ +{ + "banner.title": "这个网站很好,赞赏赞赏!", + "banner.subtitle": "捐赠给 Imput 或与朋友分享\nCobalt 的乐趣", + "body.motivation": "Cobalt 帮助制作人、教育者、视频创作者以及许多其他人做他们热爱的事情。这是一种不同的服务,充满了爱,而非为了盈利。", + "body.no_bullshit": "我们相信互联网不必可怕,这就是为什么 Cobalt 永远不会有广告或其他恶意内容。这是我们坚守的承诺。我们所做的一切都以隐私、可访问性和易用性为核心,使 Cobalt 可供所有人使用。", + "body.keep_going": "如果您觉得 Cobalt 有用,请考虑支持我们的工作!您可以通过捐赠或与朋友分享 Cobalt 来帮助我们。我们非常感谢每一笔捐款,这将帮助我们继续开发 Cobalt 及其他项目。", + "card.once": "一次性捐赠", + "card.recurring": "定期捐赠", + "card.custom": "自定义金额(从 $2 起)", + "card.processor": "通过 {{value}}", + "card.option.5": "一杯咖啡", + "card.option.10": "一份披萨", + "card.option.15": "一顿午餐", + "card.option.30": "两人的午餐", + "card.option.50": "10 公斤猫粮", + "card.option.100": "一年域名费用", + "card.option.200": "空气炸锅", + "card.option.500": "豪华办公椅", + "card.option.1599": "基础款 MacBook Pro", + "card.option.4900": "10,000 个苹果", + "card.option.7398": "顶配 MacBook Pro", + "card.option.8629": "一小块土地", + "card.option.9433": "豪华热水浴缸", + "card.custom.submit": "捐赠自定义金额", + "share.title": "与朋友分享 Cobalt", + "alternative.title": "其他捐赠方式", + "alt.copy": "{{ value }}。加密钱包地址。按下复制。", + "alt.open": "{{ value }}。按下打开。" +} diff --git a/web/i18n/zh/error.json b/web/i18n/zh/error.json new file mode 100644 index 00000000..7d173e1a --- /dev/null +++ b/web/i18n/zh/error.json @@ -0,0 +1,38 @@ +{ + "import.no_data": "文件中没有要加载的内容。您确定这是正确的文件吗?", + "import.invalid": "您的文件没有有效的 Cobalt 设置。您确定这是正确的文件吗?", + "import.hash_invalid": "文件校验和无效。请确认这是正确的文件。", + "import.unknown": "无法从文件加载数据。它可能已损坏或格式错误。以下是我收到的错误信息:\n\n{{ value }}", + "settings.invalid": "设置文件包含不支持的设置,加载已中止。", + "settings.too_large": "您导入的文件太大,无法处理。", + "api.unknown": "发生了未知错误。请稍后再试。", + "api.network": "由于网络问题,无法连接到服务器。", + "api.timeout": "请求超时,请稍后重试。", + "api.server": "服务器出现问题,请稍后重试。", + "api.maintenance": "服务器正在维护中。请稍后重试。", + "api.quota": "已达到配额限制,请稍后再试。", + "api.content.too_long": "您请求的媒体太长。目前的时长限制为 {{ limit }} 分钟。请尝试较短的内容!", + "api.content.video.unavailable": "我无法访问该视频。可能是由于 {{ service }} 的限制。您粘贴的链接正确吗?", + "api.content.video.live": "该视频当前正在直播,因此我无法下载。请等直播结束后再试!", + "api.content.video.private": "该视频是私密的,因此我无法访问。请更改其可见性或尝试其他视频!", + "api.content.video.age": "该视频有年龄限制,因此我无法匿名访问。请尝试其他视频!", + "api.content.video.region": "该视频有地区限制,而处理服务器位于不同位置。请尝试其他视频!", + "api.content.post.unavailable": "无法找到该帖子的相关信息。其可见性可能受限,或帖子不存在。请确认链接是否有效,并在几秒钟后再试!", + "api.content.post.private": "该帖子来自私人账号,因此我无法访问。您粘贴的链接正确吗?", + "api.content.post.age": "该帖子有年龄限制,因此我无法匿名访问。您粘贴的链接正确吗?", + "api.youtube.codec": "YouTube 没有返回您偏好的视频编解码器。请在设置中尝试其他选项!", + "api.youtube.decipher": "YouTube 更新了其解密算法,我无法提取视频信息。\n\n请稍后重试,如果问题仍然存在,请联系我们寻求支持。", + "api.youtube.login": "无法获取此视频,因为 YouTube 将我标记为机器人。这可能是由于处理实例没有任何活跃的账户令牌。请稍后重试,如果仍然无法解决,请告知实例拥有者此错误!", + "api.youtube.token_expired": "无法获取此视频,因为 YouTube 令牌已过期,且无法刷新。请稍后重试,如果仍然无法解决,请告知实例拥有者此错误!", + "remux.corrupted": "无法读取该文件的元数据,可能已损坏。", + "remux.out_of_resources": "Cobalt 设备资源不足,无法继续处理。这与您的浏览器限制有关。请刷新或重新打开应用并重试。某些设备只能处理较小的文件。", + "tunnel.probe": "无法验证您是否可以下载此文件。请稍后再试!", + "captcha_ongoing": "仍在检查您是否不是机器人。等待加载图标消失后再试。\n\n如果等待时间过长,请告知我们!我们使用 Cloudflare Turnstile 进行机器人保护,有时会错误阻止用户。", + "api.auth.jwt.missing": "无法确认您是否是人类,因为处理服务器没有收到人类访问令牌。请稍后重试或刷新页面!", + "api.auth.jwt.invalid": "无法确认您是否是人类,因为您的访问令牌已过期且未续期。请稍后重试或刷新页面!", + "api.auth.turnstile.missing": "无法确认您是否是人类,因为处理服务器没有收到人类访问令牌。请稍后重试或刷新页面!", + "api.auth.turnstile.invalid": "无法确认您是否是人类,因为您的访问令牌已过期且未续期。请稍后重试或刷新页面!", + "api.unreachable": "无法连接到处理服务器。请检查您的网络连接并重试。", + "api.timed_out": "处理服务器响应时间过长。可能当前服务器负载较大,请稍后重试!", + "api.rate_exceeded": "请求频率过高。请稍后再试!" +} diff --git a/web/i18n/zh/general.json b/web/i18n/zh/general.json new file mode 100644 index 00000000..9baed9d4 --- /dev/null +++ b/web/i18n/zh/general.json @@ -0,0 +1,11 @@ +{ + "cobalt": "竹子下载,免费下载视频 - B站(Bilibili),YouTube,TikTok,Instagram,Facebook,Twitter", + "meowbalt": "竹子下载", + "beta": "测试版", + "embed.description": "从YouTube、TikTok、Bilibili、Instagram、Facebook和Twitter免费下载视频。无需注册。快速简单的视频下载。", + "guide": { + "title": "使用竹子下载在线视频下载指南", + "description1": "使用竹子下载,这是首屈一指的在线视频下载器,下载视频和音乐。无需额外的软件,直接从网上保存您喜欢的媒体。我们直观的平台使视频下载变得简单高效。", + "description2": "轻松访问并下载各种内容,从热门大片和流行电视剧到激动人心的体育片段。只需将视频URL粘贴到指定字段并点击下载按钮。" + } +} diff --git a/web/i18n/zh/notification.json b/web/i18n/zh/notification.json new file mode 100644 index 00000000..fbefe539 --- /dev/null +++ b/web/i18n/zh/notification.json @@ -0,0 +1,4 @@ +{ + "update.title": "有更新可用!", + "update.subtext": "点击重新加载" +} diff --git a/web/i18n/zh/receiver.json b/web/i18n/zh/receiver.json new file mode 100644 index 00000000..4eb97a2a --- /dev/null +++ b/web/i18n/zh/receiver.json @@ -0,0 +1,5 @@ +{ + "title": "拖动或选择文件", + "title.drop": "将文件放到这里!", + "accept": "支持的格式:{{ formats }}。" +} diff --git a/web/i18n/zh/remux.json b/web/i18n/zh/remux.json new file mode 100644 index 00000000..404524d8 --- /dev/null +++ b/web/i18n/zh/remux.json @@ -0,0 +1,3 @@ +{ + "description": "混流通常可以解决旧版软件的兼容性问题。它快速、无损,且所有处理都在设备上进行。" +} diff --git a/web/i18n/zh/save.json b/web/i18n/zh/save.json new file mode 100644 index 00000000..eff743d4 --- /dev/null +++ b/web/i18n/zh/save.json @@ -0,0 +1,22 @@ +{ + "paste": "粘贴", + "paste.long": "粘贴并下载", + "auto": "自动", + "audio": "音频", + "mute": "静音", + "input.placeholder": "在此粘贴链接", + "terms.note.agreement": "继续即表示您同意", + "terms.note.link": "使用条款与伦理", + "services.title": "支持的服务", + "services.title_show": "显示支持的服务", + "services.title_hide": "隐藏支持的服务", + "services.disclaimer": "Freesavevideo 不隶属于以上列出的任何服务。", + "tutorial.title": "如何在 iOS 上保存?", + "tutorial.intro": "为了便捷地在 iOS 上保存媒体,您需要在共享菜单中使用 Siri 快捷指令。", + "tutorial.step.1": "添加 Siri 快捷指令:", + "tutorial.step.2": "在 Cobalt 的保存对话框中按下“分享”按钮。", + "tutorial.step.3": "在共享菜单中选择相应的快捷指令。", + "tutorial.outro": "这些快捷指令只能在 Cobalt 应用中使用,从其他应用分享链接将不起作用。", + "tutorial.shortcut.photos": "保存到照片", + "tutorial.shortcut.files": "保存到文件" +} diff --git a/web/i18n/zh/settings.json b/web/i18n/zh/settings.json new file mode 100644 index 00000000..69310b6f --- /dev/null +++ b/web/i18n/zh/settings.json @@ -0,0 +1,122 @@ +{ + "page.appearance": "外观", + "page.privacy": "隐私", + "page.video": "视频", + "page.audio": "音频", + "page.download": "下载", + "page.advanced": "高级", + "page.debug": "调试信息", + "page.instances": "实例", + + "section.general": "常规", + "section.save": "保存", + + "theme": "主题", + "theme.auto": "自动", + "theme.light": "亮色", + "theme.dark": "暗色", + "theme.description": "自动主题会根据设备的显示模式在亮色和暗色主题之间切换。", + + "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": "如果首选的视频质量不可用,则选择下一个最佳选项。", + + "video.youtube.codec": "YouTube 视频编解码器和容器", + "video.youtube.codec.description": "h264:最佳兼容性,平均比特率。最大质量为1080p。\nav1:质量、效率和比特率最佳。支持8k和HDR。\nvp9:与av1质量和比特率相同,但文件大小约为av1的两倍。支持4k和HDR。\n\nav1和vp9的兼容性不如h264。", + + "video.twitter.gif": "Twitter/X", + "video.twitter.gif.title": "将循环视频转换为GIF", + "video.twitter.gif.description": "GIF转换效率低,转换后的文件可能非常大且质量较低。", + + "video.tiktok.h265": "TikTok", + "video.tiktok.h265.title": "优先使用HEVC/H265格式", + "video.tiktok.h265.description": "允许以1080p下载视频,但兼容性较差。", + + "audio.format": "音频格式", + "audio.format.best": "最佳", + "audio.format.mp3": "mp3", + "audio.format.ogg": "ogg", + "audio.format.wav": "wav", + "audio.format.opus": "opus", + "audio.format.description": "除“最佳”格式外,所有格式都会被转换,这意味着会有一些质量损失。只有选择“最佳”格式时,音频不会重新编码。", + + "audio.bitrate": "音频比特率", + "audio.bitrate.kbps": "kb/s", + "audio.bitrate.description": "比特率仅适用于音频转换。Freesavevideo无法提升源音频质量,因此选择超过128kbps的比特率可能会导致文件大小膨胀,但听觉上无差异。感知质量可能因格式而异。", + + "audio.youtube.dub": "YouTube", + "audio.youtube.dub.title": "为配音视频使用浏览器语言", + "audio.youtube.dub.description": "即使Freesavevideo未翻译成您的语言,该功能仍然有效。", + + "audio.tiktok.original": "TikTok", + "audio.tiktok.original.title": "下载原声", + "audio.tiktok.original.description": "Freesavevideo将下载视频中的原声,而不会进行任何更改。", + + "metadata.filename": "文件名样式", + "metadata.filename.classic": "经典", + "metadata.filename.basic": "基础", + "metadata.filename.pretty": "美观", + "metadata.filename.nerdy": "技术风", + "metadata.filename.description": "文件名样式将仅用于Freesavevideo通过隧道传输的文件。某些服务不支持经典样式以外的文件名样式。", + + "metadata.filename.preview.video": "视频标题", + "metadata.filename.preview.audio": "音频标题 - 音频作者", + + "metadata.file": "文件元数据", + "metadata.disable.title": "禁用文件元数据", + "metadata.disable.description": "文件的标题、艺术家及其他信息将不会被添加。", + + "saving.title": "保存方式", + "saving.ask": "询问", + "saving.download": "下载", + "saving.share": "分享", + "saving.copy": "复制", + "saving.description": "首选的文件或链接保存方式。如果首选方式不可用或出错,Freesavevideo将询问您接下来要怎么做。", + + "accessibility": "辅助功能", + "accessibility.transparency.title": "减少视觉透明度", + "accessibility.transparency.description": "减少表面的透明度,并禁用模糊效果。", + "accessibility.motion.title": "减少运动效果", + "accessibility.motion.description": "尽可能禁用动画和过渡效果。", + + "language": "语言", + "language.auto.title": "自动选择", + "language.auto.description": "如果有可用翻译,Freesavevideo将使用浏览器的默认语言。否则,将使用英语。", + + "language.preferred.title": "首选语言", + "language.preferred.description": "当禁用自动选择时,将使用此语言。未翻译的文本将显示为英语。\n\n我们为英语、俄语和捷克语以外的语言使用社区翻译,它们可能不准确或不完整。", + + "privacy.analytics": "匿名流量分析", + "privacy.analytics.title": "不参与分析", + "privacy.analytics.description": "匿名流量分析用于估计Freesavevideo的活跃用户数。不会存储任何关于您的可识别信息。所有处理的数据都是匿名的并经过汇总。\n\n我们使用自托管的Plausible实例,不使用Cookie,完全符合GDPR、CCPA和PECR。", + "privacy.analytics.learnmore": "了解更多关于Plausible的隐私承诺。", + + "privacy.tunnel": "隧道", + "privacy.tunnel.title": "始终通过隧道传输文件", + "privacy.tunnel.description": "Freesavevideo将隐藏您的IP地址、浏览器信息,并绕过本地网络限制。启用后,文件名将可读,而不会是乱码。", + + "advanced.debug": "调试", + "advanced.debug.title": "启用调试功能", + "advanced.debug.description": "为您提供一个包含各种信息的页面,这些信息有助于调试。", + + "advanced.data": "设置数据", + + "processing.override": "默认实例覆盖", + "processing.override.title": "使用实例提供的处理服务器", + "processing.override.description": "如果Web实例提供自己的默认处理服务器,您可以选择使用它而不是主处理服务器。请确保它是您信任的服务器。", + + "processing.community": "社区实例", + + "processing.enable_custom.title": "使用自定义处理服务器", + "processing.enable_custom.description": "如果您选择,Freesavevideo将使用自定义处理服务器。尽管Freesavevideo采取了一些安全措施,但我们不对通过社区实例造成的任何损害负责,因为我们无法控制它们。\n\n请谨慎选择使用哪些实例,并确保它们由您信任的人托管。", + + "processing.custom.placeholder": "自定义实例域名" +} diff --git a/web/i18n/zh/tabs.json b/web/i18n/zh/tabs.json new file mode 100644 index 00000000..e07033ef --- /dev/null +++ b/web/i18n/zh/tabs.json @@ -0,0 +1,9 @@ +{ + "save": "首页", + "settings": "设置", + "updates": "更新", + "donate": "捐赠", + "about": "关于", + "remux": "混流", + "faq": "常见问题" +} diff --git a/web/i18n/zh/updates.json b/web/i18n/zh/updates.json new file mode 100644 index 00000000..d1b3112f --- /dev/null +++ b/web/i18n/zh/updates.json @@ -0,0 +1,4 @@ +{ + "button.next": "前往旧的更新日志 ({{ value }})", + "button.previous": "前往新的更新日志 ({{ value }})" +} diff --git a/web/src/app.html b/web/src/app.html index b60acb3c..923511cc 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -16,7 +16,19 @@ + + + + + diff --git a/web/src/components/misc/UseGuide.svelte b/web/src/components/misc/UseGuide.svelte new file mode 100644 index 00000000..c60d4091 --- /dev/null +++ b/web/src/components/misc/UseGuide.svelte @@ -0,0 +1,30 @@ + + +
+

{$t('general.guide.title')}

+

{$t('general.guide.description1')}

+

{$t('general.guide.description2')}

+
+ + diff --git a/web/src/components/save/SupportedServices.svelte b/web/src/components/save/SupportedServices.svelte index bb1251af..df571151 100644 --- a/web/src/components/save/SupportedServices.svelte +++ b/web/src/components/save/SupportedServices.svelte @@ -42,7 +42,9 @@
{#if loaded} {#each services as service} -
{service}
+ {/each} {:else} {#each { length: 17 } as _} diff --git a/web/src/components/sidebar/Sidebar.svelte b/web/src/components/sidebar/Sidebar.svelte index ca3a963e..7f07222d 100644 --- a/web/src/components/sidebar/Sidebar.svelte +++ b/web/src/components/sidebar/Sidebar.svelte @@ -35,17 +35,23 @@ -
- +