diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 28439369..25d56035 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,8 @@ jobs: node-version: 'lts/*' - uses: pnpm/action-setup@v4 - run: .github/test.sh web + env: + WEB_DEFAULT_API: ${{ vars.WEB_DEFAULT_API }} test-api: name: api sanity check diff --git a/api/README.md b/api/README.md index 70d85de6..36c1dc89 100644 --- a/api/README.md +++ b/api/README.md @@ -71,7 +71,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 c9955a83..6b29be75 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.4", + "version": "11.0", "author": "imput", "exports": "./src/cobalt.js", "type": "module", @@ -34,6 +34,7 @@ "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", diff --git a/api/src/config.js b/api/src/config.js index bb4994c0..a5ebf3d6 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -1,92 +1,32 @@ -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, setupEnvWatcher } from "./core/env.js"; +import * as cluster from "./misc/cluster.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 || 9000, - tunnelPort: process.env.API_PORT || 9000, - - 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, - - 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 env = loadEnvs(); const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`; +export const canonicalEnv = Object.freeze(structuredClone(process.env)); export const setTunnelPort = (port) => env.tunnelPort = port; export const isCluster = env.instanceCount > 1; +export const updateEnv = (newEnv) => { + // tunnelPort is special and needs to get carried over here + newEnv.tunnelPort = env.tunnelPort; -if (env.sessionEnabled && env.jwtSecret.length < 16) { - throw new Error("JWT_SECRET env is too short (must be at least 16 characters long)"); + for (const key in env) { + env[key] = newEnv[key]; + } + + cluster.broadcast({ env_update: newEnv }); } -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'); -} +await validateEnvs(env); -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.envFile) { + setupEnvWatcher(); } export { diff --git a/api/src/core/api.js b/api/src/core/api.js index f1b54422..eb5cf4ff 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -8,16 +8,17 @@ 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); } }); @@ -180,6 +184,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { } req.rateLimitKey = hashHmac(token, 'rate'); + req.isSession = true; } catch { return fail(res, "error.api.generic"); } @@ -244,6 +249,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { if (!parsed) { return fail(res, "error.api.link.invalid"); } + if ("error" in parsed) { let context; if (parsed?.context) { @@ -257,13 +263,23 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { host: parsed.host, patternMatch: parsed.patternMatch, params: normalizedRequest, + isSession: req.isSession ?? false, }); 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); @@ -294,35 +310,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(serverInfo); + res.status(200).send(env.envFile ? getServerInfo() : serverInfo); }) app.get('/favicon.ico', (req, res) => { @@ -342,10 +334,6 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { 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') - } - setGlobalDispatcher(new ProxyAgent(env.externalProxy)) } @@ -384,17 +372,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 00000000..4e41760c --- /dev/null +++ b/api/src/core/env.js @@ -0,0 +1,189 @@ +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 { Yellow } from "../misc/console-text.js"; + +const forceLocalProcessingOptions = ["never", "session", "always"]; + +export const loadEnvs = (env = process.env) => { + const disabledServices = env.DISABLED_SERVICES?.split(',') || []; + const enabledServices = new Set(Object.keys(services).filter(e => { + if (!disabledServices.includes(e)) { + return e; + } + })); + + 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: (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, + + 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, + + 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", + + envFile: env.API_ENV_FILE, + envRemoteReloadInterval: 300, + }; +} + +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.externalProxy && env.freebindCIDR) { + throw new Error('freebind is not available when external proxy is enabled') + } +} + +const reloadEnvs = async (contents) => { + const newEnvs = {}; + + for (let line of (await contents).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 = loadEnvs(candidate); + await validateEnvs(parsed); + updateEnv(parsed); +} + +const wrapReload = (contents) => { + reloadEnvs(contents) + .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) { + updateEnv(message.env_update); + } + }); + } +} diff --git a/api/src/core/itunnel.js b/api/src/core/itunnel.js new file mode 100644 index 00000000..e16c0345 --- /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/file-watcher.js b/api/src/misc/file-watcher.js new file mode 100644 index 00000000..d66a77be --- /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/utils.js b/api/src/misc/utils.js index 62bf6351..1cde3cdc 100644 --- a/api/src/misc/utils.js +++ b/api/src/misc/utils.js @@ -52,3 +52,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/create-filename.js b/api/src/processing/create-filename.js index 911b5603..26a85c1d 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 363cb403..e8933784 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -5,7 +5,22 @@ import { audioIgnore } from "./service-config.js"; import { createStream } from "../stream/manage.js"; import { splitFilenameExtension } from "../misc/utils.js"; -export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) { +const extraProcessingTypes = ["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 = { @@ -22,7 +37,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab 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"; @@ -216,5 +231,14 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab params.type = "proxy"; } - return createResponse(responseType, {...defaultParams, ...params}) + // TODO: add support for HLS + // (very painful) + if (localProcessing && !params.isHLS && extraProcessingTypes.includes(params.type)) { + responseType = "local-processing"; + } + + return createResponse( + responseType, + { ...defaultParams, ...params } + ); } diff --git a/api/src/processing/match.js b/api/src/processing/match.js index f6a0611e..65a021b0 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -32,7 +32,7 @@ import xiaohongshu from "./services/xiaohongshu.js"; let freebind; -export default async function({ host, patternMatch, params }) { +export default async function({ host, patternMatch, params, isSession }) { const { url } = params; assert(url instanceof URL); let dispatcher, requestIP; @@ -70,7 +70,7 @@ export default async function({ host, patternMatch, params }) { r = await twitter({ id: patternMatch.id, index: patternMatch.index - 1, - toGif: !!params.twitterGif, + toGif: !!params.convertGif, alwaysProxy: params.alwaysProxy, dispatcher }); @@ -113,6 +113,10 @@ export default async function({ host, patternMatch, params }) { fetchInfo.format = "vp9"; fetchInfo.isAudioOnly = true; fetchInfo.isAudioMuted = false; + + if (env.ytAllowBetterAudio && params.youtubeBetterAudio) { + fetchInfo.quality = "max"; + } } r = await youtube(fetchInfo); @@ -131,7 +135,7 @@ export default async function({ host, patternMatch, params }) { shortLink: patternMatch.shortLink, fullAudio: params.tiktokFullAudio, isAudioOnly, - h265: params.tiktokH265, + h265: params.allowH265, alwaysProxy: params.alwaysProxy, }); break; @@ -239,7 +243,7 @@ export default async function({ host, patternMatch, params }) { case "xiaohongshu": r = await xiaohongshu({ ...patternMatch, - h265: params.tiktokH265, + h265: params.allowH265, isAudioOnly, dispatcher, }); @@ -267,7 +271,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; @@ -288,6 +292,14 @@ export default async function({ host, patternMatch, params }) { }) } + let localProcessing = params.localProcessing; + + const lpEnv = env.forceLocalProcessing; + + if (lpEnv === "always" || (lpEnv === "session" && isSession)) { + localProcessing = true; + } + return matchAction({ r, host, @@ -296,10 +308,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, + localProcessing, }) } catch { return createResponse("error", { diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 61bf027b..697e67fc 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) => { @@ -49,6 +50,41 @@ 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, + }, + + audio: { + copy: responseData?.audioCopy, + format: responseData?.audioFormat, + bitrate: responseData?.audioBitrate, + }, + + 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, diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index 48d8b058..be895efd 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -20,7 +20,7 @@ export const apiSchema = z.object({ filenameStyle: z.enum( ["classic", "pretty", "basic", "nerdy"] - ).default("classic"), + ).default("basic"), youtubeVideoCodec: z.enum( ["h264", "av1", "vp9"] @@ -36,16 +36,20 @@ export const apiSchema = z.object({ .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), + disableMetadata: z.boolean().default(false), + + allowH265: z.boolean().default(false), + convertGif: z.boolean().default(true), + tiktokFullAudio: z.boolean().default(false), alwaysProxy: z.boolean().default(false), - disableMetadata: z.boolean().default(false), - tiktokFullAudio: z.boolean().default(false), - tiktokH265: z.boolean().default(false), - twitterGif: z.boolean().default(true), + localProcessing: z.boolean().default(false), youtubeHLS: z.boolean().default(false), + youtubeBetterAudio: z.boolean().default(false), + + // temporarily kept for backwards compatibility with cobalt 10 schema + twitterGif: z.boolean().default(false), + tiktokH265: z.boolean().default(false), }) .strict(); diff --git a/api/src/processing/services/bilibili.js b/api/src/processing/services/bilibili.js index 4ee148db..8747a781 100644 --- a/api/src/processing/services/bilibili.js +++ b/api/src/processing/services/bilibili.js @@ -47,7 +47,8 @@ async function com_download(id) { return { urls: [video.baseUrl, audio.baseUrl], audioFilename: `bilibili_${id}_audio`, - filename: `bilibili_${id}_${video.width}x${video.height}.mp4` + filename: `bilibili_${id}_${video.width}x${video.height}.mp4`, + isHLS: true }; } diff --git a/api/src/processing/services/loom.js b/api/src/processing/services/loom.js index 01f71e51..749f6e8f 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,47 @@ 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) return { error: "fetch.empty" }; + if (gql?.url?.includes('.mp4?')) { + return gql.url; + } +} - const videoUrl = 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 (videoUrl?.includes('.mp4?')) { - return { - urls: videoUrl, - filename: `loom_${id}.mp4`, - audioFilename: `loom_${id}_audio` - } + if (gql?.url?.includes('.mp4?')) { + return gql.url; + } +} + +export default async function({ id }) { + let url = await fromTranscodedURL(id); + url ??= await fromRawURL(id); + + if (!url) { + return { error: "fetch.empty" } } - return { error: "fetch.empty" } + return { + urls: url, + filename: `loom_${id}.mp4`, + audioFilename: `loom_${id}_audio` + } } diff --git a/api/src/processing/services/twitter.js b/api/src/processing/services/twitter.js index a4f4505e..faf2406b 100644 --- a/api/src/processing/services/twitter.js +++ b/api/src/processing/services/twitter.js @@ -162,6 +162,19 @@ const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) => repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities; } + if (tweetResult.card?.legacy?.binding_values?.length) { + const card = JSON.parse(tweetResult.card.legacy.binding_values[0].value?.string_value); + + if (!["video_website", "image_website"].includes(card?.type) || + !card?.media_entities || + card?.component_objects?.media_1?.type !== "media") { + return; + } + + const mediaId = card.component_objects?.media_1?.data?.id; + return [card.media_entities[mediaId]]; + } + return (repostedTweet?.media || baseTweet?.extended_entities?.media); } diff --git a/api/src/security/api-keys.js b/api/src/security/api-keys.js index d534999c..37ec66fb 100644 --- a/api/src/security/api-keys.js +++ b/api/src/security/api-keys.js @@ -1,8 +1,8 @@ import { env } from "../config.js"; -import { readFile } from "node:fs/promises"; import { Green, Yellow } from "../misc/console-text.js"; import ip from "ipaddr.js"; import * as cluster from "../misc/cluster.js"; +import { FileWatcher } from "../misc/file-watcher.js"; // this function is a modified variation of code // from https://stackoverflow.com/a/32402438/14855621 @@ -13,7 +13,7 @@ const generateWildcardRegex = rule => { const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -let keys = {}; +let keys = {}, reader = null; const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']); @@ -118,34 +118,39 @@ const formatKeys = (keyData) => { } const updateKeys = (newKeys) => { + validateKeys(newKeys); + + cluster.broadcast({ api_keys: newKeys }); + keys = formatKeys(newKeys); } -const loadKeys = async (source) => { - let updated; - if (source.protocol === 'file:') { - const pathname = source.pathname === '/' ? '' : source.pathname; - updated = JSON.parse( - await readFile( - decodeURIComponent(source.host + pathname), - 'utf8' - ) - ); - } else { - updated = await fetch(source).then(a => a.json()); - } +const loadRemoteKeys = async (source) => { + updateKeys( + await fetch(source).then(a => a.json()) + ); +} - validateKeys(updated); - - cluster.broadcast({ api_keys: updated }); - - updateKeys(updated); +const loadLocalKeys = async () => { + updateKeys( + JSON.parse(await reader.read()) + ); } const wrapLoad = (url, initial = false) => { - loadKeys(url) - .then(() => { + let load = loadRemoteKeys.bind(null, url); + + if (url.protocol === 'file:') { if (initial) { + reader = FileWatcher.fromFileProtocol(url); + reader.on('file-updated', () => wrapLoad(url)); + } + + load = loadLocalKeys; + } + + load().then(() => { + if (initial || reader) { console.log(`${Green('[✓]')} api keys loaded successfully!`) } }) @@ -214,7 +219,7 @@ export const validateAuthorization = (req) => { export const setup = (url) => { if (cluster.isPrimary) { wrapLoad(url, true); - if (env.keyReloadInterval > 0) { + if (env.keyReloadInterval > 0 && url.protocol !== 'file:') { setInterval(() => wrapLoad(url), env.keyReloadInterval * 1000); } } else if (cluster.isWorker) { diff --git a/api/src/stream/internal-hls.js b/api/src/stream/internal-hls.js index 55634c71..b56c93ff 100644 --- a/api/src/stream/internal-hls.js +++ b/api/src/stream/internal-hls.js @@ -1,5 +1,6 @@ import HLS from "hls-parser"; import { createInternalStream } from "./manage.js"; +import { request } from "undici"; function getURL(url) { try { @@ -55,8 +56,11 @@ function transformMediaPlaylist(streamInfo, hlsPlaylist) { const HLS_MIME_TYPES = ["application/vnd.apple.mpegurl", "audio/mpegurl", "application/x-mpegURL"]; -export function isHlsResponse (req) { - return HLS_MIME_TYPES.includes(req.headers['content-type']); +export function isHlsResponse(req, streamInfo) { + return HLS_MIME_TYPES.includes(req.headers['content-type']) + // bluesky's cdn responds with wrong content-type for the hls playlist, + // so we enforce it here until they fix it + || (streamInfo.service === 'bsky' && streamInfo.url.endsWith('.m3u8')); } export async function handleHlsPlaylist(streamInfo, req, res) { @@ -71,3 +75,67 @@ export async function handleHlsPlaylist(streamInfo, req, res) { res.send(hlsPlaylist); } + +async function getSegmentSize(url, config) { + const segmentResponse = await request(url, { + ...config, + throwOnError: true + }); + + if (segmentResponse.headers['content-length']) { + segmentResponse.body.dump(); + return +segmentResponse.headers['content-length']; + } + + // if the response does not have a content-length + // header, we have to compute it ourselves + let size = 0; + + for await (const data of segmentResponse.body) { + size += data.length; + } + + return size; +} + +export async function probeInternalHLSTunnel(streamInfo) { + const { url, headers, dispatcher, signal } = streamInfo; + + // remove all falsy headers + Object.keys(headers).forEach(key => { + if (!headers[key]) delete headers[key]; + }); + + const config = { headers, dispatcher, signal, maxRedirections: 16 }; + + const manifestResponse = await fetch(url, config); + + const manifest = HLS.parse(await manifestResponse.text()); + if (manifest.segments.length === 0) + return -1; + + const segmentSamples = await Promise.all( + Array(5).fill().map(async () => { + const manifestIdx = Math.floor(Math.random() * manifest.segments.length); + const randomSegment = manifest.segments[manifestIdx]; + if (!randomSegment.uri) + throw "segment is missing URI"; + + let segmentUrl; + + if (getURL(randomSegment.uri)) { + segmentUrl = new URL(randomSegment.uri); + } else { + segmentUrl = new URL(randomSegment.uri, streamInfo.url); + } + + const segmentSize = await getSegmentSize(segmentUrl, config) / randomSegment.duration; + return segmentSize; + }) + ); + + const averageBitrate = segmentSamples.reduce((a, b) => a + b) / segmentSamples.length; + const totalDuration = manifest.segments.reduce((acc, segment) => acc + segment.duration, 0); + + return averageBitrate * totalDuration; +} diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index 8c97c656..6d4ce318 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -1,7 +1,7 @@ import { request } from "undici"; import { Readable } from "node:stream"; import { closeRequest, getHeaders, pipe } from "./shared.js"; -import { handleHlsPlaylist, isHlsResponse } from "./internal-hls.js"; +import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./internal-hls.js"; const CHUNK_SIZE = BigInt(8e6); // 8 MB const min = (a, b) => a < b ? a : b; @@ -118,10 +118,7 @@ async function handleGenericStream(streamInfo, res) { res.status(fileResponse.statusCode); fileResponse.body.on('error', () => {}); - // bluesky's cdn responds with wrong content-type for the hls playlist, - // so we enforce it here until they fix it - const isHls = isHlsResponse(fileResponse) - || (streamInfo.service === "bsky" && streamInfo.url.endsWith('.m3u8')); + const isHls = isHlsResponse(fileResponse, streamInfo); for (const [ name, value ] of Object.entries(fileResponse.headers)) { if (!isHls || name.toLowerCase() !== 'content-length') { @@ -155,3 +152,40 @@ export function internalStream(streamInfo, res) { return handleGenericStream(streamInfo, res); } + +export async function probeInternalTunnel(streamInfo) { + try { + const signal = AbortSignal.timeout(3000); + const headers = { + ...Object.fromEntries(streamInfo.headers || []), + ...getHeaders(streamInfo.service), + host: undefined, + range: undefined + }; + + if (streamInfo.isHLS) { + return probeInternalHLSTunnel({ + ...streamInfo, + signal, + headers + }); + } + + const response = await request(streamInfo.url, { + method: 'HEAD', + headers, + dispatcher: streamInfo.dispatcher, + signal, + maxRedirections: 16 + }); + + if (response.statusCode !== 200) + throw "status is not 200 OK"; + + const size = +response.headers['content-length']; + if (isNaN(size)) + throw "content-length is not a number"; + + return size; + } catch {} +} diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index ebb5c6c7..6adcd553 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -70,10 +70,47 @@ export function createStream(obj) { return streamLink.toString(); } -export function getInternalStream(id) { +export function createProxyTunnels(info) { + const proxyTunnels = []; + + let urls = info.url; + + if (typeof urls === "string") { + urls = [urls]; + } + + for (const url of urls) { + proxyTunnels.push( + createStream({ + url, + type: "proxy", + + service: info?.service, + headers: info?.headers, + requestIP: info?.requestIP, + + originalRequest: info?.originalRequest + }) + ); + } + + return proxyTunnels; +} + +export function getInternalTunnel(id) { return internalStreamCache.get(id); } +export function getInternalTunnelFromURL(url) { + url = new URL(url); + if (url.hostname !== '127.0.0.1') { + return; + } + + const id = url.searchParams.get('id'); + return getInternalTunnel(id); +} + export function createInternalStream(url, obj = {}) { assert(typeof url === 'string'); @@ -131,7 +168,7 @@ export function destroyInternalStream(url) { const id = getInternalTunnelId(url); if (internalStreamCache.has(id)) { - closeRequest(getInternalStream(id)?.controller); + closeRequest(getInternalTunnel(id)?.controller); internalStreamCache.delete(id); } } @@ -143,7 +180,7 @@ const transplantInternalTunnels = function(tunnelUrls, transplantUrls) { for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) { const id = getInternalTunnelId(tun); - const itunnel = getInternalStream(id); + const itunnel = getInternalTunnel(id); if (!itunnel) continue; itunnel.url = url; diff --git a/api/src/stream/shared.js b/api/src/stream/shared.js index 65af03f0..ec06339d 100644 --- a/api/src/stream/shared.js +++ b/api/src/stream/shared.js @@ -1,5 +1,7 @@ import { genericUserAgent } from "../config.js"; import { vkClientAgent } from "../processing/services/vk.js"; +import { getInternalTunnelFromURL } from "./manage.js"; +import { probeInternalTunnel } from "./internal.js"; const defaultHeaders = { 'user-agent': genericUserAgent @@ -47,3 +49,40 @@ export function pipe(from, to, done) { from.pipe(to); } + +export async function estimateTunnelLength(streamInfo, multiplier = 1.1) { + let urls = streamInfo.urls; + if (!Array.isArray(urls)) { + urls = [ urls ]; + } + + const internalTunnels = urls.map(getInternalTunnelFromURL); + if (internalTunnels.some(t => !t)) + return -1; + + const sizes = await Promise.all(internalTunnels.map(probeInternalTunnel)); + const estimatedSize = sizes.reduce( + // if one of the sizes is missing, let's just make a very + // bold guess that it's the same size as the existing one + (acc, cur) => cur <= 0 ? acc * 2 : acc + cur, + 0 + ); + + if (isNaN(estimatedSize) || estimatedSize <= 0) { + return -1; + } + + return Math.floor(estimatedSize * multiplier); +} + +export function estimateAudioMultiplier(streamInfo) { + if (streamInfo.audioFormat === 'wav') { + return 1411 / 128; + } + + if (streamInfo.audioCopy) { + return 1; + } + + return streamInfo.audioBitrate / 128; +} diff --git a/api/src/stream/stream.js b/api/src/stream/stream.js index c7cf7b56..e714f38e 100644 --- a/api/src/stream/stream.js +++ b/api/src/stream/stream.js @@ -10,20 +10,20 @@ export default async function(res, streamInfo) { return await stream.proxy(streamInfo, res); case "internal": - return internalStream(streamInfo.data, res); + return await internalStream(streamInfo.data, res); case "merge": - return stream.merge(streamInfo, res); + return await stream.merge(streamInfo, res); case "remux": case "mute": - return stream.remux(streamInfo, res); + return await stream.remux(streamInfo, res); case "audio": - return stream.convertAudio(streamInfo, res); + return await stream.convertAudio(streamInfo, res); case "gif": - return stream.convertGif(streamInfo, res); + return await stream.convertGif(streamInfo, res); } closeResponse(res); diff --git a/api/src/stream/types.js b/api/src/stream/types.js index 0a4e2d47..1e270df8 100644 --- a/api/src/stream/types.js +++ b/api/src/stream/types.js @@ -6,7 +6,7 @@ import { create as contentDisposition } from "content-disposition-header"; import { env } from "../config.js"; import { destroyInternalStream } from "./manage.js"; import { hlsExceptions } from "../processing/service-config.js"; -import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js"; +import { getHeaders, closeRequest, closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js"; const ffmpegArgs = { webm: ["-c:v", "copy", "-c:a", "copy"], @@ -29,7 +29,7 @@ const convertMetadataToFFmpeg = (metadata) => { for (const [ name, value ] of Object.entries(metadata)) { if (metadataTags.includes(name)) { - args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); + args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); // skipcq: JS-0004 } else { throw `${name} metadata tag is not supported.`; } @@ -98,7 +98,7 @@ const proxy = async (streamInfo, res) => { } } -const merge = (streamInfo, res) => { +const merge = async (streamInfo, res) => { let process; const shutdown = () => ( killProcess(process), @@ -112,7 +112,7 @@ const merge = (streamInfo, res) => { try { if (streamInfo.urls.length !== 2) return shutdown(); - const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]; + const format = streamInfo.filename.split('.').pop(); let args = [ '-loglevel', '-8', @@ -152,6 +152,7 @@ const merge = (streamInfo, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); + res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo)); pipe(muxOutput, res, shutdown); @@ -162,7 +163,7 @@ const merge = (streamInfo, res) => { } } -const remux = (streamInfo, res) => { +const remux = async (streamInfo, res) => { let process; const shutdown = () => ( killProcess(process), @@ -196,7 +197,7 @@ const remux = (streamInfo, res) => { args.push('-bsf:a', 'aac_adtstoasc'); } - let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]; + let format = streamInfo.filename.split('.').pop(); if (format === "mp4") { args.push('-movflags', 'faststart+frag_keyframe+empty_moov') } @@ -215,6 +216,7 @@ const remux = (streamInfo, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); + res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo)); pipe(muxOutput, res, shutdown); @@ -225,7 +227,7 @@ const remux = (streamInfo, res) => { } } -const convertAudio = (streamInfo, res) => { +const convertAudio = async (streamInfo, res) => { let process; const shutdown = () => ( killProcess(process), @@ -284,6 +286,13 @@ const convertAudio = (streamInfo, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); + res.setHeader( + 'Estimated-Content-Length', + await estimateTunnelLength( + streamInfo, + estimateAudioMultiplier(streamInfo) * 1.1 + ) + ); pipe(muxOutput, res, shutdown); res.on('finish', shutdown); @@ -292,7 +301,7 @@ const convertAudio = (streamInfo, res) => { } } -const convertGif = (streamInfo, res) => { +const convertGif = async (streamInfo, res) => { let process; const shutdown = () => (killProcess(process), closeResponse(res)); @@ -321,6 +330,7 @@ const convertGif = (streamInfo, res) => { res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); + res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo, 60)); pipe(muxOutput, res, shutdown); diff --git a/api/src/util/tests/loom.json b/api/src/util/tests/loom.json index cc4273d3..0ebb4580 100644 --- a/api/src/util/tests/loom.json +++ b/api/src/util/tests/loom.json @@ -29,5 +29,32 @@ "code": 400, "status": "error" } + }, + { + "name": "video with no transcodedUrl", + "url": "https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "video with title in url", + "url": "https://www.loom.com/share/Meet-AI-workflows-aa3d8b08bee74d05af5b42989e9f33e9", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, + { + "name": "video with title in url (2)", + "url": "https://www.loom.com/share/Unlocking-Incredible-Organizational-Velocity-with-Async-Video-4a2a8baf124c4390954dcbb46a58cfd7", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } } -] \ No newline at end of file +] diff --git a/api/src/util/tests/twitter.json b/api/src/util/tests/twitter.json index 4139e39d..b776a7b2 100644 --- a/api/src/util/tests/twitter.json +++ b/api/src/util/tests/twitter.json @@ -217,5 +217,14 @@ "code": 200, "status": "tunnel" } + }, + { + "name": "video in an ad card", + "url": "https://x.com/igorbrigadir/status/1611399816487084033?s=46", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } } ] diff --git a/docs/api-env-variables.md b/docs/api-env-variables.md index 34de4b0a..92e7a6cf 100644 --- a/docs/api-env-variables.md +++ b/docs/api-env-variables.md @@ -3,15 +3,17 @@ you can customize your processing instance's behavior using these environment va this document is not final and will expand over time. feel free to improve it! ### general vars -| name | default | value example | -|:--------------------|:----------|:--------------------------------------| -| API_URL | | `https://api.url.example/` | -| API_PORT | `9000` | `1337` | -| COOKIE_PATH | | `/cookies.json` | -| PROCESSING_PRIORITY | | `10` | -| API_INSTANCE_COUNT | | `6` | -| API_REDIS_URL | | `redis://localhost:6379` | -| DISABLED_SERVICES | | `bilibili,youtube` | +| name | default | value example | +|:-----------------------|:--------|:--------------------------------------| +| API_URL | | `https://api.url.example/` | +| API_PORT | `9000` | `1337` | +| COOKIE_PATH | | `/cookies.json` | +| PROCESSING_PRIORITY | | `10` | +| API_INSTANCE_COUNT | | `6` | +| API_REDIS_URL | | `redis://localhost:6379` | +| DISABLED_SERVICES | | `bilibili,youtube` | +| FORCE_LOCAL_PROCESSING | `never` | `always` | +| API_ENV_FILE | | `/.env` | [*view details*](#general) @@ -33,6 +35,8 @@ this document is not final and will expand over time. feel free to improve it! | RATELIMIT_MAX | `20` | `30` | | SESSION_RATELIMIT_WINDOW | `60` | `60` | | SESSION_RATELIMIT | `10` | `10` | +| TUNNEL_RATELIMIT_WINDOW | `60` | `60` | +| TUNNEL_RATELIMIT | `40` | `10` | [*view details*](#limits) @@ -56,6 +60,7 @@ this document is not final and will expand over time. feel free to improve it! | CUSTOM_INNERTUBE_CLIENT | `IOS` | | YOUTUBE_SESSION_SERVER | `http://localhost:8080/` | | YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED` | +| YOUTUBE_ALLOW_BETTER_AUDIO | `1` | [*view details*](#service-specific) @@ -100,6 +105,16 @@ comma-separated list which disables certain services from being used. the value is a string of cobalt-supported services. +### FORCE_LOCAL_PROCESSING +the value is a string: `never` (default), `session`, or `always`. + +when set to `session`, only requests from session (Bearer token) clients will be forced to use on-device processing. + +when set to `always`, all requests will be forced to use on-device processing, no matter the preference. + +### API_ENV_FILE +the URL or local path to a `key=value`-style environment variable file. this is used for dynamically reloading environment variables. **not all environment variables are able to be updated by this.** (e.g. the ratelimiters are instantiated when starting cobalt, and cannot be changed) + ## networking [*jump to the table*](#networking-vars) @@ -161,6 +176,16 @@ amount of session requests to be allowed within the time window of `SESSION_RATE the value is a number. +### TUNNEL_RATELIMIT_WINDOW +rate limit time window for tunnel (proxy/stream) requests, in **seconds**. + +the value is a number. + +### TUNNEL_RATELIMIT +amount of tunnel requests to be allowed within the time window of `TUNNEL_RATELIMIT_WINDOW`. + +the value is a number. + ## security [*jump to the table*](#security-vars) @@ -170,7 +195,7 @@ the value is a number. ### CORS_WILDCARD defines whether cross-origin resource sharing is enabled. when enabled, your instance will be accessible from foreign web pages. -the value is a number. 0: disabled. 1: enabled. +the value is a number, either `0` or `1`. ### CORS_URL configures the [cross-origin resource sharing origin](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin). your instance will be available only from this URL if `CORS_WILDCARD` is set to `0`. @@ -207,7 +232,7 @@ the value is a URL. ### API_AUTH_REQUIRED when set to `1`, the user always needs to be authenticated in some way before they can access the API (either via an api key or via turnstile, if enabled). -the value is a number. +the value is a number, either `0` or `1`. ## service-specific [*jump to the table*](#service-specific-vars) @@ -226,3 +251,8 @@ the value is a URL. innertube client that's compatible with botguard's (web) `poToken` and `visitor_data`. the value is a string. + +### YOUTUBE_ALLOW_BETTER_AUDIO +when set to `1`, cobalt will try to use higher quality audio if user requests it via `youtubeBetterAudio`. will negatively impact the rate limit of a secondary youtube client with a session. + +the value is a number, either `0` or `1`. diff --git a/docs/api.md b/docs/api.md index fb1a1450..4949b3e0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,8 +1,15 @@ # cobalt api documentation -this document provides info about methods and acceptable variables for all cobalt api requests. +methods, acceptable values, headers, responses and everything else related to making and parsing requests from a cobalt api instance. > [!IMPORTANT] -> hosted api instances (such as `api.cobalt.tools`) use bot protection and are **not** intended to be used in other projects without explicit permission. if you want to access the cobalt api reliably, you should [host your own instance](/docs/run-an-instance.md) or ask an instance owner for access. +> hosted api instances (such as `api.cobalt.tools`) use bot protection and are **not** intended to be used in other projects without explicit permission. if you want to use the cobalt api, you should [host your own instance](/docs/run-an-instance.md) or ask an instance owner for access. + +- [POST /](#post) +- [POST /session](#post-session) +- [GET /](#get) +- [GET /tunnel](#get-tunnel) + +all endpoints (except for `GET /`) are rate limited and return current rate limiting status in `RateLimit-*` headers, according to the ["RateLimit Header Fields for HTTP" spec](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#name-header-specifications). ## authentication an api instance may be configured to require you to authenticate yourself. @@ -40,14 +47,11 @@ challenge, if the instance has turnstile configured. the resulting token is pass Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` -## POST: `/` +## POST `/` cobalt's main processing endpoint. -request body type: `application/json` -response body type: `application/json` - > [!IMPORTANT] -> you must include `Accept` and `Content-Type` headers with every `POST /` request. +> you must include correct `Accept` and `Content-Type` headers with every `POST /` request. ``` Accept: application/json @@ -55,103 +59,132 @@ Content-Type: application/json ``` ### request body -| key | type | expected value(s) | default | description | -|:-----------------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------| -| `url` | `string` | URL to download | -- | **must** be included in every request. | -| `videoQuality` | `string` | `144 / ... / 2160 / 4320 / max` | `1080` | `720` quality is recommended for phones. | -| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | | -| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` | `128` | specifies the bitrate to use for the audio. applies only to audio conversion. | -| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. | -| `downloadMode` | `string` | `auto / audio / mute` | `auto` | `audio` downloads only the audio, `mute` skips the audio track in videos. | -| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | `h264` is recommended for phones. | -| `youtubeDubLang` | `string` | `en / ru / cs / ja / es-US / ...` | -- | specifies the language of audio to download when a youtube video is dubbed. | -| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. | -| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. | -| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. | -| `tiktokH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. | -| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif | -| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. | +body type: `application/json` + +not a fan of reading tables of text? +you can read [the api schema](/api/src/processing/schema.js) directly from code instead! + +### api schema +all keys except for `url` are optional. value options are separated by `/`. + +#### general +| key | type | description/value | default | +|:-----------------------|:----------|:----------------------------------------------------------------|:-----------| +| `url` | `string` | source URL | *required* | +| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` (kbps) | `128` | +| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | +| `downloadMode` | `string` | `auto / audio / mute` | `auto` | +| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `basic` | +| `videoQuality` | `string` | `max / 4320 / 2160 / 1440 / 1080 / 720 / 480 / 360 / 240 / 144` | `1080` | +| `disableMetadata` | `boolean` | title, artist, and other info will not be added to the file | `false` | +| `alwaysProxy` | `boolean` | always tunnel all files, even when not necessary | `false` | +| `localProcessing` | `boolean` | remux/transcode files locally instead of the server | `false` | + +#### service-specific options +| key | type | description/value | default | +|:-----------------------|:----------|:--------------------------------------------------|:--------| +| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | +| `youtubeDubLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* | +| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` | +| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` | +| `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` | +| `youtubeBetterAudio` | `boolean` | prefer higher quality youtube audio if possible | `false` | +| `youtubeHLS` | `boolean` | use HLS formats when downloading from youtube | `false` | ### response -the response will always be a JSON object containing the `status` key, which will be one of: -- `error` - something went wrong -- `picker` - we have multiple items to choose from -- `redirect` - you are being redirected to the direct service URL -- `tunnel` - cobalt is proxying the download for you +body type: `application/json` + +the response will always be a JSON object containing the `status` key, which is one of: +- `tunnel`: cobalt is proxying and/or remuxing/transcoding the file for you. +- `local-processing`: cobalt is proxying the files for you, but you have to remux/transcode them locally. +- `redirect`: cobalt will redirect you to the direct service URL. +- `picker`: there are multiple items to choose from, a picker should be shown. +- `error`: something went wrong, here's an error code. ### tunnel/redirect response -| key | type | values | -|:-------------|:---------|:------------------------------------------------------------| -| `status` | `string` | `tunnel / redirect` | -| `url` | `string` | url for the cobalt tunnel, or redirect to an external link | -| `filename` | `string` | cobalt-generated filename for the file being downloaded | +| key | type | value | +|:-------------|:---------|:-----------------------------------------------------------| +| `status` | `string` | `tunnel / redirect` | +| `url` | `string` | url for the cobalt tunnel, or redirect to an external link | +| `filename` | `string` | cobalt-generated filename for the file being downloaded | + +### local processing response +| key | type | value | +|:-------------|:-----------|:--------------------------------------------------------------| +| `status` | `string` | `local-processing` | +| `type` | `string` | `merge`, `mute`, `audio`, `gif`, or `remux` | +| `service` | `string` | origin service (`youtube`, `twitter`, `instagram`, etc) | +| `tunnel` | `string[]` | array of tunnel URLs | +| `output` | `object` | details about the output file ([see below](#output-object)) | +| `audio` | `object` | audio-specific details (optional, [see below](#audio-object)) | +| `isHLS` | `boolean` | whether the output is in HLS format (optional) | + +#### output object +| key | type | value | +|:-----------|:---------|:----------------------------------------------------------------------------------| +| `type` | `string` | mime type of the output file | +| `filename` | `string` | filename of the output file | +| `metadata` | `object` | metadata associated with the file (optional, [see below](#outputmetadata-object)) | + +#### output.metadata object +all keys in this table are optional. + +| key | type | description | +|:------------|:---------|:-------------------------------------------| +| `album` | `string` | album name or collection title | +| `copyright` | `string` | copyright information or ownership details | +| `title` | `string` | title of the track or media file | +| `artist` | `string` | artist or creator name | +| `track` | `string` | track number or position in album | +| `date` | `string` | release date or creation date | + +#### audio object +| key | type | value | +|:----------|:----------|:-------------------------------------------| +| `copy` | `boolean` | defines whether audio codec data is copied | +| `format` | `string` | output audio format | +| `bitrate` | `string` | preferred bitrate of audio format | ### picker response -| key | type | values | -|:----------------|:---------|:-------------------------------------------------------------------------------------------------| -| `status` | `string` | `picker` | -| `audio` | `string` | **optional** returned when an image slideshow (such as on tiktok) has a general background audio | -| `audioFilename` | `string` | **optional** cobalt-generated filename, returned if `audio` exists | -| `picker` | `array` | array of objects containing the individual media | +| key | type | value | +|:----------------|:---------|:-----------------------------------------------------------------------------------------------| +| `status` | `string` | `picker` | +| `audio` | `string` | returned when an image slideshow (such as on tiktok) has a general background audio (optional) | +| `audioFilename` | `string` | cobalt-generated filename, returned if `audio` exists (optional) | +| `picker` | `array` | array of objects containing the individual media | #### picker object -| key | type | values | -|:-------------|:----------|:------------------------------------------------------------| -| `type` | `string` | `photo` / `video` / `gif` | -| `url` | `string` | | -| `thumb` | `string` | **optional** thumbnail url | +| key | type | value | +|:-------------|:----------|:--------------------------| +| `type` | `string` | `photo` / `video` / `gif` | +| `url` | `string` | | +| `thumb` | `string` | thumbnail url (optional) | ### error response -| key | type | values | -|:-------------|:---------|:------------------------------------------------------------| -| `status` | `string` | `error` | -| `error` | `object` | contains more context about the error | +| key | type | value | +|:-------------|:---------|:------------------------------| +| `status` | `string` | `error` | +| `error` | `object` | error code & optional context | #### error object -| key | type | values | -|:-------------|:---------|:------------------------------------------------------------| -| `code` | `string` | machine-readable error code explaining the failure reason | -| `context` | `object` | **optional** container for providing more context | +| key | type | value | +|:-------------|:---------|:----------------------------------------------------------| +| `code` | `string` | machine-readable error code explaining the failure reason | +| `context` | `object` | additional error context (optional) | #### error.context object -| key | type | values | -|:-------------|:---------|:---------------------------------------------------------------------------------------------------------------| -| `service` | `string` | **optional**, stating which service was being downloaded from | -| `limit` | `number` | **optional** number providing the ratelimit maximum number of requests, or maximum downloadable video duration | - -## GET: `/` -returns current basic server info. -response body type: `application/json` - -### response body -| key | type | variables | -|:------------|:---------|:---------------------------------------------------------| -| `cobalt` | `object` | information about the cobalt instance | -| `git` | `object` | information about the codebase that is currently running | - -#### cobalt object -| key | type | description | -|:----------------|:-----------|:-----------------------------------------------| -| `version` | `string` | current version | -| `url` | `string` | server url | -| `startTime` | `string` | server start time in unix milliseconds | -| `durationLimit` | `number` | maximum downloadable video length in seconds | -| `services` | `string[]` | array of services which this instance supports | - -#### git object -| key | type | variables | -|:------------|:---------|:------------------| -| `commit` | `string` | commit hash | -| `branch` | `string` | git branch | -| `remote` | `string` | git remote | - -## POST: `/session` +| key | type | value | +|:-------------|:---------|:----------------------------------------------------------------------------| +| `service` | `string` | origin service (optional) | +| `limit` | `number` | the maximum downloadable video duration or the rate limit window (optional) | +## POST `/session` used for generating JWT tokens, if enabled. currently, cobalt only supports generating tokens when a [turnstile](run-an-instance.md#list-of-all-environment-variables) challenge solution is submitted by the client. the turnstile challenge response is submitted via the `cf-turnstile-response` header. + ### response body | key | type | description | |:----------------|:-----------|:-------------------------------------------------------| @@ -159,3 +192,48 @@ the turnstile challenge response is submitted via the `cf-turnstile-response` he | `exp` | `number` | number in seconds indicating the token lifetime | on failure, an [error response](#error-response) is returned. + +## GET `/` +provides basic instance info. + +### response +body type: `application/json` + +| key | type | description | +|:------------|:---------|:---------------------------------------------------------| +| `cobalt` | `object` | information about the cobalt instance | +| `git` | `object` | information about the codebase that is currently running | + +#### cobalt object +| key | type | description | +|:-------------------|:-----------|:-----------------------------------------------| +| `version` | `string` | cobalt version | +| `url` | `string` | instance url | +| `startTime` | `string` | instance start time in unix milliseconds | +| `turnstileSitekey` | `string` | site key for a turnstile widget (optional) | +| `services` | `string[]` | array of services which this instance supports | + +#### git object +| key | type | description | +|:------------|:---------|:------------| +| `commit` | `string` | commit hash | +| `branch` | `string` | git branch | +| `remote` | `string` | git remote | + +## GET `/tunnel` +endpoint for file tunnels (proxy/remux/transcode). the response is a file stream. all errors are reported via +[HTTP status codes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status). + +### returned headers +- `Content-Length`: file size, in bytes. returned when exact final file size is known. +- `Estimated-Content-Length`: estimated file size, in bytes. returned when real `Content-Length` is not known. +a rough estimate which should NOT be used for strict size verification. +can be used to show approximate download progress in UI. + +### possible HTTP status codes +- 200: OK +- 401: Unauthorized +- 403: Bad Request +- 404: Not Found +- 429: Too Many Requests (rate limit exceeded, check [RateLimit-* headers](https://www.ietf.org/archive/id/draft-polli-ratelimit-headers-02.html#name-header-specifications)) +- 500: Internal Server Error. diff --git a/docs/examples/docker-compose.example.yml b/docs/examples/docker-compose.example.yml index b2ad73c1..a2bc87fb 100644 --- a/docs/examples/docker-compose.example.yml +++ b/docs/examples/docker-compose.example.yml @@ -1,11 +1,11 @@ services: - cobalt-api: - image: ghcr.io/imputnet/cobalt:10 + cobalt: + image: ghcr.io/imputnet/cobalt:11 init: true read_only: true restart: unless-stopped - container_name: cobalt-api + container_name: cobalt ports: - 9000:9000/tcp diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c268afe4..32364730 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: ipaddr.js: specifier: 2.2.0 version: 2.2.0 + mime: + specifier: ^4.0.4 + version: 4.0.4 nanoid: specifier: ^5.0.9 version: 5.0.9 @@ -91,33 +94,33 @@ importers: '@eslint/js': specifier: ^9.5.0 version: 9.8.0 - '@fontsource-variable/noto-sans-mono': - specifier: ^5.0.20 - version: 5.0.20 '@fontsource/ibm-plex-mono': specifier: ^5.0.13 version: 5.0.13 '@fontsource/redaction-10': specifier: ^5.0.2 version: 5.0.2 + '@imput/libav.js-encode-cli': + specifier: 6.7.7 + version: 6.7.7 '@imput/libav.js-remux-cli': - specifier: ^5.5.6 - version: 5.5.6 + specifier: ^6.5.7 + version: 6.5.7 '@imput/version-info': specifier: workspace:^ version: link:../packages/version-info '@sveltejs/adapter-static': specifier: ^3.0.6 - version: 3.0.6(@sveltejs/kit@2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))) + version: 3.0.6(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14))) '@sveltejs/kit': - specifier: ^2.9.1 - version: 2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)) + specifier: ^2.20.7 + version: 2.20.7(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)) '@sveltejs/vite-plugin-svelte': - specifier: ^3.0.0 - version: 3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)) + specifier: ^4.0.0 + version: 4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)) '@tabler/icons-svelte': specifier: 3.6.0 - version: 3.6.0(svelte@4.2.19) + version: 3.6.0(svelte@5.28.2) '@types/eslint__js': specifier: ^8.42.3 version: 8.42.3 @@ -144,25 +147,25 @@ importers: version: 11.0.0 mdsvex: specifier: ^0.11.2 - version: 0.11.2(svelte@4.2.19) + version: 0.11.2(svelte@5.28.2) mime: specifier: ^4.0.4 version: 4.0.4 svelte: - specifier: ^4.2.19 - version: 4.2.19 + specifier: ^5.0.0 + version: 5.28.2 svelte-check: - specifier: ^3.6.0 - version: 3.8.5(postcss@8.4.47)(svelte@4.2.19) + specifier: ^4.0.0 + version: 4.1.6(picomatch@4.0.2)(svelte@5.28.2)(typescript@5.5.4) svelte-preprocess: specifier: ^6.0.2 - version: 6.0.2(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4) + version: 6.0.2(postcss-load-config@6.0.1(postcss@8.4.47))(postcss@8.4.47)(svelte@5.28.2)(typescript@5.5.4) svelte-sitemap: specifier: 2.6.0 version: 2.6.0 sveltekit-i18n: specifier: ^2.4.2 - version: 2.4.2(svelte@4.2.19) + version: 2.4.2(svelte@5.28.2) ts-deepmerge: specifier: ^7.0.1 version: 7.0.1 @@ -173,13 +176,13 @@ importers: specifier: ^1.2.2 version: 1.2.2 typescript: - specifier: ^5.4.5 + specifier: ^5.5.0 version: 5.5.4 typescript-eslint: specifier: ^8.18.0 version: 8.18.0(eslint@9.16.0)(typescript@5.5.4) vite: - specifier: ^5.3.6 + specifier: ^5.4.4 version: 5.4.8(@types/node@20.14.14) packages: @@ -525,9 +528,6 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} - '@fontsource-variable/noto-sans-mono@5.0.20': - resolution: {integrity: sha512-Mik/wbKjiir7t+KBaDZnPZ5GjDnPOXpMF7obmFeyRa528ZsrKcFiSn4ZvArrn3sJMCp/k23wakOcXOWlGNc9cw==} - '@fontsource/ibm-plex-mono@5.0.13': resolution: {integrity: sha512-gtlMmvk//2AgDEZDFsoL5z9mgW3ZZg/9SC7pIfDwNKp5DtZpApgqd1Fua3HhPwYRIHrT76IQ1tMTzQKLEGtJGQ==} @@ -554,8 +554,11 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} - '@imput/libav.js-remux-cli@5.5.6': - resolution: {integrity: sha512-XdAab90EZKf6ULtD/x9Y2bnlmNJodXSO6w8aWrn97+N2IRuOS8zv3tAFPRC69SWKa8Utjeu5YTYuTolnX3QprQ==} + '@imput/libav.js-encode-cli@6.7.7': + resolution: {integrity: sha512-sy0g+IvVHo6pdbfdpAEN8i+LLw2fz5EE+PeX5FZiAOxrA5svmALZtaWtDavTbQ69Yl9vTQB2jZCR2x/NyZndmQ==} + + '@imput/libav.js-remux-cli@6.5.7': + resolution: {integrity: sha512-41ld+R5aEwllKdbiszOoCf+K614/8bnuheTZnqjgZESwDtX07xu9O8GO0m/Cpsm7O2CyqPZa1qzDx6BZVO15DQ==} '@imput/psl@2.0.4': resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==} @@ -726,13 +729,18 @@ packages: cpu: [x64] os: [win32] + '@sveltejs/acorn-typescript@1.0.5': + resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==} + peerDependencies: + acorn: ^8.9.0 + '@sveltejs/adapter-static@3.0.6': resolution: {integrity: sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==} peerDependencies: '@sveltejs/kit': ^2.0.0 - '@sveltejs/kit@2.9.1': - resolution: {integrity: sha512-D+yH3DTvvkjXdl3Xv7akKmolrArDZRtsFv3nlxJPjlIKsZEpkkInnomKJuAql2TrNGJ2dJMGBO1YYgVn2ILmag==} + '@sveltejs/kit@2.20.7': + resolution: {integrity: sha512-dVbLMubpJJSLI4OYB+yWYNHGAhgc2bVevWuBjDj8jFUXIJOAnLwYP3vsmtcgoxNGUXoq0rHS5f7MFCsryb6nzg==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -740,19 +748,19 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 vite: ^5.0.3 || ^6.0.0 - '@sveltejs/vite-plugin-svelte-inspector@2.1.0': - resolution: {integrity: sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==} - engines: {node: ^18.0.0 || >=20} + '@sveltejs/vite-plugin-svelte-inspector@3.0.1': + resolution: {integrity: sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} peerDependencies: - '@sveltejs/vite-plugin-svelte': ^3.0.0 - svelte: ^4.0.0 || ^5.0.0-next.0 + '@sveltejs/vite-plugin-svelte': ^4.0.0-next.0||^4.0.0 + svelte: ^5.0.0-next.96 || ^5.0.0 vite: ^5.0.0 - '@sveltejs/vite-plugin-svelte@3.1.1': - resolution: {integrity: sha512-rimpFEAboBBHIlzISibg94iP09k/KYdHgVhJlcsTfn7KMBhc70jFX/GRWkRdFCc2fdnk+4+Bdfej23cMDnJS6A==} - engines: {node: ^18.0.0 || >=20} + '@sveltejs/vite-plugin-svelte@4.0.4': + resolution: {integrity: sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 + svelte: ^5.0.0-next.96 || ^5.0.0 vite: ^5.0.0 '@sveltekit-i18n/base@1.3.7': @@ -798,9 +806,6 @@ packages: '@types/node@20.14.14': resolution: {integrity: sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==} - '@types/pug@2.0.10': - resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} - '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -866,11 +871,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.12.1: - resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.14.0: resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} engines: {node: '>=0.4.0'} @@ -912,8 +912,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -943,10 +944,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - buffer-crc32@1.0.0: - resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} - engines: {node: '>=8.0.0'} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -983,13 +980,18 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} - code-red@1.0.4: - resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1049,10 +1051,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-tree@2.3.1: - resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1070,6 +1068,15 @@ packages: supports-color: optional: true + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1085,18 +1092,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-indent@6.1.0: - resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} - engines: {node: '>=8'} - devalue@5.1.1: resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} @@ -1136,9 +1135,6 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es6-promise@3.3.1: - resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} - esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1181,6 +1177,9 @@ packages: esm-env@1.2.1: resolution: {integrity: sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + espree@10.3.0: resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1194,6 +1193,9 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} + esrap@1.4.6: + resolution: {integrity: sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -1202,9 +1204,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1293,9 +1292,6 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1333,26 +1329,13 @@ packages: engines: {node: 20 || >=22} hasBin: true - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globalyzer@0.1.0: - resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} - - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1412,10 +1395,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1451,8 +1430,8 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-reference@3.0.2: - resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} @@ -1537,8 +1516,8 @@ packages: magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} - mdn-data@2.0.30: - resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} mdsvex@0.11.2: resolution: {integrity: sha512-Y4ab+vLvTJS88196Scb/RFNaHMHVSWw6CwfsgWIQP8f42D57iDII0/qABSu530V4pkv8s6T2nx3ds0MC1VwFLA==} @@ -1589,10 +1568,6 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - minimatch@10.0.1: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} @@ -1611,10 +1586,6 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1672,9 +1643,6 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -1709,10 +1677,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -1728,12 +1692,6 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - periscopic@3.1.0: - resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - - picocolors@1.0.1: - resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} - picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} @@ -1828,6 +1786,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + redis@4.7.0: resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} @@ -1843,11 +1805,6 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rimraf@2.7.1: - resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - rollup@4.24.0: resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1866,9 +1823,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sander@0.5.1: - resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} - semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -1915,14 +1869,6 @@ packages: resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} engines: {node: '>=18'} - sorcery@0.11.1: - resolution: {integrity: sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==} - hasBin: true - - source-map-js@1.2.0: - resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1961,10 +1907,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1978,54 +1920,13 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - svelte-check@3.8.5: - resolution: {integrity: sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==} + svelte-check@4.1.6: + resolution: {integrity: sha512-P7w/6tdSfk3zEVvfsgrp3h3DFC75jCdZjTQvgGJtjPORs1n7/v2VMPIoty3PWv7jnfEm3x0G/p9wH4pecTb0Wg==} + engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: - svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 - - svelte-hmr@0.16.0: - resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==} - engines: {node: ^12.20 || ^14.13.1 || >= 16} - peerDependencies: - svelte: ^3.19.0 || ^4.0.0 - - svelte-preprocess@5.1.4: - resolution: {integrity: sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==} - engines: {node: '>= 16.0.0'} - peerDependencies: - '@babel/core': ^7.10.2 - coffeescript: ^2.5.1 - less: ^3.11.3 || ^4.0.0 - postcss: ^7 || ^8 - postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 - pug: ^3.0.0 - sass: ^1.26.8 - stylus: ^0.55.0 - sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 - svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 - typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' - peerDependenciesMeta: - '@babel/core': - optional: true - coffeescript: - optional: true - less: - optional: true - postcss: - optional: true - postcss-load-config: - optional: true - pug: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - typescript: - optional: true + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' svelte-preprocess@6.0.2: resolution: {integrity: sha512-OvDTLfaOkkhjprbDKO0SOCkjNYuHy16dbD4SpqbIi6QiabOMHxRT4km5/dzbFFkmW1L0E2INF3MFltG2pgOyKQ==} @@ -2069,9 +1970,9 @@ packages: engines: {node: '>= 14.17.0'} hasBin: true - svelte@4.2.19: - resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==} - engines: {node: '>=16'} + svelte@5.28.2: + resolution: {integrity: sha512-FbWBxgWOpQfhKvoGJv/TFwzqb4EhJbwCD17dB0tEpQiw1XyUEKZJtgm4nA4xq3LLsMo7hu5UY/BOFmroAxKTMg==} + engines: {node: '>=18'} sveltekit-i18n@2.4.2: resolution: {integrity: sha512-hjRWn4V4DBL8JQKJoJa3MRvn6d32Zo+rWkoSP5bsQ/XIAguPdQUZJ8LMe6Nc1rST8WEVdu9+vZI3aFdKYGR3+Q==} @@ -2088,9 +1989,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - tiny-glob@0.2.9: - resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} - tinyglobby@0.2.9: resolution: {integrity: sha512-8or1+BGEdk1Zkkw2ii16qSS7uVrQJPre5A9o/XkWPATkk23FZh/15BKFxPnlTy6vkljZxLqYCzzBMj30ZrSvjw==} engines: {node: '>=12.0.0'} @@ -2241,10 +2139,10 @@ packages: terser: optional: true - vitefu@0.2.5: - resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + vitefu@1.0.6: + resolution: {integrity: sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==} peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 peerDependenciesMeta: vite: optional: true @@ -2272,9 +2170,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - xmlbuilder2@3.1.1: resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==} engines: {node: '>=12.0'} @@ -2289,6 +2184,9 @@ packages: youtubei.js@13.4.0: resolution: {integrity: sha512-+fmIZU/dWAjsROONrASy1REwVpy6umAPVuoNLr/4iNmZXl84LyBef0n3hrd1Vn9035EuINToGyQcBmifwUEemA==} + zimmerframe@1.1.2: + resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -2500,8 +2398,6 @@ snapshots: '@fastify/busboy@2.1.1': {} - '@fontsource-variable/noto-sans-mono@5.0.20': {} - '@fontsource/ibm-plex-mono@5.0.13': {} '@fontsource/redaction-10@5.0.2': {} @@ -2519,7 +2415,9 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} - '@imput/libav.js-remux-cli@5.5.6': {} + '@imput/libav.js-encode-cli@6.7.7': {} + + '@imput/libav.js-remux-cli@6.5.7': {} '@imput/psl@2.0.4': dependencies: @@ -2665,61 +2563,63 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.24.0': optional: true - '@sveltejs/adapter-static@3.0.6(@sveltejs/kit@2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))': + '@sveltejs/acorn-typescript@1.0.5(acorn@8.14.0)': dependencies: - '@sveltejs/kit': 2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)) + acorn: 8.14.0 - '@sveltejs/kit@2.9.1(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))': + '@sveltejs/adapter-static@3.0.6(@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)) + '@sveltejs/kit': 2.20.7(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)) + + '@sveltejs/kit@2.20.7(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14))': + dependencies: + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)) '@types/cookie': 0.6.0 cookie: 0.6.0 devalue: 5.1.1 - esm-env: 1.2.1 + esm-env: 1.2.2 import-meta-resolve: 4.1.0 kleur: 4.1.5 - magic-string: 0.30.11 + magic-string: 0.30.17 mrmime: 2.0.0 sade: 1.8.1 set-cookie-parser: 2.6.0 sirv: 3.0.0 - svelte: 4.2.19 - tiny-glob: 0.2.9 + svelte: 5.28.2 vite: 5.4.8(@types/node@20.14.14) - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))': + '@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)) - debug: 4.3.6 - svelte: 4.2.19 + '@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)) + debug: 4.4.0 + svelte: 5.28.2 vite: 5.4.8(@types/node@20.14.14) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14))': + '@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)))(svelte@4.2.19)(vite@5.4.8(@types/node@20.14.14)) - debug: 4.3.6 + '@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)))(svelte@5.28.2)(vite@5.4.8(@types/node@20.14.14)) + debug: 4.4.0 deepmerge: 4.3.1 kleur: 4.1.5 - magic-string: 0.30.11 - svelte: 4.2.19 - svelte-hmr: 0.16.0(svelte@4.2.19) + magic-string: 0.30.17 + svelte: 5.28.2 vite: 5.4.8(@types/node@20.14.14) - vitefu: 0.2.5(vite@5.4.8(@types/node@20.14.14)) + vitefu: 1.0.6(vite@5.4.8(@types/node@20.14.14)) transitivePeerDependencies: - supports-color - '@sveltekit-i18n/base@1.3.7(svelte@4.2.19)': + '@sveltekit-i18n/base@1.3.7(svelte@5.28.2)': dependencies: - svelte: 4.2.19 + svelte: 5.28.2 '@sveltekit-i18n/parser-default@1.1.1': {} - '@tabler/icons-svelte@3.6.0(svelte@4.2.19)': + '@tabler/icons-svelte@3.6.0(svelte@5.28.2)': dependencies: '@tabler/icons': 3.6.0 - svelte: 4.2.19 + svelte: 5.28.2 '@tabler/icons@3.6.0': {} @@ -2750,8 +2650,6 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/pug@2.0.10': {} - '@types/unist@2.0.10': {} '@typescript-eslint/eslint-plugin@8.18.0(@typescript-eslint/parser@8.18.0(eslint@9.16.0)(typescript@5.5.4))(eslint@9.16.0)(typescript@5.5.4)': @@ -2844,8 +2742,6 @@ snapshots: dependencies: acorn: 8.14.0 - acorn@8.12.1: {} - acorn@8.14.0: {} agent-base@6.0.2: @@ -2884,9 +2780,7 @@ snapshots: argparse@2.0.1: {} - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 + aria-query@5.3.2: {} array-flatten@1.1.1: {} @@ -2926,8 +2820,6 @@ snapshots: dependencies: fill-range: 7.1.1 - buffer-crc32@1.0.0: {} - buffer-from@1.1.2: {} bundle-require@5.0.0(esbuild@0.23.0): @@ -2968,17 +2860,15 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + cluster-key-slot@1.1.2: optional: true - code-red@1.0.4: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - '@types/estree': 1.0.5 - acorn: 8.12.1 - estree-walker: 3.0.3 - periscopic: 3.1.0 - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3031,11 +2921,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-tree@2.3.1: - dependencies: - mdn-data: 2.0.30 - source-map-js: 1.2.0 - debug@2.6.9: dependencies: ms: 2.0.0 @@ -3044,6 +2929,10 @@ snapshots: dependencies: ms: 2.1.2 + debug@4.4.0: + dependencies: + ms: 2.1.3 + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -3056,12 +2945,8 @@ snapshots: depd@2.0.0: {} - dequal@2.0.3: {} - destroy@1.2.0: {} - detect-indent@6.1.0: {} - devalue@5.1.1: {} dotenv@16.4.5: {} @@ -3086,8 +2971,6 @@ snapshots: es-errors@1.3.0: {} - es6-promise@3.3.1: {} - esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -3195,6 +3078,8 @@ snapshots: esm-env@1.2.1: {} + esm-env@1.2.2: {} + espree@10.3.0: dependencies: acorn: 8.14.0 @@ -3207,16 +3092,16 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@1.4.6: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 estraverse@5.3.0: {} - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.5 - esutils@2.0.3: {} etag@1.8.1: {} @@ -3352,8 +3237,6 @@ snapshots: fresh@0.5.2: {} - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true @@ -3398,27 +3281,12 @@ snapshots: package-json-from-dist: 1.0.0 path-scurry: 2.0.0 - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - globals@14.0.0: {} - globalyzer@0.1.0: {} - - globrex@0.1.2: {} - gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 - graceful-fs@4.2.11: {} - graphemer@1.4.0: {} has-flag@4.0.0: {} @@ -3473,11 +3341,6 @@ snapshots: imurmurhash@0.1.4: {} - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -3501,9 +3364,9 @@ snapshots: is-number@7.0.0: {} - is-reference@3.0.2: + is-reference@3.0.3: dependencies: - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 is-stream@2.0.1: {} @@ -3575,14 +3438,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - mdn-data@2.0.30: {} + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 - mdsvex@0.11.2(svelte@4.2.19): + mdsvex@0.11.2(svelte@5.28.2): dependencies: '@types/unist': 2.0.10 prism-svelte: 0.4.7 prismjs: 1.29.0 - svelte: 4.2.19 + svelte: 5.28.2 vfile-message: 2.0.4 media-typer@0.3.0: {} @@ -3612,8 +3477,6 @@ snapshots: mimic-fn@2.1.0: {} - min-indent@1.0.1: {} - minimatch@10.0.1: dependencies: brace-expansion: 2.0.1 @@ -3630,10 +3493,6 @@ snapshots: minipass@7.1.2: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - mri@1.2.0: {} mrmime@2.0.0: {} @@ -3672,10 +3531,6 @@ snapshots: dependencies: ee-first: 1.1.1 - once@1.4.0: - dependencies: - wrappy: 1.0.2 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -3709,8 +3564,6 @@ snapshots: path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} - path-key@3.1.1: {} path-scurry@1.11.1: @@ -3725,14 +3578,6 @@ snapshots: path-to-regexp@0.1.12: {} - periscopic@3.1.0: - dependencies: - '@types/estree': 1.0.5 - estree-walker: 3.0.3 - is-reference: 3.0.2 - - picocolors@1.0.1: {} - picocolors@1.1.0: {} picomatch@2.3.1: {} @@ -3800,6 +3645,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + redis@4.7.0: dependencies: '@redis/bloom': 1.2.0(@redis/client@1.6.0) @@ -3816,10 +3663,6 @@ snapshots: reusify@1.0.4: {} - rimraf@2.7.1: - dependencies: - glob: 7.2.3 - rollup@4.24.0: dependencies: '@types/estree': 1.0.6 @@ -3854,13 +3697,6 @@ snapshots: safer-buffer@2.1.2: {} - sander@0.5.1: - dependencies: - es6-promise: 3.3.1 - graceful-fs: 4.2.11 - mkdirp: 0.5.6 - rimraf: 2.7.1 - semver@7.6.3: {} send@0.19.0: @@ -3926,15 +3762,6 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 - sorcery@0.11.1: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - buffer-crc32: 1.0.0 - minimist: 1.2.8 - sander: 0.5.1 - - source-map-js@1.2.0: {} - source-map-js@1.2.1: {} source-map@0.8.0-beta.0: @@ -3971,10 +3798,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - strip-json-comments@3.1.1: {} sucrase@3.35.0: @@ -3991,47 +3814,24 @@ snapshots: dependencies: has-flag: 4.0.0 - svelte-check@3.8.5(postcss@8.4.47)(svelte@4.2.19): + svelte-check@4.1.6(picomatch@4.0.2)(svelte@5.28.2)(typescript@5.5.4): dependencies: '@jridgewell/trace-mapping': 0.3.25 - chokidar: 3.6.0 - picocolors: 1.0.1 + chokidar: 4.0.3 + fdir: 6.4.0(picomatch@4.0.2) + picocolors: 1.1.0 sade: 1.8.1 - svelte: 4.2.19 - svelte-preprocess: 5.1.4(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4) + svelte: 5.28.2 typescript: 5.5.4 transitivePeerDependencies: - - '@babel/core' - - coffeescript - - less - - postcss - - postcss-load-config - - pug - - sass - - stylus - - sugarss + - picomatch - svelte-hmr@0.16.0(svelte@4.2.19): + svelte-preprocess@6.0.2(postcss-load-config@6.0.1(postcss@8.4.47))(postcss@8.4.47)(svelte@5.28.2)(typescript@5.5.4): dependencies: - svelte: 4.2.19 - - svelte-preprocess@5.1.4(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4): - dependencies: - '@types/pug': 2.0.10 - detect-indent: 6.1.0 - magic-string: 0.30.11 - sorcery: 0.11.1 - strip-indent: 3.0.0 - svelte: 4.2.19 - optionalDependencies: - postcss: 8.4.47 - typescript: 5.5.4 - - svelte-preprocess@6.0.2(postcss@8.4.47)(svelte@4.2.19)(typescript@5.5.4): - dependencies: - svelte: 4.2.19 + svelte: 5.28.2 optionalDependencies: postcss: 8.4.47 + postcss-load-config: 6.0.1(postcss@8.4.47) typescript: 5.5.4 svelte-sitemap@2.6.0: @@ -4040,28 +3840,28 @@ snapshots: minimist: 1.2.8 xmlbuilder2: 3.1.1 - svelte@4.2.19: + svelte@5.28.2: dependencies: '@ampproject/remapping': 2.3.0 '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 - '@types/estree': 1.0.5 - acorn: 8.12.1 - aria-query: 5.3.0 + '@sveltejs/acorn-typescript': 1.0.5(acorn@8.14.0) + '@types/estree': 1.0.6 + acorn: 8.14.0 + aria-query: 5.3.2 axobject-query: 4.1.0 - code-red: 1.0.4 - css-tree: 2.3.1 - estree-walker: 3.0.3 - is-reference: 3.0.2 + clsx: 2.1.1 + esm-env: 1.2.1 + esrap: 1.4.6 + is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.11 - periscopic: 3.1.0 + zimmerframe: 1.1.2 - sveltekit-i18n@2.4.2(svelte@4.2.19): + sveltekit-i18n@2.4.2(svelte@5.28.2): dependencies: - '@sveltekit-i18n/base': 1.3.7(svelte@4.2.19) + '@sveltekit-i18n/base': 1.3.7(svelte@5.28.2) '@sveltekit-i18n/parser-default': 1.1.1 - svelte: 4.2.19 + svelte: 5.28.2 syscall-napi@0.0.6: optional: true @@ -4074,11 +3874,6 @@ snapshots: dependencies: any-promise: 1.3.0 - tiny-glob@0.2.9: - dependencies: - globalyzer: 0.1.0 - globrex: 0.1.2 - tinyglobby@0.2.9: dependencies: fdir: 6.4.0(picomatch@4.0.2) @@ -4198,7 +3993,7 @@ snapshots: '@types/node': 20.14.14 fsevents: 2.3.3 - vitefu@0.2.5(vite@5.4.8(@types/node@20.14.14)): + vitefu@1.0.6(vite@5.4.8(@types/node@20.14.14)): optionalDependencies: vite: 5.4.8(@types/node@20.14.14) @@ -4228,8 +4023,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 - wrappy@1.0.2: {} - xmlbuilder2@3.1.1: dependencies: '@oozcitak/dom': 1.15.10 @@ -4249,4 +4042,6 @@ snapshots: tslib: 2.6.3 undici: 5.28.4 + zimmerframe@1.1.2: {} + zod@3.23.8: {} diff --git a/web/README.md b/web/README.md index c528f6e5..a04ff9a4 100644 --- a/web/README.md +++ b/web/README.md @@ -3,22 +3,35 @@ the cobalt frontend is a static web app built with [sveltekit](https://kit.svelte.dev/) + [vite](https://vitejs.dev/). ## configuring -- to run a dev environment, run `pnpm run dev`. -- to make a release build of the frontend, run `pnpm run build`. +- to run the dev environment, run `pnpm run dev`. +- to make the release build of the frontend, run `pnpm run build`. ## environment variables the frontend has several build-time environment variables for configuring various features. to use them, you must specify them when building the frontend (or running a vite server for development). -| name | example | description | -|:---------------------|:----------------------------|:---------------------------------------------------------------------------------------------------------| -| `WEB_HOST` | `cobalt.tools` | domain on which the frontend will be running. used for meta tags and configuring plausible. | -| `WEB_PLAUSIBLE_HOST` | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. | -| `WEB_DEFAULT_API` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. | +`WEB_DEFAULT_API` is **required** to run cobalt frontend. + +| name | example | description | +|:---------------------|:----------------------------|:--------------------------------------------------------------------------------------------| +| `WEB_HOST` | `cobalt.tools` | domain on which the frontend will be running. used for meta tags and configuring plausible. | +| `WEB_PLAUSIBLE_HOST` | `plausible.io`* | enables plausible analytics with provided hostname as receiver backend. | +| `WEB_DEFAULT_API` | `https://api.cobalt.tools/` | changes url which is used for api requests by frontend clients. | \* don't use plausible.io as receiver backend unless you paid for their cloud service. use your own domain when hosting community edition of plausible. refer to their [docs](https://plausible.io/docs) when needed. +## link prefill +to prefill the link into the input box & start the download automatically, you can pass the URL in the `#` parameter, like this: +``` +https://cobalt.tools/#https://www.youtube.com/watch?v=dQw4w9WgXcQ +``` + +the link can also be URI-encoded, like this: +``` +https://cobalt.tools/#https%3A//www.youtube.com/watch%3Fv=dQw4w9WgXcQ +``` + ## license cobalt web code is licensed under [CC-BY-NC-SA-4.0](LICENSE). @@ -38,7 +51,29 @@ you are allowed to host an ***unmodified*** instance of cobalt with branding for when making an alternative version of the project, please replace or remove all branding (including the name). -## 3rd party licenses -- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji) (used in cobalt) is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license. -- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) fonts (used in cobalt) are licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license. +## open source acknowledgments +### svelte + sveltekit +the cobalt frontend is built using [svelte](https://svelte.dev) and [sveltekit](https://svelte.dev/docs/kit/introduction), a really efficient and badass framework, we love it a lot. + +### libav.js +our remux and encode workers rely on [libav.js](https://github.com/imputnet/libav.js), which is an optimized build of ffmpeg for the browser. the ffmpeg builds are made up of many components, whose licenses can be found here: [encode](https://github.com/imputnet/libav.js/blob/main/configs/configs/encode/license.js), [remux](https://github.com/imputnet/libav.js/blob/main/configs/configs/remux/license.js). + +you can [support ffmpeg here](https://ffmpeg.org/donations.html)! + +### fonts, icons and assets +the cobalt frontend uses several different fonts and icon sets. +- [Tabler Icons](https://tabler.io/icons), released under the [MIT](https://github.com/tabler/tabler-icons?tab=MIT-1-ov-file) license. +- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji), released under the [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license. +- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) used for the download button, is licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license. +- [IBM Plex Mono](https://fonts.google.com/specimen/IBM+Plex+Mono/) used for all other text, is licensed under the [OFL](https://fonts.google.com/specimen/IBM+Plex+Mono/license) license. +- and the [Redaction](https://redaction.us/) font, which is licensed under the [OFL](https://github.com/fontsource/font-files/blob/main/fonts/other/redaction-10/LICENSE) license (as well as LGPL-2.1). - many update banners were taken from [tenor.com](https://tenor.com/). + +### other packages +- [mdsvex](https://github.com/pngwn/MDsveX) to convert the changelogs into svelte components. +- [compare-versions](https://github.com/omichelsen/compare-versions) for sorting the changelogs. +- [svelte-sitemap](https://github.com/bartholomej/svelte-sitemap) for generating a sitemap for the frontend. +- [sveltekit-i18n](https://github.com/sveltekit-i18n/lib) for displaying cobalt in many different languages. +- [vite](https://github.com/vitejs/vite) for building the frontend. + +...and many other packages that these packages rely on. diff --git a/web/changelogs/10.5.md b/web/changelogs/10.5.md index 10776b58..a9ee593a 100644 --- a/web/changelogs/10.5.md +++ b/web/changelogs/10.5.md @@ -31,8 +31,6 @@ we're back to the battlefield against youtube's scraper flattener, but we're win - rutube video is region locked. - vk video is region locked. -*~ still reading? that's impressive. ~* - ## web app (and ui/ux) improvements - added support for [instance access keys](/settings/instances#access-key)! now you can access private cobalt instances with no turnstile, directly from the web app. - redesigned the [remux page](/remux) to indicate better what remuxing does and what it's for. @@ -56,7 +54,6 @@ we're back to the battlefield against youtube's scraper flattener, but we're win *~ 🦆🔜 ~* ## processing instance improvements -*(mostly nerd talk)* - added support for one more way of youtube authentication: web cookies with poToken & visitorData, so that everyone can access youtube on their instances again! - significantly refactored the cookie system for better error resistance. - added success and error console messages to indicate whether cookies/keys were loaded successfully. diff --git a/web/changelogs/11.0.md b/web/changelogs/11.0.md new file mode 100644 index 00000000..06d3168a --- /dev/null +++ b/web/changelogs/11.0.md @@ -0,0 +1,151 @@ +--- +title: "local media processing, better performance, and a lot of polish" +date: "29 May, 2025" +banner: + file: "meowth_beach.webp" + alt: "meowth plush with obnoxious sunglasses on foreground, very close to the camera. sunset and beach in background." +--- + +long time no see! it's almost summer, the perfect time to create or discover something new. we've been busy working in the background to make cobalt better than ever, but now we're finally ready to share the new major version. + +as a part of the major update, we revised our [terms of use](/about/terms) & [privacy policy](/about/privacy) to reflect new privacy-enhancing features & to improve readability; you can compare what exactly changed in [this commit](https://github.com/imputnet/cobalt/commit/be84f66) on github. **nothing changed about our principles or dedication to privacy**, but we still thought it'd be good to let you know. + +here are the highlights of what's new in cobalt 11 and what else has changed since the last changelog in december: + +## on-device media processing (beta) +cobalt can now perform all media processing tasks *directly in your browser*. we enabled it by default on all desktop browsers & firefox on android, but if you want to try it on your device before we're sure it works the way we expect, you can do it in a new [local processing page in settings](/settings/local)! + +here's what it means for you: +- **best file compatibility**, because all processed files now have proper headers. there's **no need to remux anything manually** anymore; all editing software should support cobalt files right away! vegas pro, logic pro, many DAWs, windows media player, whatsapp, etc, — all of them now support files from cobalt *(but only if they were processed on-device)*. +- **detailed progress** of file processing. cobalt now displays all steps and the current progress of all of them. no more guessing when's the file gonna be ready. +- **faster processing** for all tasks that require remuxing or transcoding, such as downloading youtube videos, transcoding audio, muting videos, or converting gifs from twitter. +- **better reliability** of all processing tasks. cobalt can finally catch all processing errors properly, meaning that the corrupted file rate will drop significantly. if anything ever goes wrong, cobalt will let you know, and you'll be able to retry right away! +- **reduced load on public instances**, which makes cobalt faster than ever for everyone. servers will no longer be busy transcoding someone's 10 hour audio of "beats to vibe and study to" — because now their own device is responsible for this work. it's really cool! + +we're also introducing the processing queue, which allows you to schedule many tasks at once! it's always present on the screen, in the top right corner. the button for it displays precise progress across all tasks, so you know when tasks are done at a glance. + +all processed videos are temporarily stored on your device and are automatically wiped when you reload the page. no need to rush saving them; they'll be there as long as you don't close cobalt or delete them from the queue. + +on modern ios (18.0+), we made it possible to download, process, and export giant files. the limit is now your device's storage, so go wild! + +processing queue & local processing may not be perfect as-is, so please let us know about any frustrations you face when using them! this is just the beginning of an on-device era of cobalt. we hope to explore even more cool local processing features in the future. + +## web app improvements: ui/ux upgrade and svelte 5 +aside from local processing, we put in a ton of effort to make the cobalt web app faster and even more comfortable for everyone. we fixed all minor ui nicks, polished it, and improved turnstile behavior! + +- **svelte 5:** many parts of cobalt's frontend have been migrated to svelte 5. this is mostly an internal change, but it majorly improves the performance and reduces extra ui renders, making the overall experience snappier. +- **downloading flow:** + - cobalt will now start the downloading task right away, even if turnstile is not finished verifying the browser yet. it will wait for turnstile's solution instead of showing an annoying dialog. + - pressing "paste" before turnstile is finished now starts the download right away. + - prefilled links via url parameters (`#urlhere` or `?u=urlhere`) are now downloaded right away. one less button press! + - replaced an invasive turnstile dialog with a dynamic tooltip. + - ideally, you should no longer know that cloudflare turnstile is even there. +- **remux**: + - remux is now a part of the processing queue! the remux page now serves as an importer. no need to stay on the same page for remux to complete. + - you can now remux several files at once. + - cobalt now automatically filters out unsupported files on import, so you can drag and drop whatever. +- **visuals & animations:** + - the dialog animation & visual effects have been optimized to improve performance. the picker dialog no longer lags like hell! + - all images now fade in smoothly on load. + - the update notification now has a new, springier animation. + - enhanced focus rings across the whole app for better accessibility, a cleaner look, and ease of internal maintenance. + - sidebar is now bright in light theme mode on desktop, and is more visible in dark mode. + - sidebar buttons are now more compact. + - the status bar color on desktop (primarily safari) now adapts to the current theme. + - fixed many various rendering quirks in webkit and blink. + - all input bars are now pressable everywhere. + - popovers (such as supported services & queue) are now rendered only when needed. + - the font on the about/changelog pages is now consistent with the rest of the ui (IBM Plex Mono). + - all image assets have been re-compressed for even faster loading. + - the download button now uses a super tiny custom font instead of a full noto sans mono font. + - countless padding, margin, and alignment tweaks for overall consistency and a fresh vibe. +- **accessibility & usability:** + - created a dedicated [accessibility settings page](/settings/accessibility) and moved relevant settings there. + - improved screen reader accessibility & tab navigation across ui. + - added an option to prevent the processing queue from opening automatically in [accessibility settings](/settings/accessibility#behavior). + - files now save properly on desktop in pwa mode (when using local processing). +- **ios-specific improvements**: + - added haptic feedback to toggles, switchers, buttons, dropdowns, and error dialogs. not a fan of haptics? disable them in [accessibility settings](/settings/accessibility/haptics). + - made it possible to process giant files without crashing on ios 18.0+. the cobalt tab/pwa no longer crashes if a file is too big for safari to handle. *(previously anything >384mb lol)* + - improved file saving, now cobalt selects the most comfortable way to save a file automatically. +- **settings page:** + - sensitive inputs (like api keys) are now hidden by default with an option to reveal them. + - added an option to hide the remux tab on mobile devices in [appearance settings](/settings/appearance#navigation). + - filename previews in settings now more accurately reflect the actual output. + - improved the toggle animation. + - redesigned settings page icons. + - updated some descriptions to be more accurate. +- all [about](/about) pages have been revised for improved readability and clarity. +- the web instance now requires the `WEB_DEFAULT_API` env variable to run. it's enforced to avoid any confusion. +- the plausible script is no longer loaded when anonymous analytics are disabled. + +## general improvements +- filenames can now include a wider range of characters, thanks to relaxed sanitization & use of fullwidth replacements. +- "basic" is now the default filename style. + +## processing instance improvements +- env variables can now be loaded & updated dynamically. this allows for configuration changes without downtime! +- tunnels now provide an `Estimated-Content-Length` header when exact file size isn't available. +- many internal tunnel improvements. +- the api now returns a `429` http status code when rate limits are hit. +- the `allowH265` (formerly `tiktokH265`) and `convertGif` (formerly `twitterGif`) api parameters have been renamed for clarity as they apply (or will apply) to more services. +- added a bunch of new [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md): `FORCE_LOCAL_PROCESSING`, `API_ENV_FILE`, `SESSION_RATELIMIT_WINDOW`, `SESSION_RATELIMIT`, `TUNNEL_RATELIMIT_WINDOW`, `TUNNEL_RATELIMIT`, `CUSTOM_INNERTUBE_CLIENT`, `YOUTUBE_SESSION_SERVER`, `YOUTUBE_SESSION_INNERTUBE_CLIENT`, `YOUTUBE_ALLOW_BETTER_AUDIO`. + +## youtube improvements +- added a new option in [audio settings](/settings/audio#youtube-better-audio) to prefer better audio quality from youtube when available. +- near infinite amount of changes and improvements on cobalt & infrastructure levels to improve reliability and recover youtube functionality. +- cobalt now returns a more appropriate error message if a youtube video is locked behind drm. +- added itunnel transplating to allow in-place tunnel resume after an [intentional] error from origin's side. + +## other service improvements +- added support for **xiaohongshu**. +- **twitter:** + - added support for saving media from ad cards. + - added fallback to the syndication api for better reliability due to constant twitter downtimes & lockdowns. +- **reddit:** + - expanded support for various link types, including mobile (e.g., `m.reddit.com`) and many other short link formats. +- **instagram:** + - added support for more links, including the new `share` format. + - implemented more specific errors for age-restricted and private content. + - fixed an issue where posts might have not correctly fallen back to a photo if a video URL was missing. +- **tiktok:** + - added support for tiktok lite urls. + - fixed parsing of some mobile tiktok links. + - updated the primary tiktok domain used by the api due to previous dns issues. +- **snapchat:** + - fixed an issue where story extraction could fail if certain profile parameters were missing. + - added support for new link patterns. +- **pinterest:** + - fixed video parsing for certain types of pins. +- **bluesky:** + - added support for downloading tenor gifs from bluesky posts. +- **odnoklassniki (ok):** + - fixed an issue where author information wasn't handled properly. +- **loom:** + - added support for links with video titles. + - fixed support for more video types. +- **facebook:** + - fixed issues caused by rate limiting. + +## documentation improvements +- created a new document for [api instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md) with detailed & up-to-date info about each variable. +- rewrote [api docs](https://github.com/imputnet/cobalt/blob/main/docs/api.md) to be easier to read and added all new & previously missing info. +- updated the list of dependencies & open-source shoutouts in [api](https://github.com/imputnet/cobalt/blob/main/api/README.md) & [web](https://github.com/imputnet/cobalt/blob/main/web/README.md) readme docs. +- added an example for setting up `yt-session-generator` in the docker compose documentation. +- updated the "run an instance" guide with a more prominent note about abuse prevention. + +## more internal improvements +- introduced an abstract storage class and implemented opfs (origin private file system) and memory storage backends. this is the foundation of the new local processing features, and makes it possible to operate on devices with low RAM. +- session tokens are now bound to ip hashes, locking down their usage and improving security. +- lots of other refactoring and code cleanups across both api and web components. +- numerous test fixes, additions, and ci pipeline improvements. +- removed unused packages & updated many dependencies. + +## all changes are on github +like always, you can check [all commits since the 10.5 release on github](https://github.com/imputnet/cobalt/compare/41430ff...main) for even more details, if you're curious. + +this update was made with a lot of love and care, so we hope you enjoy it as much as we enjoyed making it. + +that's all for now, we wish you an amazing summer! + +\~ your friends at imput ❤️ \ No newline at end of file diff --git a/web/i18n/en/a11y/queue.json b/web/i18n/en/a11y/queue.json new file mode 100644 index 00000000..08e243f1 --- /dev/null +++ b/web/i18n/en/a11y/queue.json @@ -0,0 +1,5 @@ +{ + "status.default": "processing queue", + "status.completed": "processing queue. all tasks are completed.", + "status.ongoing": "processing queue. ongoing tasks." +} diff --git a/web/i18n/en/about.json b/web/i18n/en/about.json index 72b7fe40..0a794c77 100644 --- a/web/i18n/en/about.json +++ b/web/i18n/en/about.json @@ -11,9 +11,9 @@ "heading.general": "general terms", "heading.licenses": "licenses", "heading.summary": "best way to save what you love", - "heading.privacy": "leading privacy", + "heading.privacy_efficiency": "leading privacy & efficiency", "heading.community": "open community", - "heading.local": "on-device processing", + "heading.local": "local processing", "heading.saving": "saving", "heading.encryption": "encryption", "heading.plausible": "anonymous traffic analytics", @@ -22,6 +22,7 @@ "heading.abuse": "reporting abuse", "heading.motivation": "motivation", "heading.testers": "beta testers", + "heading.partners": "partners", "support.github": "check out cobalt's source code, contribute changes, or report issues", "support.discord": "chat with the community and developers about cobalt or ask for help", diff --git a/web/i18n/en/about/credits.md b/web/i18n/en/about/credits.md index 812f3394..3d23f5b4 100644 --- a/web/i18n/en/about/credits.md +++ b/web/i18n/en/about/credits.md @@ -1,5 +1,5 @@ @@ -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. -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 a request requires additional processing, such as remuxing or transcoding, cobalt processes media +directly on your device. this ensures best efficiency and privacy. -additionally, you can [enable forced tunneling](/settings/privacy#tunnel) to protect your privacy. -when enabled, cobalt will tunnel all downloaded 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. + +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 7291aff4..7ae2affb 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,18 @@ 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/) for anonymous traffic analytics, +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. +we self-host and manage the plausible instance 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 +104,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 pages). + +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 634e7502..bef2f7dc 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 1ea7fb41..643f4b8e 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 3e6f5dec..35a15a8c 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.", @@ -21,5 +21,8 @@ "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" + "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 2c347951..f670962b 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.", + "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.auth.jwt.missing": "couldn't authenticate with the processing instance because the access token is missing. try again in a few seconds or reload the page!", - "api.auth.jwt.invalid": "couldn't authenticate with the processing instance because the access token is invalid. try again in a few seconds or reload the page!", - "api.auth.turnstile.missing": "couldn't authenticate with the processing instance because the captcha solution is missing. try again in a few seconds or reload the page!", - "api.auth.turnstile.invalid": "couldn't authenticate with the processing instance because the captcha solution is invalid. try again in a few seconds or reload the page!", - - "api.auth.key.missing": "an access key is required to use this processing instance but it's missing. add it in instance settings!", - "api.auth.key.not_api_key": "an access key is required to use this processing instance but it's missing. add it in instance settings!", - - "api.auth.key.invalid": "the access key is invalid. reset it in instance settings and use a proper one!", - "api.auth.key.not_found": "the access key you used couldn't be found. are you sure this instance has your key?", - "api.auth.key.invalid_ip": "your ip address couldn't be parsed. something went very wrong. report this issue!", - "api.auth.key.ip_not_allowed": "your ip address is not allowed to use this access key. use a different instance or network!", - "api.auth.key.ua_not_allowed": "your user agent is not allowed to use this access key. use a different client or device!", - - "api.unreachable": "couldn't connect to the processing instance. check your internet connection and try again!", - "api.timed_out": "the processing instance took too long to respond. it may be overwhelmed at the moment, try again in a few seconds!", - "api.rate_exceeded": "you're making too many requests. try again in {{ limit }} seconds.", - "api.capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!", - - "api.generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!", - "api.unknown_response": "couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!", - "api.invalid_body": "couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!", - - "api.service.unsupported": "this service is not supported yet. have you pasted the right link?", - "api.service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!", - "api.service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!", - - "api.link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?", - "api.link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?", - - "api.fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!", - "api.fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!", - "api.fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?", - "api.fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!", - "api.fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!", - - "api.content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!", - - "api.content.video.unavailable": "i can't access this video. it may be restricted on {{ service }}'s side. try a different link!", - "api.content.video.live": "this video is currently live, so i can't download it yet. wait for the live stream to finish and try again!", - "api.content.video.private": "this video is private, so i can't access it. change its visibility or try another one!", - "api.content.video.age": "this video is age-restricted, so i can't access it anonymously. try a different link!", - "api.content.video.region": "this video is region locked, and the processing instance is in a different location. try a different link!", - - "api.content.region": "this content is region locked, and the processing instance is in a different location. try a different link!", - "api.content.paid": "this content requires purchase. cobalt can't download paid content. try a different link!", - - "api.content.post.unavailable": "couldn't find anything about this post. its visibility may be limited or it may not exist. make sure your link works and try again in a few seconds!", - "api.content.post.private": "couldn't get anything about this post because it's from a private account. try a different link!", - "api.content.post.age": "this post is age-restricted and isn't available without logging in. try a different link!", - - "api.youtube.no_matching_format": "youtube didn't return a valid video + audio format combo, either video or audio is missing. formats for this video may be re-encoding on youtube's side or something went wrong when parsing them. try enabling the hls option in video settings!", - "api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!", - "api.youtube.login": "couldn't get this video because youtube asked the instance to log in. this is potentially caused by the processing instance not having any active account tokens or youtube updating something about their api. try again in a few seconds, but if it still doesn't work, please report this issue!", - "api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!", - "api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!", - "api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!", - "api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!", - "api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!", - "api.youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!" + "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 00000000..70c996e6 --- /dev/null +++ b/web/i18n/en/error/api.json @@ -0,0 +1,62 @@ +{ + "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.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 the 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 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!", + "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 00000000..d25a118e --- /dev/null +++ b/web/i18n/en/error/queue.json @@ -0,0 +1,18 @@ +{ + "no_final_file": "no final file output", + "worker_didnt_start": "couldn't start a processing worker", + + "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", + "fetch.corrupted_file": "file wasn't downloaded fully, try again", + + "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" +} diff --git a/web/i18n/en/queue.json b/web/i18n/en/queue.json new file mode 100644 index 00000000..0943ba6b --- /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 567e569f..43144ae9 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/save.json b/web/i18n/en/save.json index e6edc0de..361361f8 100644 --- a/web/i18n/en/save.json +++ b/web/i18n/en/save.json @@ -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 418410bf..7ab13fa0 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -3,10 +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", + "page.local": "local processing", + "page.accessibility": "accessibility", "section.general": "general", "section.save": "save", @@ -30,11 +32,11 @@ "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.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.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,10 @@ "audio.youtube.dub.description": "cobalt will use a dubbed audio track for selected language if it's available. if not, original will be used instead.", "youtube.dub.original": "original", + "audio.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,7 +78,7 @@ "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", "metadata.file": "file metadata", @@ -86,11 +92,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 +124,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 +133,20 @@ "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": "media processing", + "local.saving.title": "download & process media locally", + "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. processing instances may enforce this feature to save resources.\n\nexclusive on-device features are not affected by this toggle, they always run locally.", + + "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/package.json b/web/package.json index 96900d0c..4a04590d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@imput/cobalt-web", - "version": "10.9", + "version": "11.0", "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.7.7", + "@imput/libav.js-remux-cli": "^6.5.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 00000000..55d94e39 --- /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 b60acb3c..0b902896 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 713c003f..30cd2fa6 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 5d5941a2..13a1d0ec 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 ea839538..8e8e1b54 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 00000000..5ab4e03d --- /dev/null +++ b/web/src/components/queue/ProcessingQueueItem.svelte @@ -0,0 +1,442 @@ + + + +
+
+
+
+ +
+ + {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 00000000..82648491 --- /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 00000000..bd0a7223 --- /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 00000000..f5db2dc5 --- /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 00000000..f114090d --- /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 c10db574..f45f54a7 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 49d673c6..2b1f05de 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 6dcb9244..61d721dd 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}
- {/if} +
+ {$t("save.services.disclaimer")} +
+
diff --git a/web/src/components/settings/ClearStorageButton.svelte b/web/src/components/settings/ClearStorageButton.svelte new file mode 100644 index 00000000..2ea31a1f --- /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 00000000..653a72a7 --- /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 829e1745..4af25be9 100644 --- a/web/src/components/settings/FilenamePreview.svelte +++ b/web/src/components/settings/FilenamePreview.svelte @@ -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 abc053bf..273f0f32 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 acc1465d..f05b0684 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 689316d6..6ea00f37 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" > -