diff --git a/.DS_Store b/.DS_Store index dae1ee16bd797c5d08d02e139d30197b3cc37995..f48fec7db07eeaf3f1e12612e5179052934878e5 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/api/README.md b/api/README.md index 70d85de674a2e9c9d25ea6f5510dcd2949562bee..3bdfb519046caca97f6f87c0acc7f3bbfd0b4c74 100644 --- a/api/README.md +++ b/api/README.md @@ -23,6 +23,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i | instagram | ✅ | ✅ | ✅ | ➖ | ➖ | | facebook | ✅ | ❌ | ✅ | ➖ | ➖ | | loom | ✅ | ❌ | ✅ | ✅ | ➖ | +| newgrounds | ✅ | ✅ | ✅ | ✅ | ✅ | | ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ | | pinterest | ✅ | ✅ | ✅ | ➖ | ➖ | | reddit | ✅ | ✅ | ✅ | ❌ | ❌ | @@ -71,7 +72,7 @@ as long as you: ## open source acknowledgements ### ffmpeg -cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have an ability to use it for free, just like anyone else. we believe it should be way more recognized. +cobalt relies on ffmpeg for muxing and encoding media files. ffmpeg is absolutely spectacular and we're privileged to have the ability to use it for free, just like anyone else. we believe it should be way more recognized. you can [support ffmpeg here](https://ffmpeg.org/donations.html)! diff --git a/api/package.json b/api/package.json index 20b86b15effeb500a4f7049236689cec6050da1c..664ffbd727f6ac8a46a0038f32d468a0b19e5599 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@imput/cobalt-api", "description": "save what you love", - "version": "10.9.1", + "version": "11.5", "author": "imput", "exports": "./src/cobalt.js", "type": "module", @@ -34,11 +34,12 @@ "ffmpeg-static": "^5.1.0", "hls-parser": "^0.10.7", "ipaddr.js": "2.2.0", + "mime": "^4.0.4", "nanoid": "^5.0.9", "set-cookie-parser": "2.6.0", - "undici": "^5.19.1", + "undici": "^6.21.3", "url-pattern": "1.0.3", - "youtubei.js": "^13.3.0", + "youtubei.js": "15.1.1", "zod": "^3.23.8" }, "optionalDependencies": { diff --git a/api/src/.env b/api/src/.env deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/api/src/cobalt.js b/api/src/cobalt.js index 5cac208daee73eb50d7517ef5b9e0cc0bacaabb9..cf5312976c01106bde51121456815ca230a931b2 100644 --- a/api/src/cobalt.js +++ b/api/src/cobalt.js @@ -9,6 +9,7 @@ import { fileURLToPath } from "url"; import { env, isCluster } from "./config.js" import { Red } from "./misc/console-text.js"; import { initCluster } from "./misc/cluster.js"; +import { setupEnvWatcher } from "./core/env.js"; const app = express(); @@ -24,6 +25,10 @@ if (env.apiURL) { await initCluster(); } + if (env.envFile) { + setupEnvWatcher(); + } + runAPI(express, app, __dirname, cluster.isPrimary); } else { console.log( diff --git a/api/src/config.js b/api/src/config.js index 2e9436d276ac96d5cc8683941371981de3631d02..2d8ac7a088b93f0bf2a752274785c42b4bbf71ea 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -1,97 +1,41 @@ -import { Constants } from "youtubei.js"; import { getVersion } from "@imput/version-info"; -import { services } from "./processing/service-config.js"; -import { supportsReusePort } from "./misc/cluster.js"; +import { loadEnvs, validateEnvs } from "./core/env.js"; const version = await getVersion(); -const disabledServices = process.env.DISABLED_SERVICES?.split(',') || []; -const enabledServices = new Set(Object.keys(services).filter(e => { - if (!disabledServices.includes(e)) { - return e; - } -})); - -const env = { - apiURL: process.env.API_URL || '', - apiPort: process.env.API_PORT || 7860, - tunnelPort: process.env.API_PORT || 7860, - appKey: process.env.APPKEY, - - listenAddress: process.env.API_LISTEN_ADDRESS, - freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR, - - corsWildcard: process.env.CORS_WILDCARD !== '0', - corsURL: process.env.CORS_URL, - - cookiePath: process.env.COOKIE_PATH, - - rateLimitWindow: (process.env.RATELIMIT_WINDOW && parseInt(process.env.RATELIMIT_WINDOW)) || 60, - rateLimitMax: (process.env.RATELIMIT_MAX && parseInt(process.env.RATELIMIT_MAX)) || 20, - - sessionRateLimitWindow: (process.env.SESSION_RATELIMIT_WINDOW && parseInt(process.env.SESSION_RATELIMIT_WINDOW)) || 60, - sessionRateLimit: (process.env.SESSION_RATELIMIT && parseInt(process.env.SESSION_RATELIMIT)) || 10, - - durationLimit: (process.env.DURATION_LIMIT && parseInt(process.env.DURATION_LIMIT)) || 10800, - streamLifespan: (process.env.TUNNEL_LIFESPAN && parseInt(process.env.TUNNEL_LIFESPAN)) || 90, - - processingPriority: process.platform !== 'win32' - && process.env.PROCESSING_PRIORITY - && parseInt(process.env.PROCESSING_PRIORITY), - - externalProxy: process.env.API_EXTERNAL_PROXY, - - turnstileSitekey: process.env.TURNSTILE_SITEKEY, - turnstileSecret: process.env.TURNSTILE_SECRET, - jwtSecret: process.env.JWT_SECRET, - jwtLifetime: process.env.JWT_EXPIRY || 120, +const canonicalEnv = Object.freeze(structuredClone(process.env)); +const env = loadEnvs(); - sessionEnabled: process.env.TURNSTILE_SITEKEY - && process.env.TURNSTILE_SECRET - && process.env.JWT_SECRET, - - apiKeyURL: process.env.API_KEY_URL && new URL(process.env.API_KEY_URL), - authRequired: process.env.API_AUTH_REQUIRED === '1', - redisURL: process.env.API_REDIS_URL, - instanceCount: (process.env.API_INSTANCE_COUNT && parseInt(process.env.API_INSTANCE_COUNT)) || 1, - keyReloadInterval: 900, - - enabledServices, - - customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT, - ytSessionServer: process.env.YOUTUBE_SESSION_SERVER, - ytSessionReloadInterval: 300, - ytSessionInnertubeClient: process.env.YOUTUBE_SESSION_INNERTUBE_CLIENT, -} - -const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; +const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.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; +export const updateEnv = (newEnv) => { + const changes = []; -if (env.sessionEnabled && env.jwtSecret.length < 16) { - throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)"); -} + // tunnelPort is special and needs to get carried over here + newEnv.tunnelPort = env.tunnelPort; -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'); -} + for (const key in env) { + if (key === 'subscribe') { + continue; + } -if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) { - console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported."); - console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`); - throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT"); + if (String(env[key]) !== String(newEnv[key])) { + changes.push(key); + } + env[key] = newEnv[key]; + } + + return changes; } +await validateEnvs(env); + export { env, + canonicalEnv, genericUserAgent, cobaltUserAgent, } diff --git a/api/src/core/api.js b/api/src/core/api.js index 3d01e6e4defcc7db885ebabd3359c04dab98990d..d1f6545ac0b134cff74f4d238c50cdb1a8284348 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -1,23 +1,24 @@ import cors from "cors"; import http from "node:http"; import rateLimit from "express-rate-limit"; -import { setGlobalDispatcher, ProxyAgent } from "undici"; +import { setGlobalDispatcher, EnvHttpProxyAgent } from "undici"; import { getCommit, getBranch, getRemote, getVersion } from "@imput/version-info"; import jwt from "../security/jwt.js"; import stream from "../stream/stream.js"; import match from "../processing/match.js"; -import { env, isCluster, setTunnelPort } from "../config.js"; +import { env } from "../config.js"; import { extract } from "../processing/url.js"; -import { Green, Bright, Cyan } from "../misc/console-text.js"; +import { 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 { verifyStream } from "../stream/manage.js"; import { createResponse, normalizeRequest, getIP } from "../processing/request.js"; +import { setupTunnelHandler } from "./itunnel.js"; import * as APIKeys from "../security/api-keys.js"; import * as Cookies from "../processing/cookie/manager.js"; @@ -47,28 +48,31 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { const startTime = new Date(); const startTimestamp = startTime.getTime(); - const serverInfo = JSON.stringify({ - cobalt: { - version: version, - url: env.apiURL, - startTime: `${startTimestamp}`, - durationLimit: env.durationLimit, - turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined, - services: [...env.enabledServices].map(e => { - return friendlyServiceName(e); - }), - }, - git, - }) + const getServerInfo = () => { + return JSON.stringify({ + cobalt: { + version: version, + url: env.apiURL, + startTime: `${startTimestamp}`, + turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined, + services: [...env.enabledServices].map(e => { + return friendlyServiceName(e); + }), + }, + git, + }); + } + + const serverInfo = getServerInfo(); const handleRateExceeded = (_, res) => { - const { status, body } = createResponse("error", { + const { body } = createResponse("error", { code: "error.api.rate_exceeded", context: { limit: env.rateLimitWindow } }); - return res.status(status).json(body); + return res.status(429).json(body); }; const keyGenerator = (req) => hashHmac(getIP(req), 'rate').toString('base64url'); @@ -94,14 +98,14 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { }); const apiTunnelLimiter = rateLimit({ - windowMs: env.rateLimitWindow * 1000, - limit: (req) => req.rateLimitMax || env.rateLimitMax, + windowMs: env.tunnelRateLimitWindow * 1000, + limit: env.tunnelRateLimitMax, standardHeaders: 'draft-6', legacyHeaders: false, - keyGenerator: req => req.rateLimitKey || keyGenerator(req), + keyGenerator: req => keyGenerator(req), store: await createStore('tunnel'), handler: (_, res) => { - return res.sendStatus(429) + return res.sendStatus(429); } }); @@ -128,20 +132,6 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { next(); }); - app.post('/', (req, res, next) => { - const appkey = req.query.appkey; - - if (!appkey) { - return fail(res, "error.api.auth.appkey.missing"); - } - - if (appkey !== env.appKey) { - return fail(res, "error.api.auth.appkey.invalid"); - } - - next(); - }); - app.post('/', (req, res, next) => { if (!env.apiKeyURL) { return next(); @@ -166,6 +156,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { return fail(res, `error.api.auth.key.${error}`); } + req.authType = "key"; return next(); }); @@ -184,7 +175,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { return fail(res, "error.api.auth.jwt.invalid"); } - const [type, token, ...rest] = authorization.split(" "); + const [ type, token, ...rest ] = authorization.split(" "); if (!token || type.toLowerCase() !== 'bearer' || rest.length) { return fail(res, "error.api.auth.jwt.invalid"); } @@ -194,6 +185,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { } req.rateLimitKey = hashHmac(token, 'rate'); + req.authType = "session"; } catch { return fail(res, "error.api.generic"); } @@ -253,11 +245,15 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { return fail(res, "error.api.invalid_body"); } - const parsed = extract(normalizedRequest.url); + const parsed = extract( + normalizedRequest.url, + APIKeys.getAllowedServices(req.rateLimitKey), + ); if (!parsed) { return fail(res, "error.api.link.invalid"); } + if ("error" in parsed) { let context; if (parsed?.context) { @@ -271,13 +267,23 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { host: parsed.host, patternMatch: parsed.patternMatch, params: normalizedRequest, + authType: req.authType ?? "none", }); res.status(result.status).json(result.body); } catch { fail(res, "error.api.generic"); } - }) + }); + + app.use('/tunnel', cors({ + methods: ['GET'], + exposedHeaders: [ + 'Estimated-Content-Length', + 'Content-Disposition' + ], + ...corsConfig, + })); app.get('/tunnel', apiTunnelLimiter, async (req, res) => { const id = String(req.query.id); @@ -308,43 +314,11 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { } return stream(res, streamInfo); - }) - - const itunnelHandler = (req, res) => { - if (!req.ip.endsWith('127.0.0.1')) { - return res.sendStatus(403); - } - - if (String(req.query.id).length !== 21) { - return res.sendStatus(400); - } - - const streamInfo = getInternalStream(req.query.id); - if (!streamInfo) { - return res.sendStatus(404); - } - - streamInfo.headers = new Map([ - ...(streamInfo.headers || []), - ...Object.entries(req.headers) - ]); - - return stream(res, { type: 'internal', data: streamInfo }); - }; - - app.get('/itunnel', itunnelHandler); + }); app.get('/', (_, res) => { res.type('json'); - res.status(200).send(JSON.stringify({ - status: 'success', - message: 'Hello, world!' - })); - }) - - app.get('/api-status', (_, res) => { - res.type('json'); - res.status(200).send(serverInfo); + res.status(200).send(env.envFile ? getServerInfo() : serverInfo); }) app.get('/favicon.ico', (req, res) => { @@ -363,13 +337,17 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { randomizeCiphers(); setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes - if (env.externalProxy) { - if (env.freebindCIDR) { - throw new Error('Freebind is not available when external proxy is enabled') + env.subscribe(['externalProxy', 'httpProxyValues'], () => { + // TODO: remove env.externalProxy in a future version + const options = {}; + if (env.externalProxy) { + options.httpProxy = env.externalProxy; } - setGlobalDispatcher(new ProxyAgent(env.externalProxy)) - } + setGlobalDispatcher( + new EnvHttpProxyAgent(options) + ); + }); http.createServer(app).listen({ port: env.apiPort, @@ -406,17 +384,5 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { } }); - 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); - }); - } + setupTunnelHandler(); } diff --git a/api/src/core/env.js b/api/src/core/env.js new file mode 100644 index 0000000000000000000000000000000000000000..e504a8dce1d05ae35e97260281e01589e63d7a3b --- /dev/null +++ b/api/src/core/env.js @@ -0,0 +1,289 @@ +import { Constants } from "youtubei.js"; +import { services } from "../processing/service-config.js"; +import { updateEnv, canonicalEnv, env as currentEnv } from "../config.js"; + +import { FileWatcher } from "../misc/file-watcher.js"; +import { isURL } from "../misc/utils.js"; +import * as cluster from "../misc/cluster.js"; +import { Green, Yellow } from "../misc/console-text.js"; + +const forceLocalProcessingOptions = ["never", "session", "always"]; +const youtubeHlsOptions = ["never", "key", "always"]; + +const httpProxyVariables = ["NO_PROXY", "HTTP_PROXY", "HTTPS_PROXY"].flatMap( + k => [ k, k.toLowerCase() ] +); + +const changeCallbacks = {}; + +const onEnvChanged = (changes) => { + for (const key of changes) { + if (changeCallbacks[key]) { + changeCallbacks[key].map(fn => { + try { fn() } catch {} + }); + } + } +} + +const subscribe = (keys, fn) => { + keys = [keys].flat(); + + for (const key of keys) { + if (key in currentEnv && key !== 'subscribe') { + changeCallbacks[key] ??= []; + changeCallbacks[key].push(fn); + fn(); + } else throw `invalid env key ${key}`; + } +} + +export const loadEnvs = (env = process.env) => { + const allServices = new Set(Object.keys(services)); + const disabledServices = env.DISABLED_SERVICES?.split(',') || []; + const enabledServices = new Set(Object.keys(services).filter(e => { + if (!disabledServices.includes(e)) { + return e; + } + })); + + // we need to copy the proxy envs (HTTP_PROXY, HTTPS_PROXY) + // back into process.env, so that EnvHttpProxyAgent can pick + // them up later + for (const key of httpProxyVariables) { + const value = env[key] ?? canonicalEnv[key]; + if (value !== undefined) { + process.env[key] = env[key]; + } else { + delete process.env[key]; + } + } + + return { + apiURL: env.API_URL || '', + apiPort: env.API_PORT || 9000, + tunnelPort: env.API_PORT || 9000, + + listenAddress: env.API_LISTEN_ADDRESS, + freebindCIDR: process.platform === 'linux' && env.FREEBIND_CIDR, + + corsWildcard: env.CORS_WILDCARD !== '0', + corsURL: env.CORS_URL, + + cookiePath: env.COOKIE_PATH, + + rateLimitWindow: (env.RATELIMIT_WINDOW && parseInt(env.RATELIMIT_WINDOW)) || 60, + rateLimitMax: (env.RATELIMIT_MAX && parseInt(env.RATELIMIT_MAX)) || 20, + + tunnelRateLimitWindow: (env.TUNNEL_RATELIMIT_WINDOW && parseInt(env.TUNNEL_RATELIMIT_WINDOW)) || 60, + tunnelRateLimitMax: (env.TUNNEL_RATELIMIT_MAX && parseInt(env.TUNNEL_RATELIMIT_MAX)) || 40, + + sessionRateLimitWindow: (env.SESSION_RATELIMIT_WINDOW && parseInt(env.SESSION_RATELIMIT_WINDOW)) || 60, + sessionRateLimit: + // backwards compatibility with SESSION_RATELIMIT + // till next major due to an error in docs + (env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX)) + || (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT)) + || 10, + + durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800, + streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90, + + processingPriority: process.platform !== 'win32' + && env.PROCESSING_PRIORITY + && parseInt(env.PROCESSING_PRIORITY), + + externalProxy: env.API_EXTERNAL_PROXY, + + // used only for comparing against old values when envs are being updated + httpProxyValues: httpProxyVariables.map(k => String(env[k])).join(''), + + turnstileSitekey: env.TURNSTILE_SITEKEY, + turnstileSecret: env.TURNSTILE_SECRET, + jwtSecret: env.JWT_SECRET, + jwtLifetime: env.JWT_EXPIRY || 120, + + sessionEnabled: env.TURNSTILE_SITEKEY + && env.TURNSTILE_SECRET + && env.JWT_SECRET, + + apiKeyURL: env.API_KEY_URL && new URL(env.API_KEY_URL), + authRequired: env.API_AUTH_REQUIRED === '1', + redisURL: env.API_REDIS_URL, + instanceCount: (env.API_INSTANCE_COUNT && parseInt(env.API_INSTANCE_COUNT)) || 1, + keyReloadInterval: 900, + + allServices, + enabledServices, + + customInnertubeClient: env.CUSTOM_INNERTUBE_CLIENT, + ytSessionServer: env.YOUTUBE_SESSION_SERVER, + ytSessionReloadInterval: 300, + ytSessionInnertubeClient: env.YOUTUBE_SESSION_INNERTUBE_CLIENT, + ytAllowBetterAudio: env.YOUTUBE_ALLOW_BETTER_AUDIO !== "0", + + // "never" | "session" | "always" + forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never", + + // "never" | "key" | "always" + enableDeprecatedYoutubeHls: env.ENABLE_DEPRECATED_YOUTUBE_HLS ?? "never", + + envFile: env.API_ENV_FILE, + envRemoteReloadInterval: 300, + + subscribe, + }; +} + +let loggedProxyWarning = false; + +export const validateEnvs = async (env) => { + 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 cluster.supportsReusePort()) { + console.error('API_INSTANCE_COUNT is not supported in your environment. to use this env, your node.js'); + console.error('version must be >= 23.1.0, and you must be running a recent enough version of linux'); + console.error('(or other OS that supports it). for more info, see `reusePort` option on'); + console.error('https://nodejs.org/api/net.html#serverlistenoptions-callback'); + throw new Error('SO_REUSEPORT is not supported'); + } + + if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) { + console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported."); + console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`); + throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT"); + } + + if (env.forceLocalProcessing && !forceLocalProcessingOptions.includes(env.forceLocalProcessing)) { + console.error("FORCE_LOCAL_PROCESSING is invalid."); + console.error(`Supported options are are: ${forceLocalProcessingOptions.join(', ')}\n`); + throw new Error("Invalid FORCE_LOCAL_PROCESSING"); + } + + if (env.enableDeprecatedYoutubeHls && !youtubeHlsOptions.includes(env.enableDeprecatedYoutubeHls)) { + console.error("ENABLE_DEPRECATED_YOUTUBE_HLS is invalid."); + console.error(`Supported options are are: ${youtubeHlsOptions.join(', ')}\n`); + throw new Error("Invalid ENABLE_DEPRECATED_YOUTUBE_HLS"); + } + + if (env.externalProxy && env.freebindCIDR) { + throw new Error('freebind is not available when external proxy is enabled') + } + + if (env.externalProxy && !loggedProxyWarning) { + console.error('API_EXTERNAL_PROXY is deprecated and will be removed in a future release.'); + console.error('Use HTTP_PROXY or HTTPS_PROXY instead.'); + console.error('You can read more about the new proxy variables in docs/api-env-variables.md\n'); + + // prevent the warning from being printed on every env validation + loggedProxyWarning = true; + } + + return env; +} + +const reloadEnvs = async (contents) => { + const newEnvs = {}; + const resolvedContents = await contents; + + for (let line of resolvedContents.split('\n')) { + line = line.trim(); + if (line === '') { + continue; + } + + let [ key, value ] = line.split(/=(.+)?/); + if (key) { + if (value.match(/^['"]/) && value.match(/['"]$/)) { + value = JSON.parse(value); + } + + newEnvs[key] = value || ''; + } + } + + const candidate = { + ...canonicalEnv, + ...newEnvs, + }; + + const parsed = await validateEnvs( + loadEnvs(candidate) + ); + + cluster.broadcast({ env_update: resolvedContents }); + return updateEnv(parsed); +} + +const wrapReload = (contents) => { + reloadEnvs(contents) + .then(changes => { + if (changes.length === 0) { + return; + } + + onEnvChanged(changes); + + console.log(`${Green('[✓]')} envs reloaded successfully!`); + for (const key of changes) { + const value = currentEnv[key]; + const isSecret = key.toLowerCase().includes('apikey') + || key.toLowerCase().includes('secret') + || key === 'httpProxyValues'; + + if (!value) { + console.log(` removed: ${key}`); + } else { + console.log(` changed: ${key} -> ${isSecret ? '***' : value}`); + } + } + }) + .catch((e) => { + console.error(`${Yellow('[!]')} Failed reloading environment variables at ${new Date().toISOString()}.`); + console.error('Error:', e); + }); +} + +let watcher; +const setupWatcherFromFile = (path) => { + const load = () => wrapReload(watcher.read()); + + if (isURL(path)) { + watcher = FileWatcher.fromFileProtocol(path); + } else { + watcher = new FileWatcher({ path }); + } + + watcher.on('file-updated', load); + load(); +} + +const setupWatcherFromFetch = (url) => { + const load = () => wrapReload(fetch(url).then(r => r.text())); + setInterval(load, currentEnv.envRemoteReloadInterval); + load(); +} + +export const setupEnvWatcher = () => { + if (cluster.isPrimary) { + const envFile = currentEnv.envFile; + const isFile = !isURL(envFile) + || new URL(envFile).protocol === 'file:'; + + if (isFile) { + setupWatcherFromFile(envFile); + } else { + setupWatcherFromFetch(envFile); + } + } else if (cluster.isWorker) { + process.on('message', (message) => { + if ('env_update' in message) { + reloadEnvs(message.env_update); + } + }); + } +} diff --git a/api/src/core/itunnel.js b/api/src/core/itunnel.js new file mode 100644 index 0000000000000000000000000000000000000000..e16c0345db4aa8ee6493e76e4d64498969079ba3 --- /dev/null +++ b/api/src/core/itunnel.js @@ -0,0 +1,61 @@ +import stream from "../stream/stream.js"; +import { getInternalTunnel } from "../stream/manage.js"; +import { setTunnelPort } from "../config.js"; +import { Green } from "../misc/console-text.js"; +import express from "express"; + +const validateTunnel = (req, res) => { + if (!req.ip.endsWith('127.0.0.1')) { + res.sendStatus(403); + return; + } + + if (String(req.query.id).length !== 21) { + res.sendStatus(400); + return; + } + + const streamInfo = getInternalTunnel(req.query.id); + if (!streamInfo) { + res.sendStatus(404); + return; + } + + return streamInfo; +} + +const streamTunnel = (req, res) => { + const streamInfo = validateTunnel(req, res); + if (!streamInfo) { + return; + } + + streamInfo.headers = new Map([ + ...(streamInfo.headers || []), + ...Object.entries(req.headers) + ]); + + return stream(res, { type: 'internal', data: streamInfo }); +} + +export const setupTunnelHandler = () => { + const tunnelHandler = express(); + + tunnelHandler.get('/itunnel', streamTunnel); + + // fallback + tunnelHandler.use((_, res) => res.sendStatus(400)); + // error handler + tunnelHandler.use((_, __, res, ____) => res.socket.end()); + + + const server = tunnelHandler.listen({ + port: 0, + host: '127.0.0.1', + exclusive: true + }, () => { + const { port } = server.address(); + console.log(`${Green('[✓]')} internal tunnel handler running on 127.0.0.1:${port}`); + setTunnelPort(port); + }); +} diff --git a/api/src/misc/cluster.js b/api/src/misc/cluster.js index 56664d15f5b488fbd2e9e1290928334d135e5b7d..1320bb4cc68d814e2ba08e5b505e1142f86ade03 100644 --- a/api/src/misc/cluster.js +++ b/api/src/misc/cluster.js @@ -13,7 +13,8 @@ export const supportsReusePort = async () => { server.on('error', (err) => (server.close(), reject(err))); }); - return true; + const [major, minor] = process.versions.node.split('.').map(Number); + return major > 23 || (major === 23 && minor >= 1); } catch { return false; } diff --git a/api/src/misc/file-watcher.js b/api/src/misc/file-watcher.js new file mode 100644 index 0000000000000000000000000000000000000000..d66a77be99e00a43f5dd931c23f17e6d8c83c09c --- /dev/null +++ b/api/src/misc/file-watcher.js @@ -0,0 +1,43 @@ +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs/promises'; + +export class FileWatcher extends EventEmitter { + #path; + #hasWatcher = false; + #lastChange = new Date().getTime(); + + constructor({ path, ...rest }) { + super(rest); + this.#path = path; + } + + async #setupWatcher() { + if (this.#hasWatcher) + return; + + this.#hasWatcher = true; + const watcher = fs.watch(this.#path); + for await (const _ of watcher) { + if (new Date() - this.#lastChange > 50) { + this.emit('file-updated'); + this.#lastChange = new Date().getTime(); + } + } + } + + read() { + this.#setupWatcher(); + return fs.readFile(this.#path, 'utf8'); + } + + static fromFileProtocol(url_) { + const url = new URL(url_); + if (url.protocol !== 'file:') { + return; + } + + const pathname = url.pathname === '/' ? '' : url.pathname; + const file_path = decodeURIComponent(url.host + pathname); + return new this({ path: file_path }); + } +} diff --git a/api/src/misc/language-codes.js b/api/src/misc/language-codes.js new file mode 100644 index 0000000000000000000000000000000000000000..1f69260151df7bde574fa8e2769a64d2ca38bcab --- /dev/null +++ b/api/src/misc/language-codes.js @@ -0,0 +1,54 @@ +// converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt +const iso639_1to2 = { + 'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi', + 'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm', + 'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak', + 'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis', + 'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat', + 'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv', + 'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan', + 'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo', + 'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin', + 'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu', + 'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell', + 'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb', + 'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun', + 'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku', + 'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita', + 'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas', + 'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin', + 'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua', + 'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim', + 'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug', + 'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar', + 'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau', + 'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep', + 'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci', + 'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan', + 'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus', + 'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus', + 'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv', + 'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som', + 'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw', + 'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam', + 'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha', + 'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso', + 'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr', + 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol', + 'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid', + 'yo': 'yor', 'za': 'zha', 'zu': 'zul', +} + +const iso639_2to1 = Object.fromEntries( + Object.entries(iso639_1to2).map(([k, v]) => [v, k]) +); + +const maps = { + 2: iso639_1to2, + 3: iso639_2to1, +} + +export const convertLanguageCode = (code) => { + code = code?.split("-")[0]?.split("_")[0] || ""; + return maps[code.length]?.[code.toLowerCase()] || null; +} diff --git a/api/src/misc/utils.js b/api/src/misc/utils.js index 62bf6351b1706edb2ab92f7070c8f0e2d8a646f3..82fe40a78fea4cb10347ddd1ae99bf713d61fc72 100644 --- a/api/src/misc/utils.js +++ b/api/src/misc/utils.js @@ -1,4 +1,4 @@ -import { request } from 'undici'; +import { request } from "undici"; const redirectStatuses = new Set([301, 302, 303, 307, 308]); export async function getRedirectingURL(url, dispatcher, headers) { @@ -8,18 +8,34 @@ export async function getRedirectingURL(url, dispatcher, headers) { headers, redirect: 'manual' }; + const getParams = { + ...params, + method: 'GET', + }; - let location = await request(url, params).then(r => { + const callback = (r) => { if (redirectStatuses.has(r.statusCode) && r.headers['location']) { return r.headers['location']; } - }).catch(() => null); + } - location ??= await fetch(url, params).then(r => { - if (redirectStatuses.has(r.status) && r.headers.has('location')) { - return r.headers.get('location'); - } - }).catch(() => null); + /* + try request() with HEAD & GET, + then do the same with fetch + (fetch is required for shortened reddit links) + */ + + let location = await request(url, params) + .then(callback).catch(() => null); + + location ??= await request(url, getParams) + .then(callback).catch(() => null); + + location ??= await fetch(url, params) + .then(callback).catch(() => null); + + location ??= await fetch(url, getParams) + .then(callback).catch(() => null); return location; } @@ -52,3 +68,12 @@ export function splitFilenameExtension(filename) { export function zip(a, b) { return a.map((value, i) => [ value, b[i] ]); } + +export function isURL(input) { + try { + new URL(input); + return true; + } catch { + return false; + } +} diff --git a/api/src/processing/cookie/manager.js b/api/src/processing/cookie/manager.js index 9e23374b97fcd5b74eacfb2f1c0897df4fb95c27..cad11f77f0e72ace0352395e65d9c2a4048ef627 100644 --- a/api/src/processing/cookie/manager.js +++ b/api/src/processing/cookie/manager.js @@ -13,6 +13,7 @@ const VALID_SERVICES = new Set([ 'reddit', 'twitter', 'youtube', + 'vimeo_bearer', ]); const invalidCookies = {}; diff --git a/api/src/processing/create-filename.js b/api/src/processing/create-filename.js index 911b5603eb517bfa2f6b2b191e76e30971cc6a97..26a85c1d62885093e12b77271df64eeca0fa5150 100644 --- a/api/src/processing/create-filename.js +++ b/api/src/processing/create-filename.js @@ -1,10 +1,25 @@ -const illegalCharacters = ['}', '{', '%', '>', '<', '^', ';', ':', '`', '$', '"', "@", '=', '?', '|', '*']; +// characters that are disallowed on windows: +// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions +const characterMap = { + '<': '<', + '>': '>', + ':': ':', + '"': '"', + '/': '/', + '\\': '\', + '|': '|', + '?': '?', + '*': '*' +}; -const sanitizeString = (string) => { - for (const i in illegalCharacters) { - string = string.replaceAll("/", "_").replaceAll("\\", "_") - .replaceAll(illegalCharacters[i], '') +export const sanitizeString = (string) => { + // remove any potential control characters the string might contain + string = string.replace(/[\u0000-\u001F\u007F-\u009F]/g, ""); + + for (const [ char, replacement ] of Object.entries(characterMap)) { + string = string.replaceAll(char, replacement); } + return string; } diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 363cb4031933dc4f523d7edf3ff7c89097d38147..5852b19d7d2fa47d3905b59ddc945ad72d1b6be4 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -4,8 +4,24 @@ import { createResponse } from "./request.js"; import { audioIgnore } from "./service-config.js"; import { createStream } from "../stream/manage.js"; import { splitFilenameExtension } from "../misc/utils.js"; +import { convertLanguageCode } from "../misc/language-codes.js"; -export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) { +const extraProcessingTypes = new Set(["merge", "remux", "mute", "audio", "gif"]); + +export default function({ + r, + host, + audioFormat, + isAudioOnly, + isAudioMuted, + disableMetadata, + filenameStyle, + convertGif, + requestIP, + audioBitrate, + alwaysProxy, + localProcessing, +}) { let action, responseType = "tunnel", defaultParams = { @@ -16,13 +32,16 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename, fileMetadata: !disableMetadata ? r.fileMetadata : false, requestIP, - originalRequest: r.originalRequest + originalRequest: r.originalRequest, + subtitles: r.subtitles, + cover: !disableMetadata ? r.cover : false, + cropCover: !disableMetadata ? r.cropCover : false, }, params = {}; if (r.isPhoto) action = "photo"; else if (r.picker) action = "picker" - else if (r.isGif && twitterGif) action = "gif"; + else if (r.isGif && convertGif) action = "gif"; else if (isAudioOnly) action = "audio"; else if (isAudioMuted) action = "muteVideo"; else if (r.isHLS) action = "hls"; @@ -128,7 +147,9 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab case "vimeo": if (Array.isArray(r.urls)) { - params = { type: "merge" } + params = { type: "merge" }; + } else if (r.subtitles) { + params = { type: "remux" }; } else { responseType = "redirect"; } @@ -142,10 +163,24 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab } break; - case "ok": + case "loom": + if (r.subtitles) { + params = { type: "remux" }; + } else { + responseType = "redirect"; + } + break; + case "vk": case "tiktok": + params = { + type: r.subtitles ? "remux" : "proxy" + }; + break; + + case "ok": case "xiaohongshu": + case "newgrounds": params = { type: "proxy" }; break; @@ -155,7 +190,6 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab case "pinterest": case "streamable": case "snapchat": - case "loom": case "twitch": responseType = "redirect"; break; @@ -163,7 +197,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab break; case "audio": - if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) { + if (audioIgnore.has(host) || (host === "reddit" && r.typeId === "redirect")) { return createResponse("error", { code: "error.api.service.audio_not_supported" }) @@ -211,10 +245,39 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab defaultParams.filename += `.${audioFormat}`; } + // alwaysProxy is set to true in match.js if localProcessing is forced if (alwaysProxy && responseType === "redirect") { responseType = "tunnel"; params.type = "proxy"; } - return createResponse(responseType, {...defaultParams, ...params}) + // TODO: add support for HLS + // (very painful) + if (!params.isHLS && responseType !== "picker") { + const isPreferredWithExtra = + localProcessing === "preferred" && extraProcessingTypes.has(params.type); + + if (localProcessing === "forced" || isPreferredWithExtra) { + responseType = "local-processing"; + } + } + + // extractors usually return ISO 639-1 language codes, + // but video players expect ISO 639-2, so we convert them here + const sublanguage = defaultParams.fileMetadata?.sublanguage; + if (sublanguage && sublanguage.length !== 3) { + const code = convertLanguageCode(sublanguage); + if (code) { + defaultParams.fileMetadata.sublanguage = code; + } else { + // if a language code couldn't be converted, + // then we don't want it at all + delete defaultParams.fileMetadata.sublanguage; + } + } + + return createResponse( + responseType, + { ...defaultParams, ...params } + ); } diff --git a/api/src/processing/match.js b/api/src/processing/match.js index ee4fdc1a57e64839955eb6b70170644ef0e4fb57..1265297cddfa6b782237aa978a1cfec6f049631d 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -29,10 +29,11 @@ import loom from "./services/loom.js"; import facebook from "./services/facebook.js"; import bluesky from "./services/bluesky.js"; import xiaohongshu from "./services/xiaohongshu.js"; +import newgrounds from "./services/newgrounds.js"; let freebind; -export default async function({ host, patternMatch, params }) { +export default async function({ host, patternMatch, params, authType }) { const { url } = params; assert(url instanceof URL); let dispatcher, requestIP; @@ -65,14 +66,26 @@ export default async function({ host, patternMatch, params }) { }); } + // youtubeHLS will be fully removed in the future + let youtubeHLS = params.youtubeHLS; + const hlsEnv = env.enableDeprecatedYoutubeHls; + + if (hlsEnv === "never" || (hlsEnv === "key" && authType !== "key")) { + youtubeHLS = false; + } + + const subtitleLang = + params.subtitleLang !== "none" ? params.subtitleLang : undefined; + switch (host) { case "twitter": r = await twitter({ id: patternMatch.id, index: patternMatch.index - 1, - toGif: !!params.twitterGif, + toGif: !!params.convertGif, alwaysProxy: params.alwaysProxy, - dispatcher + dispatcher, + subtitleLang }); break; @@ -81,7 +94,8 @@ export default async function({ host, patternMatch, params }) { ownerId: patternMatch.ownerId, videoId: patternMatch.videoId, accessKey: patternMatch.accessKey, - quality: params.videoQuality + quality: params.videoQuality, + subtitleLang, }); break; @@ -101,18 +115,24 @@ export default async function({ host, patternMatch, params }) { dispatcher, id: patternMatch.id.slice(0, 11), quality: params.videoQuality, - format: params.youtubeVideoCodec, + codec: params.youtubeVideoCodec, + container: params.youtubeVideoContainer, isAudioOnly, isAudioMuted, dubLang: params.youtubeDubLang, - youtubeHLS: params.youtubeHLS, + youtubeHLS, + subtitleLang, } if (url.hostname === "music.youtube.com" || isAudioOnly) { fetchInfo.quality = "1080"; - fetchInfo.format = "vp9"; + fetchInfo.codec = "vp9"; fetchInfo.isAudioOnly = true; fetchInfo.isAudioMuted = false; + + if (env.ytAllowBetterAudio && params.youtubeBetterAudio) { + fetchInfo.quality = "max"; + } } r = await youtube(fetchInfo); @@ -131,8 +151,9 @@ export default async function({ host, patternMatch, params }) { shortLink: patternMatch.shortLink, fullAudio: params.tiktokFullAudio, isAudioOnly, - h265: params.tiktokH265, + h265: params.allowH265, alwaysProxy: params.alwaysProxy, + subtitleLang, }); break; @@ -150,6 +171,7 @@ export default async function({ host, patternMatch, params }) { password: patternMatch.password, quality: params.videoQuality, isAudioOnly, + subtitleLang, }); break; @@ -157,12 +179,8 @@ export default async function({ host, patternMatch, params }) { isAudioOnly = true; isAudioMuted = false; r = await soundcloud({ - url, - author: patternMatch.author, - song: patternMatch.song, + ...patternMatch, format: params.audioFormat, - shortLink: patternMatch.shortLink || false, - accessKey: patternMatch.accessKey || false }); break; @@ -205,6 +223,7 @@ export default async function({ host, patternMatch, params }) { key: patternMatch.key, quality: params.videoQuality, isAudioOnly, + subtitleLang, }); break; @@ -221,7 +240,8 @@ export default async function({ host, patternMatch, params }) { case "loom": r = await loom({ - id: patternMatch.id + id: patternMatch.id, + subtitleLang, }); break; @@ -243,12 +263,19 @@ export default async function({ host, patternMatch, params }) { case "xiaohongshu": r = await xiaohongshu({ ...patternMatch, - h265: params.tiktokH265, + h265: params.allowH265, isAudioOnly, dispatcher, }); break; + case "newgrounds": + r = await newgrounds({ + ...patternMatch, + quality: params.videoQuality, + }); + break; + default: return createResponse("error", { code: "error.api.service.unsupported" @@ -271,7 +298,7 @@ export default async function({ host, patternMatch, params }) { switch(r.error) { case "content.too_long": context = { - limit: env.durationLimit / 60, + limit: parseFloat((env.durationLimit / 60).toFixed(2)), } break; @@ -292,6 +319,15 @@ export default async function({ host, patternMatch, params }) { }) } + let localProcessing = params.localProcessing; + const lpEnv = env.forceLocalProcessing; + const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session"); + const localDisabled = (!localProcessing || localProcessing === "disabled"); + + if (shouldForceLocal && localDisabled) { + localProcessing = "preferred"; + } + return matchAction({ r, host, @@ -300,10 +336,11 @@ export default async function({ host, patternMatch, params }) { isAudioMuted, disableMetadata: params.disableMetadata, filenameStyle: params.filenameStyle, - twitterGif: params.twitterGif, + convertGif: params.convertGif, requestIP, audioBitrate: params.audioBitrate, - alwaysProxy: params.alwaysProxy, + alwaysProxy: params.alwaysProxy || localProcessing === "forced", + localProcessing, }) } catch { return createResponse("error", { diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 61bf027b1ecba6fa2423758cb0849a1109b20858..b9ebca055de8afc80c678c09bb30fa2843db7904 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -1,7 +1,8 @@ +import mime from "mime"; import ipaddr from "ipaddr.js"; -import { createStream } from "../stream/manage.js"; import { apiSchema } from "./schema.js"; +import { createProxyTunnels, createStream } from "../stream/manage.js"; export function createResponse(responseType, responseData) { const internalError = (code) => { @@ -10,7 +11,7 @@ export function createResponse(responseType, responseData) { body: { status: "error", error: { - code: code || "error.api.fetch.critical", + code: code || "error.api.fetch.critical.core", }, critical: true } @@ -49,6 +50,44 @@ export function createResponse(responseType, responseData) { } break; + case "local-processing": + response = { + type: responseData?.type, + service: responseData?.service, + tunnel: createProxyTunnels(responseData), + + output: { + type: mime.getType(responseData?.filename) || undefined, + filename: responseData?.filename, + metadata: responseData?.fileMetadata || undefined, + subtitles: !!responseData?.subtitles || undefined, + }, + + audio: { + copy: responseData?.audioCopy, + format: responseData?.audioFormat, + bitrate: responseData?.audioBitrate, + cover: !!responseData?.cover || undefined, + cropCover: !!responseData?.cropCover || undefined, + }, + + isHLS: responseData?.isHLS, + } + + if (!response.audio.format) { + if (response.type === "audio") { + // audio response without a format is invalid + return internalError(); + } + delete response.audio; + } + + if (!response.output.type || !response.output.filename) { + // response without a type or filename is invalid + return internalError(); + } + break; + case "picker": response = { picker: responseData?.picker, @@ -72,11 +111,16 @@ export function createResponse(responseType, responseData) { } } } catch { - return internalError() + return internalError(); } } export function normalizeRequest(request) { + // TODO: remove after backwards compatibility period + if ("localProcessing" in request && typeof request.localProcessing === "boolean") { + request.localProcessing = request.localProcessing ? "preferred" : "disabled"; + } + return apiSchema.safeParseAsync(request).catch(() => ( { success: false } )); diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index 48d8b0580b73b6d5996b32af83c2f3607dc96c6e..7570a8b04d8e1b1b22bbafee9250d055aa03c15d 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -20,32 +20,45 @@ export const apiSchema = z.object({ filenameStyle: z.enum( ["classic", "pretty", "basic", "nerdy"] - ).default("classic"), + ).default("basic"), youtubeVideoCodec: z.enum( ["h264", "av1", "vp9"] ).default("h264"), + youtubeVideoContainer: z.enum( + ["auto", "mp4", "webm", "mkv"] + ).default("auto"), + videoQuality: z.enum( ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"] ).default("1080"), + localProcessing: z.enum( + ["disabled", "preferred", "forced"] + ).default("disabled"), + youtubeDubLang: z.string() .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), + subtitleLang: z.string() + .min(2) + .max(8) + .regex(/^[0-9a-zA-Z\-]+$/) + .optional(), - alwaysProxy: z.boolean().default(false), disableMetadata: z.boolean().default(false), + + allowH265: z.boolean().default(false), + convertGif: z.boolean().default(true), tiktokFullAudio: z.boolean().default(false), - tiktokH265: z.boolean().default(false), - twitterGif: z.boolean().default(true), + + alwaysProxy: z.boolean().default(false), youtubeHLS: z.boolean().default(false), + youtubeBetterAudio: z.boolean().default(false), }) .strict(); diff --git a/api/src/processing/service-alias.js b/api/src/processing/service-alias.js index 611401b97f7af1ed629a3be3991f992be4682e3d..c52bdb053968319cb0469ba0f438aa82ffe371a2 100644 --- a/api/src/processing/service-alias.js +++ b/api/src/processing/service-alias.js @@ -1,5 +1,6 @@ const friendlyNames = { bsky: "bluesky", + twitch: "twitch clips" } export const friendlyServiceName = (service) => { diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 87a71c38ec158ce925f8442d294d133a931359f5..0a35838bdd076e39bde3296550b9297148efe8b6 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -1,12 +1,13 @@ import UrlPattern from "url-pattern"; -export const audioIgnore = ["vk", "ok", "loom"]; -export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"]; +export const audioIgnore = new Set(["vk", "ok", "loom"]); +export const hlsExceptions = new Set(["dailymotion", "vimeo", "rutube", "bsky", "youtube"]); export const services = { bilibili: { patterns: [ "video/:comId", + "video/:comId?p=:partId", "_shortLink/:comShortLink", "_tv/:lang/video/:tvId", "_tv/video/:tvId" @@ -74,6 +75,12 @@ export const services = { "url_shortener/:shortLink" ], }, + newgrounds: { + patterns: [ + "portal/view/:id", + "audio/listen/:audioId", + ] + }, reddit: { patterns: [ "comments/:id", @@ -116,6 +123,7 @@ export const services = { "add/:username", "u/:username", "t/:shortLink", + "o/:spotlightId", ], subdomains: ["t", "story"], }, @@ -158,6 +166,7 @@ export const services = { twitch: { patterns: [":channel/clip/:clip"], tld: "tv", + subdomains: ["clips", "www", "m"], }, twitter: { patterns: [ @@ -176,7 +185,8 @@ export const services = { ":id", "video/:id", ":id/:password", - "/channels/:user/:id" + "/channels/:user/:id", + "groups/:groupId/videos/:id" ], subdomains: ["player"], }, @@ -184,12 +194,13 @@ export const services = { patterns: [ "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" + + // links with a duplicate author id and/or zipper query param + "clips:duplicateId", + "videos:duplicateId", + "search/video" ], subdomains: ["m"], altDomains: ["vkvideo.ru", "vk.ru"], @@ -198,7 +209,7 @@ export const services = { patterns: [ "explore/:id?xsec_token=:token", "discovery/item/:id?xsec_token=:token", - "a/:shareId" + ":shareType/:shareId", ], altDomains: ["xhslink.com"], }, @@ -206,7 +217,8 @@ export const services = { patterns: [ "watch?v=:id", "embed/:id", - "watch/:id" + "watch/:id", + "v/:id" ], subdomains: ["music", "m"], } diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index 2412fd46442f96c3b4d2adfb08dfb39da75b5743..6dc3ccbd07b2128e543a466c0a9ca142bd73c94c 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -1,53 +1,72 @@ export const testers = { "bilibili": pattern => - pattern.comId?.length <= 12 || pattern.comShortLink?.length <= 16 - || pattern.tvId?.length <= 24, + (pattern.comId?.length <= 12 && pattern.partId?.length <= 3) || + (pattern.comId?.length <= 12 && !pattern.partId) || + pattern.comShortLink?.length <= 16 || + pattern.tvId?.length <= 24, + + "bsky": pattern => + pattern.user?.length <= 128 && pattern.post?.length <= 128, "dailymotion": pattern => pattern.id?.length <= 32, + "facebook": pattern => + pattern.shortLink?.length <= 11 || + pattern.username?.length <= 30 || + pattern.caption?.length <= 255 || + pattern.id?.length <= 20 && !pattern.shareType || + pattern.id?.length <= 20 && pattern.shareType?.length === 1, + "instagram": pattern => - pattern.postId?.length <= 48 - || pattern.shareId?.length <= 16 - || (pattern.username?.length <= 30 && pattern.storyId?.length <= 24), + pattern.postId?.length <= 48 || + pattern.shareId?.length <= 16 || + (pattern.username?.length <= 30 && pattern.storyId?.length <= 24), "loom": pattern => pattern.id?.length <= 32, + "newgrounds": pattern => + pattern.id?.length <= 12 || + pattern.audioId?.length <= 12, + "ok": pattern => pattern.id?.length <= 16, "pinterest": pattern => - pattern.id?.length <= 128 || pattern.shortLink?.length <= 32, + pattern.id?.length <= 128 || + pattern.shortLink?.length <= 32, "reddit": pattern => - pattern.id?.length <= 16 && !pattern.sub && !pattern.user - || (pattern.sub?.length <= 22 && pattern.id?.length <= 16) - || (pattern.user?.length <= 22 && pattern.id?.length <= 16) - || (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16) - || (pattern.shortId?.length <= 16), + pattern.id?.length <= 16 && !pattern.sub && !pattern.user || + (pattern.sub?.length <= 22 && pattern.id?.length <= 16) || + (pattern.user?.length <= 22 && pattern.id?.length <= 16) || + (pattern.sub?.length <= 22 && pattern.shareId?.length <= 16) || + (pattern.shortId?.length <= 16), "rutube": pattern => (pattern.id?.length === 32 && pattern.key?.length <= 32) || - pattern.id?.length === 32 || pattern.yappyId?.length === 32, - - "soundcloud": pattern => - (pattern.author?.length <= 255 && pattern.song?.length <= 255) - || pattern.shortLink?.length <= 32, + pattern.id?.length === 32 || + pattern.yappyId?.length === 32, "snapchat": pattern => - (pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255)) - || pattern.spotlightId?.length <= 255 - || pattern.shortLink?.length <= 16, + (pattern.username?.length <= 32 && (!pattern.storyId || pattern.storyId?.length <= 255)) || + pattern.spotlightId?.length <= 255 || + pattern.shortLink?.length <= 16, + + "soundcloud": pattern => + (pattern.author?.length <= 255 && pattern.song?.length <= 255) || + pattern.shortLink?.length <= 32, "streamable": pattern => pattern.id?.length <= 6, "tiktok": pattern => - pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13, + pattern.postId?.length <= 21 || + pattern.shortLink?.length <= 21, "tumblr": pattern => - pattern.id?.length < 21 - || (pattern.id?.length < 21 && pattern.user?.length <= 32), + pattern.id?.length < 21 || + (pattern.id?.length < 21 && pattern.user?.length <= 32), "twitch": pattern => pattern.channel && pattern.clip?.length <= 100, @@ -56,27 +75,16 @@ export const testers = { pattern.id?.length < 20, "vimeo": pattern => - pattern.id?.length <= 11 - && (!pattern.password || pattern.password.length < 16), + pattern.id?.length <= 11 && (!pattern.password || pattern.password.length < 16), "vk": pattern => (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) || (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18), + "xiaohongshu": pattern => + pattern.id?.length <= 24 && pattern.token?.length <= 64 || + pattern.shareId?.length <= 24 && pattern.shareType?.length === 1, + "youtube": pattern => pattern.id?.length <= 11, - - "facebook": pattern => - pattern.shortLink?.length <= 11 - || pattern.username?.length <= 30 - || pattern.caption?.length <= 255 - || pattern.id?.length <= 20 && !pattern.shareType - || pattern.id?.length <= 20 && pattern.shareType?.length === 1, - - "bsky": pattern => - pattern.user?.length <= 128 && pattern.post?.length <= 128, - - "xiaohongshu": pattern => - pattern.id?.length <= 24 && pattern.token?.length <= 64 - || pattern.shareId?.length <= 12, } diff --git a/api/src/processing/services/bilibili.js b/api/src/processing/services/bilibili.js index 4ee148dbb76c686b16879da160cd560f7870e8c4..297c5f9f2f16759b58b3df085b9b1f4c42f84f7a 100644 --- a/api/src/processing/services/bilibili.js +++ b/api/src/processing/services/bilibili.js @@ -17,8 +17,14 @@ function extractBestQuality(dashData) { return [ bestVideo, bestAudio ]; } -async function com_download(id) { - let html = await fetch(`https://bilibili.com/video/${id}`, { +async function com_download(id, partId) { + const url = new URL(`https://bilibili.com/video/${id}`); + + if (partId) { + url.searchParams.set('p', partId); + } + + const html = await fetch(url, { headers: { "user-agent": genericUserAgent } @@ -34,7 +40,10 @@ async function com_download(id) { return { error: "fetch.empty" }; } - let streamData = JSON.parse(html.split('')[0]); + const streamData = JSON.parse( + html.split('')[0] + ); + if (streamData.data.timelength > env.durationLimit * 1000) { return { error: "content.too_long" }; } @@ -44,10 +53,15 @@ async function com_download(id) { return { error: "fetch.empty" }; } + let filenameBase = `bilibili_${id}`; + if (partId) { + filenameBase += `_${partId}`; + } + return { urls: [video.baseUrl, audio.baseUrl], - audioFilename: `bilibili_${id}_audio`, - filename: `bilibili_${id}_${video.width}x${video.height}.mp4` + audioFilename: `${filenameBase}_audio`, + filename: `${filenameBase}_${video.width}x${video.height}.mp4`, }; } @@ -86,14 +100,14 @@ async function tv_download(id) { }; } -export default async function({ comId, tvId, comShortLink }) { +export default async function({ comId, tvId, comShortLink, partId }) { if (comShortLink) { const patternMatch = await resolveRedirectingURL(`https://b23.tv/${comShortLink}`); comId = patternMatch?.comId; } if (comId) { - return com_download(comId); + return com_download(comId, partId); } else if (tvId) { return tv_download(tvId); } diff --git a/api/src/processing/services/loom.js b/api/src/processing/services/loom.js index 01f71e5154521adaa0664b2c41418b2d953a4d0b..64382108eb74a8741d7a7bef4b45d224697bdb57 100644 --- a/api/src/processing/services/loom.js +++ b/api/src/processing/services/loom.js @@ -1,18 +1,18 @@ import { genericUserAgent } from "../../config.js"; -export default async function({ id }) { +const craftHeaders = id => ({ + "user-agent": genericUserAgent, + "content-type": "application/json", + origin: "https://www.loom.com", + referer: `https://www.loom.com/share/${id}`, + cookie: `loom_referral_video=${id};`, + "x-loom-request-source": "loom_web_be851af", +}); + +async function fromTranscodedURL(id) { const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, { method: "POST", - headers: { - "user-agent": genericUserAgent, - origin: "https://www.loom.com", - referer: `https://www.loom.com/share/${id}`, - cookie: `loom_referral_video=${id};`, - - "apollographql-client-name": "web", - "apollographql-client-version": "14c0b42", - "x-loom-request-source": "loom_web_14c0b42", - }, + headers: craftHeaders(id), body: JSON.stringify({ force_original: false, password: null, @@ -20,20 +20,89 @@ export default async function({ id }) { deviceID: null }) }) - .then(r => r.status === 200 ? r.json() : false) + .then(r => r.status === 200 && r.json()) + .catch(() => {}); + + if (gql?.url?.includes('.mp4?')) { + return gql.url; + } +} + +async function fromRawURL(id) { + const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/raw-url`, { + method: "POST", + headers: craftHeaders(id), + body: JSON.stringify({ + anonID: crypto.randomUUID(), + client_name: "web", + client_version: "be851af", + deviceID: null, + force_original: false, + password: null, + supported_mime_types: ["video/mp4"], + }) + }) + .then(r => r.status === 200 && r.json()) + .catch(() => {}); + + if (gql?.url?.includes('.mp4?')) { + return gql.url; + } +} + +async function getTranscript(id) { + const gql = await fetch(`https://www.loom.com/graphql`, { + method: "POST", + headers: craftHeaders(id), + body: JSON.stringify({ + operationName: "FetchVideoTranscriptForFetchTranscript", + variables: { + videoId: id, + password: null, + }, + query: ` + query FetchVideoTranscriptForFetchTranscript($videoId: ID!, $password: String) { + fetchVideoTranscript(videoId: $videoId, password: $password) { + ... on VideoTranscriptDetails { + captions_source_url + language + __typename + } + ... on GenericError { + message + __typename + } + __typename + } + }`, + }) + }) + .then(r => r.status === 200 && r.json()) .catch(() => {}); - if (!gql) return { error: "fetch.empty" }; + if (gql?.data?.fetchVideoTranscript?.captions_source_url?.includes('.vtt?')) { + return gql.data.fetchVideoTranscript.captions_source_url; + } +} + +export default async function({ id, subtitleLang }) { + let url = await fromTranscodedURL(id); + url ??= await fromRawURL(id); - const videoUrl = gql?.url; + if (!url) { + return { error: "fetch.empty" } + } - if (videoUrl?.includes('.mp4?')) { - return { - urls: videoUrl, - filename: `loom_${id}.mp4`, - audioFilename: `loom_${id}_audio` - } + let subtitles; + if (subtitleLang) { + const transcript = await getTranscript(id); + if (transcript) subtitles = transcript; } - return { error: "fetch.empty" } + return { + urls: url, + subtitles, + filename: `loom_${id}.mp4`, + audioFilename: `loom_${id}_audio` + } } diff --git a/api/src/processing/services/newgrounds.js b/api/src/processing/services/newgrounds.js new file mode 100644 index 0000000000000000000000000000000000000000..7519a6cfee558c834ed40ea53b60c95ae441f1c6 --- /dev/null +++ b/api/src/processing/services/newgrounds.js @@ -0,0 +1,103 @@ +import { genericUserAgent } from "../../config.js"; + +const getVideo = async ({ id, quality }) => { + const json = await fetch(`https://www.newgrounds.com/portal/video/${id}`, { + headers: { + "User-Agent": genericUserAgent, + "X-Requested-With": "XMLHttpRequest", // required to get the JSON response + } + }) + .then(r => r.json()) + .catch(() => {}); + + if (!json) return { error: "fetch.empty" }; + + const videoSources = json.sources; + const videoQualities = Object.keys(videoSources); + + if (videoQualities.length === 0) { + return { error: "fetch.empty" }; + } + + const bestVideo = videoSources[videoQualities[0]]?.[0], + userQuality = quality === "2160" ? "4k" : `${quality}p`, + preferredVideo = videoSources[userQuality]?.[0], + video = preferredVideo || bestVideo, + videoQuality = preferredVideo ? userQuality : videoQualities[0]; + + if (!bestVideo || !video.type.includes("mp4")) { + return { error: "fetch.empty" }; + } + + const fileMetadata = { + title: decodeURIComponent(json.title), + artist: decodeURIComponent(json.author), + } + + return { + urls: video.src, + filenameAttributes: { + service: "newgrounds", + id, + title: fileMetadata.title, + author: fileMetadata.artist, + extension: "mp4", + qualityLabel: videoQuality, + resolution: videoQuality, + }, + fileMetadata, + } +} + +const getMusic = async ({ id }) => { + const html = await fetch(`https://www.newgrounds.com/audio/listen/${id}`, { + headers: { + "User-Agent": genericUserAgent, + } + }) + .then(r => r.text()) + .catch(() => {}); + + if (!html) return { error: "fetch.fail" }; + + const params = JSON.parse( + `{${html.split(',"params":{')[1]?.split(',"images":')[0]}}` + ); + if (!params) return { error: "fetch.empty" }; + + if (!params.name || !params.artist || !params.filename || !params.icon) { + return { error: "fetch.empty" }; + } + + const fileMetadata = { + title: decodeURIComponent(params.name), + artist: decodeURIComponent(params.artist), + } + + return { + urls: params.filename, + filenameAttributes: { + service: "newgrounds", + id, + title: fileMetadata.title, + author: fileMetadata.artist, + }, + fileMetadata, + cover: + params.icon.includes(".png?") || params.icon.includes(".jpg?") + ? params.icon + : undefined, + isAudioOnly: true, + bestAudio: "mp3", + } +} + +export default function({ id, audioId, quality }) { + if (id) { + return getVideo({ id, quality }); + } else if (audioId) { + return getMusic({ id: audioId }); + } + + return { error: "fetch.empty" }; +} diff --git a/api/src/processing/services/pinterest.js b/api/src/processing/services/pinterest.js index 15566cc49aef6f8e0e7b95030c00727f2f859084..c21400e1a53cac671ef6f5d6fbf8bd0738526c9f 100644 --- a/api/src/processing/services/pinterest.js +++ b/api/src/processing/services/pinterest.js @@ -3,6 +3,7 @@ import { resolveRedirectingURL } from "../url.js"; const videoRegex = /"url":"(https:\/\/v1\.pinimg\.com\/videos\/.*?)"/g; const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g; +const notFoundRegex = /"__typename"\s*:\s*"PinNotFound"/; export default async function(o) { let id = o.id; @@ -19,6 +20,10 @@ export default async function(o) { headers: { "user-agent": genericUserAgent } }).then(r => r.text()).catch(() => {}); + const invalidPin = html.match(notFoundRegex); + + if (invalidPin) return { error: "fetch.empty" }; + if (!html) return { error: "fetch.fail" }; const videoLink = [...html.matchAll(videoRegex)] diff --git a/api/src/processing/services/rutube.js b/api/src/processing/services/rutube.js index 5b502452922c5905d930556306cef0d4e26b6baf..9fe37d3486fa7aabdd379a3ef325ad618d14092b 100644 --- a/api/src/processing/services/rutube.js +++ b/api/src/processing/services/rutube.js @@ -65,8 +65,21 @@ export default async function(obj) { artist: play.author.name.trim(), } + let subtitles; + if (obj.subtitleLang && play.captions?.length) { + const subtitle = play.captions.find( + s => ["webvtt", "srt"].includes(s.format) && s.code.startsWith(obj.subtitleLang) + ); + + if (subtitle) { + subtitles = subtitle.file; + fileMetadata.sublanguage = obj.subtitleLang; + } + } + return { urls: matchingQuality.uri, + subtitles, isHLS: true, filenameAttributes: { service: "rutube", diff --git a/api/src/processing/services/snapchat.js b/api/src/processing/services/snapchat.js index f5d66136298ac657119bc29662259fdefa2ba908..b2791704c2ca350bccffa44c0b5d19892dcec9ec 100644 --- a/api/src/processing/services/snapchat.js +++ b/api/src/processing/services/snapchat.js @@ -102,10 +102,10 @@ export default async function (obj) { params = await resolveRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`); } - if (params.spotlightId) { + if (params?.spotlightId) { const result = await getSpotlight(params.spotlightId); if (result) return result; - } else if (params.username) { + } else if (params?.username) { const result = await getStory(params.username, params.storyId, obj.alwaysProxy); if (result) return result; } diff --git a/api/src/processing/services/soundcloud.js b/api/src/processing/services/soundcloud.js index ad53547950ae6f67c498483609e7274df4d9f5ea..a73e6474cc55d01d5a552aafa149f1270c528cb6 100644 --- a/api/src/processing/services/soundcloud.js +++ b/api/src/processing/services/soundcloud.js @@ -1,4 +1,5 @@ import { env } from "../../config.js"; +import { resolveRedirectingURL } from "../url.js"; const cachedID = { version: '', @@ -7,22 +8,25 @@ const cachedID = { async function findClientID() { try { - let sc = await fetch('https://soundcloud.com/').then(r => r.text()).catch(() => {}); - let scVersion = String(sc.match(/ @@ -22,27 +22,27 @@ no ads, trackers, paywalls, or other nonsense. just a convenient web app that wo sectionId="motivation" /> -cobalt was created for public benefit, to protect people from ads and malware pushed by its alternatives. -we believe that the best software is safe, open, and accessible. - -a part of our infrastructure is provided by our long-standing partner, [royalehosting.net]({partners.royalehosting})! +cobalt was created for public benefit, to protect people from ads and malware pushed by alternative downloaders. +we believe that the best software is safe, open, and accessible. all imput project follow these basic principles. -
+
-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. +all requests to the backend are anonymous and all information about potential file tunnels is encrypted. +we have a strict zero log policy and don't store or track *anything* about individual people. + +if a request requires additional processing, such as remuxing or transcoding, cobalt processes media +directly on your device. this ensures best efficiency and privacy. -when a request needs additional processing, cobalt processes files on-the-fly. -it's done by tunneling processed parts directly to the client, without ever saving anything to disk. -for example, this method is used when the source service provides video and audio channels as separate files. +if your device doesn't support local processing, then server-based live processing is used instead. +in this scenario, processed media is streamed directly to client, without ever being stored on server's disk. -additionally, you can [enable forced tunneling](/settings/privacy#tunnel) to protect your privacy. -when enabled, cobalt will tunnel all downloaded files. +you can [enable forced tunneling](/settings/privacy#tunnel) to boost privacy even further. +when enabled, cobalt will tunnel all downloaded files, not just those that require it. no one will know where you download something from, even your network provider. all they'll see is that you're using a cobalt instance.
@@ -65,14 +65,3 @@ if your friend hosts a processing instance, just ask them for a domain and [add you can check the source code and contribute [on github]({contacts.github}) at any time. we welcome all contributions and suggestions!
- -
- - -newest features, such as [remuxing](/remux), work locally on your device. -on-device processing is efficient and never sends anything over the internet. -it perfectly aligns with our future goal of moving as much processing as possible to the client. -
diff --git a/web/i18n/en/about/privacy.md b/web/i18n/en/about/privacy.md index 7291aff490e090507f9d69716bbd0b67423b4816..a345000aa59a5a3b7d8d1230d3457161a6c228f9 100644 --- a/web/i18n/en/about/privacy.md +++ b/web/i18n/en/about/privacy.md @@ -11,9 +11,11 @@ sectionId="general" /> -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. +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. +these terms are applicable only when using the official cobalt instance. +in other cases, you may need to contact the instance hoster for accurate info.
@@ -22,7 +24,9 @@ these terms are applicable only when using the official cobalt instance. in othe sectionId="local" /> -tools that use on-device processing work offline, locally, and never send any data anywhere. they are explicitly marked as such whenever applicable. +tools that use on-device processing work offline, locally, +and never send any processed data anywhere. +they are explicitly marked as such whenever applicable.
@@ -31,9 +35,33 @@ tools that use on-device processing work offline, locally, and never send any da sectionId="saving" /> -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. +when using saving functionality, cobalt may need to proxy or remux/transcode files. +if that's the case, then a temporary tunnel is created for this purpose +and minimal required information about the media is stored for 90 seconds. -processed/tunneled files are never cached anywhere. everything is tunneled live. cobalt's saving functionality is essentially a fancy proxy service. +on an unmodified & official cobalt instance, +**all tunnel data is encrypted with a key that only the end user has access to**. + +encrypted tunnel data may include: +- origin service's name. +- original URLs for media files. +- internal arguments needed to differentiate between types of processing. +- minimal file metadata (generated filename, title, author, creation year, copyright info). +- minimal information about the original request that may be used in case of an URL failure during the tunnelling process. + +this data is irreversibly purged from server's RAM after 90 seconds. +no one has access to cached tunnel data, even instance owners, +as long as cobalt's source code is not modified. + +media data from tunnels is never stored/cached anywhere. +everything is processed live, even during remuxing and transcoding. +cobalt tunnels function like an anonymous proxy. + +if your device supports local processing, +then encrypted tunnel info includes way less info, because it's returned to client instead. + +see the [related source code on github](https://github.com/imputnet/cobalt/tree/main/api/src/stream) +to learn more about how it works.
@@ -42,7 +70,10 @@ processed/tunneled files are never cached anywhere. everything is tunneled live. sectionId="encryption" /> -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. +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} @@ -52,13 +83,17 @@ temporarily stored tunnel data is encrypted using the AES-256 standard. decrypti sectionId="plausible" /> -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. +we use [plausible](https://plausible.io/) to get an approximate number +of active cobalt users, fully anonymously. no identifiable information about +you or your requests is ever stored. all data is anonymized and aggregated. +we self-host and manage the [plausible instance](https://{env.PLAUSIBLE_HOST}/) that cobalt uses. plausible doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR. -[learn more about plausible's dedication to privacy.](https://plausible.io/privacy-focused-web-analytics) - if you wish to opt out of anonymous analytics, you can do it in [privacy settings](/settings/privacy#analytics). +if you opt out, the plausible script will not be loaded at all. + +[learn more about plausible's dedication to privacy](https://plausible.io/privacy-focused-web-analytics). {/if} @@ -68,9 +103,15 @@ if you wish to opt out of anonymous analytics, you can do it in [privacy setting sectionId="cloudflare" /> -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. +we use cloudflare services for: +- ddos & abuse protection. +- bot protection (cloudflare turnstile). +- hosting & deploying the statically rendered web app (cloudflare workers). + +all of these are required to provide the best experience for everyone. +cloudflare is the most private & reliable provider for all mentioned solutions 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/) +[learn more about cloudflare's dedication to privacy](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/). diff --git a/web/i18n/en/about/terms.md b/web/i18n/en/about/terms.md index 634e7502e42f82b14dbc78939150359210f8ef14..bef2f7dc503b81631d9ca21937f382e0412f7d27 100644 --- a/web/i18n/en/about/terms.md +++ b/web/i18n/en/about/terms.md @@ -10,7 +10,7 @@ /> these terms are applicable only when using the official cobalt instance. -in other cases, you may need to contact the hoster for accurate info. +in other cases, you may need to contact the instance hoster for accurate info.
@@ -19,12 +19,14 @@ in other cases, you may need to contact the hoster for accurate info. sectionId="saving" /> -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. +saving functionality simplifies downloading content from the internet +and we take zero liability for what the saved content is used for. -[you can read more about how tunnels work in our privacy policy.](/about/privacy) +processing servers operate like advanced proxies and don't ever write any requested content to disk. +everything is handled in RAM and permanently purged once the tunnel is completed. +we have no downloading logs and cannot identify anyone. + +you can learn more about how tunnels work in [privacy policy](/about/privacy).
@@ -48,10 +50,10 @@ fair use and credits benefit everyone. sectionId="abuse" /> -we have no way of detecting abusive behavior automatically because cobalt is 100% anonymous. +we have no way of detecting abusive behavior automatically because cobalt is fully anonymous. however, you can report such activities to us via email and we'll do our best to comply manually: abuse[at]imput.net **this email is not intended for user support, you will not get a response if your concern is not related to abuse.** -if you're experiencing issues, contact us via any preferred method on [the support page](/about/community). +if you're experiencing issues, you can reach out for support via any preferred method on [the community page](/about/community).
diff --git a/web/i18n/en/button.json b/web/i18n/en/button.json index 1ea7fb41d2ff908573296e4fba00eb1c48a4f477..643f4b8eb75c6e895129de6b2a57878e429b8c1f 100644 --- a/web/i18n/en/button.json +++ b/web/i18n/en/button.json @@ -16,5 +16,14 @@ "save": "save", "export": "export", "yes": "yes", - "no": "no" + "no": "no", + "clear": "clear", + "show_input": "show input", + "hide_input": "hide input", + "restore_input": "restore input", + "clear_input": "clear input", + "clear_cache": "clear cache", + "remove": "remove", + "retry": "retry", + "delete": "delete" } diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index 3e6f5dece6ca5da5c2ecda973c50e736039dd54e..44e92dab08b2409639117c374de8a32a5e9a222e 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -1,6 +1,6 @@ { - "reset.title": "reset all data?", - "reset.body": "are you sure you want to reset all data? this action is immediate and irreversible.", + "reset_settings.title": "reset all settings?", + "reset_settings.body": "are you sure you want to reset all settings? this action is immediate and irreversible.", "picker.title": "select what to save", "picker.description.desktop": "click an item to save it. images can also be saved via the right click menu.", @@ -15,11 +15,8 @@ "import.body": "importing unknown or corrupted files may unexpectedly alter or break cobalt functionality. only import files that you've personally exported and haven't modified. if you were asked to import this file by someone - don't do it.\n\nwe are not responsible for any harm caused by importing unknown setting files.", - "api.override.title": "processing instance override", - "api.override.body": "{{ value }} is now the processing instance. if you don't trust it, press \"cancel\" and it'll be ignored.\n\nyou can change your choice later in processing settings.", - "safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.", - "processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?", - "processing.title.ongoing": "processing will be cancelled" + "clear_cache.title": "clear all cache?", + "clear_cache.body": "all files from the processing queue will be removed and on-device features will take longer to load. this action is immediate and irreversible." } diff --git a/web/i18n/en/error.json b/web/i18n/en/error.json index 2c347951f68605a9d85f8216d1654b605875bc19..f670962be859028d13c925037925146344be6bda 100644 --- a/web/i18n/en/error.json +++ b/web/i18n/en/error.json @@ -3,71 +3,9 @@ "import.invalid": "this file doesn't have valid cobalt settings to import. are you sure it's the right one?", "import.unknown": "couldn't load data from the file. it may be corrupted or of wrong format. here's the error i got:\n\n{{ value }}", - "remux.corrupted": "couldn't read the metadata from this file, it may be corrupted.", - "remux.out_of_resources": "cobalt ran out of resources and can't continue with on-device processing. this is caused by your browser's limitations. refresh or reopen the app and try again!", - "tunnel.probe": "couldn't test this tunnel. your browser or network configuration may be blocking access to one of cobalt servers. are you sure you don't have any weird browser extensions?", - "captcha_ongoing": "cloudflare turnstile is still checking if you're not a bot. if it takes too long, you can try: disabling weird browser extensions, changing networks, using a different browser, or checking your device for malware.", - - "api.auth.jwt.missing": "couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!", - "api.auth.jwt.invalid": "couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!", - "api.auth.turnstile.missing": "couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!", - "api.auth.turnstile.invalid": "couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!", - - "api.auth.key.missing": "an access key is required to use this processing instance but it's missing. add it in instance settings!", - "api.auth.key.not_api_key": "an access key is required to use this processing instance but it's missing. add it in instance settings!", - - "api.auth.key.invalid": "the access key is invalid. reset it in instance settings and use a proper one!", - "api.auth.key.not_found": "the access key you used couldn't be found. are you sure this instance has your key?", - "api.auth.key.invalid_ip": "your ip address couldn't be parsed. something went very wrong. report this issue!", - "api.auth.key.ip_not_allowed": "your ip address is not allowed to use this access key. use a different instance or network!", - "api.auth.key.ua_not_allowed": "your user agent is not allowed to use this access key. use a different client or device!", - - "api.unreachable": "couldn't connect to the processing instance. check your internet connection and try again!", - "api.timed_out": "the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!", - "api.rate_exceeded": "you're making too many requests. try again in {{ limit }} seconds.", - "api.capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!", - - "api.generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!", - "api.unknown_response": "couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!", - "api.invalid_body": "couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!", - - "api.service.unsupported": "this service is not supported yet. have you pasted the right link?", - "api.service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!", - "api.service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!", - - "api.link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?", - "api.link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?", - - "api.fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!", - "api.fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", - "api.fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?", - "api.fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!", - "api.fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!", - - "api.content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!", - - "api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!", - "api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!", - "api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!", - "api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try a different link!", - "api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!", - - "api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!", - "api.content.paid": "this content requires purchase. cobalt can't download paid content. try a different link!", - - "api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!", - "api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!", - "api.content.post.age": "this post is age-restricted and isn't available without logging in. try a different link!", + "captcha_too_long": "cloudflare turnstile is taking too long to check if you're not a bot. try again, but if it takes way too long again, you can try: disabling weird browser extensions, changing networks, using a different browser, or checking your device for malware.", - "api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!", - "api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!", - "api.youtube.login": "couldn't get this video because youtube asked the instance to log in. this is potentially caused by the processing instance not having any active account tokens or youtube updating something about their api. try again in a few seconds, but if it still doesn't work, please report this issue!", - "api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!", - "api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!", - "api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!", - "api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!", - "api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!", - "api.youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!" + "pipeline.missing_response_data": "the processing instance didn't return required file info, so i can't create a local processing pipeline for you. try again in a few seconds and report the issue if it sticks!" } diff --git a/web/i18n/en/error/api.json b/web/i18n/en/error/api.json new file mode 100644 index 0000000000000000000000000000000000000000..9e9227452314aaf586a86c5af81177b1045b3c6e --- /dev/null +++ b/web/i18n/en/error/api.json @@ -0,0 +1,63 @@ +{ + "auth.jwt.missing": "couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!", + "auth.jwt.invalid": "couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!", + "auth.turnstile.missing": "couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!", + "auth.turnstile.invalid": "couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!", + + "auth.key.missing": "an access key is required to use this processing instance but it's missing. add it in instance settings!", + "auth.key.not_api_key": "an access key is required to use this processing instance but it's missing. add it in instance settings!", + + "auth.key.invalid": "the access key is invalid. reset it in instance settings and use a proper one!", + "auth.key.not_found": "the access key you used couldn't be found. are you sure this instance has your key?", + "auth.key.invalid_ip": "your ip address couldn't be parsed. something went very wrong. report this issue!", + "auth.key.ip_not_allowed": "your ip address is not allowed to use this access key. use a different instance or network!", + "auth.key.ua_not_allowed": "your user agent is not allowed to use this access key. use a different client or device!", + + "unreachable": "couldn't connect to the processing instance. check your internet connection and try again!", + "timed_out": "the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!", + "rate_exceeded": "you're making too many requests. try again in {{ limit }} seconds.", + "capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!", + + "generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!", + "unknown_response": "couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!", + "invalid_body": "couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!", + + "service.unsupported": "this service is not supported yet. have you pasted the right link?", + "service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!", + "service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!", + + "link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?", + "link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?", + + "fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!", + "fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", + "fetch.critical.core": "one of the core modules returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", + "fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?", + "fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!", + "fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report this issue!", + + "content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!", + + "content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!", + "content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!", + "content.video.private": "this video is private, so i can't access it. change its visibility or try another one!", + "content.video.age": "this video is age-restricted, so i can't access it anonymously. try again or try a different link!", + "content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!", + + "content.region": "this content is region locked, and the processing instance is in a different location. try a different link!", + "content.paid": "this content requires purchase. cobalt can't download paid content. try a different link!", + + "content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!", + "content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!", + "content.post.age": "this post is age-restricted, so i can't access it anonymously. try again or try a different link!", + + "youtube.no_matching_format": "youtube didn't return any acceptable formats. cobalt may not support them or they're re-encoding on youtube's side. try again a bit later, but if this issue sticks, please report it!", + "youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!", + "youtube.login": "couldn't get this video because youtube asked the processing instance to prove that it's not a bot. try again in a few seconds, but if it still doesn't work, please report this issue!", + "youtube.token_expired": "couldn't get this video because the youtube token expired and wasn't refreshed. try again in a few seconds, but if it still doesn't work, please report this issue!", + "youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!", + "youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!", + "youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!", + "youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!", + "youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!" +} diff --git a/web/i18n/en/error/queue.json b/web/i18n/en/error/queue.json new file mode 100644 index 0000000000000000000000000000000000000000..5df31caff3c09269de4cf8776c57302cc0651704 --- /dev/null +++ b/web/i18n/en/error/queue.json @@ -0,0 +1,22 @@ +{ + "no_final_file": "no final file output", + "worker_didnt_start": "couldn't start a processing worker", + + "generic_error": "processing worker crashed, see console for details", + + "fetch.crashed": "fetch worker crashed, see console for details", + "fetch.bad_response": "couldn't access the file tunnel", + "fetch.no_file_reader": "couldn't write a file to cache", + "fetch.empty_tunnel": "file tunnel is empty, try again in a few minutes", + "fetch.corrupted_file": "file wasn't downloaded fully, try again", + "fetch.network_error": "downloading was interrupted by a network issue", + + "ffmpeg.probe_failed": "couldn't probe this file, it may be unsupported or corrupted", + "ffmpeg.out_of_memory": "not enough available memory, can't continue", + "ffmpeg.no_input_format": "the file's format isn't supported", + "ffmpeg.no_input_type": "the file's type isn't supported", + "ffmpeg.crashed": "ffmpeg worker crashed, see console for details", + "ffmpeg.no_render": "ffmpeg render is empty, something very odd happened", + "ffmpeg.no_args": "ffmpeg worker didn't get required arguments", + "ffmpeg.no_audio_channel": "this video has no audio track, nothing to do" +} diff --git a/web/i18n/en/queue.json b/web/i18n/en/queue.json new file mode 100644 index 0000000000000000000000000000000000000000..0943ba6b60b6ffffa9b1565351d64004a737d80e --- /dev/null +++ b/web/i18n/en/queue.json @@ -0,0 +1,16 @@ +{ + "title": "processing queue", + "stub": "nothing here yet, just the two of us.\ntry downloading something!", + + "state.waiting": "queued", + "state.retrying": "retrying", + "state.starting": "starting", + + "state.starting.fetch": "starting downloading", + "state.starting.remux": "starting remuxing", + "state.starting.encode": "starting transcoding", + + "state.running.remux": "remuxing", + "state.running.fetch": "downloading", + "state.running.encode": "transcoding" +} diff --git a/web/i18n/en/receiver.json b/web/i18n/en/receiver.json index 567e569fb7b8a2d20947a23b2c644d82861d16bc..43144ae97fb29fce06034a57771c8a54abeeb12a 100644 --- a/web/i18n/en/receiver.json +++ b/web/i18n/en/receiver.json @@ -1,5 +1,7 @@ { "title": "drag or select a file", + "title.multiple": "drag or select files", "title.drop": "drop the file here!", + "title.drop.multiple": "drop the files here!", "accept": "supported formats: {{ formats }}." } diff --git a/web/i18n/en/remux.json b/web/i18n/en/remux.json index d8b031c34a1509849f336264d27a92ba4d5fdb15..cec0d2215d1bd93ccd956eede45462640edbb4d9 100644 --- a/web/i18n/en/remux.json +++ b/web/i18n/en/remux.json @@ -2,7 +2,7 @@ "bullet.purpose.title": "what does remux do?", "bullet.purpose.description": "remux fixes any issues with the file container, such as missing time info. it helps increase compatibility with old software, such as vegas pro and windows media player.", "bullet.explainer.title": "how does it work?", - "bullet.explainer.description": "remuxing takes existing codec data and copies it over to a new media container. it's lossless, media data doesn't get re-encoded.", + "bullet.explainer.description": "remuxing takes existing codec data and copies it over to a new media container. it's lossless; media data doesn't get re-encoded.", "bullet.privacy.title": "on-device processing", "bullet.privacy.description": "cobalt remuxes files locally. files never leave your device, so processing is nearly instant." } diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json index e6edc0de6519a2eaef936623ebbe4d4577dc3bc0..91716ab48dbd0ff9d4da609758de64507675e2d0 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": "support for a service does not imply affiliation, endorsement, or any form of support other than technical compatibility.", "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.", @@ -21,5 +21,7 @@ "tutorial.shortcut.photos": "to photos", "tutorial.shortcut.files": "to files", - "label.community_instance": "community instance" + "label.community_instance": "community instance", + + "tooltip.captcha": "cloudflare turnstile is checking if you're not a bot, please wait!" } diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index 418410bf13f4cff3e3dd2318f8ad6aef8ba73a3f..5caf67f519e968d2acc8a29e6809e682e9f2b29c 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -3,13 +3,12 @@ "page.privacy": "privacy", "page.video": "video", "page.audio": "audio", - "page.download": "downloading", + "page.metadata": "metadata", "page.advanced": "advanced", "page.debug": "info for nerds", "page.instances": "instances", - - "section.general": "general", - "section.save": "save", + "page.local": "local processing", + "page.accessibility": "accessibility", "theme": "theme", "theme.auto": "auto", @@ -29,12 +28,15 @@ "video.quality.144": "144p", "video.quality.description": "if preferred video quality isn't available, next best is picked instead.", - "video.youtube.codec": "youtube codec and container", - "video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't as widely supported as h264. if av1 or vp9 isn't available, h264 is used instead.", + "video.youtube.codec": "preferred youtube video codec", + "video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't widely supported, you might have to use additional software to play/edit them. cobalt picks next best codec if preferred one isn't available.", + + "video.youtube.container": "youtube file container", + "video.youtube.container.description": "when \"auto\" is selected, cobalt will pick the best container automatically depending on selected codec: mp4 for h264; webm for vp9/av1.", "video.youtube.hls": "youtube hls formats", "video.youtube.hls.title": "prefer hls for video & audio", - "video.youtube.hls.description": "files download faster and are less prone to errors or getting abruptly cut off. only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\n\nthis option is experimental, it may go away or change in the future.", + "video.youtube.hls.description": "only h264 and vp9 codecs are available in this mode. original audio codec is aac, it's re-encoded for compatibility, audio quality may be slightly worse than the non-HLS counterpart.\n\nthis option is experimental, it may go away or change in the future.", "video.twitter.gif": "twitter/x", "video.twitter.gif.title": "convert looping videos to GIF", @@ -61,6 +63,15 @@ "audio.youtube.dub.description": "cobalt will use a dubbed audio track for selected language if it's available. if not, original will be used instead.", "youtube.dub.original": "original", + "subtitles": "subtitles", + "subtitles.title": "preferred subtitle language", + "subtitles.description": "cobalt will add subtitles to the downloaded file in the preferred language if they're available.\n\nsome services don't have a language selection, and if that's the case, cobalt will add the only available subtitle track if you have any language selected.", + "subtitles.none": "none", + + "audio.youtube.better_audio": "youtube audio quality", + "audio.youtube.better_audio.title": "prefer better quality", + "audio.youtube.better_audio.description": "cobalt will try to pick highest quality audio in audio mode. it may not be available depending on youtube's response, current traffic, and server status. custom instances may not support this option.", + "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.", @@ -72,8 +83,10 @@ "metadata.filename.nerdy": "nerdy", "metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.", - "metadata.filename.preview.video": "Video Title", + "metadata.filename.preview.video": "Video Title - Video Author", "metadata.filename.preview.audio": "Audio Title - Audio Author", + "filename.preview_desc.video": "video file preview", + "filename.preview_desc.audio": "audio file preview", "metadata.file": "file metadata", "metadata.disable.title": "disable file metadata", @@ -86,11 +99,18 @@ "saving.copy": "copy", "saving.description": "preferred way of saving the file or link from cobalt. if preferred method is unavailable or something goes wrong, cobalt will ask you what to do next.", - "accessibility": "accessibility", + "accessibility.visual": "visual", + "accessibility.haptics": "haptics", + "accessibility.behavior": "behavior", + "accessibility.transparency.title": "reduce visual transparency", - "accessibility.transparency.description": "reduces transparency of surfaces and disables blur effects. may also improve ui performance on low performance devices.", + "accessibility.transparency.description": "transparency of surfaces will be reduced and all blur effects will be disabled. may also improve ui performance on less powerful devices.", "accessibility.motion.title": "reduce motion", - "accessibility.motion.description": "disables animations and transitions whenever possible.", + "accessibility.motion.description": "animations and transitions will be disabled whenever possible.", + "accessibility.haptics.title": "disable haptics", + "accessibility.haptics.description": "all haptic effects will be disabled.", + "accessibility.auto_queue.title": "don't open the queue automatically", + "accessibility.auto_queue.description": "the processing queue will not be opened automatically whenever a new item is added to it. progress will still be displayed and you will still be able to open it manually.", "language": "language", "language.auto.title": "automatic selection", @@ -111,8 +131,6 @@ "advanced.debug.title": "enable features for nerds", "advanced.debug.description": "gives you easy access to app info that can be useful for debugging. enabling this does not affect functionality of cobalt in any way.", - "advanced.data": "data management", - "processing.community": "community instances", "processing.enable_custom.title": "use a custom processing server", "processing.enable_custom.description": "cobalt will use a custom processing instance if you choose to. even though cobalt has some security measures in place, we are not responsible for any damages done via a community instance, as we have no control over them.\n\nplease be mindful of what instances you use and make sure they're hosted by people you trust.", @@ -122,5 +140,22 @@ "processing.access_key.description": "cobalt will use this key to make requests to the processing instance instead of other authentication methods. make sure the instance supports api keys!", "processing.custom_instance.input.alt_text": "custom instance domain", - "processing.access_key.input.alt_text": "u-u-i-d access key" + "processing.access_key.input.alt_text": "u-u-i-d access key", + + "advanced.settings_data": "settings data", + "advanced.local_storage": "local storage", + + "local.saving": "local media processing", + "local.saving.description": "when downloading media, remuxing and transcoding will be done on-device instead of the cloud. you'll see detailed progress in the processing queue.\n\ndisabled: local processing will not be used. processing instances can enforce local processing, so this option may not have effect.\npreferred: media that requires extra processing will be downloaded through the processing queue, but the rest of media will be downloaded by your browser's download manager.\nforced: all media will always be proxied and downloaded through the processing queue.\n\nexclusive on-device features are not affected by this setting, they always run locally.", + "local.saving.disabled": "disabled", + "local.saving.preferred": "preferred", + "local.saving.forced": "forced", + + "local.webcodecs": "webcodecs", + "local.webcodecs.title": "use webcodecs for on-device processing", + "local.webcodecs.description": "when decoding or encoding files, cobalt will try to use webcodecs. this feature allows for GPU-accelerated processing of media files, meaning that all decoding & encoding will be way faster.\n\navailability and stability of this feature depends on your device's and browser's capabilities. stuff might break or not work properly.", + + "tabs": "navigation", + "tabs.hide_remux": "hide the remux tab", + "tabs.hide_remux.description": "if you don't use the remux tool, you can hide it from the navigation bar." } diff --git a/web/i18n/ru/a11y/dialog.json b/web/i18n/ru/a11y/dialog.json new file mode 100644 index 0000000000000000000000000000000000000000..ea7c32cece95cb9a318f576f3db618bc734058d8 --- /dev/null +++ b/web/i18n/ru/a11y/dialog.json @@ -0,0 +1,5 @@ +{ + "picker.item.photo": "превью фотографии", + "picker.item.video": "превью видео", + "picker.item.gif": "превью gif" +} diff --git a/web/i18n/ru/a11y/donate.json b/web/i18n/ru/a11y/donate.json new file mode 100644 index 0000000000000000000000000000000000000000..93561b71a7867827d273c1ffe2f6963bbed9faac --- /dev/null +++ b/web/i18n/ru/a11y/donate.json @@ -0,0 +1,4 @@ +{ + "share.qr.expand": "qr-код. нажми, чтобы развернуть.", + "share.qr.collapse": "развёрнутый qr-код. нажми, чтобы свернуть." +} diff --git a/web/i18n/ru/a11y/queue.json b/web/i18n/ru/a11y/queue.json new file mode 100644 index 0000000000000000000000000000000000000000..3921c99b522fce44079011136e5dc633e52af70d --- /dev/null +++ b/web/i18n/ru/a11y/queue.json @@ -0,0 +1,5 @@ +{ + "status.completed": "очередь обработки. все задачи завершены.", + "status.ongoing": "очередь обработки. есть текущие задачи.", + "status.default": "очередь обработки" +} diff --git a/web/i18n/ru/a11y/save.json b/web/i18n/ru/a11y/save.json index d6def5e5cfe31a9b0ac23376cf913a28ed59023f..831481257548a78f38b0421058c8b4302e978419 100644 --- a/web/i18n/ru/a11y/save.json +++ b/web/i18n/ru/a11y/save.json @@ -1,9 +1,12 @@ { - "link_area": "зона вставки ссылки", - "clear_input": "clear input", + "link_area": "поле ввода ссылки", + "clear_input": "очистить поле ввода", "download": "скачать", "download.think": "обрабатываю ссылку...", "download.check": "проверяю загрузку...", - "download.done": "загрузка завершена!", - "download.error": "ошибка загрузки" + "download.done": "загрузка завершена", + "download.error": "ошибка загрузки", + "link_area.turnstile": "поле ввода ссылки. проверяю, что ты не робот.", + "tutorial.shortcut.photos": "добавить команду \"в фото\"", + "tutorial.shortcut.files": "добавить команду \"в файлы\"" } diff --git a/web/i18n/ru/about.json b/web/i18n/ru/about.json new file mode 100644 index 0000000000000000000000000000000000000000..87dacd5ae38d30661511229f79baca33c759b56e --- /dev/null +++ b/web/i18n/ru/about.json @@ -0,0 +1,30 @@ +{ + "page.general": "что такое кобальт?", + "heading.general": "общие условия", + "heading.saving": "скачивание", + "heading.encryption": "шифрование", + "heading.abuse": "сообщение о злоупотреблении", + "heading.motivation": "мотивация", + "heading.licenses": "лицензии", + "heading.summary": "лучший способ сохранять то, что ты любишь", + "page.community": "сообщество и поддержка", + "page.privacy": "конфиденциальность", + "page.terms": "условия и этика", + "page.credits": "благодарности и лицензии", + "heading.testers": "бета-тестеры", + "heading.community": "открытое сообщество", + "heading.local": "обработка на устройстве", + "heading.plausible": "анонимная аналитика трафика", + "heading.cloudflare": "веб-приватность и безопасность", + "heading.responsibility": "ответственности пользователя", + "support.github": "смотри исходный код кобальта, вноси свой вклад или сообщай о проблемах", + "support.discord": "общайся с сообществом и разработчиками кобальта или попроси о помощи", + "support.description.issue": "если ты хочешь сообщить о баге или какой-то другой повторяющейся проблеме, то делай это на github.", + "support.description.help": "используй discord для любых других вопросов. чётко опиши проблему в #cobalt-support, иначе никто не сможет тебе помочь.", + "support.twitter": "следи за обновлениями и разработкой кобальта в своей ленте твиттера", + "support.telegram": "следи за обновлениями кобальта в телеграм-канале", + "support.description.best-effort": "вся поддержка осуществляется по мере возможности и не гарантируется, а ответ может занять какое-то время.", + "heading.privacy_efficiency": "лучшая приватность и эффективность", + "heading.partners": "партнёры", + "support.bluesky": "следи за обновлениями и разработкой кобальта в своей ленте bluesky" +} diff --git a/web/i18n/ru/about/credits.md b/web/i18n/ru/about/credits.md new file mode 100644 index 0000000000000000000000000000000000000000..a58bd2231e17a785a330ddfc0d45c12a39fb9e35 --- /dev/null +++ b/web/i18n/ru/about/credits.md @@ -0,0 +1,94 @@ + + +
+ + +кобальт сделан с любовью и заботой руками [imput](https://imput.net/) ❤️ + +мы маленькая команда из двух человек, но мы очень усердно работаем, чтобы делать +классный софт, который приносит пользу всем. если тебе нравится то, что мы +делаем, поддержи нас на [странице донатов](/donate)! +
+ +
+ + +огромное спасибо нашим тестерам за то, что они тестировали обновления заранее и +следили за их стабильностью. они ещё помогли нам выпустить cobalt 10! + + +все ссылки внешние и ведут на их личные сайты или соцсети. +
+ +
+ + +часть инфраструктуры кобальта предоставлена нашим давним партнёром, +[royalehosting.net]({partners.royalehosting})! +
+ +
+ + +мяубальт — это шустрый маскот кобальта, очень выразительный кот, который любит +быстрый интернет. + +весь потрясающий арт мяубальта, который ты видишь в кобальте, был сделан +[GlitchyPSI](https://glitchypsi.xyz/). он ещё и оригинальный создатель этого +персонажа. + +imput владеет юридическими правами на дизайн персонажа мяубальта, но не на +конкретные арты, которые были созданы GlitchyPSI. + +мы любим мяубальта, поэтому мы вынуждены установить пару правил, чтобы его +защитить: +- ты не можешь использовать дизайн персонажа мяубальта ни в какой форме, кроме + фанарта. +- ты не можешь использовать дизайн или арты мяубальта в коммерческих целях. +- ты не можешь использовать дизайн или арты мяубальта в своих проектах. +- ты не можешь использовать или изменять работы GlitchyPSI с мяубальтом ни в + каком виде. + +если ты нарисуешь фанарт мяубальта, не стесняйся делиться им в [нашем +дискорд-сервере](/about/community), мы с нетерпением ждём! +
+ +
+ + +код api (сервера обработки) кобальта — open source и распространяется по +лицензии [AGPL-3.0]({docs.apiLicense}). + +код фронтенда кобальта — [source first](https://sourcefirst.com/) и +распространяется по лицензии [CC-BY-NC-SA 4.0]({docs.webLicense}). + +нам пришлось сделать фронтенд source first, чтобы грифтеры не наживались на +нашем труде и не создавали вредоносные клоны для обмана людей и порче нашей +репутации. кроме коммерческого использования, у этого типа лицензии те же +принципы, что и у многих open source лицензий. + +мы используем много опенсорсных библиотек, но также создаём и распространяем +свои собственные. полный список зависимостей можно посмотреть на +[github]({contacts.github})! +
diff --git a/web/i18n/ru/about/general.md b/web/i18n/ru/about/general.md new file mode 100644 index 0000000000000000000000000000000000000000..1657ca7bb8603ae0577f7f3a42965e99f7fda2f9 --- /dev/null +++ b/web/i18n/ru/about/general.md @@ -0,0 +1,79 @@ + + +
+ + +кобальт помогает сохранять что угодно с твоих любимых сайтов: видео, аудио, фото +или гифки. просто вставь ссылку и вперёд! + +никакой рекламы, трекеров, платных подписок и прочей ерунды. просто удобное +веб-приложение, которое работает где угодно и когда угодно. +
+ +
+ + +кобальт был создан для всеобщего блага, чтобы защитить людей от рекламы и +вредоносных программ, которые навязывают альтернативные загрузчики. мы верим, +что лучший софт — безопасный, открытый и доступный. все проекты imput следуют +этим принципам. +
+ +
+ + +все запросы к бэкенду анонимны, и вся инфа о потенциальных файловых туннелях +зашифрована. у нас строгая политика нулевых логов, мы *никогда* не храним +идентифицирующую инфу о людях и никого не отслеживаем. + +если запрос требует дополнительной обработки, например ремукса или +транскодирования, то кобальт обрабатывает медиафайлы прямо на твоём устройстве. +это обеспечивает максимальную эффективность и приватность. + +если твоё устройство не поддерживает локальную обработку, то вместо неё +используется серверная обработка в реальном времени. в этом сценарии +обработанные медиаданные передаются напрямую клиенту, никогда не сохраняясь на +диске сервера. + +ты можешь [включить принудительное туннелирование](/settings/privacy#tunnel), +чтобы ещё сильнее повысить приватность. когда оно включено, кобальт будет +туннелировать все скачиваемые файлы, а не только те, которым это необходимо. +никто не узнает, откуда и что ты скачиваешь, даже твой провайдер. всё, что они +увидят, это то, что ты используешь инстанс кобальта. +
+ +
+ + +кобальт используют бесчисленные артисты, преподаватели и прочие создатели +контента, чтобы заниматься любимым делом. мы всегда на связи с нашим сообществом +и работаем вместе, чтобы делать кобальт ещё полезнее. не стесняйся +[присоединиться к разговору](/about/community)! + +мы верим, что будущее интернета — открытое и свободное, поэтому кобальт +опубликован с [открытым исходным кодом](https://sourcefirst.com/) и его можно +легко [захостить самому]({docs.instanceHosting}). + +если твой друг хостит инстанс обработки, просто попроси у него домен и [добавь +его в настройках инстанса](/settings/instances#community). + +ты можешь посмотреть исходный код и внести свой вклад [на +github]({contacts.github}) в любое время. мы рады любым предложениям и помощи! +
diff --git a/web/i18n/ru/about/privacy.md b/web/i18n/ru/about/privacy.md new file mode 100644 index 0000000000000000000000000000000000000000..d8522f999a4abb434412d3130f45ab44f0298094 --- /dev/null +++ b/web/i18n/ru/about/privacy.md @@ -0,0 +1,129 @@ + + +
+ + +политика конфиденциальности кобальта проста: мы ничего не собираем и не храним о +тебе. то, что ты делаешь, — это исключительно твоё дело, а не наше или чьё-либо +ещё. + +эти условия применяются только при использовании официального инстанса кобальта. +в других случаях, возможно, придётся обратиться к хостеру инстанса за точной +информацией. +
+ +
+ + +инструменты, которые используют обработку на устройстве, работают офлайн, +локально и никогда никуда не отправляют обработанные данные. они явно помечены +как таковые, когда это применимо. +
+ +
+ + +при использовании функции сохранения, кобальту может понадобиться проксировать +или ремуксировать/транскодировать файлы. если это так, то для этой цели +создаётся временный туннель, и минимально необходимая информация о медиа +хранится в течение 90 секунд. + +на неизменённом и официальном инстансе кобальта **все данные туннеля шифруются +ключом, к которому имеет доступ только конечный пользователь**. + +зашифрованные данные туннеля могут включать: +- название исходного сервиса. +- исходные ссылки на медиафайлы. +- необходимые внутренние аргументы для различения типов обработки. +- ключевые метаданные файла (сгенерированное имя, заголовок, автор, год + создания, данные об авторских правах). +- минимальная информация об исходном запросе, которая может быть использована + для восстановления туннеля после ошибки ссылки во время скачивания. + +эти данные безвозвратно удаляются из оперативной памяти сервера через 90 секунд. +никто не имеет доступа к кэшированным данным туннеля, даже владельцы инстансов, +если исходный код кобальта не изменён. + +медиаданные из туннелей нигде не хранятся/кэшируются. всё обрабатывается в +реальном времени, даже при ремуксинге и транскодировании. туннели кобальта +работают как анонимный прокси. + +если твоё устройство поддерживает локальную обработку, то зашифрованный туннель +содержит намного меньше информации, потому что она возвращается клиенту. + +смотри [соответствующий исходный код на +github](https://github.com/imputnet/cobalt/tree/main/api/src/stream), чтобы +узнать больше о том, как это работает. +
+ +
+ + +временно хранящиеся данные туннеля шифруются с использованием стандарта AES-256. +ключи расшифровки включены только в ссылку доступа и никогда не +логируются/кэшируются/хранятся где-либо. только конечный пользователь имеет +доступ к ссылке и ключам шифрования. ключи генерируются уникально для каждого +запрошенного туннеля. +
+ +{#if env.PLAUSIBLE_ENABLED} +
+ + +мы используем [plausible](https://plausible.io/), чтобы знать приблизительное +число активных пользователей кобальта, полностью анонимно. никакая +идентифицирующая информация о тебе или твоих запросах никогда не хранится. все +данные анонимизированы и агрегированы. мы сами хостим и управляем [инстансом +plausible](https://{env.PLAUSIBLE_HOST}/), который использует кобальт. + +plausible не использует куки и полностью соответствует GDPR, CCPA и PECR. + +если ты хочешь отказаться от анонимной аналитики, то это можно сделать в +[настройках приватности](/settings/privacy#analytics). после отказа скрипт +plausible не будет загружаться. + +[узнай больше о преданности plausible к +приватности](https://plausible.io/privacy-focused-web-analytics). +
+{/if} + +
+ + +мы используем сервисы cloudflare для: +- защиты от ddos и абьюза. +- защиты от ботов (cloudflare turnstile). +- хостинга и деплоя статического веб-приложения (cloudflare workers). + +всё это необходимо для обеспечения лучшего опыта для всех. cloudflare — наиболее +приватный и надёжный провайдер всех упомянутых решений из всех известных нам +провайдеров. + +cloudflare полностью соответствует требованиям GDPR и HIPAA. + +[узнай больше о преданности cloudflare к +приватности](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/). +
diff --git a/web/i18n/ru/about/terms.md b/web/i18n/ru/about/terms.md new file mode 100644 index 0000000000000000000000000000000000000000..edb6df38d17340678f196512669097e637d0ed62 --- /dev/null +++ b/web/i18n/ru/about/terms.md @@ -0,0 +1,69 @@ + + +
+ + +эти условия применяются только при использовании официального инстанса кобальта. +в других случаях, возможно, придётся обратиться к хостеру инстанса за точной +информацией. +
+ +
+ + +функция сохранения упрощает скачивание контента из интернета, и мы не несём +никакой ответственности за то, как будет использоваться сохранённый контент. + +серверы обработки работают как продвинутые прокси и никогда не записывают +запрошенный контент на диск. всё происходит в оперативной памяти и полностью +удаляется после завершения туннеля. у нас нет логов загрузок, и мы не можем +никого идентифицировать. + +подробнее о том, как работают туннели, можно узнать в [политике +конфиденциальности](/about/privacy). +
+ +
+ + +ты (конечный пользователь) несёшь ответственность за то, что делаешь с нашими +инструментами, как используешь и распространяешь полученный контент. пожалуйста, +уважай чужой труд и всегда указывай авторов. убедись, что ты не нарушаешь +никаких условий или лицензий. + +при использовании в образовательных целях всегда ссылайся на источники и +указывай авторов. + +добросовестное использование и указание авторства приносят пользу всем. +
+ +
+ + +у нас нет возможности автоматически выявлять злоупотребления, так как кобальт +полностью анонимен. однако, есть возможность сообщить нам о такой деятельности +по почте, и мы сделаем всё возможное, чтобы принять нужные меры вручную: +abuse[at]imput.net + +**этот адрес не предназначен для поддержки пользователей. ты не получишь ответ, +если твой запрос не связан со злоупотреблениями.** + +если у тебя возникли проблемы с работой кобальта, то ты можешь обратиться за +помощью любым удобным способом на [странице поддержки и +сообщества](/about/community). +
diff --git a/web/i18n/ru/button.json b/web/i18n/ru/button.json new file mode 100644 index 0000000000000000000000000000000000000000..d87f75b855ec360d735ba3ffe9b3c77803d800ff --- /dev/null +++ b/web/i18n/ru/button.json @@ -0,0 +1,27 @@ +{ + "download.audio": "скачать аудио", + "import": "импортировать", + "copied": "скопировано", + "copy": "скопировать", + "share": "поделиться", + "download": "скачать", + "no": "нет", + "yes": "да", + "save": "скачать", + "continue": "продолжить", + "done": "готово", + "reset": "сбросить", + "cancel": "отменить", + "export": "экспортировать", + "gotit": "понятно", + "copy.section": "скопировать ссылку на раздел", + "clear_input": "очистить поле ввода", + "show_input": "показать ввод", + "hide_input": "скрыть ввод", + "restore_input": "восстановить ввод", + "clear": "очистить", + "remove": "убрать", + "clear_cache": "очистить кэш", + "retry": "повторить", + "delete": "удалить" +} diff --git a/web/i18n/ru/dialog.json b/web/i18n/ru/dialog.json new file mode 100644 index 0000000000000000000000000000000000000000..8e9d538bdfd98d56b2765c3ee814686f692cf91f --- /dev/null +++ b/web/i18n/ru/dialog.json @@ -0,0 +1,16 @@ +{ + "picker.title": "что сохранить?", + "saving.title": "как сохранить?", + "saving.timeout": "кобальт попытался сохранить файл автоматически, но твой браузер остановил это. выбери способ вручную.", + "reset_settings.title": "сбросить все настройки?", + "reset_settings.body": "ты точно хочешь сбросить все настройки? это действие мгновенное и необратимое.", + "picker.description.phone": "нажми на то, что хочешь скачать. картинки также можно скачать долгим нажатием.", + "picker.description.desktop": "кликни на то, что хочешь скачать. картинки также можно скачать через контекстное меню.", + "picker.description.ios": "нажми на то, что хочешь скачать через команду siri. картинки также можно скачать долгим нажатием.", + "saving.blocked": "кобальт попытался открыть файл в новой вкладке, но твой браузер заблокировал это. разреши всплывающие окна для кобальта, чтобы избежать этого в следующий раз.", + "clear_cache.title": "очистить весь кэш?", + "import.body": "импорт неизвестных или повреждённых файлов может неожиданно изменить или сломать работу кобальта. импортируй только те файлы, которые ты экспортировал сам и не изменял. если кто-то попросил тебя импортировать этот файл — не делай этого.\n\nмы не несём ответственности за любой вред, причинённый импортом неизвестных файлов настроек.", + "safety.custom_instance.body": "сторонние инстансы могут быть опасны для твоей приватности и безопасности.\n\nвредоносные инстансы могут:\n1. перенаправлять тебя с кобальта и пытаться обмануть.\n2. записывать всю информацию о твоих запросах, хранить её вечно и использовать для слежки за тобой.\n3. скачивать вредоносные файлы (например, вирусы).\n4. заставлять тебя смотреть рекламу или платить за скачивание.\n\nпосле этого момента мы не сможем тебя защитить. пожалуйста, будь осторожен с выбором инстанса и всегда доверяй своей интуиции. если что-то кажется странным, то вернись на эту страницу, сбрось пользовательский инстанс и сообщи нам об этом на github.", + "clear_cache.body": "все файлы из очереди обработки будут удалены и локальные фичи займут больше времени на загрузку. это действие мгновенное и необратимое.", + "safety.title": "важное предупреждение о безопасности" +} diff --git a/web/i18n/ru/donate.json b/web/i18n/ru/donate.json new file mode 100644 index 0000000000000000000000000000000000000000..9b88340a665901d17c5faf42c73da5a828666868 --- /dev/null +++ b/web/i18n/ru/donate.json @@ -0,0 +1,29 @@ +{ + "card.once": "одноразовый донат", + "card.option.30": "обед для двоих", + "body.no_bullshit": "мы считаем, что интернет не должен быть страшным. поэтому в кобальте никогда не будет рекламы или другого вредоносного контента. это обещание, за которым мы стоим горой. всё, что мы делаем, создаётся с учётом конфиденциальности, доступности и простоты использования, что делает кобальт доступным для всех.", + "card.custom": "своя сумма (от $2)", + "card.processor": "через {{value}}", + "card.option.5": "чашка кофе", + "card.option.50": "10кг кошачьего корма", + "card.option.1599": "базовый макбук", + "card.option.4900": "10,000 яблок", + "share.title": "поделись кобальтом с другом", + "alternative.title": "альтернативные способы доната", + "alt.copy": "{{ value }}. адрес криптокошелька. нажми, чтобы скопировать.", + "alt.open": "{{ value }}. нажми, чтобы открыть.", + "body.motivation": "кобальт помогает продюсерам, преподавателям, видеомейкерам и многим другим заниматься тем, что они любят. это особый сервис, создающийся с любовью, а не ради прибыли.", + "body.keep_going": "если кобальт помог тебе, пожалуйста, подумай над тем, чтобы поддержать нашу работу! ты можешь поддержать нас донатом, либо поделившись кобальтом с другом. каждый донат очень ценится и помогает нам продолжать работу над кобальтом и другими проектами.", + "card.recurring": "регулярный донат", + "card.option.10": "большая пицца", + "card.option.15": "полный обед", + "card.custom.submit": "своя сумма", + "banner.title": "Поддержи безопасный\nи открытый Интернет", + "banner.subtitle": "поддержи imput или поделись\nкобальтом с другом", + "card.option.100": "один год доменов", + "card.option.200": "аэрогриль", + "card.option.500": "крутое офисное кресло", + "card.option.7398": "флагманский макбук", + "card.option.8629": "маленький земельный участок", + "card.option.9433": "джакузи класса люкс" +} diff --git a/web/i18n/ru/error.json b/web/i18n/ru/error.json new file mode 100644 index 0000000000000000000000000000000000000000..a8f0d64350589295b5c0f04a2453089a58cf4fd8 --- /dev/null +++ b/web/i18n/ru/error.json @@ -0,0 +1,8 @@ +{ + "pipeline.missing_response_data": "инстанс обработки не ответил с нужной информацией о файле, поэтому я не могу создать задачи для локальной обработки. попробуй ещё раз через несколько секунд и сообщи о проблеме, если она не исчезнет!", + "captcha_too_long": "cloudflare turnstile слишком долго проверяет, что ты не бот. попробуй ещё раз, но если снова появится эта ошибка, то можно попробовать: отключить странные расширения браузера, сменить сеть, использовать другой браузер или проверить устройство на наличие вредоносных программ.", + "import.invalid": "в этом файле нет совместимых настроек кобальта для импорта. ты уверен, что это тот файл?", + "tunnel.probe": "не удалось протестировать этот туннель. возможно, твой браузер или настройки сети блокируют доступ к одному из серверов кобальта. ты уверен, что у тебя нет каких-то странных расширений для браузера?", + "import.unknown": "не удалось загрузить данные из файла. возможно, он повреждён или не того формата. вот ошибка, которую я получил:\n\n{{ value }}", + "import.no_data": "из этого файла нечего загружать. ты уверен, что это тот файл?" +} diff --git a/web/i18n/ru/error/api.json b/web/i18n/ru/error/api.json new file mode 100644 index 0000000000000000000000000000000000000000..535a100a0ddbdab0998df6ea6caf759f2877a4e3 --- /dev/null +++ b/web/i18n/ru/error/api.json @@ -0,0 +1,51 @@ +{ + "auth.jwt.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что токен доступа недействителен. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.turnstile.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что решение капчи недействительно. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.key.not_api_key": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!", + "auth.key.invalid": "ключ доступа недействителен. сбрось его в настройках инстанса и используй правильный!", + "auth.key.ua_not_allowed": "ты не можешь использовать этот ключ доступа с текущего юзер агента. попробуй другой клиент или устройство!", + "unreachable": "не удалось подключиться к инстансу обработки. проверь своё интернет-соединение и попробуй ещё раз!", + "rate_exceeded": "ты делаешь слишком много запросов. попробуй снова через {{ limit }}с.", + "capacity": "кобальт сейчас перегружен и не может обработать твой запрос. попробуй ещё раз через пару секунд!", + "service.unsupported": "этот сервис ещё не поддерживается. ты уверен, что вставил правильную ссылку?", + "service.audio_not_supported": "этот сервис не поддерживает извлечение аудио. попробуй ссылку с другого сервиса!", + "link.invalid": "твоя ссылка недействительна или этот сервис ещё не поддерживается. ты точно вставил правильную ссылку?", + "fetch.fail": "что-то пошло не так при получении инфы из {{ service }}, и я ничего не смог для тебя достать. если эта проблема не исчезнет, пожалуйста, сообщи о ней!", + "auth.jwt.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует токен доступа. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.key.missing": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!", + "generic": "что-то пошло не так, и я не смог ничего найти для тебя. попробуй ещё раз через пару секунд. если проблема останется, пожалуйста, сообщи об этом!", + "auth.turnstile.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует решение капчи. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "unknown_response": "не удалось прочитать ответ от инстанса обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!", + "auth.key.not_found": "использованный тобой ключ доступа не найден. ты уверен, что у этого инстанса есть твой ключ?", + "invalid_body": "не удалось отправить запрос на инстанс обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!", + "auth.key.invalid_ip": "не удалось распарсить твой ip-адрес. что-то пошло совсем не так, пожалуйста, сообщи об этой ошибке!", + "auth.key.ip_not_allowed": "ты не можешь использовать этот ключ доступа с текущего ip-адреса. попробуй другой инстанс или сеть!", + "timed_out": "инстанс обработки слишком долго не отвечал. возможно, он сейчас перегружен, попробуй ещё раз через пару секунд!", + "service.disabled": "этот сервис обычно поддерживается кобальтом, но он отключён на этом инстансе. попробуй ссылку с другого сервиса!", + "link.unsupported": "{{ service }} поддерживается, но я не смог распознать твою ссылку. ты точно вставил правильную?", + "fetch.critical": "модуль {{ service }} вернул ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "content.too_long": "запрошенное медиа слишком длинное. лимит длительности на этом инстансе — {{ limit }}мин. попробуй что-нибудь покороче!", + "content.video.unavailable": "я не могу получить доступ к этому видео. оно может быть ограничено со стороны {{ service }}. попробуй другую ссылку!", + "content.video.private": "это видео приватное, поэтому я не могу получить к нему доступ. измени его видимость или попробуй другое!", + "content.video.region": "это видео ограничено по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!", + "content.paid": "этот контент требует покупки. кобальт не может скачивать платный контент. попробуй другую ссылку!", + "content.post.private": "не удалось получить инфу об этом посте, потому что он от закрытого аккаунта. попробуй другую ссылку!", + "youtube.token_expired": "не удалось получить это видео, потому что токен youtube истёк и не был обновлён. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!", + "youtube.no_hls_streams": "не удалось найти ни одного подходящего HLS-потока для этого видео. попробуй скачать его без HLS!", + "youtube.api_error": "youtube что-то обновил в своём api, и я не смог получить инфу об этом видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "youtube.drm": "это youtube-видео защищено widevine DRM, так что я не могу его скачать. попробуй другую ссылку!", + "fetch.rate": "{{ service }} ограничил частоту запросов от инстанса обработки. попробуй ещё раз через пару секунд!", + "youtube.temporary_disabled": "скачивание с youtube временно отключено из-за ограничений со стороны youtube. мы уже ищем способы их обойти.\n\nприносим извинения за неудобства и делаем всё возможное, чтобы восстановить эту функциональность. следи за обновлениями в соцсетях или на github!", + "content.video.age": "это видео ограничено по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!", + "content.region": "этот контент ограничен по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!", + "youtube.no_matching_format": "youtube не вернул ни одного подходящего формата. возможно, кобальт их не поддерживает или же они перекодируются на стороне youtube. попробуй ещё раз чуть позже, а если проблема останется, сообщи о ней!", + "youtube.no_session_tokens": "не удалось получить необходимые токены сессии для ютуба. это может быть вызвано ограничением со стороны ютуба. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "youtube.decipher": "youtube обновил свой алгоритм расшифровки, и из-за этого мне не удалось получить информацию о видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "fetch.short_link": "не удалось получить инфу по короткой ссылке. ты уверен, что она работает? если да, а ты всё равно видишь эту ошибку, пожалуйста, сообщи о ней!", + "fetch.empty": "не смог найти медиа, которое я мог бы скачать для тебя. ты уверен, что вставил правильную ссылку?", + "content.post.age": "этот пост ограничен по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!", + "youtube.login": "не удалось получить это видео, потому что youtube попросил доказать, что инстанс обработки — не бот. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!", + "content.video.live": "это видео сейчас идёт в прямом эфире, поэтому я ещё не могу его скачать. подожди, пока стрим закончится, и попробуй снова!", + "content.post.unavailable": "не удалось ничего найти об этом посте. его видимость может быть ограничена или он может не существовать. убедись, что твоя ссылка работает, и попробуй снова через пару секунд!", + "fetch.critical.core": "один из основных модулей выдал ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!" +} diff --git a/web/i18n/ru/error/queue.json b/web/i18n/ru/error/queue.json new file mode 100644 index 0000000000000000000000000000000000000000..5c3df793922d9f96bd707d6c6cfb52b7881982db --- /dev/null +++ b/web/i18n/ru/error/queue.json @@ -0,0 +1,19 @@ +{ + "fetch.no_file_reader": "не смог записать файл в кэш", + "worker_didnt_start": "не смог запустить воркер обработки", + "ffmpeg.probe_failed": "не удалось проверить этот файл, возможно, он повреждён или не поддерживается", + "fetch.network_error": "скачивание было прервано из-за проблем с сетью", + "no_final_file": "финальный файл пропал", + "fetch.corrupted_file": "файл был скачан не полностью, попробуй ещё раз", + "fetch.crashed": "воркер скачивания вылетел, смотри детали в консоли", + "fetch.bad_response": "не смог получить туннель файла", + "fetch.empty_tunnel": "туннель файла пустой, попробуй ещё раз через несколько минут", + "ffmpeg.no_input_type": "тип этого файла не поддерживается", + "ffmpeg.crashed": "воркер ffmpeg вылетел, смотри детали в консоли", + "ffmpeg.no_input_format": "формат этого файла не поддерживается", + "ffmpeg.out_of_memory": "не хватает памяти, не могу продолжить", + "ffmpeg.no_render": "рендер ffmpeg пустой, произошло что-то очень странное", + "ffmpeg.no_args": "воркер ffmpeg не получил нужные аргументы", + "generic_error": "воркер обработки вылетел, смотри детали в консоли", + "ffmpeg.no_audio_channel": "у этого видео нет аудиодорожки, ничего нельзя сделать" +} diff --git a/web/i18n/ru/general.json b/web/i18n/ru/general.json index 90cbfef58ae9872b8d4621508d275ce6be4fb6e1..d10e39a210685cf992d8e358363c6dee46726711 100644 --- a/web/i18n/ru/general.json +++ b/web/i18n/ru/general.json @@ -2,6 +2,5 @@ "cobalt": "кобальт", "meowbalt": "мяубальт", "beta": "бета", - - "embed.description": "сохраняй то, что любишь: без рекламы, трекеров и прочей чепухи. кобальт создан с любовью, а не с целью заработать." + "embed.description": "кобальт помогает тебе сохранять то, что ты любишь, без рекламы, трекеров и прочей ерунды. просто вставь ссылку!" } diff --git a/web/i18n/ru/notification.json b/web/i18n/ru/notification.json new file mode 100644 index 0000000000000000000000000000000000000000..14822cf3a405299c1a30f21866ad87f5e63fb037 --- /dev/null +++ b/web/i18n/ru/notification.json @@ -0,0 +1,4 @@ +{ + "update.title": "доступно обновление!", + "update.subtext": "нажми, чтобы обновить" +} diff --git a/web/i18n/ru/queue.json b/web/i18n/ru/queue.json new file mode 100644 index 0000000000000000000000000000000000000000..62a8102a4a9189f9d2196b1ca5765f033e33ca9d --- /dev/null +++ b/web/i18n/ru/queue.json @@ -0,0 +1,13 @@ +{ + "state.waiting": "в очереди", + "state.starting.fetch": "начинаю скачивание", + "state.running.remux": "ремуксирую", + "state.retrying": "повторяю", + "state.starting.encode": "начинаю транскодирование", + "title": "очередь обработки", + "state.starting": "начинаю", + "state.starting.remux": "начинаю ремуксинг", + "state.running.fetch": "скачиваю", + "state.running.encode": "транскодирую", + "stub": "тут пока что ничего нет, только мы вдвоём.\nпопробуй скачать что-нибудь!" +} diff --git a/web/i18n/ru/receiver.json b/web/i18n/ru/receiver.json new file mode 100644 index 0000000000000000000000000000000000000000..2f808fac681ad547db42f4a17be0003524ecc0fa --- /dev/null +++ b/web/i18n/ru/receiver.json @@ -0,0 +1,7 @@ +{ + "accept": "поддерживаемые форматы: {{ formats }}.", + "title": "перетащи или выбери файл", + "title.drop": "скинь файл сюда!", + "title.multiple": "перетащи или выбери файлы", + "title.drop.multiple": "скинь файлы сюда!" +} diff --git a/web/i18n/ru/remux.json b/web/i18n/ru/remux.json new file mode 100644 index 0000000000000000000000000000000000000000..1ee7fa6d1119f74a7f2fb2dc9c4232bb31fef4ae --- /dev/null +++ b/web/i18n/ru/remux.json @@ -0,0 +1,8 @@ +{ + "bullet.purpose.description": "ремукс исправляет любые проблемы с файлом, например, отсутствие информации о времени. он помогает повысить совместимость со старыми программами, такими как vegas pro и windows media player.", + "bullet.purpose.title": "что делает ремукс?", + "bullet.explainer.title": "как он работает?", + "bullet.explainer.description": "ремукс берёт существующие данные кодека и копирует их в новый медиаконтейнер. это происходит без потери качества, так как медиаданные не перекодируются.", + "bullet.privacy.title": "локальная обработка", + "bullet.privacy.description": "кобальт ремуксирует файлы локально. файлы никогда не покидают твоё устройство, поэтому обработка происходит практически мгновенно." +} diff --git a/web/i18n/ru/save.json b/web/i18n/ru/save.json index ce64917a9f62e3b057ebdaa4926a6c21ed86d95c..26dedbd60fa0061966585e3f53d275a941e493e2 100644 --- a/web/i18n/ru/save.json +++ b/web/i18n/ru/save.json @@ -10,5 +10,15 @@ "services.title": "поддерживаемые сервисы", "services.title_show": "показать поддерживаемые сервисы", "services.title_hide": "скрыть поддерживаемые сервисы", - "services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской." + "services.disclaimer": "поддержка сервиса не означает аффилированность, одобрение или любую другую форму поддержки, кроме технической совместимости.\n\nдеятельность владельца facebook и instagram запрещена на территории РФ и признана экстремистской.", + "tutorial.step.1": "добавь команды-компаньоны:", + "tutorial.step.2": "нажми кнопку \"поделиться\" в диалоге сохранения кобальта.", + "tutorial.step.3": "выбери нужную команду в окне обмена.", + "tutorial.shortcut.photos": "в фото", + "tutorial.shortcut.files": "в файлы", + "tutorial.title": "как сохранить на ios?", + "tutorial.intro": "чтобы удобно сохранять файлы на ios, придётся использовать команду siri в меню обмена.", + "tutorial.outro": "эти команды siri будут работать только из приложения кобальта, использовать их из других приложений не получится.", + "tooltip.captcha": "cloudflare turnstile проверяет, что ты не бот. подожди, пожалуйста!", + "label.community_instance": "инстанс сообщества" } diff --git a/web/i18n/ru/settings.json b/web/i18n/ru/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..044087c2fc162d2bff02b0368724f1e973ee7c63 --- /dev/null +++ b/web/i18n/ru/settings.json @@ -0,0 +1,131 @@ +{ + "theme.auto": "авто", + "theme.light": "светлая", + "audio.bitrate.kbps": "кб/с", + "theme.dark": "тёмная", + "audio.youtube.dub": "звуковая дорожка youtube", + "video.quality.max": "8k+", + "page.video": "видео", + "page.audio": "аудио", + "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", + "metadata.file": "метаданные файла", + "saving.title": "метод сохранения", + "saving.ask": "спросить", + "saving.download": "скачать", + "saving.share": "поделиться", + "saving.copy": "скопировать", + "language": "язык", + "language.preferred.title": "предпочитаемый язык", + "privacy.analytics": "анонимная аналитика трафика", + "audio.tiktok.original.title": "скачивать оригинальный звук", + "privacy.tunnel": "туннелирование", + "privacy.tunnel.title": "всегда туннелировать файлы", + "audio.format.mp3": "mp3", + "audio.format.ogg": "ogg", + "audio.format.wav": "wav", + "audio.format.opus": "opus", + "page.privacy": "приватность", + "theme": "тема", + "video.quality": "качество видео", + "video.twitter.gif": "twitter/x", + "video.quality.2160": "4k", + "audio.format": "формат аудио", + "audio.bitrate": "битрейт аудио", + "audio.tiktok.original": "tiktok", + "metadata.disable.title": "отключить метаданные", + "language.auto.title": "автоматический выбор", + "metadata.disable.description": "название, исполнитель и другая информация не будут добавлены в файл.", + "language.preferred.description": "этот язык будет использоваться когда автоматический выбор отключен. любой непереведённый текст будет отображаться на английском языке.\n\nмы используем переводы, предоставленные сообществом. они могут быть неточными или неполными.", + "audio.youtube.dub.description": "cobalt будет использовать дублированную аудиодорожку для выбранного языка, если она доступна. в противном случае будет использоваться оригинальная.", + "language.auto.description": "если доступен перевод, то кобальт будет использовать язык твоего браузера. в ином случае будет использоваться английский.", + "theme.description": "авто тема переключается между светлой и тёмной темой в зависимости от системной темы.", + "page.debug": "инфа для зануд", + "page.appearance": "внешний вид", + "page.instances": "инстансы", + "page.advanced": "продвинутые", + "page.accessibility": "общедоступность", + "page.metadata": "метаданные", + "page.local": "локальная обработка", + "video.youtube.codec": "предпочитаемый кодек для youtube", + "audio.youtube.dub.title": "предпочитаемый язык озвучки", + "metadata.filename.basic": "базовый", + "video.twitter.gif.title": "конвертировать зацикленные видео в GIF", + "metadata.filename.description": "стиль названий файлов используется только для файлов, туннелированных через кобальт. некоторые сервисы поддерживают только классический стиль.", + "youtube.dub.original": "оригинальный", + "metadata.filename.pretty": "красивый", + "metadata.filename.nerdy": "занудный", + "audio.tiktok.original.description": "кобальт будет скачивать оригинальный звук из видео без каких-либо изменений от автора поста.", + "metadata.filename": "стиль названий файлов", + "metadata.filename.classic": "классический", + "video.twitter.gif.description": "GIF конвертация неэффективна, финальный файл может быть огромным и в плохом качестве.", + "audio.youtube.better_audio.title": "предпочитать лучшее качество", + "audio.format.description": "все форматы кроме \"лучшего\" конвертируются из исходного формата, поэтому возможна небольшая потеря качества. когда выбран \"лучший\" формат, аудио остаётся в оригинальном формате, если это возможно.", + "audio.youtube.better_audio.description": "кобальт будет пытаться выбрать самое качественное аудио в режиме скачивания аудио. оно может быть недоступно в зависимости от ответа youtube, текущей нагрузки и состояния сервера. на кастомных инстансах эта опция может не поддерживаться.", + "audio.youtube.better_audio": "качество аудио с youtube", + "video.quality.description": "если предпочитаемое качество недоступно, то выбирается следующий лучший вариант.", + "video.youtube.codec.description": "h264: наилучшая совместимость, среднее качество. максимальное качество — 1080p.\nav1: наилучшее качество и сжатие. поддерживает 8k и HDR.\nvp9: то же качество, что и у av1, но файл в ~2x больше. поддерживает 4k & HDR.\n\nav1 и vp9 не очень широко поддерживаются, возможно придётся использовать дополнительное ПО для их проигрывания/обработки. кобальт выбирает следующий лучший кодек, если предпочитаемый недоступен.", + "audio.bitrate.description": "битрейт применяется только при конвертации аудио в формат с потерями. кобальт не может улучшить качество исходного аудио, поэтому выбор битрейта выше 128 кб/с может увеличить размер файла без заметной разницы в звуке. воспринимаемое качество может различаться в зависимости от формата.", + "video.h265": "high efficiency video codec", + "video.h265.title": "использовать h265 для видео", + "video.h265.description": "позволяет скачивать видео с tiktok и xiaohongshu в более высоком качестве, но с потерей совместимости.", + "video.youtube.hls": "форматы hls для youtube", + "video.youtube.hls.description": "в этом режиме доступны только кодеки h264 и vp9. оригинальный аудио кодек aac перекодируется для совместимости, поэтому качество аудио может быть хуже чем у варианта без HLS.\n\nэта функция экспериментальна, поэтому может быть убрана или изменена в будущем.", + "audio.format.best": "лучший", + "video.youtube.hls.title": "предпочитать hls для видео и аудио", + "metadata.filename.preview.video": "Название Видео - Автор Видео", + "metadata.filename.preview.audio": "Название Аудио - Автор Аудио", + "filename.preview_desc.video": "превью видео файла", + "filename.preview_desc.audio": "превью аудио файла", + "saving.description": "предпочтительный способ сохранения файла или ссылки с кобальта. если предпочитаемый метод недоступен или что-то пойдёт не так, кобальт спросит тебя как поступить.", + "accessibility.transparency.description": "уменьшает прозрачность поверхностей и выключает эффекты размытия. также может улучшить работу интерфейса на менее мощных устройствах.", + "accessibility.transparency.title": "уменьшить визуальную прозрачность", + "accessibility.visual": "интерфейс", + "accessibility.haptics": "вибрация", + "accessibility.behavior": "поведение", + "accessibility.auto_queue.description": "очередь обработки не будет открываться автоматически при добавлении новой задачи. прогресс всё равно будет отображаться, и ты всё равно сможешь открыть её вручную.", + "privacy.analytics.learnmore": "узнай больше о преданности plausible к приватности.", + "accessibility.motion.description": "анимации и переходы будут отключены, когда это возможно.", + "accessibility.haptics.title": "отключить вибрацию", + "accessibility.haptics.description": "вся вибрация будет отключена.", + "accessibility.auto_queue.title": "не открывать очередь обработки", + "privacy.analytics.description": "анонимная аналитика трафика нужна, чтобы знать приблизительное количество активных пользователей кобальта. идентифицирующая информация о тебе никогда не сохраняется. все обрабатываемые данные анонимизированы и агрегированы.\n\nмы используем собственный инстанс plausible, который не использует куки и полностью соответствует требованиям GDPR, CCPA и PECR.", + "privacy.tunnel.description": "cobalt скроет твой ip адрес, информацию о браузере и обойдёт местные сетевые ограничения. когда включено, у всех файлов будут читаемые названия вместо абракадабры.", + "accessibility.motion.title": "уменьшить движение", + "privacy.analytics.title": "не участвовать в аналитике", + "advanced.debug": "отладка", + "advanced.debug.description": "даёт доступ к странице с различной информацией, которая может быть полезна для отладки. никак не меняет поведение кобальта.", + "advanced.debug.title": "включить функции для зануд", + "processing.community": "инстансы сообщества", + "processing.enable_custom.description": "кобальт будет использовать сторонний инстанс обработки, если ты так решишь. несмотря на то, что у кобальта есть некоторые меры безопасности, мы не несём ответственности за любой ущерб, причинённый сторонним инстансом, так как мы его не контролируем.\n\nбудь осторожен с тем, какие инстансы ты используешь, и убедись, что их хостят люди, которым ты доверяешь.", + "processing.enable_custom.title": "использовать сторонний инстанс", + "local.saving": "локальная обработка медиа", + "local.saving.description": "при скачивании медиа, ремуксинг и транскодирование будут выполняться на устройстве, а не в облаке. ты увидишь подробный прогресс в очереди обработки.\n\nникогда: локальная обработка не будет использоваться. инстансы обработки могут принудительно включать эту функцию, поэтому эта опция может не иметь эффекта.\nиногда: медиафайлы, требующие дополнительной обработки, будут загружаться через очередь обработки, но остальные медиафайлы будут загружаться менеджером загрузок твоего браузера.\nвсегда: все медиафайлы всегда будут проксироваться и загружаться через очередь обработки.\n\nэксклюзивные функции на устройстве не зависят от этой настройки, они всегда работают локально.", + "advanced.settings_data": "данные настроек", + "local.webcodecs.description": "при декодировании или кодировании файлов кобальт будет пытаться использовать webcodecs. эта функция позволяет обрабатывать медиафайлы с ускорением на GPU, так что всё декодирование и кодирование будет намного быстрее.\n\nдоступность и стабильность этой функции зависят от возможностей твоего устройства и браузера. что-то может сломаться или работать некорректно.", + "processing.access_key": "ключ доступа к инстансу", + "advanced.local_storage": "локальное хранилище", + "local.webcodecs": "webcodecs", + "local.webcodecs.title": "использовать webcodecs для локальной обработки", + "processing.access_key.title": "использовать ключ доступа", + "processing.custom_instance.input.alt_text": "домен стороннего инстанса", + "tabs": "навигация", + "tabs.hide_remux": "скрыть страницу ремукса", + "tabs.hide_remux.description": "если ты не пользуешься ремуксом, то его можно скрыть из панели навигации.", + "processing.access_key.description": "кобальт будет использовать этот ключ для запросов к инстансу обработки вместо других методов аутентификации. убедись, что инстанс поддерживает api ключи!", + "processing.access_key.input.alt_text": "ключ доступа u-u-i-d", + "video.youtube.container": "контейнер файла для youtube", + "video.youtube.container.description": "когда выбран \"авто\" контейнер, кобальт автоматически подберёт оптимальный контейнер в зависимости от выбранного кодека: mp4 для h264; webm для vp9/av1.", + "subtitles.description": "кобальт добавит субтитры к скачанному файлу на предпочитаемом языке, если они доступны.\n\nнекоторые сервисы не имеют выбора языка, и в таком случае кобальт добавит единственную доступную дорожку субтитров, если выбран любой язык.", + "subtitles": "субтитры", + "subtitles.title": "язык субтитров", + "subtitles.none": "никакой", + "local.saving.disabled": "никогда", + "local.saving.preferred": "иногда", + "local.saving.forced": "всегда" +} diff --git a/web/i18n/ru/tabs.json b/web/i18n/ru/tabs.json index 0b93cc7f4dbf4f3e180066e70873d898ab37a5b0..afe0d69332c1f83a18b6ac0b32769ec024efc6d6 100644 --- a/web/i18n/ru/tabs.json +++ b/web/i18n/ru/tabs.json @@ -3,6 +3,6 @@ "settings": "настройки", "updates": "новости", "donate": "донаты", - "about": "инфа", + "about": "инфо", "remux": "ремукс" } diff --git a/web/i18n/ru/updates.json b/web/i18n/ru/updates.json new file mode 100644 index 0000000000000000000000000000000000000000..f6ab7698d83716d89c96dc83c61dd94a91f25000 --- /dev/null +++ b/web/i18n/ru/updates.json @@ -0,0 +1,4 @@ +{ + "button.next": "перейти к предыдущему обновлению ({{ value }})", + "button.previous": "перейти к следующему обновлению ({{ value }})" +} diff --git a/web/package.json b/web/package.json index 96900d0cb248b6dd801cb553db7d513b65b4dd40..7de6f75dc0a57ab7abc59ac302160da9870cb18e 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "10.9", + "version": "11.3", "type": "module", "private": true, "scripts": { @@ -25,14 +25,14 @@ "homepage": "https://cobalt.tools/", "devDependencies": { "@eslint/js": "^9.5.0", - "@fontsource-variable/noto-sans-mono": "^5.0.20", "@fontsource/ibm-plex-mono": "^5.0.13", "@fontsource/redaction-10": "^5.0.2", - "@imput/libav.js-remux-cli": "^5.5.6", + "@imput/libav.js-encode-cli": "6.8.7", + "@imput/libav.js-remux-cli": "^6.8.7", "@imput/version-info": "workspace:^", "@sveltejs/adapter-static": "^3.0.6", - "@sveltejs/kit": "^2.9.1", - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/kit": "^2.20.7", + "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tabler/icons-svelte": "3.6.0", "@types/eslint__js": "^8.42.3", "@types/fluent-ffmpeg": "^2.1.25", @@ -44,16 +44,16 @@ "glob": "^11.0.0", "mdsvex": "^0.11.2", "mime": "^4.0.4", - "svelte": "^4.2.19", - "svelte-check": "^3.6.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", "svelte-preprocess": "^6.0.2", "svelte-sitemap": "2.6.0", "sveltekit-i18n": "^2.4.2", "ts-deepmerge": "^7.0.1", "tslib": "^2.4.1", "turnstile-types": "^1.2.2", - "typescript": "^5.4.5", + "typescript": "^5.5.0", "typescript-eslint": "^8.18.0", - "vite": "^5.3.6" + "vite": "^5.4.4" } } diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000000000000000000000000000000000000..55d94e3965186fe15c2869e7b4e1bea3d0c2b1cc --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,474 @@ +:root { + --primary: #ffffff; + --secondary: #000000; + + --white: #ffffff; + --gray: #75757e; + + --red: #ed2236; + --medium-red: #ce3030; + --dark-red: #d61c2e; + --green: #30bd1b; + --blue: #2f8af9; + --magenta: #eb445a; + --purple: #5857d4; + --orange: #f19a38; + + --focus-ring: solid 2px var(--blue); + --focus-ring-offset: -2px; + + --button: #f4f4f4; + --button-hover: #ededed; + --button-press: #e8e8e8; + --button-active-hover: #2a2a2a; + + --button-hover-transparent: rgba(0, 0, 0, 0.06); + --button-press-transparent: rgba(0, 0, 0, 0.09); + --button-stroke: rgba(0, 0, 0, 0.06); + --button-text: #282828; + --button-box-shadow: 0 0 0 1px var(--button-stroke) inset; + + --button-elevated: #e3e3e3; + --button-elevated-hover: #dadada; + --button-elevated-press: #d3d3d3; + --button-elevated-shimmer: #ededed; + + --popover-glow: var(--button-stroke); + + --popup-bg: #f1f1f1; + --popup-stroke: rgba(0, 0, 0, 0.08); + + --dialog-backdrop: rgba(255, 255, 255, 0.3); + + --sidebar-bg: var(--button); + --sidebar-highlight: var(--secondary); + --sidebar-stroke: rgba(0, 0, 0, 0.04); + + --content-border: rgba(0, 0, 0, 0.03); + --content-border-thickness: 1px; + + --input-border: #adadb7; + + --toggle-bg: var(--input-border); + --toggle-bg-enabled: var(--secondary); + + --padding: 12px; + --border-radius: 11px; + + --sidebar-width: 80px; + --sidebar-font-size: 11px; + --sidebar-inner-padding: 4px; + --sidebar-tab-padding: 10px; + + /* reduce default inset by 5px if it's not 0 */ + --sidebar-height-mobile: calc( + 50px + + calc( + env(safe-area-inset-bottom) - 5px * + sign(env(safe-area-inset-bottom)) + ) + ); + + --safe-area-inset-top: env(safe-area-inset-top); + --safe-area-inset-bottom: env(safe-area-inset-bottom); + + --switcher-padding: 3.5px; + + /* used for fading the tab bar on scroll */ + --sidebar-mobile-gradient: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.9) 0%, + rgba(0, 0, 0, 0) 5%, + rgba(0, 0, 0, 0) 50%, + rgba(0, 0, 0, 0) 95%, + rgba(0, 0, 0, 0.9) 100% + ); + + --skeleton-gradient: linear-gradient( + 90deg, + var(--button-hover), + var(--button), + var(--button-hover) + ); + + --skeleton-gradient-elevated: linear-gradient( + 90deg, + var(--button-elevated), + var(--button-elevated-shimmer), + var(--button-elevated) + ); +} + +[data-theme="dark"] { + --primary: #000000; + --secondary: #e1e1e1; + + --gray: #818181; + + --blue: #2a7ce1; + --green: #37aa42; + + --button: #191919; + --button-hover: #242424; + --button-press: #2a2a2a; + + --button-active-hover: #f9f9f9; + + --button-hover-transparent: rgba(225, 225, 225, 0.1); + --button-press-transparent: rgba(225, 225, 225, 0.15); + --button-stroke: rgba(255, 255, 255, 0.05); + --button-text: #e1e1e1; + --button-box-shadow: 0 0 0 1px var(--button-stroke) inset; + + --button-elevated: #282828; + --button-elevated-hover: #2f2f2f; + --button-elevated-press: #343434; + + --popover-glow: rgba(135, 135, 135, 0.12); + + --popup-bg: #191919; + --popup-stroke: rgba(255, 255, 255, 0.08); + + --dialog-backdrop: rgba(0, 0, 0, 0.3); + + --sidebar-bg: #131313; + --sidebar-highlight: var(--secondary); + --sidebar-stroke: rgba(255, 255, 255, 0.04); + + --content-border: rgba(255, 255, 255, 0.045); + + --input-border: #383838; + + --toggle-bg: var(--input-border); + --toggle-bg-enabled: #8a8a8a; + + --sidebar-mobile-gradient: linear-gradient( + 90deg, + rgba(19, 19, 19, 0.9) 0%, + rgba(19, 19, 19, 0) 5%, + rgba(19, 19, 19, 0) 50%, + rgba(19, 19, 19, 0) 95%, + rgba(19, 19, 19, 0.9) 100% + ); + + --skeleton-gradient: linear-gradient( + 90deg, + var(--button), + var(--button-hover), + var(--button) + ); + + --skeleton-gradient-elevated: linear-gradient( + 90deg, + var(--button-elevated), + var(--button-elevated-hover), + var(--button-elevated) + ); +} + +/* fall back to less pretty value cuz chrome doesn't support sign() */ +[data-chrome="true"] { + --sidebar-height-mobile: calc(50px + env(safe-area-inset-bottom)); +} + +[data-theme="light"] [data-reduce-transparency="true"] { + --dialog-backdrop: rgba(255, 255, 255, 0.6); +} + +[data-theme="dark"] [data-reduce-transparency="true"] { + --dialog-backdrop: rgba(0, 0, 0, 0.5); +} + +html, +body { + margin: 0; + height: 100vh; + overflow: hidden; + overscroll-behavior-y: none; +} + +* { + font-family: "IBM Plex Mono", monospace; + user-select: none; + scrollbar-width: none; + -webkit-user-select: none; + -webkit-user-drag: none; + -webkit-tap-highlight-color: transparent; +} + +::-webkit-scrollbar { + display: none; +} + +::selection { + color: var(--primary); + background: var(--secondary); +} + +a { + color: inherit; + text-underline-offset: 3px; + -webkit-touch-callout: none; +} + +a:visited { + color: inherit; +} + +svg, +img { + pointer-events: none; +} + +button, .button { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 13px; + gap: 6px; + border: none; + border-radius: var(--border-radius); + font-size: 14.5px; + cursor: pointer; + background-color: var(--button); + color: var(--button-text); + box-shadow: var(--button-box-shadow); +} + +:focus-visible { + outline: none; +} + +button:focus-visible, +a:focus-visible, +select:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + +a:not(.sidebar-tab):not(.subnav-tab):focus-visible { + outline-offset: 3px; + border-radius: 2px; +} + +.button.elevated { + background-color: var(--button-elevated); +} + +.button.active { + color: var(--primary); + background-color: var(--secondary); +} + +/* important is used because active class is toggled by state */ +/* and added to the end of the list, taking priority */ +.button.active:focus-visible, +a.active:focus-visible { + color: var(--white) !important; + background-color: var(--blue) !important; +} + +@media (hover: hover) { + .button:hover { + background-color: var(--button-hover); + } + + .button.elevated:not(.color):hover { + background-color: var(--button-elevated-hover); + } + + .button.active:not(.color):hover { + background-color: var(--button-active-hover); + } +} + +.button:active { + background-color: var(--button-press); +} + +.button.elevated:not(.color):active { + background-color: var(--button-elevated-press); +} + +.button.elevated { + box-shadow: none; +} + +.button.active:not(.color):active { + background-color: var(--button-active-hover); +} + +button[disabled] { + cursor: default; +} + +/* workaround for typing into inputs being ignored on iPadOS 15 */ +input { + user-select: text; + -webkit-user-select: text; +} + +.center-column-container { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + justify-content: center; +} + +button { + font-weight: 500; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 500; + margin-block: 0; +} + +h1 { + font-size: 24px; + letter-spacing: -1px; +} + +h2 { + font-size: 20px; + letter-spacing: -1px; +} + +h3 { + font-size: 16px; +} + +h4 { + font-size: 14.5px; +} + +h5 { + font-size: 12px; +} + +h6 { + font-size: 11px; +} + +.subtext { + font-size: 12.5px; + font-weight: 500; + color: var(--gray); + line-height: 1.4; + padding: 0 var(--padding); + white-space: pre-line; + user-select: text; + -webkit-user-select: text; +} + +.long-text, +.long-text *:not(h1, h2, h3, h4, h5, h6) { + line-height: 1.8; + font-size: 14.5px; + font-family: "IBM Plex Mono", monospace; + user-select: text; + -webkit-user-select: text; +} + +.long-text, +.long-text *:not(h1, h2, h3, h4, h5, h6, strong, em, del) { + font-weight: 400; +} + +.long-text ul { + padding-inline-start: 30px; +} + +.long-text li { + padding-left: 3px; +} + +.long-text:not(.about) h1, +.long-text:not(.about) h2, +.long-text:not(.about) h3 { + user-select: text; + -webkit-user-select: text; + letter-spacing: 0; + margin-block-start: 1rem; +} + +.long-text h3 { + font-size: 17px; +} + +.long-text h2 { + font-size: 19px; +} + +.long-text:not(.about) h3 { + margin-block-end: -0.5rem; +} + +.long-text:not(.about) h2 { + font-size: 19px; + line-height: 1.3; + margin-block-end: -0.3rem; + padding: 6px 0; + border-bottom: 1.5px solid var(--button-elevated-hover); +} + +.long-text img { + border-radius: 6px; +} + +table, +td, +th { + border-spacing: 0; + border-style: solid; + border-width: 1px; + border-collapse: collapse; + text-align: center; + padding: 3px 8px; +} + +code { + background: var(--button-elevated); + padding: 1px 4px; + border-radius: 4px; +} + +tr td:first-child, +tr th:first-child { + text-align: right; +} + +.long-text.about section p:first-of-type { + margin-block-start: 0.3em; +} + +.long-text.about .heading-container { + padding-top: calc(var(--padding) / 2); +} + +.long-text.about section:first-of-type .heading-container { + padding-top: 0; +} + + +@media screen and (max-width: 535px) { + .long-text, + .long-text *:not(h1, h2, h3, h4, h5, h6) { + font-size: 14px; + } +} + +[data-reduce-motion="true"] * { + animation: none !important; + transition: none !important; +} + +@keyframes spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/web/src/app.html b/web/src/app.html index b60acb3c1896a040465e435dbb13758b6f2d2a41..0b902896f64a031e2cba3be1640fe6048a03b56f 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -18,7 +18,7 @@ - + diff --git a/web/src/components/about/AboutSupport.svelte b/web/src/components/about/AboutSupport.svelte index 713c003f20c1f02fed2fc5f5db59112125b72c49..30cd2fa66c9537a40aa6380d4e7d76791a89b224 100644 --- a/web/src/components/about/AboutSupport.svelte +++ b/web/src/components/about/AboutSupport.svelte @@ -38,7 +38,7 @@ diff --git a/web/src/components/buttons/SettingsToggle.svelte b/web/src/components/buttons/SettingsToggle.svelte index 5d5941a273f4a6959099df8636817a605a456567..13a1d0ec3629828c23c8b727bc8f13636e068f5d 100644 --- a/web/src/components/buttons/SettingsToggle.svelte +++ b/web/src/components/buttons/SettingsToggle.svelte @@ -5,6 +5,7 @@ Id extends keyof CobaltSettings[Context] " > + import { hapticSwitch } from "$lib/haptics"; import settings, { updateSetting } from "$lib/state/settings"; import type { CobaltSettings } from "$lib/types/settings"; @@ -31,17 +32,18 @@ aria-hidden={disabled} > + {#if !nolink} + + {/if} diff --git a/web/src/components/misc/UpdateNotification.svelte b/web/src/components/misc/UpdateNotification.svelte index ea839538f8846d5790006117bb4a47dfbfe6a981..77f3a966184409805d20499277d4d006e24a203e 100644 --- a/web/src/components/misc/UpdateNotification.svelte +++ b/web/src/components/misc/UpdateNotification.svelte @@ -1,11 +1,26 @@ + + + +
+ {#each queue as [id, item]} + + {/each} + {#if queue.length === 0} + + {/if} +
+ + + + diff --git a/web/src/components/queue/ProcessingQueueItem.svelte b/web/src/components/queue/ProcessingQueueItem.svelte new file mode 100644 index 0000000000000000000000000000000000000000..c8c2e79c7dde176e3e8fc10d70ad1b84230a09b4 --- /dev/null +++ b/web/src/components/queue/ProcessingQueueItem.svelte @@ -0,0 +1,444 @@ + + + +
+
+
+
+ +
+ + {info.filename} + +
+ + {#if info.state === "running"} +
+ {#each info.pipeline as task} + + {/each} +
+ {/if} + +
+
+ {#if info.state === "done"} + + {/if} + {#if info.state === "error" && !retrying} + + {/if} + {#if info.state === "running" || retrying} +
+ +
+ {/if} +
+ +
+ {statusText} +
+
+
+ +
+ {#if info.state === "done" && info.resultFile} + + {/if} + + {#if !retrying} + {#if info.state === "error" && info?.canRetry} + + {/if} + + {/if} +
+
+ + diff --git a/web/src/components/queue/ProcessingQueueStub.svelte b/web/src/components/queue/ProcessingQueueStub.svelte new file mode 100644 index 0000000000000000000000000000000000000000..82648491af67de37d4b308f4ae6ad0a41610f6ed --- /dev/null +++ b/web/src/components/queue/ProcessingQueueStub.svelte @@ -0,0 +1,38 @@ + + +
+ + + {$t("queue.stub", { + value: $t("queue.stub"), + })} + +
+ + diff --git a/web/src/components/queue/ProcessingStatus.svelte b/web/src/components/queue/ProcessingStatus.svelte new file mode 100644 index 0000000000000000000000000000000000000000..bd0a7223e39a5a0355e4476155e86197cdb74dc4 --- /dev/null +++ b/web/src/components/queue/ProcessingStatus.svelte @@ -0,0 +1,152 @@ + + + + + diff --git a/web/src/components/queue/ProgressBar.svelte b/web/src/components/queue/ProgressBar.svelte new file mode 100644 index 0000000000000000000000000000000000000000..f5db2dc532c36e490e54796023eb5945791d8bc8 --- /dev/null +++ b/web/src/components/queue/ProgressBar.svelte @@ -0,0 +1,54 @@ + + +
+ {#if percentage} +
+ {:else if pipelineResults[workerId]} +
+ {:else} + + {/if} +
+ + diff --git a/web/src/components/save/CaptchaTooltip.svelte b/web/src/components/save/CaptchaTooltip.svelte new file mode 100644 index 0000000000000000000000000000000000000000..af769e6fa8ba19619365dfbec75ce78cf52e846c --- /dev/null +++ b/web/src/components/save/CaptchaTooltip.svelte @@ -0,0 +1,77 @@ + + + + + diff --git a/web/src/components/save/Omnibox.svelte b/web/src/components/save/Omnibox.svelte index c10db5746f99e4a5b6ac4b5a9d1f7ef8c8264396..f45f54a7beaefc8725d54fecd7bf3c5f76f3cfcb 100644 --- a/web/src/components/save/Omnibox.svelte +++ b/web/src/components/save/Omnibox.svelte @@ -1,16 +1,18 @@ - + -{#if env.DEFAULT_API || (!$page.url.host.endsWith(".cobalt.tools") && $page.url.host !== "cobalt.tools")} +{#if env.DEFAULT_API !== officialApiURL}
{$t("save.label.community_instance")}
{/if}
+ {#if $turnstileEnabled} + + {/if} +
+ (isFocused = true)} - on:focus={() => (isFocused = true)} - on:blur={() => (isFocused = false)} + oninput={() => (isFocused = true)} + onfocus={() => (isFocused = true)} + onblur={() => (isFocused = false)} + onmouseover={() => (isHovered = true)} + onmouseleave={() => (isHovered = false)} spellcheck="false" autocomplete="off" autocapitalize="off" @@ -162,17 +189,12 @@ disabled={isDisabled} /> - {#if $link && !isLoading} - ($link = "")} /> - {/if} - {#if validLink($link)} - - {/if} + ($link = "")} /> +
@@ -217,33 +239,54 @@ flex-direction: column; max-width: 640px; width: 100%; - gap: 8px; + gap: 6px; + position: relative; } #input-container { --input-padding: 10px; display: flex; box-shadow: 0 0 0 1.5px var(--input-border) inset; + /* webkit can't render the 1.5px box shadow properly, + so we duplicate the border as outline to fix it visually */ + outline: 1.5px solid var(--input-border); + outline-offset: -1.5px; border-radius: var(--border-radius); - padding: 0 var(--input-padding); align-items: center; gap: var(--input-padding); font-size: 14px; flex: 1; } + #input-container:not(.clear-visible) :global(#clear-button) { + display: none; + } + + #input-container:not(.downloadable) :global(#download-button) { + display: none; + } + + #input-container.clear-visible { + padding-right: var(--input-padding); + } + + :global([dir="rtl"]) #input-container.clear-visible { + padding-right: unset; + padding-left: var(--input-padding); + } + #input-container.downloadable { padding-right: 0; } #input-container.downloadable:dir(rtl) { - padding-right: var(--input-padding); padding-left: 0; } #input-container.focused { - box-shadow: 0 0 0 1.5px var(--secondary) inset; - outline: var(--secondary) 0.5px solid; + box-shadow: none; + outline: var(--secondary) 2px solid; + outline-offset: -1px; } #input-container.focused :global(#input-icons svg) { @@ -259,6 +302,7 @@ width: 100%; margin: 0; padding: var(--input-padding) 0; + padding-left: calc(var(--input-padding) + 28px); height: 18px; align-items: center; @@ -275,10 +319,14 @@ /* workaround for safari */ font-size: inherit; + + /* prevents input from poking outside of rounded corners */ + border-radius: var(--border-radius); } - #link-area:focus-visible { - box-shadow: unset !important; + :global([dir="rtl"]) #link-area { + padding-left: unset; + padding-right: calc(var(--input-padding) + 28px); } #link-area::placeholder { diff --git a/web/src/components/save/OmniboxIcon.svelte b/web/src/components/save/OmniboxIcon.svelte index 49d673c61e95a5d5bef9ebe8bca7185c8614d41f..2b1f05de8cbaa18522c6f25cf180a41ab64dd9e5 100644 --- a/web/src/components/save/OmniboxIcon.svelte +++ b/web/src/components/save/OmniboxIcon.svelte @@ -2,11 +2,41 @@ import IconLink from "@tabler/icons-svelte/IconLink.svelte"; import IconLoader2 from "@tabler/icons-svelte/IconLoader2.svelte"; - export let loading: boolean; + type Props = { + loading: boolean; + }; + + let { loading }: Props = $props(); + + let animated = $state(loading); + + /* + initial spinner state is equal to loading state, + just so it's animated on init (or not). + on transition start, it overrides the value + to start spinning (to prevent zooming in with no spinning). + + then, on transition end, when the spinner is hidden, + and if loading state is false, the class is removed + and the spinner doesn't spin in background while being invisible. + + if loading state is true, then it will just stay spinning + (aka when it's visible and should be spinning). + + the spin on transition start is needed for the whirlpool effect + of the link icon being sucked into the spinner. + + this may be unnecessarily complicated but i think it looks neat. + */
-
+
(animated = true)} + ontransitionend={() => (animated = loading)} + >
diff --git a/web/src/components/save/SupportedServices.svelte b/web/src/components/save/SupportedServices.svelte index 6dcb9244b4584525f6d8b6097bbc4314fc5b83b9..61d721dd81a652bc35654fe87ee015c9cd8e3f56 100644 --- a/web/src/components/save/SupportedServices.svelte +++ b/web/src/components/save/SupportedServices.svelte @@ -1,18 +1,18 @@ @@ -49,7 +37,8 @@
- {#if renderPopover} -
-
- {#if loaded} - {#each services as service} -
{service}
- {/each} - {:else} - {#each { length: 17 } as _} - - {/each} - {/if} -
-
- {$t("save.services.disclaimer")} -
+ +
+ {#if loaded} + {#each services as service} +
{service}
+ {/each} + {:else} + {#each { length: 17 } as _} + + {/each} + {/if} +
+
+ {$t("save.services.disclaimer")}
- {/if} +
diff --git a/web/src/components/settings/ClearStorageButton.svelte b/web/src/components/settings/ClearStorageButton.svelte new file mode 100644 index 0000000000000000000000000000000000000000..2ea31a1fb33095e649cf9184cba09edc1dd228c9 --- /dev/null +++ b/web/src/components/settings/ClearStorageButton.svelte @@ -0,0 +1,45 @@ + + + + + {$t("button.clear_cache")} + diff --git a/web/src/components/settings/DataSettingsButton.svelte b/web/src/components/settings/DataSettingsButton.svelte new file mode 100644 index 0000000000000000000000000000000000000000..653a72a79f7b924c4754b0c6974018d28d054142 --- /dev/null +++ b/web/src/components/settings/DataSettingsButton.svelte @@ -0,0 +1,32 @@ + + + + + diff --git a/web/src/components/settings/FilenamePreview.svelte b/web/src/components/settings/FilenamePreview.svelte index 829e1745d2b3f338b54c8ff239b893361dca3a54..35a7612ffc6415445f3d37948db9350604dd3eb0 100644 --- a/web/src/components/settings/FilenamePreview.svelte +++ b/web/src/components/settings/FilenamePreview.svelte @@ -75,7 +75,7 @@
{`${videoFilePreview}.${youtubeVideoExt}`}
-
video file preview
+
{$t("settings.filename.preview_desc.video")}
@@ -84,7 +84,7 @@
{`${audioFilePreview}.${audioFormat}`}
-
audio file preview
+
{$t("settings.filename.preview_desc.audio")}
@@ -106,12 +106,16 @@ flex-direction: row; align-items: center; justify-content: flex-start; - gap: 8px; - padding: 8px var(--padding); + gap: 9px; + padding: 7px var(--padding); } .filename-preview-item:first-child { - border-bottom: 1.5px var(--button-stroke) solid; + border-bottom: 1px var(--button-stroke) solid; + } + + .filename-preview-item:last-child { + padding-top: 6px; } .item-icon { @@ -144,6 +148,7 @@ .item-text .description { padding: 0; + line-height: 1.3; } @media screen and (max-width: 750px) { diff --git a/web/src/components/settings/ManageSettings.svelte b/web/src/components/settings/ManageSettings.svelte index abc053bff2c6af6b1b90b0bb0c6ed760278f4e49..273f0f326617df44644c60b37748752b0b523f5d 100644 --- a/web/src/components/settings/ManageSettings.svelte +++ b/web/src/components/settings/ManageSettings.svelte @@ -5,7 +5,7 @@ import { validateSettings } from "$lib/settings/validate"; import { storedSettings, updateSetting, loadFromString } from "$lib/state/settings"; - import ActionButton from "$components/buttons/ActionButton.svelte"; + import DataSettingsButton from "$components/settings/DataSettingsButton.svelte"; import ResetSettingsButton from "$components/settings/ResetSettingsButton.svelte"; import IconFileExport from "@tabler/icons-svelte/IconFileExport.svelte"; @@ -95,16 +95,16 @@
- + {$t("button.import")} - + {#if $storedSettings.schemaVersion} - + {$t("button.export")} - + {/if} {#if $storedSettings.schemaVersion} diff --git a/web/src/components/settings/ResetSettingsButton.svelte b/web/src/components/settings/ResetSettingsButton.svelte index acc1465d673f04a319136e447c12dbc9fdfc1604..f05b0684d40c4efb2e7989e9e961da907ef8ae1b 100644 --- a/web/src/components/settings/ResetSettingsButton.svelte +++ b/web/src/components/settings/ResetSettingsButton.svelte @@ -3,15 +3,16 @@ import { createDialog } from "$lib/state/dialogs"; import { resetSettings } from "$lib/state/settings"; - import IconTrash from "@tabler/icons-svelte/IconTrash.svelte"; + import IconRestore from "@tabler/icons-svelte/IconRestore.svelte"; + import DataSettingsButton from "$components/settings/DataSettingsButton.svelte"; const resetDialog = () => { createDialog({ id: "wipe-confirm", type: "small", icon: "warn-red", - title: $t("dialog.reset.title"), - bodyText: $t("dialog.reset.body"), + title: $t("dialog.reset_settings.title"), + bodyText: $t("dialog.reset_settings.body"), buttons: [ { text: $t("button.cancel"), @@ -30,26 +31,7 @@ }; - - - + diff --git a/web/src/components/settings/SettingsCategory.svelte b/web/src/components/settings/SettingsCategory.svelte index 689316d65aae62df806b61a8448921f099a12694..6ea00f37c353287d0965a10b81ba674e535935da 100644 --- a/web/src/components/settings/SettingsCategory.svelte +++ b/web/src/components/settings/SettingsCategory.svelte @@ -1,7 +1,5 @@ @@ -17,8 +17,8 @@ class:active={isActive} role="button" > -