Merge branch 'imputnet:main' into cobalt-sidepanel-extension

This commit is contained in:
D3f4ult Trades 2025-07-18 22:55:07 -04:00 committed by GitHub
commit 5d20664eb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 2441 additions and 818 deletions

View File

@ -23,6 +23,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i
| instagram | ✅ | ✅ | ✅ | | |
| facebook | ✅ | ❌ | ✅ | | |
| loom | ✅ | ❌ | ✅ | ✅ | |
| newgrounds | ✅ | ✅ | ✅ | ✅ | ✅ |
| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
| pinterest | ✅ | ✅ | ✅ | | |
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |

View File

@ -1,7 +1,7 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
"version": "11.1",
"version": "11.3.1",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
@ -26,6 +26,7 @@
"@datastructures-js/priority-queue": "^6.3.1",
"@imput/psl": "^2.0.4",
"@imput/version-info": "workspace:^",
"@imput/youtubei.js": "^14.0.0",
"content-disposition-header": "0.6.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
@ -37,9 +38,8 @@
"mime": "^4.0.4",
"nanoid": "^5.0.9",
"set-cookie-parser": "2.6.0",
"undici": "^5.19.1",
"undici": "^6.21.3",
"url-pattern": "1.0.3",
"youtubei.js": "^13.4.0",
"zod": "^3.23.8"
},
"optionalDependencies": {

View File

@ -5,7 +5,7 @@ const version = await getVersion();
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 genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
const cobaltUserAgent = `cobalt/${version} (+https://github.com/imputnet/cobalt)`;
export const canonicalEnv = Object.freeze(structuredClone(process.env));

View File

@ -156,6 +156,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
return fail(res, `error.api.auth.key.${error}`);
}
req.authType = "key";
return next();
});
@ -184,7 +185,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
}
req.rateLimitKey = hashHmac(token, 'rate');
req.isSession = true;
req.authType = "session";
} catch {
return fail(res, "error.api.generic");
}
@ -244,7 +245,10 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
return fail(res, "error.api.invalid_body");
}
const parsed = extract(normalizedRequest.url);
const parsed = extract(
normalizedRequest.url,
APIKeys.getAllowedServices(req.rateLimitKey),
);
if (!parsed) {
return fail(res, "error.api.link.invalid");
@ -263,7 +267,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
host: parsed.host,
patternMatch: parsed.patternMatch,
params: normalizedRequest,
isSession: req.isSession ?? false,
authType: req.authType ?? "none",
});
res.status(result.status).json(result.body);

View File

@ -1,4 +1,4 @@
import { Constants } from "youtubei.js";
import { Constants } from "@imput/youtubei.js";
import { services } from "../processing/service-config.js";
import { updateEnv, canonicalEnv, env as currentEnv } from "../config.js";
@ -8,8 +8,10 @@ import * as cluster from "../misc/cluster.js";
import { Green, Yellow } from "../misc/console-text.js";
const forceLocalProcessingOptions = ["never", "session", "always"];
const youtubeHlsOptions = ["never", "key", "always"];
export const loadEnvs = (env = process.env) => {
const allServices = new Set(Object.keys(services));
const disabledServices = env.DISABLED_SERVICES?.split(',') || [];
const enabledServices = new Set(Object.keys(services).filter(e => {
if (!disabledServices.includes(e)) {
@ -37,7 +39,12 @@ export const loadEnvs = (env = process.env) => {
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,
sessionRateLimit:
// backwards compatibility with SESSION_RATELIMIT
// till next major due to an error in docs
(env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX))
|| (env.SESSION_RATELIMIT && parseInt(env.SESSION_RATELIMIT))
|| 10,
durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800,
streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90,
@ -63,6 +70,7 @@ export const loadEnvs = (env = process.env) => {
instanceCount: (env.API_INSTANCE_COUNT && parseInt(env.API_INSTANCE_COUNT)) || 1,
keyReloadInterval: 900,
allServices,
enabledServices,
customInnertubeClient: env.CUSTOM_INNERTUBE_CLIENT,
@ -74,6 +82,9 @@ export const loadEnvs = (env = process.env) => {
// "never" | "session" | "always"
forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never",
// "never" | "key" | "always"
enableDeprecatedYoutubeHls: env.ENABLE_DEPRECATED_YOUTUBE_HLS ?? "never",
envFile: env.API_ENV_FILE,
envRemoteReloadInterval: 300,
};
@ -106,6 +117,12 @@ export const validateEnvs = async (env) => {
throw new Error("Invalid FORCE_LOCAL_PROCESSING");
}
if (env.enableDeprecatedYoutubeHls && !youtubeHlsOptions.includes(env.enableDeprecatedYoutubeHls)) {
console.error("ENABLE_DEPRECATED_YOUTUBE_HLS is invalid.");
console.error(`Supported options are are: ${youtubeHlsOptions.join(', ')}\n`);
throw new Error("Invalid ENABLE_DEPRECATED_YOUTUBE_HLS");
}
if (env.externalProxy && env.freebindCIDR) {
throw new Error('freebind is not available when external proxy is enabled')
}

View File

@ -0,0 +1,54 @@
// converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt
const iso639_1to2 = {
'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi',
'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm',
'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak',
'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis',
'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat',
'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv',
'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan',
'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo',
'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin',
'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu',
'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell',
'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb',
'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun',
'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku',
'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita',
'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas',
'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin',
'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua',
'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim',
'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug',
'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar',
'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau',
'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep',
'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci',
'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan',
'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus',
'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus',
'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv',
'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som',
'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw',
'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam',
'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha',
'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso',
'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr',
'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol',
'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid',
'yo': 'yor', 'za': 'zha', 'zu': 'zul',
}
const iso639_2to1 = Object.fromEntries(
Object.entries(iso639_1to2).map(([k, v]) => [v, k])
);
const maps = {
2: iso639_1to2,
3: iso639_2to1,
}
export const convertLanguageCode = (code) => {
code = code?.split("-")[0]?.split("_")[0] || "";
return maps[code.length]?.[code.toLowerCase()] || null;
}

View File

@ -4,8 +4,9 @@ import { createResponse } from "./request.js";
import { audioIgnore } from "./service-config.js";
import { createStream } from "../stream/manage.js";
import { splitFilenameExtension } from "../misc/utils.js";
import { convertLanguageCode } from "../misc/language-codes.js";
const extraProcessingTypes = ["merge", "remux", "mute", "audio", "gif"];
const extraProcessingTypes = new Set(["merge", "remux", "mute", "audio", "gif"]);
export default function({
r,
@ -19,7 +20,7 @@ export default function({
requestIP,
audioBitrate,
alwaysProxy,
localProcessing
localProcessing,
}) {
let action,
responseType = "tunnel",
@ -31,7 +32,10 @@ export default function({
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
fileMetadata: !disableMetadata ? r.fileMetadata : false,
requestIP,
originalRequest: r.originalRequest
originalRequest: r.originalRequest,
subtitles: r.subtitles,
cover: !disableMetadata ? r.cover : false,
cropCover: !disableMetadata ? r.cropCover : false,
},
params = {};
@ -143,7 +147,9 @@ export default function({
case "vimeo":
if (Array.isArray(r.urls)) {
params = { type: "merge" }
params = { type: "merge" };
} else if (r.subtitles) {
params = { type: "remux" };
} else {
responseType = "redirect";
}
@ -157,10 +163,24 @@ export default function({
}
break;
case "ok":
case "loom":
if (r.subtitles) {
params = { type: "remux" };
} else {
responseType = "redirect";
}
break;
case "vk":
case "tiktok":
params = {
type: r.subtitles ? "remux" : "proxy"
};
break;
case "ok":
case "xiaohongshu":
case "newgrounds":
params = { type: "proxy" };
break;
@ -170,7 +190,6 @@ export default function({
case "pinterest":
case "streamable":
case "snapchat":
case "loom":
case "twitch":
responseType = "redirect";
break;
@ -178,7 +197,7 @@ export default function({
break;
case "audio":
if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) {
if (audioIgnore.has(host) || (host === "reddit" && r.typeId === "redirect")) {
return createResponse("error", {
code: "error.api.service.audio_not_supported"
})
@ -226,6 +245,7 @@ export default function({
defaultParams.filename += `.${audioFormat}`;
}
// alwaysProxy is set to true in match.js if localProcessing is forced
if (alwaysProxy && responseType === "redirect") {
responseType = "tunnel";
params.type = "proxy";
@ -233,8 +253,27 @@ export default function({
// TODO: add support for HLS
// (very painful)
if (localProcessing && !params.isHLS && extraProcessingTypes.includes(params.type)) {
responseType = "local-processing";
if (!params.isHLS && responseType !== "picker") {
const isPreferredWithExtra =
localProcessing === "preferred" && extraProcessingTypes.has(params.type);
if (localProcessing === "forced" || isPreferredWithExtra) {
responseType = "local-processing";
}
}
// extractors usually return ISO 639-1 language codes,
// but video players expect ISO 639-2, so we convert them here
const sublanguage = defaultParams.fileMetadata?.sublanguage;
if (sublanguage && sublanguage.length !== 3) {
const code = convertLanguageCode(sublanguage);
if (code) {
defaultParams.fileMetadata.sublanguage = code;
} else {
// if a language code couldn't be converted,
// then we don't want it at all
delete defaultParams.fileMetadata.sublanguage;
}
}
return createResponse(

View File

@ -29,10 +29,11 @@ import loom from "./services/loom.js";
import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js";
import xiaohongshu from "./services/xiaohongshu.js";
import newgrounds from "./services/newgrounds.js";
let freebind;
export default async function({ host, patternMatch, params, isSession }) {
export default async function({ host, patternMatch, params, authType }) {
const { url } = params;
assert(url instanceof URL);
let dispatcher, requestIP;
@ -65,6 +66,17 @@ export default async function({ host, patternMatch, params, isSession }) {
});
}
// youtubeHLS will be fully removed in the future
let youtubeHLS = params.youtubeHLS;
const hlsEnv = env.enableDeprecatedYoutubeHls;
if (hlsEnv === "never" || (hlsEnv === "key" && authType !== "key")) {
youtubeHLS = false;
}
const subtitleLang =
params.subtitleLang !== "none" ? params.subtitleLang : undefined;
switch (host) {
case "twitter":
r = await twitter({
@ -72,7 +84,8 @@ export default async function({ host, patternMatch, params, isSession }) {
index: patternMatch.index - 1,
toGif: !!params.convertGif,
alwaysProxy: params.alwaysProxy,
dispatcher
dispatcher,
subtitleLang
});
break;
@ -81,7 +94,8 @@ export default async function({ host, patternMatch, params, isSession }) {
ownerId: patternMatch.ownerId,
videoId: patternMatch.videoId,
accessKey: patternMatch.accessKey,
quality: params.videoQuality
quality: params.videoQuality,
subtitleLang,
});
break;
@ -101,16 +115,18 @@ export default async function({ host, patternMatch, params, isSession }) {
dispatcher,
id: patternMatch.id.slice(0, 11),
quality: params.videoQuality,
format: params.youtubeVideoCodec,
codec: params.youtubeVideoCodec,
container: params.youtubeVideoContainer,
isAudioOnly,
isAudioMuted,
dubLang: params.youtubeDubLang,
youtubeHLS: params.youtubeHLS,
youtubeHLS,
subtitleLang,
}
if (url.hostname === "music.youtube.com" || isAudioOnly) {
fetchInfo.quality = "1080";
fetchInfo.format = "vp9";
fetchInfo.codec = "vp9";
fetchInfo.isAudioOnly = true;
fetchInfo.isAudioMuted = false;
@ -137,6 +153,7 @@ export default async function({ host, patternMatch, params, isSession }) {
isAudioOnly,
h265: params.allowH265,
alwaysProxy: params.alwaysProxy,
subtitleLang,
});
break;
@ -154,6 +171,7 @@ export default async function({ host, patternMatch, params, isSession }) {
password: patternMatch.password,
quality: params.videoQuality,
isAudioOnly,
subtitleLang,
});
break;
@ -205,6 +223,7 @@ export default async function({ host, patternMatch, params, isSession }) {
key: patternMatch.key,
quality: params.videoQuality,
isAudioOnly,
subtitleLang,
});
break;
@ -221,7 +240,8 @@ export default async function({ host, patternMatch, params, isSession }) {
case "loom":
r = await loom({
id: patternMatch.id
id: patternMatch.id,
subtitleLang,
});
break;
@ -249,6 +269,13 @@ export default async function({ host, patternMatch, params, isSession }) {
});
break;
case "newgrounds":
r = await newgrounds({
...patternMatch,
quality: params.videoQuality,
});
break;
default:
return createResponse("error", {
code: "error.api.service.unsupported"
@ -293,11 +320,12 @@ export default async function({ host, patternMatch, params, isSession }) {
}
let localProcessing = params.localProcessing;
const lpEnv = env.forceLocalProcessing;
const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session");
const localDisabled = (!localProcessing || localProcessing === "disabled");
if (lpEnv === "always" || (lpEnv === "session" && isSession)) {
localProcessing = true;
if (shouldForceLocal && localDisabled) {
localProcessing = "preferred";
}
return matchAction({
@ -311,7 +339,7 @@ export default async function({ host, patternMatch, params, isSession }) {
convertGif: params.convertGif,
requestIP,
audioBitrate: params.audioBitrate,
alwaysProxy: params.alwaysProxy,
alwaysProxy: params.alwaysProxy || localProcessing === "forced",
localProcessing,
})
} catch {

View File

@ -11,7 +11,7 @@ export function createResponse(responseType, responseData) {
body: {
status: "error",
error: {
code: code || "error.api.fetch.critical",
code: code || "error.api.fetch.critical.core",
},
critical: true
}
@ -60,12 +60,15 @@ export function createResponse(responseType, responseData) {
type: mime.getType(responseData?.filename) || undefined,
filename: responseData?.filename,
metadata: responseData?.fileMetadata || undefined,
subtitles: !!responseData?.subtitles || undefined,
},
audio: {
copy: responseData?.audioCopy,
format: responseData?.audioFormat,
bitrate: responseData?.audioBitrate,
cover: !!responseData?.cover || undefined,
cropCover: !!responseData?.cropCover || undefined,
},
isHLS: responseData?.isHLS,
@ -108,11 +111,16 @@ export function createResponse(responseType, responseData) {
}
}
} catch {
return internalError()
return internalError();
}
}
export function normalizeRequest(request) {
// TODO: remove after backwards compatibility period
if ("localProcessing" in request && typeof request.localProcessing === "boolean") {
request.localProcessing = request.localProcessing ? "preferred" : "disabled";
}
return apiSchema.safeParseAsync(request).catch(() => (
{ success: false }
));

View File

@ -26,16 +26,30 @@ export const apiSchema = z.object({
["h264", "av1", "vp9"]
).default("h264"),
youtubeVideoContainer: z.enum(
["auto", "mp4", "webm", "mkv"]
).default("auto"),
videoQuality: z.enum(
["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"]
).default("1080"),
localProcessing: z.enum(
["disabled", "preferred", "forced"]
).default("disabled"),
youtubeDubLang: z.string()
.min(2)
.max(8)
.regex(/^[0-9a-zA-Z\-]+$/)
.optional(),
subtitleLang: z.string()
.min(2)
.max(8)
.regex(/^[0-9a-zA-Z\-]+$/)
.optional(),
disableMetadata: z.boolean().default(false),
allowH265: z.boolean().default(false),
@ -43,13 +57,8 @@ export const apiSchema = z.object({
tiktokFullAudio: z.boolean().default(false),
alwaysProxy: z.boolean().default(false),
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();

View File

@ -1,7 +1,7 @@
import UrlPattern from "url-pattern";
export const audioIgnore = ["vk", "ok", "loom"];
export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "youtube"];
export const audioIgnore = new Set(["vk", "ok", "loom"]);
export const hlsExceptions = new Set(["dailymotion", "vimeo", "rutube", "bsky", "youtube"]);
export const services = {
bilibili: {
@ -74,6 +74,12 @@ export const services = {
"url_shortener/:shortLink"
],
},
newgrounds: {
patterns: [
"portal/view/:id",
"audio/listen/:audioId",
]
},
reddit: {
patterns: [
"comments/:id",
@ -186,12 +192,13 @@ export const services = {
patterns: [
"video:ownerId_:videoId",
"clip:ownerId_:videoId",
"clips:duplicate?z=clip:ownerId_:videoId",
"videos:duplicate?z=video:ownerId_:videoId",
"video:ownerId_:videoId_:accessKey",
"clip:ownerId_:videoId_:accessKey",
"clips:duplicate?z=clip:ownerId_:videoId_:accessKey",
"videos:duplicate?z=video:ownerId_:videoId_:accessKey"
// links with a duplicate author id and/or zipper query param
"clips:duplicateId",
"videos:duplicateId",
"search/video"
],
subdomains: ["m"],
altDomains: ["vkvideo.ru", "vk.ru"],

View File

@ -43,7 +43,7 @@ export const testers = {
pattern.id?.length <= 6,
"tiktok": pattern =>
pattern.postId?.length <= 21 || pattern.shortLink?.length <= 13,
pattern.postId?.length <= 21 || pattern.shortLink?.length <= 21,
"tumblr": pattern =>
pattern.id?.length < 21
@ -79,4 +79,7 @@ export const testers = {
"xiaohongshu": pattern =>
pattern.id?.length <= 24 && pattern.token?.length <= 64
|| pattern.shareId?.length <= 24,
"newgrounds": pattern =>
pattern.id?.length <= 12 || pattern.audioId?.length <= 12,
}

View File

@ -18,7 +18,7 @@ function extractBestQuality(dashData) {
}
async function com_download(id) {
let html = await fetch(`https://bilibili.com/video/${id}`, {
const html = await fetch(`https://bilibili.com/video/${id}`, {
headers: {
"user-agent": genericUserAgent
}
@ -34,7 +34,10 @@ async function com_download(id) {
return { error: "fetch.empty" };
}
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
const streamData = JSON.parse(
html.split('<script>window.__playinfo__=')[1].split('</script>')[0]
);
if (streamData.data.timelength > env.durationLimit * 1000) {
return { error: "content.too_long" };
}
@ -48,7 +51,6 @@ async function com_download(id) {
urls: [video.baseUrl, audio.baseUrl],
audioFilename: `bilibili_${id}_audio`,
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`,
isHLS: true
};
}

View File

@ -50,7 +50,42 @@ async function fromRawURL(id) {
}
}
export default async function({ id }) {
async function getTranscript(id) {
const gql = await fetch(`https://www.loom.com/graphql`, {
method: "POST",
headers: craftHeaders(id),
body: JSON.stringify({
operationName: "FetchVideoTranscriptForFetchTranscript",
variables: {
videoId: id,
password: null,
},
query: `
query FetchVideoTranscriptForFetchTranscript($videoId: ID!, $password: String) {
fetchVideoTranscript(videoId: $videoId, password: $password) {
... on VideoTranscriptDetails {
captions_source_url
language
__typename
}
... on GenericError {
message
__typename
}
__typename
}
}`,
})
})
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (gql?.data?.fetchVideoTranscript?.captions_source_url?.includes('.vtt?')) {
return gql.data.fetchVideoTranscript.captions_source_url;
}
}
export default async function({ id, subtitleLang }) {
let url = await fromTranscodedURL(id);
url ??= await fromRawURL(id);
@ -58,8 +93,15 @@ export default async function({ id }) {
return { error: "fetch.empty" }
}
let subtitles;
if (subtitleLang) {
const transcript = await getTranscript(id);
if (transcript) subtitles = transcript;
}
return {
urls: url,
subtitles,
filename: `loom_${id}.mp4`,
audioFilename: `loom_${id}_audio`
}

View File

@ -0,0 +1,103 @@
import { genericUserAgent } from "../../config.js";
const getVideo = async ({ id, quality }) => {
const json = await fetch(`https://www.newgrounds.com/portal/video/${id}`, {
headers: {
"User-Agent": genericUserAgent,
"X-Requested-With": "XMLHttpRequest", // required to get the JSON response
}
})
.then(r => r.json())
.catch(() => {});
if (!json) return { error: "fetch.empty" };
const videoSources = json.sources;
const videoQualities = Object.keys(videoSources);
if (videoQualities.length === 0) {
return { error: "fetch.empty" };
}
const bestVideo = videoSources[videoQualities[0]]?.[0],
userQuality = quality === "2160" ? "4k" : `${quality}p`,
preferredVideo = videoSources[userQuality]?.[0],
video = preferredVideo || bestVideo,
videoQuality = preferredVideo ? userQuality : videoQualities[0];
if (!bestVideo || !video.type.includes("mp4")) {
return { error: "fetch.empty" };
}
const fileMetadata = {
title: decodeURIComponent(json.title),
artist: decodeURIComponent(json.author),
}
return {
urls: video.src,
filenameAttributes: {
service: "newgrounds",
id,
title: fileMetadata.title,
author: fileMetadata.artist,
extension: "mp4",
qualityLabel: videoQuality,
resolution: videoQuality,
},
fileMetadata,
}
}
const getMusic = async ({ id }) => {
const html = await fetch(`https://www.newgrounds.com/audio/listen/${id}`, {
headers: {
"User-Agent": genericUserAgent,
}
})
.then(r => r.text())
.catch(() => {});
if (!html) return { error: "fetch.fail" };
const params = JSON.parse(
`{${html.split(',"params":{')[1]?.split(',"images":')[0]}}`
);
if (!params) return { error: "fetch.empty" };
if (!params.name || !params.artist || !params.filename || !params.icon) {
return { error: "fetch.empty" };
}
const fileMetadata = {
title: decodeURIComponent(params.name),
artist: decodeURIComponent(params.artist),
}
return {
urls: params.filename,
filenameAttributes: {
service: "newgrounds",
id,
title: fileMetadata.title,
author: fileMetadata.artist,
},
fileMetadata,
cover:
params.icon.includes(".png?") || params.icon.includes(".jpg?")
? params.icon
: undefined,
isAudioOnly: true,
bestAudio: "mp3",
}
}
export default function({ id, audioId, quality }) {
if (id) {
return getVideo({ id, quality });
} else if (audioId) {
return getMusic({ id: audioId });
}
return { error: "fetch.empty" };
}

View File

@ -65,8 +65,21 @@ export default async function(obj) {
artist: play.author.name.trim(),
}
let subtitles;
if (obj.subtitleLang && play.captions?.length) {
const subtitle = play.captions.find(
s => ["webvtt", "srt"].includes(s.format) && s.code.startsWith(obj.subtitleLang)
);
if (subtitle) {
subtitles = subtitle.file;
fileMetadata.sublanguage = obj.subtitleLang;
}
}
return {
urls: matchingQuality.uri,
subtitles,
isHLS: true,
filenameAttributes: {
service: "rutube",

View File

@ -146,8 +146,21 @@ export default async function(obj) {
copyright: json.license?.trim(),
}
let cover;
if (json.artwork_url) {
const coverUrl = json.artwork_url.replace(/-large/, "-t1080x1080");
const testCover = await fetch(coverUrl)
.then(r => r.status === 200)
.catch(() => {});
if (testCover) {
cover = coverUrl;
}
}
return {
urls: file.toString(),
cover,
filenameAttributes: {
service: "soundcloud",
id: json.id,

View File

@ -4,6 +4,7 @@ import { extract, normalizeURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { updateCookie } from "../cookie/manager.js";
import { createStream } from "../../stream/manage.js";
import { convertLanguageCode } from "../../misc/language-codes.js";
const shortDomain = "https://vt.tiktok.com/";
@ -23,8 +24,10 @@ export default async function(obj) {
if (html.startsWith('<a href="https://')) {
const extractedURL = html.split('<a href="')[1].split('?')[0];
const { patternMatch } = extract(normalizeURL(extractedURL));
postId = patternMatch?.postId;
const { host, patternMatch } = extract(normalizeURL(extractedURL));
if (host === "tiktok") {
postId = patternMatch?.postId;
}
}
}
if (!postId) return { error: "fetch.short_link" };
@ -97,8 +100,23 @@ export default async function(obj) {
}
if (video) {
let subtitles, fileMetadata;
if (obj.subtitleLang && detail?.video?.subtitleInfos?.length) {
const langCode = convertLanguageCode(obj.subtitleLang);
const subtitle = detail?.video?.subtitleInfos.find(
s => s.LanguageCodeName.startsWith(langCode) && s.Format === "webvtt"
)
if (subtitle) {
subtitles = subtitle.Url;
fileMetadata = {
sublanguage: langCode,
}
}
}
return {
urls: video,
subtitles,
fileMetadata,
filename: videoFilename,
headers: { cookie }
}
@ -150,4 +168,6 @@ export default async function(obj) {
headers: { cookie }
}
}
return { error: "fetch.empty" };
}

View File

@ -1,3 +1,4 @@
import HLS from "hls-parser";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
import { getCookie, updateCookie } from "../cookie/manager.js";
@ -192,7 +193,7 @@ const testResponse = (result) => {
return true;
}
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
export default async function({ id, index, toGif, dispatcher, alwaysProxy, subtitleLang }) {
const cookie = await getCookie('twitter');
let syndication = false;
@ -252,6 +253,30 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
url, filename,
});
const extractSubtitles = async (hlsUrl) => {
const mainHls = await fetch(hlsUrl).then(r => r.text()).catch(() => {});
if (!mainHls) return;
const subtitle = HLS.parse(mainHls)?.variants[0]?.subtitles?.find(
s => s.language.startsWith(subtitleLang)
);
if (!subtitle) return;
const subtitleUrl = new URL(subtitle.uri, hlsUrl).toString();
const subtitleHls = await fetch(subtitleUrl).then(r => r.text());
if (!subtitleHls) return;
const finalSubtitlePath = HLS.parse(subtitleHls)?.segments?.[0].uri;
if (!finalSubtitlePath) return;
const finalSubtitleUrl = new URL(finalSubtitlePath, hlsUrl).toString();
return {
url: finalSubtitleUrl,
language: subtitle.language,
};
}
switch (media?.length) {
case undefined:
case 0:
@ -259,21 +284,37 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
error: "fetch.empty"
}
case 1:
if (media[0].type === "photo") {
const mediaItem = media[0];
if (mediaItem.type === "photo") {
return {
type: "proxy",
isPhoto: true,
filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`,
urls: `${media[0].media_url_https}?name=4096x4096`
filename: `twitter_${id}.${getFileExt(mediaItem.media_url_https)}`,
urls: `${mediaItem.media_url_https}?name=4096x4096`
}
}
let subtitles;
let fileMetadata;
if (mediaItem.type === "video" && subtitleLang) {
const hlsVariant = mediaItem.video_info?.variants?.find(
v => v.content_type === "application/x-mpegURL"
);
if (hlsVariant) {
const { url, language } = await extractSubtitles(hlsVariant.url) || {};
subtitles = url;
if (language) fileMetadata = { sublanguage: language };
}
}
return {
type: needsFixing(media[0]) ? "remux" : "proxy",
urls: bestQuality(media[0].video_info.variants),
type: subtitles || needsFixing(mediaItem) ? "remux" : "proxy",
urls: bestQuality(mediaItem.video_info.variants),
filename: `twitter_${id}.mp4`,
audioFilename: `twitter_${id}_audio`,
isGif: media[0].type === "animated_gif"
isGif: mediaItem.type === "animated_gif",
subtitles,
fileMetadata,
}
default:
const proxyThumb = (url, i) =>

View File

@ -15,7 +15,43 @@ const resolutionMatch = {
"426": 240
}
const requestApiInfo = (videoId, password) => {
const genericHeaders = {
Accept: 'application/vnd.vimeo.*+json; version=3.4.10',
'User-Agent': 'com.vimeo.android.videoapp (Google, Pixel 7a, google, Android 16/36 Version 11.8.1) Kotlin VimeoNetworking/3.12.0',
Authorization: 'Basic NzRmYTg5YjgxMWExY2JiNzUwZDg1MjhkMTYzZjQ4YWYyOGEyZGJlMTp4OGx2NFd3QnNvY1lkamI2UVZsdjdDYlNwSDUrdm50YzdNNThvWDcwN1JrenJGZC9tR1lReUNlRjRSVklZeWhYZVpRS0tBcU9YYzRoTGY2Z1dlVkJFYkdJc0dMRHpoZWFZbU0reDRqZ1dkZ1diZmdIdGUrNUM5RVBySlM0VG1qcw==',
'Accept-Language': 'en',
}
let bearer = '';
const getBearer = async (refresh = false) => {
if (bearer && !refresh) return bearer;
const oauthResponse = await fetch(
'https://api.vimeo.com/oauth/authorize/client',
{
method: 'POST',
body: new URLSearchParams({
scope: 'private public create edit delete interact upload purchased stats',
grant_type: 'client_credentials',
}).toString(),
headers: {
...genericHeaders,
'Content-Type': 'application/x-www-form-urlencoded',
}
}
)
.then(a => a.json())
.catch(() => {});
if (!oauthResponse || !oauthResponse.access_token) {
return;
}
return bearer = oauthResponse.access_token;
}
const requestApiInfo = (bearerToken, videoId, password) => {
if (password) {
videoId += `:${password}`
}
@ -24,10 +60,8 @@ const requestApiInfo = (videoId, password) => {
`https://api.vimeo.com/videos/${videoId}`,
{
headers: {
Accept: 'application/vnd.vimeo.*+json; version=3.4.2',
'User-Agent': 'Vimeo/10.19.0 (com.vimeo; build:101900.57.0; iOS 17.5.1) Alamofire/5.9.0 VimeoNetworking/5.0.0',
Authorization: 'Basic MTMxNzViY2Y0NDE0YTQ5YzhjZTc0YmU0NjVjNDQxYzNkYWVjOWRlOTpHKzRvMmgzVUh4UkxjdU5FRW80cDNDbDhDWGR5dVJLNUJZZ055dHBHTTB4V1VzaG41bEx1a2hiN0NWYWNUcldSSW53dzRUdFRYZlJEZmFoTTArOTBUZkJHS3R4V2llYU04Qnl1bERSWWxUdXRidjNqR2J4SHFpVmtFSUcyRktuQw==',
'Accept-Language': 'en'
...genericHeaders,
Authorization: `Bearer ${bearerToken}`,
}
}
)
@ -40,7 +74,7 @@ const compareQuality = (rendition, requestedQuality) => {
return Math.abs(quality - requestedQuality);
}
const getDirectLink = (data, quality) => {
const getDirectLink = async (data, quality, subtitleLang) => {
if (!data.files) return;
const match = data.files
@ -56,8 +90,23 @@ const getDirectLink = (data, quality) => {
if (!match) return;
let subtitles;
if (subtitleLang && data.config_url) {
const config = await fetch(data.config_url)
.then(r => r.json())
.catch(() => {});
if (config && config.request?.text_tracks?.length) {
subtitles = config.request.text_tracks.find(
t => t.lang.startsWith(subtitleLang)
);
subtitles = new URL(subtitles.url, "https://player.vimeo.com/").toString();
}
}
return {
urls: match.link,
subtitles,
filenameAttributes: {
resolution: `${match.width}x${match.height}`,
qualityLabel: match.rendition,
@ -136,14 +185,33 @@ export default async function(obj) {
if (quality < 240) quality = 240;
if (!quality || obj.isAudioOnly) quality = 9000;
const info = await requestApiInfo(obj.id, obj.password);
const bearerToken = await getBearer();
if (!bearerToken) {
return { error: "fetch.fail" };
}
let info = await requestApiInfo(bearerToken, obj.id, obj.password);
let response;
// auth error, try to refresh the token
if (info?.error_code === 8003) {
const newBearer = await getBearer(true);
if (!newBearer) {
return { error: "fetch.fail" };
}
info = await requestApiInfo(newBearer, obj.id, obj.password);
}
// if there's still no info, then return a generic error
if (!info || info.error_code) {
return { error: "fetch.empty" };
}
if (obj.isAudioOnly) {
response = await getHLS(info.config_url, { ...obj, quality });
}
if (!response) response = getDirectLink(info, quality);
if (!response) response = await getDirectLink(info, quality, obj.subtitleLang);
if (!response) response = { error: "fetch.empty" };
if (response.error) {
@ -155,6 +223,10 @@ export default async function(obj) {
artist: info.user.name,
};
if (response.subtitles) {
fileMetadata.sublanguage = obj.subtitleLang;
}
return merge(
{
fileMetadata,

View File

@ -76,7 +76,7 @@ const getVideo = async (ownerId, videoId, accessKey) => {
return video;
}
export default async function ({ ownerId, videoId, accessKey, quality }) {
export default async function ({ ownerId, videoId, accessKey, quality, subtitleLang }) {
const token = await getToken();
if (!token) return { error: "fetch.fail" };
@ -125,8 +125,20 @@ export default async function ({ ownerId, videoId, accessKey, quality }) {
title: video.title.trim(),
}
let subtitles;
if (subtitleLang && video.subtitles?.length) {
const subtitle = video.subtitles.find(
s => s.title.endsWith(".vtt") && s.lang.startsWith(subtitleLang)
);
if (subtitle) {
subtitles = subtitle.url;
fileMetadata.sublanguage = subtitleLang;
}
}
return {
urls: url,
subtitles,
fileMetadata,
filenameAttributes: {
service: "vk",

View File

@ -1,7 +1,7 @@
import HLS from "hls-parser";
import { fetch } from "undici";
import { Innertube, Session } from "youtubei.js";
import { Innertube, Session } from "@imput/youtubei.js";
import { env } from "../../config.js";
import { getCookie } from "../cookie/manager.js";
@ -87,6 +87,83 @@ const cloneInnertube = async (customFetch, useSession) => {
return yt;
}
const getHlsVariants = async (hlsManifest, dispatcher) => {
if (!hlsManifest) {
return { error: "youtube.no_hls_streams" };
}
const fetchedHlsManifest =
await fetch(hlsManifest, { dispatcher })
.then(r => r.status === 200 ? r.text() : undefined)
.catch(() => {});
if (!fetchedHlsManifest) {
return { error: "youtube.no_hls_streams" };
}
const variants = HLS.parse(fetchedHlsManifest).variants.sort(
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
);
if (!variants || variants.length === 0) {
return { error: "youtube.no_hls_streams" };
}
return variants;
}
const getSubtitles = async (info, dispatcher, subtitleLang) => {
const preferredCap = info.captions.caption_tracks.find(caption =>
caption.kind !== 'asr' && caption.language_code.startsWith(subtitleLang)
);
const captionsUrl = preferredCap?.base_url;
if (!captionsUrl) return;
if (!captionsUrl.includes("exp=xpe")) {
let url = new URL(captionsUrl);
url.searchParams.set('fmt', 'vtt');
return {
url: url.toString(),
language: preferredCap.language_code,
}
}
// if we have exp=xpe in the url, then captions are
// locked down and can't be accessed without a yummy potoken,
// so instead we just use subtitles from HLS
const hlsVariants = await getHlsVariants(
info.streaming_data.hls_manifest_url,
dispatcher
);
if (hlsVariants?.error) return;
// all variants usually have the same set of subtitles
const hlsSubtitles = hlsVariants[0]?.subtitles;
if (!hlsSubtitles?.length) return;
const preferredHls = hlsSubtitles.find(
subtitle => subtitle.language.startsWith(subtitleLang)
);
if (!preferredHls) return;
const fetchedHlsSubs =
await fetch(preferredHls.uri, { dispatcher })
.then(r => r.status === 200 ? r.text() : undefined)
.catch(() => {});
const parsedSubs = HLS.parse(fetchedHlsSubs);
if (!parsedSubs) return;
return {
url: parsedSubs.segments[0]?.uri,
language: preferredHls.language,
}
}
export default async function (o) {
const quality = o.quality === "max" ? 9000 : Number(o.quality);
@ -94,7 +171,7 @@ export default async function (o) {
let innertubeClient = o.innertubeClient || env.customInnertubeClient || "IOS";
// HLS playlists from the iOS client don't contain the av1 video format.
if (useHLS && o.format === "av1") {
if (useHLS && o.codec === "av1") {
useHLS = false;
}
@ -104,18 +181,24 @@ export default async function (o) {
// iOS client doesn't have adaptive formats of resolution >1080p,
// so we use the WEB_EMBEDDED client instead for those cases
const useSession =
let useSession =
env.ytSessionServer && (
(
!useHLS
&& innertubeClient === "IOS"
&& (
(quality > 1080 && o.format !== "h264")
|| (quality > 1080 && o.format !== "vp9")
(quality > 1080 && o.codec !== "h264")
|| (quality > 1080 && o.codec !== "vp9")
)
)
);
// we can get subtitles reliably only from the iOS client
if (o.subtitleLang) {
innertubeClient = "IOS";
useSession = false;
}
if (useSession) {
innertubeClient = env.ytSessionInnertubeClient || "WEB_EMBEDDED";
}
@ -222,37 +305,16 @@ export default async function (o) {
return videoQualities.find(qual => qual >= shortestSide);
}
let video, audio, dubbedLanguage,
codec = o.format || "h264", itag = o.itag;
let video, audio, subtitles, dubbedLanguage,
codec = o.codec || "h264", itag = o.itag;
if (useHLS) {
const hlsManifest = info.streaming_data.hls_manifest_url;
if (!hlsManifest) {
return { error: "youtube.no_hls_streams" };
}
const fetchedHlsManifest = await fetch(hlsManifest, {
dispatcher: o.dispatcher,
}).then(r => {
if (r.status === 200) {
return r.text();
} else {
throw new Error("couldn't fetch the HLS playlist");
}
}).catch(() => { });
if (!fetchedHlsManifest) {
return { error: "youtube.no_hls_streams" };
}
const variants = HLS.parse(fetchedHlsManifest).variants.sort(
(a, b) => Number(b.bandwidth) - Number(a.bandwidth)
const variants = await getHlsVariants(
info.streaming_data.hls_manifest_url,
o.dispatcher
);
if (!variants || variants.length === 0) {
return { error: "youtube.no_hls_streams" };
}
if (variants?.error) return variants;
const matchHlsCodec = codecs => (
codecs.includes(hlsCodecList[codec].videoCodec)
@ -403,6 +465,13 @@ export default async function (o) {
if (!video) video = sorted_formats[codec].bestVideo;
}
if (o.subtitleLang && !o.isAudioOnly && info.captions?.caption_tracks?.length) {
const videoSubtitles = await getSubtitles(info, o.dispatcher, o.subtitleLang);
if (videoSubtitles) {
subtitles = videoSubtitles;
}
}
}
if (video?.drm_families || audio?.drm_families) {
@ -426,6 +495,10 @@ export default async function (o) {
}
}
if (subtitles) {
fileMetadata.sublanguage = subtitles.language;
}
const filenameAttributes = {
service: "youtube",
id: o.id,
@ -459,6 +532,15 @@ export default async function (o) {
urls = audio.decipher(innertube.session.player);
}
let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher })
.then(r => r.status === 200)
.catch(() => {});
if (!testMaxCover) {
cover = basicInfo.thumbnail?.[0]?.url;
}
return {
type: "audio",
isAudioOnly: true,
@ -467,7 +549,10 @@ export default async function (o) {
fileMetadata,
bestAudio,
isHLS: useHLS,
originalRequest
originalRequest,
cover,
cropCover: basicInfo.author.endsWith("- Topic"),
}
}
@ -477,7 +562,7 @@ export default async function (o) {
if (useHLS) {
resolution = normalizeQuality(video.resolution);
filenameAttributes.resolution = `${video.resolution.width}x${video.resolution.height}`;
filenameAttributes.extension = hlsCodecList[codec].container;
filenameAttributes.extension = o.container === "auto" ? hlsCodecList[codec].container : o.container;
video = video.uri;
audio = audio.uri;
@ -488,7 +573,7 @@ export default async function (o) {
});
filenameAttributes.resolution = `${video.width}x${video.height}`;
filenameAttributes.extension = codecList[codec].container;
filenameAttributes.extension = o.container === "auto" ? codecList[codec].container : o.container;
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
video = video.decipher(innertube.session.player);
@ -508,6 +593,7 @@ export default async function (o) {
video,
audio,
],
subtitles: subtitles?.url,
filenameAttributes,
fileMetadata,
isHLS: useHLS,

View File

@ -17,7 +17,7 @@ function aliasURL(url) {
if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) {
url.pathname = '/watch';
// parts := ['', 'live' || 'shorts', id, ...rest]
url.search = `?v=${encodeURIComponent(parts[2])}`
url.search = `?v=${encodeURIComponent(parts[2])}`;
}
break;
@ -61,23 +61,23 @@ function aliasURL(url) {
case "b23":
if (url.hostname === 'b23.tv' && parts.length === 2) {
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`);
}
break;
case "dai":
if (url.hostname === 'dai.ly' && parts.length === 2) {
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
url = new URL(`https://dailymotion.com/video/${parts[1]}`);
}
break;
case "facebook":
case "fb":
if (url.searchParams.get('v')) {
url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`)
url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`);
}
if (url.hostname === 'fb.watch') {
url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`)
url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`);
}
break;
@ -92,6 +92,9 @@ function aliasURL(url) {
if (services.vk.altDomains.includes(url.hostname)) {
url.hostname = 'vk.com';
}
if (url.searchParams.get('z')) {
url = new URL(`https://vk.com/${url.searchParams.get('z')}`);
}
break;
case "xhslink":
@ -196,7 +199,7 @@ export function normalizeURL(url) {
);
}
export function extract(url) {
export function extract(url, enabledServices = env.enabledServices) {
if (!(url instanceof URL)) {
url = new URL(url);
}
@ -207,7 +210,7 @@ export function extract(url) {
return { error: "link.invalid" };
}
if (!env.enabledServices.has(host)) {
if (!enabledServices.has(host)) {
// show a different message when youtube is disabled on official instances
// as it only happens when shit hits the fan
if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {

View File

@ -15,7 +15,7 @@ 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 = {}, reader = null;
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit', 'allowedServices']);
/* Expected format pseudotype:
** type KeyFileContents = Record<
@ -24,7 +24,8 @@ const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
** name?: string,
** limit?: number | "unlimited",
** ips?: CIDRString[],
** userAgents?: string[]
** userAgents?: string[],
** allowedServices?: "all" | string[],
** }
** >;
*/
@ -77,6 +78,19 @@ const validateKeys = (input) => {
throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
}
}
if (details.allowedServices) {
if (Array.isArray(details.allowedServices)) {
const invalid_services = details.allowedServices.some(
service => !env.allServices.has(service)
);
if (invalid_services) {
throw "`allowedServices` in details contains an invalid service";
}
} else if (details.allowedServices !== "all") {
throw "details object contains value for `allowedServices` which is not an array or `all`";
}
}
});
}
@ -112,6 +126,14 @@ const formatKeys = (keyData) => {
if (data.userAgents) {
formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);
}
if (data.allowedServices) {
if (Array.isArray(data.allowedServices)) {
formatted[key].allowedServices = new Set(data.allowedServices);
} else {
formatted[key].allowedServices = data.allowedServices;
}
}
}
return formatted;
@ -230,3 +252,15 @@ export const setup = (url) => {
});
}
}
export const getAllowedServices = (key) => {
if (typeof key !== "string") return;
const allowedServices = keys[key.toLowerCase()]?.allowedServices;
if (!allowedServices) return;
if (allowedServices === "all") {
return env.allServices;
}
return allowedServices;
}

215
api/src/stream/ffmpeg.js Normal file
View File

@ -0,0 +1,215 @@
import ffmpeg from "ffmpeg-static";
import { spawn } from "child_process";
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 { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
const metadataTags = new Set([
"album",
"composer",
"genre",
"copyright",
"title",
"artist",
"album_artist",
"track",
"date",
"sublanguage"
]);
const convertMetadataToFFmpeg = (metadata) => {
const args = [];
for (const [ name, value ] of Object.entries(metadata)) {
if (metadataTags.has(name)) {
if (name === "sublanguage") {
args.push('-metadata:s:s:0', `language=${value}`);
continue;
}
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, '')}`); // skipcq: JS-0004
} else {
throw `${name} metadata tag is not supported.`;
}
}
return args;
}
const killProcess = (p) => {
p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
setTimeout(() => {
if (p?.exitCode === null)
p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
}, 5000);
}
const getCommand = (args) => {
if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
}
return [ffmpeg, args]
}
const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
let process;
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
const shutdown = () => (
killProcess(process),
closeResponse(res),
urls.map(destroyInternalStream)
);
try {
const args = [
'-loglevel', '-8',
...ffargs,
];
process = spawn(...getCommand(args), {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe'
],
});
const [,,, muxOutput] = process.stdio;
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader(
'Estimated-Content-Length',
await estimateTunnelLength(streamInfo, estimateMultiplier)
);
pipe(muxOutput, res, shutdown);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
const remux = async (streamInfo, res) => {
const format = streamInfo.filename.split('.').pop();
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
const args = urls.flatMap(url => ['-i', url]);
// if the stream type is merge, we expect two URLs
if (streamInfo.type === 'merge' && urls.length !== 2) {
return closeResponse(res);
}
if (streamInfo.subtitles) {
args.push(
'-i', streamInfo.subtitles,
'-map', `${urls.length}:s`,
'-c:s', format === 'mp4' ? 'mov_text' : 'webvtt',
);
}
if (urls.length === 2) {
args.push(
'-map', '0:v',
'-map', '1:a',
);
} else {
args.push(
'-map', '0:v:0',
'-map', '0:a:0'
);
}
args.push(
'-c:v', 'copy',
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
);
if (format === 'mp4') {
args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
}
if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.has(streamInfo.service)) {
if (streamInfo.service === 'youtube' && format === 'webm') {
args.push('-c:a', 'libopus');
} else {
args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');
}
}
if (streamInfo.metadata) {
args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
}
args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
await render(res, streamInfo, args);
}
const convertAudio = async (streamInfo, res) => {
const args = [
'-i', streamInfo.urls,
'-vn',
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
];
if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
args.push('-ar', '12000');
}
if (streamInfo.audioFormat === 'opus') {
args.push('-vbr', 'off');
}
if (streamInfo.audioFormat === 'mp4a') {
args.push('-movflags', 'frag_keyframe+empty_moov');
}
if (streamInfo.metadata) {
args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
}
args.push(
'-f',
streamInfo.audioFormat === 'm4a' ? 'ipod' : streamInfo.audioFormat,
'pipe:3',
);
await render(
res,
streamInfo,
args,
estimateAudioMultiplier(streamInfo) * 1.1,
);
}
const convertGif = async (streamInfo, res) => {
const args = [
'-i', streamInfo.urls,
'-vf',
'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
'-loop', '0',
'-f', 'gif', 'pipe:3',
];
await render(
res,
streamInfo,
args,
60,
);
}
export default {
remux,
convertAudio,
convertGif,
}

View File

@ -6,6 +6,8 @@ import { handleHlsPlaylist, isHlsResponse, probeInternalHLSTunnel } from "./inte
const CHUNK_SIZE = BigInt(8e6); // 8 MB
const min = (a, b) => a < b ? a : b;
const serviceNeedsChunks = new Set(["youtube", "vk"]);
async function* readChunks(streamInfo, size) {
let read = 0n, chunksSinceTransplant = 0;
while (read < size) {
@ -15,7 +17,7 @@ async function* readChunks(streamInfo, size) {
const chunk = await request(streamInfo.url, {
headers: {
...getHeaders('youtube'),
...getHeaders(streamInfo.service),
Range: `bytes=${read}-${read + CHUNK_SIZE}`
},
dispatcher: streamInfo.dispatcher,
@ -48,7 +50,7 @@ async function* readChunks(streamInfo, size) {
}
}
async function handleYoutubeStream(streamInfo, res) {
async function handleChunkedStream(streamInfo, res) {
const { signal } = streamInfo.controller;
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
@ -56,7 +58,7 @@ async function handleYoutubeStream(streamInfo, res) {
let req, attempts = 3;
while (attempts--) {
req = await fetch(streamInfo.url, {
headers: getHeaders('youtube'),
headers: getHeaders(streamInfo.service),
method: 'HEAD',
dispatcher: streamInfo.dispatcher,
signal
@ -146,8 +148,8 @@ export function internalStream(streamInfo, res) {
streamInfo.headers.delete('icy-metadata');
}
if (streamInfo.service === 'youtube' && !streamInfo.isHLS) {
return handleYoutubeStream(streamInfo, res);
if (serviceNeedsChunks.has(streamInfo.service) && !streamInfo.isHLS) {
return handleChunkedStream(streamInfo, res);
}
return handleGenericStream(streamInfo, res);

View File

@ -41,7 +41,10 @@ export function createStream(obj) {
audioFormat: obj.audioFormat,
isHLS: obj.isHLS || false,
originalRequest: obj.originalRequest
originalRequest: obj.originalRequest,
// url to a subtitle file
subtitles: obj.subtitles,
};
// FIXME: this is now a Promise, but it is not awaited
@ -79,17 +82,39 @@ export function createProxyTunnels(info) {
urls = [urls];
}
const tunnelTemplate = {
type: "proxy",
headers: info?.headers,
requestIP: info?.requestIP,
}
for (const url of urls) {
proxyTunnels.push(
createStream({
...tunnelTemplate,
url,
type: "proxy",
service: info?.service,
headers: info?.headers,
requestIP: info?.requestIP,
originalRequest: info?.originalRequest,
})
);
}
originalRequest: info?.originalRequest
if (info.subtitles) {
proxyTunnels.push(
createStream({
...tunnelTemplate,
url: info.subtitles,
service: `${info?.service}-subtitles`,
})
);
}
if (info.cover) {
proxyTunnels.push(
createStream({
...tunnelTemplate,
url: info.cover,
service: `${info?.service}-cover`,
})
);
}
@ -111,7 +136,7 @@ export function getInternalTunnelFromURL(url) {
return getInternalTunnel(id);
}
export function createInternalStream(url, obj = {}) {
export function createInternalStream(url, obj = {}, isSubtitles) {
assert(typeof url === 'string');
let dispatcher = obj.dispatcher;
@ -132,9 +157,12 @@ export function createInternalStream(url, obj = {}) {
headers = new Map(Object.entries(obj.headers));
}
// subtitles don't need special treatment unlike big media files
const service = isSubtitles ? `${obj.service}-subtitles` : obj.service;
internalStreamCache.set(streamID, {
url,
service: obj.service,
service,
headers,
controller,
dispatcher,
@ -245,6 +273,14 @@ function wrapStream(streamInfo) {
}
} else throw 'invalid urls';
if (streamInfo.subtitles) {
streamInfo.subtitles = createInternalStream(
streamInfo.subtitles,
streamInfo,
/*isSubtitles=*/true
);
}
return streamInfo;
}

43
api/src/stream/proxy.js Normal file
View File

@ -0,0 +1,43 @@
import { Agent, request } from "undici";
import { create as contentDisposition } from "content-disposition-header";
import { destroyInternalStream } from "./manage.js";
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
const defaultAgent = new Agent();
export default async function (streamInfo, res) {
const abortController = new AbortController();
const shutdown = () => (
closeRequest(abortController),
closeResponse(res),
destroyInternalStream(streamInfo.urls)
);
try {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
headers: {
...getHeaders(streamInfo.service),
Range: streamInfo.range
},
signal: abortController.signal,
maxRedirections: 16,
dispatcher: defaultAgent,
});
res.status(statusCode);
for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {
if (headers[headerName]) {
res.setHeader(headerName, headers[headerName]);
}
}
pipe(stream, res, shutdown);
} catch {
shutdown();
}
}

View File

@ -19,6 +19,9 @@ const serviceHeaders = {
},
vk: {
'user-agent': vkClientAgent
},
tiktok: {
referer: 'https://www.tiktok.com/',
}
}

View File

@ -1,4 +1,5 @@
import stream from "./types.js";
import proxy from "./proxy.js";
import ffmpeg from "./ffmpeg.js";
import { closeResponse } from "./shared.js";
import { internalStream } from "./internal.js";
@ -7,23 +8,21 @@ export default async function(res, streamInfo) {
try {
switch (streamInfo.type) {
case "proxy":
return await stream.proxy(streamInfo, res);
return await proxy(streamInfo, res);
case "internal":
return await internalStream(streamInfo.data, res);
case "merge":
return await stream.merge(streamInfo, res);
case "remux":
case "mute":
return await stream.remux(streamInfo, res);
return await ffmpeg.remux(streamInfo, res);
case "audio":
return await stream.convertAudio(streamInfo, res);
return await ffmpeg.convertAudio(streamInfo, res);
case "gif":
return await stream.convertGif(streamInfo, res);
return await ffmpeg.convertGif(streamInfo, res);
}
closeResponse(res);

View File

@ -1,353 +0,0 @@
import { Agent, request } from "undici";
import ffmpeg from "ffmpeg-static";
import { spawn } from "child_process";
import { create as contentDisposition } from "content-disposition-header";
import { env } from "../config.js";
import { destroyInternalStream } from "./manage.js";
import { hlsExceptions } from "../processing/service-config.js";
import { getHeaders, closeRequest, closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
const ffmpegArgs = {
webm: ["-c:v", "copy", "-c:a", "copy"],
mp4: ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"],
m4a: ["-movflags", "frag_keyframe+empty_moov"],
gif: ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"]
}
const metadataTags = [
"album",
"composer",
"genre",
"copyright",
"title",
"artist",
"album_artist",
"track",
"date",
];
const convertMetadataToFFmpeg = (metadata) => {
let args = [];
for (const [ name, value ] of Object.entries(metadata)) {
if (metadataTags.includes(name)) {
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, "")}`); // skipcq: JS-0004
} else {
throw `${name} metadata tag is not supported.`;
}
}
return args;
}
const toRawHeaders = (headers) => {
return Object.entries(headers)
.map(([key, value]) => `${key}: ${value}\r\n`)
.join('');
}
const killProcess = (p) => {
p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
setTimeout(() => {
if (p?.exitCode === null)
p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
}, 5000);
}
const getCommand = (args) => {
if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
}
return [ffmpeg, args]
}
const defaultAgent = new Agent();
const proxy = async (streamInfo, res) => {
const abortController = new AbortController();
const shutdown = () => (
closeRequest(abortController),
closeResponse(res),
destroyInternalStream(streamInfo.urls)
);
try {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
headers: {
...getHeaders(streamInfo.service),
Range: streamInfo.range
},
signal: abortController.signal,
maxRedirections: 16,
dispatcher: defaultAgent,
});
res.status(statusCode);
for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {
if (headers[headerName]) {
res.setHeader(headerName, headers[headerName]);
}
}
pipe(stream, res, shutdown);
} catch {
shutdown();
}
}
const merge = async (streamInfo, res) => {
let process;
const shutdown = () => (
killProcess(process),
closeResponse(res),
streamInfo.urls.map(destroyInternalStream)
);
const headers = getHeaders(streamInfo.service);
const rawHeaders = toRawHeaders(headers);
try {
if (streamInfo.urls.length !== 2) return shutdown();
const format = streamInfo.filename.split('.').pop();
let args = [
'-loglevel', '-8',
'-headers', rawHeaders,
'-i', streamInfo.urls[0],
'-headers', rawHeaders,
'-i', streamInfo.urls[1],
'-map', '0:v',
'-map', '1:a',
]
args = args.concat(ffmpegArgs[format]);
if (hlsExceptions.includes(streamInfo.service) && streamInfo.isHLS) {
if (streamInfo.service === "youtube" && format === "webm") {
args.push('-c:a', 'libopus');
} else {
args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');
}
}
if (streamInfo.metadata) {
args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata))
}
args.push('-f', format, 'pipe:3');
process = spawn(...getCommand(args), {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe'
],
});
const [,,, muxOutput] = process.stdio;
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
pipe(muxOutput, res, shutdown);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
const remux = async (streamInfo, res) => {
let process;
const shutdown = () => (
killProcess(process),
closeResponse(res),
destroyInternalStream(streamInfo.urls)
);
try {
let args = [
'-loglevel', '-8',
'-headers', toRawHeaders(getHeaders(streamInfo.service)),
]
if (streamInfo.service === "twitter") {
args.push('-seekable', '0')
}
args.push(
'-i', streamInfo.urls,
'-c:v', 'copy',
)
if (streamInfo.type === "mute") {
args.push('-an');
}
if (hlsExceptions.includes(streamInfo.service)) {
if (streamInfo.type !== "mute") {
args.push('-c:a', 'aac')
}
args.push('-bsf:a', 'aac_adtstoasc');
}
let format = streamInfo.filename.split('.').pop();
if (format === "mp4") {
args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
}
args.push('-f', format, 'pipe:3');
process = spawn(...getCommand(args), {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe'
],
});
const [,,, muxOutput] = process.stdio;
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
res.setHeader('Estimated-Content-Length', await estimateTunnelLength(streamInfo));
pipe(muxOutput, res, shutdown);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
const convertAudio = async (streamInfo, res) => {
let process;
const shutdown = () => (
killProcess(process),
closeResponse(res),
destroyInternalStream(streamInfo.urls)
);
try {
let args = [
'-loglevel', '-8',
'-headers', toRawHeaders(getHeaders(streamInfo.service)),
]
if (streamInfo.service === "twitter") {
args.push('-seekable', '0');
}
args.push(
'-i', streamInfo.urls,
'-vn'
)
if (streamInfo.audioCopy) {
args.push("-c:a", "copy")
} else {
args.push("-b:a", `${streamInfo.audioBitrate}k`)
}
if (streamInfo.audioFormat === "mp3" && streamInfo.audioBitrate === "8") {
args.push("-ar", "12000");
}
if (streamInfo.audioFormat === "opus") {
args.push("-vbr", "off")
}
if (ffmpegArgs[streamInfo.audioFormat]) {
args = args.concat(ffmpegArgs[streamInfo.audioFormat])
}
if (streamInfo.metadata) {
args = args.concat(convertMetadataToFFmpeg(streamInfo.metadata))
}
args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3');
process = spawn(...getCommand(args), {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe'
],
});
const [,,, muxOutput] = process.stdio;
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);
} catch {
shutdown();
}
}
const convertGif = async (streamInfo, res) => {
let process;
const shutdown = () => (killProcess(process), closeResponse(res));
try {
let args = [
'-loglevel', '-8'
]
if (streamInfo.service === "twitter") {
args.push('-seekable', '0')
}
args.push('-i', streamInfo.urls);
args = args.concat(ffmpegArgs.gif);
args.push('-f', "gif", 'pipe:3');
process = spawn(...getCommand(args), {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe'
],
});
const [,,, muxOutput] = process.stdio;
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);
process.on('close', shutdown);
res.on('finish', shutdown);
} catch {
shutdown();
}
}
export default {
proxy,
merge,
remux,
convertAudio,
convertGif,
}

View File

@ -1,7 +1,7 @@
[
{
"name": "direct video with username and id",
"url": "https://web.facebook.com/100048111287134/videos/1157798148685638/",
"url": "https://web.facebook.com/100071784061914/videos/588631943886661/",
"params": {},
"expected": {
"code": 200,

View File

@ -0,0 +1,42 @@
[
{
"name": "regular video",
"url": "https://www.newgrounds.com/portal/view/938050",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular video (audio only)",
"url": "https://www.newgrounds.com/portal/view/938050",
"params": {
"downloadMode": "audio"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular video (muted)",
"url": "https://www.newgrounds.com/portal/view/938050",
"params": {
"downloadMode": "mute"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "regular music",
"url": "https://www.newgrounds.com/audio/listen/500476",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
}
]

View File

@ -47,6 +47,7 @@
"name": "private video",
"url": "https://vimeo.com/903115595/f14d06da38",
"params": {},
"canFail": true,
"expected": {
"code": 200,
"status": "redirect"
@ -56,6 +57,7 @@
"name": "mature video",
"url": "https://vimeo.com/973212054",
"params": {},
"canFail": true,
"expected": {
"code": 200,
"status": "redirect"

View File

@ -34,9 +34,9 @@ this document is not final and will expand over time. feel free to improve it!
| RATELIMIT_WINDOW | `60` | `120` |
| RATELIMIT_MAX | `20` | `30` |
| SESSION_RATELIMIT_WINDOW | `60` | `60` |
| SESSION_RATELIMIT | `10` | `10` |
| SESSION_RATELIMIT_MAX | `10` | `10` |
| TUNNEL_RATELIMIT_WINDOW | `60` | `60` |
| TUNNEL_RATELIMIT | `40` | `10` |
| TUNNEL_RATELIMIT_MAX | `40` | `10` |
[*view details*](#limits)
@ -61,6 +61,7 @@ this document is not final and will expand over time. feel free to improve it!
| YOUTUBE_SESSION_SERVER | `http://localhost:8080/` |
| YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED` |
| YOUTUBE_ALLOW_BETTER_AUDIO | `1` |
| ENABLE_DEPRECATED_YOUTUBE_HLS | `key` |
[*view details*](#service-specific)
@ -106,11 +107,10 @@ 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.
the value is a string: `never` (default), `session`, or `always`:
- when the var is not defined or set to `never`, all requests will be able to set a preference via `localProcessing` in POST requests.
- 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)
@ -171,7 +171,7 @@ rate limit time window for session creation requests, in **seconds**.
the value is a number.
### SESSION_RATELIMIT
### SESSION_RATELIMIT_MAX
amount of session requests to be allowed within the time window of `SESSION_RATELIMIT_WINDOW`.
the value is a number.
@ -181,7 +181,7 @@ rate limit time window for tunnel (proxy/stream) requests, in **seconds**.
the value is a number.
### TUNNEL_RATELIMIT
### TUNNEL_RATELIMIT_MAX
amount of tunnel requests to be allowed within the time window of `TUNNEL_RATELIMIT_WINDOW`.
the value is a number.
@ -256,3 +256,9 @@ the value is a string.
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`.
### ENABLE_DEPRECATED_YOUTUBE_HLS
the value is a string: `never` (default), `key`, or `always`:
- when the var is not defined or set to `never`, `youtubeHLS` in POST requests will be ignored.
- when set to `key`, only requests from api-key clients will be able to use `youtubeHLS` in POST requests.
- when set to `always`, all requests will be able to use `youtubeHLS` in POST requests.

View File

@ -68,28 +68,30 @@ you can read [the api schema](/api/src/processing/schema.js) directly from code
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` |
| 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` | `string` | `disabled / preferred / forced` | `disabled` |
| `subtitleLang` | `string` | any valid ISO 639-1 language code | *none* |
#### 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` |
| key | type | description/value | default |
|:------------------------|:----------|:--------------------------------------------------|:--------|
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` |
| `youtubeVideoContainer` | `string` | `auto / mp4 / webm / mkv` | `auto` |
| `youtubeDubLang` | `string` | any valid ISO 639-1 language code | *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
body type: `application/json`
@ -120,11 +122,12 @@ the response will always be a JSON object containing the `status` key, which is
| `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)) |
| 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)) |
| `subtitles` | `boolean` | whether tunnels include a subtitle file |
#### output.metadata object
all keys in this table are optional.
@ -140,13 +143,16 @@ all keys in this table are optional.
| `album_artist` | `string` | album's artist or creator name |
| `track` | `string` | track number or position in album |
| `date` | `string` | release date or creation date |
| `sublanguage` | `string` | subtitle language code (ISO 639-2) |
#### 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 |
| key | type | value |
|:------------|:----------|:-----------------------------------------------------------|
| `copy` | `boolean` | defines whether audio codec data is copied |
| `format` | `string` | output audio format |
| `bitrate` | `string` | preferred bitrate of audio format |
| `cover` | `boolean` | whether tunnels include a cover art file (optional) |
| `cropCover` | `boolean` | whether cover art should be cropped to a square (optional) |
### picker response
| key | type | value |

View File

@ -159,7 +159,8 @@ type KeyFileContents = Record<
name?: string,
limit?: number | "unlimited",
ips?: (CIDRString | IPString)[],
userAgents?: string[]
userAgents?: string[],
allowedServices?: "all" | string[],
}
>;
```
@ -179,6 +180,11 @@ where *`UUIDv4String`* is a stringified version of a UUIDv4 identifier.
- when specified, requests with a `user-agent` that does not appear in this array will be rejected.
- when omitted, any user agent can be specified to make requests with that API key.
- **`allowedServices`** is an array of allowed services or `"all"`.
- when `"all"` is specified, the key will be able to access all supported services, even if they're globally disabled via `DISABLED_SERVICES`.
- when an array of services is specified, the key will be able to access only the services included in the array.
- when omitted, the key will use the global list of supported services.
- if both `ips` and `userAgents` are set, the tokens will be limited by both parameters.
- if cobalt detects any problem with your key file, it will be ignored and a warning will be printed to the console.

View File

@ -39,6 +39,10 @@ export const getBranch = async () => {
return process.env.CF_PAGES_BRANCH;
}
if (process.env.WORKERS_CI_BRANCH) {
return process.env.WORKERS_CI_BRANCH;
}
return (await readGit('.git/HEAD'))
?.replace(/^ref: refs\/heads\//, '')
?.trim();

View File

@ -1,6 +1,6 @@
{
"name": "@imput/version-info",
"version": "1.0.0",
"version": "1.0.1",
"description": "helper package for cobalt that provides commit info & version from package file.",
"main": "index.js",
"types": "index.d.ts",

View File

@ -19,6 +19,9 @@ importers:
'@imput/version-info':
specifier: workspace:^
version: link:../packages/version-info
'@imput/youtubei.js':
specifier: ^14.0.0
version: 14.0.0
content-disposition-header:
specifier: 0.6.0
version: 0.6.0
@ -53,14 +56,11 @@ importers:
specifier: 2.6.0
version: 2.6.0
undici:
specifier: ^5.19.1
version: 5.28.4
specifier: ^6.21.3
version: 6.21.3
url-pattern:
specifier: 1.0.3
version: 1.0.3
youtubei.js:
specifier: ^13.4.0
version: 13.4.0
zod:
specifier: ^3.23.8
version: 3.23.8
@ -101,11 +101,11 @@ importers:
specifier: ^5.0.2
version: 5.0.2
'@imput/libav.js-encode-cli':
specifier: 6.7.7
version: 6.7.7
specifier: 6.8.7
version: 6.8.7
'@imput/libav.js-remux-cli':
specifier: ^6.5.7
version: 6.5.7
specifier: ^6.8.7
version: 6.8.7
'@imput/version-info':
specifier: workspace:^
version: link:../packages/version-info
@ -554,15 +554,18 @@ packages:
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
engines: {node: '>=18.18'}
'@imput/libav.js-encode-cli@6.7.7':
resolution: {integrity: sha512-sy0g+IvVHo6pdbfdpAEN8i+LLw2fz5EE+PeX5FZiAOxrA5svmALZtaWtDavTbQ69Yl9vTQB2jZCR2x/NyZndmQ==}
'@imput/libav.js-encode-cli@6.8.7':
resolution: {integrity: sha512-kWZmCwDYOQVSFu1ARsFfd5P0HqEx5TlhDMZFM/o8cWvMv7okCZWzKRMlEvw3EEGkxWkXUsgcf6F65wQEOE/08A==}
'@imput/libav.js-remux-cli@6.5.7':
resolution: {integrity: sha512-41ld+R5aEwllKdbiszOoCf+K614/8bnuheTZnqjgZESwDtX07xu9O8GO0m/Cpsm7O2CyqPZa1qzDx6BZVO15DQ==}
'@imput/libav.js-remux-cli@6.8.7':
resolution: {integrity: sha512-EXyRSaIGDSLs98dFxPsRPWOr0G/cNWPKe94u0Ch4/ZwopDVfi7Z0utekluhowUns09LJ5RN9BuCZwc6slMcaLg==}
'@imput/psl@2.0.4':
resolution: {integrity: sha512-vuy76JX78/DnJegLuJoLpMmw11JTA/9HvlIADg/f8dDVXyxbh0jnObL0q13h+WvlBO4Gk26Pu8sUa7/h0JGQig==}
'@imput/youtubei.js@14.0.0':
resolution: {integrity: sha512-YvTnh53URPlzsmMzqF/DFHZyR9HrpgoWYHzEOklx5OCkwk1/0F/CrO9gqArXw/1oI6GjaTS2CqBd1CzyFZB07A==}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@ -2080,6 +2083,10 @@ packages:
resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==}
engines: {node: '>=14.0'}
undici@6.21.3:
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
engines: {node: '>=18.17'}
unist-util-stringify-position@2.0.3:
resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==}
@ -2181,9 +2188,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
youtubei.js@13.4.0:
resolution: {integrity: sha512-+fmIZU/dWAjsROONrASy1REwVpy6umAPVuoNLr/4iNmZXl84LyBef0n3hrd1Vn9035EuINToGyQcBmifwUEemA==}
zimmerframe@1.1.2:
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
@ -2396,7 +2400,8 @@ snapshots:
dependencies:
levn: 0.4.1
'@fastify/busboy@2.1.1': {}
'@fastify/busboy@2.1.1':
optional: true
'@fontsource/ibm-plex-mono@5.0.13': {}
@ -2415,14 +2420,21 @@ snapshots:
'@humanwhocodes/retry@0.4.1': {}
'@imput/libav.js-encode-cli@6.7.7': {}
'@imput/libav.js-encode-cli@6.8.7': {}
'@imput/libav.js-remux-cli@6.5.7': {}
'@imput/libav.js-remux-cli@6.8.7': {}
'@imput/psl@2.0.4':
dependencies:
punycode: 2.3.1
'@imput/youtubei.js@14.0.0':
dependencies:
'@bufbuild/protobuf': 2.2.5
jintr: 3.3.1
tslib: 2.6.3
undici: 6.21.3
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@ -3960,6 +3972,9 @@ snapshots:
undici@5.28.4:
dependencies:
'@fastify/busboy': 2.1.1
optional: true
undici@6.21.3: {}
unist-util-stringify-position@2.0.3:
dependencies:
@ -4035,13 +4050,6 @@ snapshots:
yocto-queue@0.1.0: {}
youtubei.js@13.4.0:
dependencies:
'@bufbuild/protobuf': 2.2.5
jintr: 3.3.1
tslib: 2.6.3
undici: 5.28.4
zimmerframe@1.1.2: {}
zod@3.23.8: {}

View File

@ -12,11 +12,12 @@ them, you must specify them when building the frontend (or running a vite server
`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. |
| 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. |
| `ENABLE_DEPRECATED_YOUTUBE_HLS` | `true` | enables the youtube HLS settings entry; allows sending the related variable to the processing instance. |
\* 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.

View File

@ -89,7 +89,7 @@ aside from local processing, we put in a ton of effort to make the cobalt web ap
- 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`.
- 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_MAX`, `TUNNEL_RATELIMIT_WINDOW`, `TUNNEL_RATELIMIT_MAX`, `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.
@ -142,7 +142,7 @@ aside from local processing, we put in a ton of effort to make the cobalt web ap
- 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.
like always, you can check [all commits since the 10.5 release on github](https://github.com/imputnet/cobalt/compare/41430ff...a52dde7) 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.

92
web/changelogs/11.2.md Normal file
View File

@ -0,0 +1,92 @@
---
title: "local processing for everyone, subtitles, audio covers, and more"
date: "30 June, 2025"
banner:
file: "meowth_sunrise.webp"
alt: "meowth plush in a forest looking at the rising sun between the trees."
---
it's summertime! even though it's been rainy for us lately, the sun is right on the horizon, just like this cobalt update. we improved local processing, added long-awaited features, and improved a ton of other stuff.
here's what's new since 11.0:
## on-device media processing
local processing is now enabled for everyone by default! it allows for faster downloading, file consistency, and best media compatibility. in this update, we optimized it to work on older browsers, just so no one's missing out on cobalt due to having outdated software.
thanks to local processing, we were able to add **audio covers** in this update. cobalt will automatically add covers/thumbnails from youtube or soundcloud, and it'll be cropped to a square when needed. really cool stuff, and it just works!
please let us know if local processing doesn't work properly on your device, we'll try to improve it!
## video subtitles
we added support for downloading videos with subtitles! in this update, we added full support for subtitles from: `youtube`, `twitter`, `tiktok`, `vimeo`, `loom`, `vk video`, and `rutube`. we'll keep adding support for more services in the future!
to download subtitles, just pick your preferred language in [metadata settings](/settings/metadata#subtitles)! cobalt will add subtitles in this language if they're available.
pro-tip: if you don't need audio, you can save a bit of storage by switching to the "mute" mode on the home page. you'll get a mute video with subtitles and the rest of the metadata!
don't want metadata or subtitles? just [disable metadata](/settings/metadata#metadata) in settings, and cobalt won't add anything.
## youtube downloading
downloading from youtube on the main instance is restored! sorry that it took a bit over a week; we were trying our best to speed it up.
hopefully it'll last for a while, but we think downloading from youtube will get significantly more annoying/complex in next few weeks-months. **right now is the best time to download everything you've been putting off**, either with cobalt or other tools.
**update**: unfortunately it did not last, youtube is unavailable on the main instance again. we will try one more way soon and update this changelog and post about it on socials accordingly.
we're not trying to scare you; it's our educated guess based on what youtube has been doing lately:
- roll out of SABR & related limitations for more clients. SABR is Server ABR, Google's proprietary HLS alternative, controlled by the server.
- growing potoken enforcement.
- various other experiments to restrict "unauthorized access".
we currently have no exact plan on how to handle SABR in cobalt, but we will try to figure it out. for now, we're using youtube clients that don't have it enforced, but we have no clue for how long this will last.
by the way, we also made it possible to [choose any preferred media container](/settings/video#youtube-container) independently from the youtube video codec. could be useful for this occasion!
## general service improvements
- added more metadata to audio files from soundcloud.
- added support for `/groups/` vimeo links.
- added support for ``/v/:id` youtube links.
- added support for new share links from tiktok.
- added support for more vk video links.
- pinterest now returns an appropriate error when a pin is unavailable.
- AI dubs on youtube are no longer accidentally selected as default tracks.
- youtube HLS preference is now deprecated, but can be enabled on a self-hosted instance via `ENABLE_DEPRECATED_YOUTUBE_HLS` in env.
- downloads from vk are now way faster.
## web app improvements
- improved compatibility of local processing & related code with older browsers.
- disabled multithreading in old mobile safari, making it possible to use local processing on iOS 15+.
- local processing workers:
- the fetch worker is now less sensitive to network-related errors and returns a descriptive error whenever necessary.
- the ffmpeg worker now returns an appropriate error when a required stream is missing.
- the generic crash error is now localized.
- added a default file icon in case cobalt can't detect the file type.
- made frontend compatible with static cloudflare workers.
- most used languages in [subtitle](/settings/metadata#subtitles) and [audio track](/settings/audio#youtube-dub) dropdowns are now on top.
- default values of subtitle/audio track dropdowns are now localized.
- translated all UI strings to russian.
- fixed overflow in the processing queue.
- updated a bunch of localization strings.
- slightly updated the update notification & fixed its location in RTL layouts.
## processing instance improvements
- fixed HLS downloading from soundcloud that was accidentally broken in the 11.0 update.
- fixed dynamic env reloading.
- added console messages about dynamic env changes.
- `SESSION_RATELIMIT` is now `SESSION_RATELIMIT_MAX`, but the old name remains valid until the next major update. this is a result of a typo in 11.0, sorry!
- `localProcessing` is now `disabled | preferred | forced`, not a boolean. 11.2 accepts boolean values, but this will be removed in a future version.
- added `subtitleLang`, which is any valid ISO 639-1 language code.
- removed backwards compatibility with `twitterGif` and `tiktokH265`.
- updated `local-processing` response in correlation to addition of subtitles and audio covers.
- a lot of refactoring.
for up-to-date info about instance variables, check the docs on github:
- [processing instance environment variables](https://github.com/imputnet/cobalt/blob/main/docs/api-env-variables.md).
- [api documentation](https://github.com/imputnet/cobalt/blob/main/docs/api.md).
## all changes are on github
as usual, you can check [all commits since the 11.0 release on github](https://github.com/imputnet/cobalt/compare/a52dde7...main) for even more details and exact code changes.
we hope that you enjoy this update and have a great rest of your day!
\~ your friends at imput ❤️

View File

@ -1,6 +1,5 @@
{
"page.general": "what's cobalt?",
"page.faq": "FAQ",
"page.community": "community & support",

View File

@ -15,14 +15,8 @@
"import.body": "importing unknown or corrupted files may unexpectedly alter or break cobalt functionality. only import files that you've personally exported and haven't modified. if you were asked to import this file by someone - don't do it.\n\nwe are not responsible for any harm caused by importing unknown setting files.",
"api.override.title": "processing instance override",
"api.override.body": "{{ value }} is now the processing instance. if you don't trust it, press \"cancel\" and it'll be ignored.\n\nyou can change your choice later in processing settings.",
"safety.custom_instance.body": "custom instances can potentially pose privacy & safety risks.\n\nbad instances can:\n1. redirect you away from cobalt and try to scam you.\n2. log all information about your requests, store it forever, and use it to track you.\n3. serve you malicious files (such as malware).\n4. force you to watch ads, or make you pay for downloading.\n\nafter this point, we can't protect you. please be mindful of what instances to use and always trust your gut. if anything feels off, come back to this page, reset the custom instance, and report it to us on github.",
"processing.ongoing": "cobalt is currently processing media in this tab. going away will abort it. are you sure you want to do this?",
"processing.title.ongoing": "processing will be cancelled",
"clear_cache.title": "clear all cache?",
"clear_cache.body": "all files from the processing queue will be removed and on-device features will take longer to load. this action is immediate and irreversible."
}

View File

@ -31,9 +31,10 @@
"fetch.fail": "something went wrong when fetching info from {{ service }} and i couldn't get anything for you. if this issue sticks, please report it!",
"fetch.critical": "the {{ service }} module returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!",
"fetch.critical.core": "one of the core modules returned an error that i don't recognize. try again in a few seconds, but if this issue sticks, please report it!",
"fetch.empty": "couldn't find any media that i could download for you. are you sure you pasted the right link?",
"fetch.rate": "the processing instance got rate limited by {{ service }}. try again in a few seconds!",
"fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report the issue!",
"fetch.short_link": "couldn't get info from the short link. are you sure it works? if it does and you still get this error, please report this issue!",
"content.too_long": "media you requested is too long. the duration limit on this instance is {{ limit }} minutes. try something shorter instead!",
@ -50,7 +51,7 @@
"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.no_matching_format": "youtube didn't return any acceptable formats. cobalt may not support them or they're re-encoding on youtube's side. try again a bit later, but if this issue sticks, please report it!",
"youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video. try again in a few seconds, but if this issue sticks, please report it!",
"youtube.login": "couldn't get this video because youtube asked the processing instance to prove that it's not a bot. try again in a few seconds, but if it still doesn't work, please report this issue!",
"youtube.token_expired": "couldn't get this video because the youtube token expired and wasn't refreshed. try again in a few seconds, but if it still doesn't work, please report this issue!",

View File

@ -2,6 +2,8 @@
"no_final_file": "no final file output",
"worker_didnt_start": "couldn't start a processing worker",
"generic_error": "processing worker crashed, see console for details",
"fetch.crashed": "fetch worker crashed, see console for details",
"fetch.bad_response": "couldn't access the file tunnel",
"fetch.no_file_reader": "couldn't write a file to cache",
@ -15,5 +17,6 @@
"ffmpeg.no_input_type": "the file's type isn't supported",
"ffmpeg.crashed": "ffmpeg worker crashed, see console for details",
"ffmpeg.no_render": "ffmpeg render is empty, something very odd happened",
"ffmpeg.no_args": "ffmpeg worker didn't get required arguments"
"ffmpeg.no_args": "ffmpeg worker didn't get required arguments",
"ffmpeg.no_audio_channel": "this video has no audio track, nothing to do"
}

View File

@ -10,7 +10,7 @@
"services.title": "supported services",
"services.title_show": "show supported services",
"services.title_hide": "hide supported services",
"services.disclaimer": "cobalt is not affiliated with any of the services listed above.",
"services.disclaimer": "support for a service does not imply affiliation, endorsement, or any form of support other than technical compatibility.",
"tutorial.title": "how to save on ios?",
"tutorial.intro": "to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.",

View File

@ -10,9 +10,6 @@
"page.local": "local processing",
"page.accessibility": "accessibility",
"section.general": "general",
"section.save": "save",
"theme": "theme",
"theme.auto": "auto",
"theme.light": "light",
@ -31,9 +28,12 @@
"video.quality.144": "144p",
"video.quality.description": "if preferred video quality isn't available, next best is picked instead.",
"video.youtube.codec": "youtube codec and container",
"video.youtube.codec": "preferred youtube video codec",
"video.youtube.codec.description": "h264: best compatibility, average quality. max quality is 1080p. \nav1: best quality and efficiency. supports 8k & HDR. \nvp9: same quality as av1, but file is ~2x bigger. supports 4k & HDR.\n\nav1 and vp9 aren't widely supported, you might have to use additional software to play/edit them. cobalt picks next best codec if preferred one isn't available.",
"video.youtube.container": "youtube file container",
"video.youtube.container.description": "when \"auto\" is selected, cobalt will pick the best container automatically depending on selected codec: mp4 for h264; webm for vp9/av1.",
"video.youtube.hls": "youtube hls formats",
"video.youtube.hls.title": "prefer hls for video & audio",
"video.youtube.hls.description": "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.",
@ -63,6 +63,11 @@
"audio.youtube.dub.description": "cobalt will use a dubbed audio track for selected language if it's available. if not, original will be used instead.",
"youtube.dub.original": "original",
"subtitles": "subtitles",
"subtitles.title": "preferred subtitle language",
"subtitles.description": "cobalt will add subtitles to the downloaded file in the preferred language if they're available.\n\nsome services don't have a language selection, and if that's the case, cobalt will add the only available subtitle track if you have any language selected.",
"subtitles.none": "none",
"audio.youtube.better_audio": "youtube audio quality",
"audio.youtube.better_audio.title": "prefer better quality",
"audio.youtube.better_audio.description": "cobalt will try to pick highest quality audio in audio mode. it may not be available depending on youtube's response, current traffic, and server status. custom instances may not support this option.",
@ -80,6 +85,8 @@
"metadata.filename.preview.video": "Video Title - Video Author",
"metadata.filename.preview.audio": "Audio Title - Audio Author",
"filename.preview_desc.video": "video file preview",
"filename.preview_desc.audio": "audio file preview",
"metadata.file": "file metadata",
"metadata.disable.title": "disable file metadata",
@ -138,9 +145,11 @@
"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.saving": "local media processing",
"local.saving.description": "when downloading media, remuxing and transcoding will be done on-device instead of the cloud. you'll see detailed progress in the processing queue.\n\ndisabled: local processing will not be used. processing instances can enforce local processing, so this option may not have effect.\npreferred: media that requires extra processing will be downloaded through the processing queue, but the rest of media will be downloaded by your browser's download manager.\nforced: all media will always be proxied and downloaded through the processing queue.\n\nexclusive on-device features are not affected by this setting, they always run locally.",
"local.saving.disabled": "disabled",
"local.saving.preferred": "preferred",
"local.saving.forced": "forced",
"local.webcodecs": "webcodecs",
"local.webcodecs.title": "use webcodecs for on-device processing",

View File

@ -0,0 +1,5 @@
{
"picker.item.photo": "превью фотографии",
"picker.item.video": "превью видео",
"picker.item.gif": "превью gif"
}

View File

@ -0,0 +1,4 @@
{
"share.qr.expand": "qr-код. нажми, чтобы развернуть.",
"share.qr.collapse": "развёрнутый qr-код. нажми, чтобы свернуть."
}

View File

@ -0,0 +1,5 @@
{
"status.completed": "очередь обработки. все задачи завершены.",
"status.ongoing": "очередь обработки. есть текущие задачи.",
"status.default": "очередь обработки"
}

View File

@ -1,9 +1,12 @@
{
"link_area": "зона вставки ссылки",
"clear_input": "clear input",
"link_area": "поле ввода ссылки",
"clear_input": "очистить поле ввода",
"download": "скачать",
"download.think": "обрабатываю ссылку...",
"download.check": "проверяю загрузку...",
"download.done": "загрузка завершена!",
"download.error": "ошибка загрузки"
"download.done": "загрузка завершена",
"download.error": "ошибка загрузки",
"link_area.turnstile": "поле ввода ссылки. проверяю, что ты не робот.",
"tutorial.shortcut.photos": "добавить команду \"в фото\"",
"tutorial.shortcut.files": "добавить команду \"в файлы\""
}

30
web/i18n/ru/about.json Normal file
View File

@ -0,0 +1,30 @@
{
"page.general": "что такое кобальт?",
"heading.general": "общие условия",
"heading.saving": "скачивание",
"heading.encryption": "шифрование",
"heading.abuse": "сообщение о злоупотреблении",
"heading.motivation": "мотивация",
"heading.licenses": "лицензии",
"heading.summary": "лучший способ сохранять то, что ты любишь",
"page.community": "сообщество и поддержка",
"page.privacy": "конфиденциальность",
"page.terms": "условия и этика",
"page.credits": "благодарности и лицензии",
"heading.testers": "бета-тестеры",
"heading.community": "открытое сообщество",
"heading.local": "обработка на устройстве",
"heading.plausible": "анонимная аналитика трафика",
"heading.cloudflare": "веб-приватность и безопасность",
"heading.responsibility": "ответственности пользователя",
"support.github": "смотри исходный код кобальта, вноси свой вклад или сообщай о проблемах",
"support.discord": "общайся с сообществом и разработчиками кобальта или попроси о помощи",
"support.description.issue": "если ты хочешь сообщить о баге или какой-то другой повторяющейся проблеме, то делай это на github.",
"support.description.help": "используй discord для любых других вопросов. чётко опиши проблему в #cobalt-support, иначе никто не сможет тебе помочь.",
"support.twitter": "следи за обновлениями и разработкой кобальта в своей ленте твиттера",
"support.telegram": "следи за обновлениями кобальта в телеграм-канале",
"support.description.best-effort": "вся поддержка осуществляется по мере возможности и не гарантируется, а ответ может занять какое-то время.",
"heading.privacy_efficiency": "лучшая приватность и эффективность",
"heading.partners": "партнёры",
"support.bluesky": "следи за обновлениями и разработкой кобальта в своей ленте bluesky"
}

View File

@ -0,0 +1,94 @@
<script lang="ts">
import { contacts, docs, partners } from "$lib/env";
import { t } from "$lib/i18n/translations";
import SectionHeading from "$components/misc/SectionHeading.svelte";
import BetaTesters from "$components/misc/BetaTesters.svelte";
</script>
<section id="imput">
<SectionHeading
title="imput"
sectionId="imput"
/>
кобальт сделан с любовью и заботой руками [imput](https://imput.net/) ❤️
мы маленькая команда из двух человек, но мы очень усердно работаем, чтобы делать
классный софт, который приносит пользу всем. если тебе нравится то, что мы
делаем, поддержи нас на [странице донатов](/donate)!
</section>
<section id="testers">
<SectionHeading
title={$t("about.heading.testers")}
sectionId="testers"
/>
огромное спасибо нашим тестерам за то, что они тестировали обновления заранее и
следили за их стабильностью. они ещё помогли нам выпустить cobalt 10!
<BetaTesters />
все ссылки внешние и ведут на их личные сайты или соцсети.
</section>
<section id="partners">
<SectionHeading
title={$t("about.heading.partners")}
sectionId="partners"
/>
часть инфраструктуры кобальта предоставлена нашим давним партнёром,
[royalehosting.net]({partners.royalehosting})!
</section>
<section id="meowbalt">
<SectionHeading
title={$t("general.meowbalt")}
sectionId="meowbalt"
/>
мяубальт — это шустрый маскот кобальта, очень выразительный кот, который любит
быстрый интернет.
весь потрясающий арт мяубальта, который ты видишь в кобальте, был сделан
[GlitchyPSI](https://glitchypsi.xyz/). он ещё и оригинальный создатель этого
персонажа.
imput владеет юридическими правами на дизайн персонажа мяубальта, но не на
конкретные арты, которые были созданы GlitchyPSI.
мы любим мяубальта, поэтому мы вынуждены установить пару правил, чтобы его
защитить:
- ты не можешь использовать дизайн персонажа мяубальта ни в какой форме, кроме
фанарта.
- ты не можешь использовать дизайн или арты мяубальта в коммерческих целях.
- ты не можешь использовать дизайн или арты мяубальта в своих проектах.
- ты не можешь использовать или изменять работы GlitchyPSI с мяубальтом ни в
каком виде.
если ты нарисуешь фанарт мяубальта, не стесняйся делиться им в [нашем
дискорд-сервере](/about/community), мы с нетерпением ждём!
</section>
<section id="licenses">
<SectionHeading
title={$t("about.heading.licenses")}
sectionId="licenses"
/>
код api (сервера обработки) кобальта — open source и распространяется по
лицензии [AGPL-3.0]({docs.apiLicense}).
код фронтенда кобальта — [source first](https://sourcefirst.com/) и
распространяется по лицензии [CC-BY-NC-SA 4.0]({docs.webLicense}).
нам пришлось сделать фронтенд source first, чтобы грифтеры не наживались на
нашем труде и не создавали вредоносные клоны для обмана людей и порче нашей
репутации. кроме коммерческого использования, у этого типа лицензии те же
принципы, что и у многих open source лицензий.
мы используем много опенсорсных библиотек, но также создаём и распространяем
свои собственные. полный список зависимостей можно посмотреть на
[github]({contacts.github})!
</section>

View File

@ -0,0 +1,79 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { contacts, docs } from "$lib/env";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
<section id="summary">
<SectionHeading
title={$t("about.heading.summary")}
sectionId="summary"
/>
кобальт помогает сохранять что угодно с твоих любимых сайтов: видео, аудио, фото
или гифки. просто вставь ссылку и вперёд!
никакой рекламы, трекеров, платных подписок и прочей ерунды. просто удобное
веб-приложение, которое работает где угодно и когда угодно.
</section>
<section id="motivation">
<SectionHeading
title={$t("about.heading.motivation")}
sectionId="motivation"
/>
кобальт был создан для всеобщего блага, чтобы защитить людей от рекламы и
вредоносных программ, которые навязывают альтернативные загрузчики. мы верим,
что лучший софт — безопасный, открытый и доступный. все проекты imput следуют
этим принципам.
</section>
<section id="privacy-efficiency">
<SectionHeading
title={$t("about.heading.privacy_efficiency")}
sectionId="privacy-efficiency"
/>
все запросы к бэкенду анонимны, и вся инфа о потенциальных файловых туннелях
зашифрована. у нас строгая политика нулевых логов, мы *никогда* не храним
идентифицирующую инфу о людях и никого не отслеживаем.
если запрос требует дополнительной обработки, например ремукса или
транскодирования, то кобальт обрабатывает медиафайлы прямо на твоём устройстве.
это обеспечивает максимальную эффективность и приватность.
если твоё устройство не поддерживает локальную обработку, то вместо неё
используется серверная обработка в реальном времени. в этом сценарии
обработанные медиаданные передаются напрямую клиенту, никогда не сохраняясь на
диске сервера.
ты можешь [включить принудительное туннелирование](/settings/privacy#tunnel),
чтобы ещё сильнее повысить приватность. когда оно включено, кобальт будет
туннелировать все скачиваемые файлы, а не только те, которым это необходимо.
никто не узнает, откуда и что ты скачиваешь, даже твой провайдер. всё, что они
увидят, это то, что ты используешь инстанс кобальта.
</section>
<section id="community">
<SectionHeading
title={$t("about.heading.community")}
sectionId="community"
/>
кобальт используют бесчисленные артисты, преподаватели и прочие создатели
контента, чтобы заниматься любимым делом. мы всегда на связи с нашим сообществом
и работаем вместе, чтобы делать кобальт ещё полезнее. не стесняйся
[присоединиться к разговору](/about/community)!
мы верим, что будущее интернета — открытое и свободное, поэтому кобальт
опубликован с [открытым исходным кодом](https://sourcefirst.com/) и его можно
легко [захостить самому]({docs.instanceHosting}).
если твой друг хостит инстанс обработки, просто попроси у него домен и [добавь
его в настройках инстанса](/settings/instances#community).
ты можешь посмотреть исходный код и внести свой вклад [на
github]({contacts.github}) в любое время. мы рады любым предложениям и помощи!
</section>

View File

@ -0,0 +1,129 @@
<script lang="ts">
import env from "$lib/env";
import { t } from "$lib/i18n/translations";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
<section id="general">
<SectionHeading
title={$t("about.heading.general")}
sectionId="general"
/>
политика конфиденциальности кобальта проста: мы ничего не собираем и не храним о
тебе. то, что ты делаешь, — это исключительно твоё дело, а не наше или чьё-либо
ещё.
эти условия применяются только при использовании официального инстанса кобальта.
в других случаях, возможно, придётся обратиться к хостеру инстанса за точной
информацией.
</section>
<section id="local">
<SectionHeading
title={$t("about.heading.local")}
sectionId="local"
/>
инструменты, которые используют обработку на устройстве, работают офлайн,
локально и никогда никуда не отправляют обработанные данные. они явно помечены
как таковые, когда это применимо.
</section>
<section id="saving">
<SectionHeading
title={$t("about.heading.saving")}
sectionId="saving"
/>
при использовании функции сохранения, кобальту может понадобиться проксировать
или ремуксировать/транскодировать файлы. если это так, то для этой цели
создаётся временный туннель, и минимально необходимая информация о медиа
хранится в течение 90 секунд.
на неизменённом и официальном инстансе кобальта **все данные туннеля шифруются
ключом, к которому имеет доступ только конечный пользователь**.
зашифрованные данные туннеля могут включать:
- название исходного сервиса.
- исходные ссылки на медиафайлы.
- необходимые внутренние аргументы для различения типов обработки.
- ключевые метаданные файла (сгенерированное имя, заголовок, автор, год
создания, данные об авторских правах).
- минимальная информация об исходном запросе, которая может быть использована
для восстановления туннеля после ошибки ссылки во время скачивания.
эти данные безвозвратно удаляются из оперативной памяти сервера через 90 секунд.
никто не имеет доступа к кэшированным данным туннеля, даже владельцы инстансов,
если исходный код кобальта не изменён.
медиаданные из туннелей нигде не хранятся/кэшируются. всё обрабатывается в
реальном времени, даже при ремуксинге и транскодировании. туннели кобальта
работают как анонимный прокси.
если твоё устройство поддерживает локальную обработку, то зашифрованный туннель
содержит намного меньше информации, потому что она возвращается клиенту.
смотри [соответствующий исходный код на
github](https://github.com/imputnet/cobalt/tree/main/api/src/stream), чтобы
узнать больше о том, как это работает.
</section>
<section id="encryption">
<SectionHeading
title={$t("about.heading.encryption")}
sectionId="encryption"
/>
временно хранящиеся данные туннеля шифруются с использованием стандарта AES-256.
ключи расшифровки включены только в ссылку доступа и никогда не
логируются/кэшируются/хранятся где-либо. только конечный пользователь имеет
доступ к ссылке и ключам шифрования. ключи генерируются уникально для каждого
запрошенного туннеля.
</section>
{#if env.PLAUSIBLE_ENABLED}
<section id="plausible">
<SectionHeading
title={$t("about.heading.plausible")}
sectionId="plausible"
/>
мы используем [plausible](https://plausible.io/), чтобы знать приблизительное
число активных пользователей кобальта, полностью анонимно. никакая
идентифицирующая информация о тебе или твоих запросах никогда не хранится. все
данные анонимизированы и агрегированы. мы сами хостим и управляем [инстансом
plausible](https://{env.PLAUSIBLE_HOST}/), который использует кобальт.
plausible не использует куки и полностью соответствует GDPR, CCPA и PECR.
если ты хочешь отказаться от анонимной аналитики, то это можно сделать в
[настройках приватности](/settings/privacy#analytics). после отказа скрипт
plausible не будет загружаться.
[узнай больше о преданности plausible к
приватности](https://plausible.io/privacy-focused-web-analytics).
</section>
{/if}
<section id="cloudflare">
<SectionHeading
title={$t("about.heading.cloudflare")}
sectionId="cloudflare"
/>
мы используем сервисы cloudflare для:
- защиты от ddos и абьюза.
- защиты от ботов (cloudflare turnstile).
- хостинга и деплоя статического веб-приложения (cloudflare workers).
всё это необходимо для обеспечения лучшего опыта для всех. cloudflare — наиболее
приватный и надёжный провайдер всех упомянутых решений из всех известных нам
провайдеров.
cloudflare полностью соответствует требованиям GDPR и HIPAA.
[узнай больше о преданности cloudflare к
приватности](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/).
</section>

View File

@ -0,0 +1,69 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
<section id="general">
<SectionHeading
title={$t("about.heading.general")}
sectionId="general"
/>
эти условия применяются только при использовании официального инстанса кобальта.
в других случаях, возможно, придётся обратиться к хостеру инстанса за точной
информацией.
</section>
<section id="saving">
<SectionHeading
title={$t("about.heading.saving")}
sectionId="saving"
/>
функция сохранения упрощает скачивание контента из интернета, и мы не несём
никакой ответственности за то, как будет использоваться сохранённый контент.
серверы обработки работают как продвинутые прокси и никогда не записывают
запрошенный контент на диск. всё происходит в оперативной памяти и полностью
удаляется после завершения туннеля. у нас нет логов загрузок, и мы не можем
никого идентифицировать.
подробнее о том, как работают туннели, можно узнать в [политике
конфиденциальности](/about/privacy).
</section>
<section id="responsibility">
<SectionHeading
title={$t("about.heading.responsibility")}
sectionId="responsibility"
/>
ты (конечный пользователь) несёшь ответственность за то, что делаешь с нашими
инструментами, как используешь и распространяешь полученный контент. пожалуйста,
уважай чужой труд и всегда указывай авторов. убедись, что ты не нарушаешь
никаких условий или лицензий.
при использовании в образовательных целях всегда ссылайся на источники и
указывай авторов.
добросовестное использование и указание авторства приносят пользу всем.
</section>
<section id="abuse">
<SectionHeading
title={$t("about.heading.abuse")}
sectionId="abuse"
/>
у нас нет возможности автоматически выявлять злоупотребления, так как кобальт
полностью анонимен. однако, есть возможность сообщить нам о такой деятельности
по почте, и мы сделаем всё возможное, чтобы принять нужные меры вручную:
abuse[at]imput.net
**этот адрес не предназначен для поддержки пользователей. ты не получишь ответ,
если твой запрос не связан со злоупотреблениями.**
если у тебя возникли проблемы с работой кобальта, то ты можешь обратиться за
помощью любым удобным способом на [странице поддержки и
сообщества](/about/community).
</section>

27
web/i18n/ru/button.json Normal file
View File

@ -0,0 +1,27 @@
{
"download.audio": "скачать аудио",
"import": "импортировать",
"copied": "скопировано",
"copy": "скопировать",
"share": "поделиться",
"download": "скачать",
"no": "нет",
"yes": "да",
"save": "скачать",
"continue": "продолжить",
"done": "готово",
"reset": "сбросить",
"cancel": "отменить",
"export": "экспортировать",
"gotit": "понятно",
"copy.section": "скопировать ссылку на раздел",
"clear_input": "очистить поле ввода",
"show_input": "показать ввод",
"hide_input": "скрыть ввод",
"restore_input": "восстановить ввод",
"clear": "очистить",
"remove": "убрать",
"clear_cache": "очистить кэш",
"retry": "повторить",
"delete": "удалить"
}

16
web/i18n/ru/dialog.json Normal file
View File

@ -0,0 +1,16 @@
{
"picker.title": "что сохранить?",
"saving.title": "как сохранить?",
"saving.timeout": "кобальт попытался сохранить файл автоматически, но твой браузер остановил это. выбери способ вручную.",
"reset_settings.title": "сбросить все настройки?",
"reset_settings.body": "ты точно хочешь сбросить все настройки? это действие мгновенное и необратимое.",
"picker.description.phone": "нажми на то, что хочешь скачать. картинки также можно скачать долгим нажатием.",
"picker.description.desktop": "кликни на то, что хочешь скачать. картинки также можно скачать через контекстное меню.",
"picker.description.ios": "нажми на то, что хочешь скачать через команду siri. картинки также можно скачать долгим нажатием.",
"saving.blocked": "кобальт попытался открыть файл в новой вкладке, но твой браузер заблокировал это. разреши всплывающие окна для кобальта, чтобы избежать этого в следующий раз.",
"clear_cache.title": "очистить весь кэш?",
"import.body": "импорт неизвестных или повреждённых файлов может неожиданно изменить или сломать работу кобальта. импортируй только те файлы, которые ты экспортировал сам и не изменял. если кто-то попросил тебя импортировать этот файл — не делай этого.\n\nмы не несём ответственности за любой вред, причинённый импортом неизвестных файлов настроек.",
"safety.custom_instance.body": "сторонние инстансы могут быть опасны для твоей приватности и безопасности.\n\nвредоносные инстансы могут:\n1. перенаправлять тебя с кобальта и пытаться обмануть.\n2. записывать всю информацию о твоих запросах, хранить её вечно и использовать для слежки за тобой.\n3. скачивать вредоносные файлы (например, вирусы).\n4. заставлять тебя смотреть рекламу или платить за скачивание.\n\nпосле этого момента мы не сможем тебя защитить. пожалуйста, будь осторожен с выбором инстанса и всегда доверяй своей интуиции. если что-то кажется странным, то вернись на эту страницу, сбрось пользовательский инстанс и сообщи нам об этом на github.",
"clear_cache.body": "все файлы из очереди обработки будут удалены и локальные фичи займут больше времени на загрузку. это действие мгновенное и необратимое.",
"safety.title": "важное предупреждение о безопасности"
}

29
web/i18n/ru/donate.json Normal file
View File

@ -0,0 +1,29 @@
{
"card.once": "одноразовый донат",
"card.option.30": "обед для двоих",
"body.no_bullshit": "мы считаем, что интернет не должен быть страшным. поэтому в кобальте никогда не будет рекламы или другого вредоносного контента. это обещание, за которым мы стоим горой. всё, что мы делаем, создаётся с учётом конфиденциальности, доступности и простоты использования, что делает кобальт доступным для всех.",
"card.custom": "своя сумма (от $2)",
"card.processor": "через {{value}}",
"card.option.5": "чашка кофе",
"card.option.50": "10кг кошачьего корма",
"card.option.1599": "базовый макбук",
"card.option.4900": "10,000 яблок",
"share.title": "поделись кобальтом с другом",
"alternative.title": "альтернативные способы доната",
"alt.copy": "{{ value }}. адрес криптокошелька. нажми, чтобы скопировать.",
"alt.open": "{{ value }}. нажми, чтобы открыть.",
"body.motivation": "кобальт помогает продюсерам, преподавателям, видеомейкерам и многим другим заниматься тем, что они любят. это особый сервис, создающийся с любовью, а не ради прибыли.",
"body.keep_going": "если кобальт помог тебе, пожалуйста, подумай над тем, чтобы поддержать нашу работу! ты можешь поддержать нас донатом, либо поделившись кобальтом с другом. каждый донат очень ценится и помогает нам продолжать работу над кобальтом и другими проектами.",
"card.recurring": "регулярный донат",
"card.option.10": "большая пицца",
"card.option.15": "полный обед",
"card.custom.submit": "своя сумма",
"banner.title": "Поддержи безопасный\nи открытый Интернет",
"banner.subtitle": "поддержи imput или поделись\nкобальтом с другом",
"card.option.100": "один год доменов",
"card.option.200": "аэрогриль",
"card.option.500": "крутое офисное кресло",
"card.option.7398": "флагманский макбук",
"card.option.8629": "маленький земельный участок",
"card.option.9433": "джакузи класса люкс"
}

8
web/i18n/ru/error.json Normal file
View File

@ -0,0 +1,8 @@
{
"pipeline.missing_response_data": "инстанс обработки не ответил с нужной информацией о файле, поэтому я не могу создать задачи для локальной обработки. попробуй ещё раз через несколько секунд и сообщи о проблеме, если она не исчезнет!",
"captcha_too_long": "cloudflare turnstile слишком долго проверяет, что ты не бот. попробуй ещё раз, но если снова появится эта ошибка, то можно попробовать: отключить странные расширения браузера, сменить сеть, использовать другой браузер или проверить устройство на наличие вредоносных программ.",
"import.invalid": "в этом файле нет совместимых настроек кобальта для импорта. ты уверен, что это тот файл?",
"tunnel.probe": "не удалось протестировать этот туннель. возможно, твой браузер или настройки сети блокируют доступ к одному из серверов кобальта. ты уверен, что у тебя нет каких-то странных расширений для браузера?",
"import.unknown": "не удалось загрузить данные из файла. возможно, он повреждён или не того формата. вот ошибка, которую я получил:\n\n{{ value }}",
"import.no_data": "из этого файла нечего загружать. ты уверен, что это тот файл?"
}

View File

@ -0,0 +1,51 @@
{
"auth.jwt.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что токен доступа недействителен. попробуй ещё раз через пару секунд или перезагрузи страницу!",
"auth.turnstile.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что решение капчи недействительно. попробуй ещё раз через пару секунд или перезагрузи страницу!",
"auth.key.not_api_key": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!",
"auth.key.invalid": "ключ доступа недействителен. сбрось его в настройках инстанса и используй правильный!",
"auth.key.ua_not_allowed": "ты не можешь использовать этот ключ доступа с текущего юзер агента. попробуй другой клиент или устройство!",
"unreachable": "не удалось подключиться к инстансу обработки. проверь своё интернет-соединение и попробуй ещё раз!",
"rate_exceeded": "ты делаешь слишком много запросов. попробуй снова через {{ limit }}с.",
"capacity": "кобальт сейчас перегружен и не может обработать твой запрос. попробуй ещё раз через пару секунд!",
"service.unsupported": "этот сервис ещё не поддерживается. ты уверен, что вставил правильную ссылку?",
"service.audio_not_supported": "этот сервис не поддерживает извлечение аудио. попробуй ссылку с другого сервиса!",
"link.invalid": "твоя ссылка недействительна или этот сервис ещё не поддерживается. ты точно вставил правильную ссылку?",
"fetch.fail": "что-то пошло не так при получении инфы из {{ service }}, и я ничего не смог для тебя достать. если эта проблема не исчезнет, пожалуйста, сообщи о ней!",
"auth.jwt.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует токен доступа. попробуй ещё раз через пару секунд или перезагрузи страницу!",
"auth.key.missing": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!",
"generic": "что-то пошло не так, и я не смог ничего найти для тебя. попробуй ещё раз через пару секунд. если проблема останется, пожалуйста, сообщи об этом!",
"auth.turnstile.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует решение капчи. попробуй ещё раз через пару секунд или перезагрузи страницу!",
"unknown_response": "не удалось прочитать ответ от инстанса обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!",
"auth.key.not_found": "использованный тобой ключ доступа не найден. ты уверен, что у этого инстанса есть твой ключ?",
"invalid_body": "не удалось отправить запрос на инстанс обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!",
"auth.key.invalid_ip": "не удалось распарсить твой ip-адрес. что-то пошло совсем не так, пожалуйста, сообщи об этой ошибке!",
"auth.key.ip_not_allowed": "ты не можешь использовать этот ключ доступа с текущего ip-адреса. попробуй другой инстанс или сеть!",
"timed_out": "инстанс обработки слишком долго не отвечал. возможно, он сейчас перегружен, попробуй ещё раз через пару секунд!",
"service.disabled": "этот сервис обычно поддерживается кобальтом, но он отключён на этом инстансе. попробуй ссылку с другого сервиса!",
"link.unsupported": "{{ service }} поддерживается, но я не смог распознать твою ссылку. ты точно вставил правильную?",
"fetch.critical": "модуль {{ service }} вернул ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!",
"content.too_long": "запрошенное медиа слишком длинное. лимит длительности на этом инстансе — {{ limit }}мин. попробуй что-нибудь покороче!",
"content.video.unavailable": "я не могу получить доступ к этому видео. оно может быть ограничено со стороны {{ service }}. попробуй другую ссылку!",
"content.video.private": "это видео приватное, поэтому я не могу получить к нему доступ. измени его видимость или попробуй другое!",
"content.video.region": "это видео ограничено по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!",
"content.paid": "этот контент требует покупки. кобальт не может скачивать платный контент. попробуй другую ссылку!",
"content.post.private": "не удалось получить инфу об этом посте, потому что он от закрытого аккаунта. попробуй другую ссылку!",
"youtube.token_expired": "не удалось получить это видео, потому что токен youtube истёк и не был обновлён. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!",
"youtube.no_hls_streams": "не удалось найти ни одного подходящего HLS-потока для этого видео. попробуй скачать его без HLS!",
"youtube.api_error": "youtube что-то обновил в своём api, и я не смог получить инфу об этом видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!",
"youtube.drm": "это youtube-видео защищено widevine DRM, так что я не могу его скачать. попробуй другую ссылку!",
"fetch.rate": "{{ service }} ограничил частоту запросов от инстанса обработки. попробуй ещё раз через пару секунд!",
"youtube.temporary_disabled": "скачивание с youtube временно отключено из-за ограничений со стороны youtube. мы уже ищем способы их обойти.\n\nприносим извинения за неудобства и делаем всё возможное, чтобы восстановить эту функциональность. следи за обновлениями в соцсетях или на github!",
"content.video.age": "это видео ограничено по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!",
"content.region": "этот контент ограничен по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!",
"youtube.no_matching_format": "youtube не вернул ни одного подходящего формата. возможно, кобальт их не поддерживает или же они перекодируются на стороне youtube. попробуй ещё раз чуть позже, а если проблема останется, сообщи о ней!",
"youtube.no_session_tokens": "не удалось получить необходимые токены сессии для ютуба. это может быть вызвано ограничением со стороны ютуба. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!",
"youtube.decipher": "youtube обновил свой алгоритм расшифровки, и из-за этого мне не удалось получить информацию о видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!",
"fetch.short_link": "не удалось получить инфу по короткой ссылке. ты уверен, что она работает? если да, а ты всё равно видишь эту ошибку, пожалуйста, сообщи о ней!",
"fetch.empty": "не смог найти медиа, которое я мог бы скачать для тебя. ты уверен, что вставил правильную ссылку?",
"content.post.age": "этот пост ограничен по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!",
"youtube.login": "не удалось получить это видео, потому что youtube попросил доказать, что инстанс обработки — не бот. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!",
"content.video.live": "это видео сейчас идёт в прямом эфире, поэтому я ещё не могу его скачать. подожди, пока стрим закончится, и попробуй снова!",
"content.post.unavailable": "не удалось ничего найти об этом посте. его видимость может быть ограничена или он может не существовать. убедись, что твоя ссылка работает, и попробуй снова через пару секунд!",
"fetch.critical.core": "один из основных модулей выдал ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!"
}

View File

@ -0,0 +1,19 @@
{
"fetch.no_file_reader": "не смог записать файл в кэш",
"worker_didnt_start": "не смог запустить воркер обработки",
"ffmpeg.probe_failed": "не удалось проверить этот файл, возможно, он повреждён или не поддерживается",
"fetch.network_error": "скачивание было прервано из-за проблем с сетью",
"no_final_file": "финальный файл пропал",
"fetch.corrupted_file": "файл был скачан не полностью, попробуй ещё раз",
"fetch.crashed": "воркер скачивания вылетел, смотри детали в консоли",
"fetch.bad_response": "не смог получить туннель файла",
"fetch.empty_tunnel": "туннель файла пустой, попробуй ещё раз через несколько минут",
"ffmpeg.no_input_type": "тип этого файла не поддерживается",
"ffmpeg.crashed": "воркер ffmpeg вылетел, смотри детали в консоли",
"ffmpeg.no_input_format": "формат этого файла не поддерживается",
"ffmpeg.out_of_memory": "не хватает памяти, не могу продолжить",
"ffmpeg.no_render": "рендер ffmpeg пустой, произошло что-то очень странное",
"ffmpeg.no_args": "воркер ffmpeg не получил нужные аргументы",
"generic_error": "воркер обработки вылетел, смотри детали в консоли",
"ffmpeg.no_audio_channel": "у этого видео нет аудиодорожки, ничего нельзя сделать"
}

View File

@ -2,6 +2,5 @@
"cobalt": "кобальт",
"meowbalt": "мяубальт",
"beta": "бета",
"embed.description": "сохраняй то, что любишь: без рекламы, трекеров и прочей чепухи. кобальт создан с любовью, а не с целью заработать."
"embed.description": "кобальт помогает тебе сохранять то, что ты любишь, без рекламы, трекеров и прочей ерунды. просто вставь ссылку!"
}

View File

@ -0,0 +1,4 @@
{
"update.title": "доступно обновление!",
"update.subtext": "нажми, чтобы обновить"
}

13
web/i18n/ru/queue.json Normal file
View File

@ -0,0 +1,13 @@
{
"state.waiting": "в очереди",
"state.starting.fetch": "начинаю скачивание",
"state.running.remux": "ремуксирую",
"state.retrying": "повторяю",
"state.starting.encode": "начинаю транскодирование",
"title": "очередь обработки",
"state.starting": "начинаю",
"state.starting.remux": "начинаю ремуксинг",
"state.running.fetch": "скачиваю",
"state.running.encode": "транскодирую",
"stub": "тут пока что ничего нет, только мы вдвоём.\nпопробуй скачать что-нибудь!"
}

View File

@ -0,0 +1,7 @@
{
"accept": "поддерживаемые форматы: {{ formats }}.",
"title": "перетащи или выбери файл",
"title.drop": "скинь файл сюда!",
"title.multiple": "перетащи или выбери файлы",
"title.drop.multiple": "скинь файлы сюда!"
}

8
web/i18n/ru/remux.json Normal file
View File

@ -0,0 +1,8 @@
{
"bullet.purpose.description": "ремукс исправляет любые проблемы с файлом, например, отсутствие информации о времени. он помогает повысить совместимость со старыми программами, такими как vegas pro и windows media player.",
"bullet.purpose.title": "что делает ремукс?",
"bullet.explainer.title": "как он работает?",
"bullet.explainer.description": "ремукс берёт существующие данные кодека и копирует их в новый медиаконтейнер. это происходит без потери качества, так как медиаданные не перекодируются.",
"bullet.privacy.title": "локальная обработка",
"bullet.privacy.description": "кобальт ремуксирует файлы локально. файлы никогда не покидают твоё устройство, поэтому обработка происходит практически мгновенно."
}

View File

@ -10,5 +10,15 @@
"services.title": "поддерживаемые сервисы",
"services.title_show": "показать поддерживаемые сервисы",
"services.title_hide": "скрыть поддерживаемые сервисы",
"services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской."
"services.disclaimer": "поддержка сервиса не означает аффилированность, одобрение или любую другую форму поддержки, кроме технической совместимости.\n\nдеятельность владельца facebook и instagram запрещена на территории РФ и признана экстремистской.",
"tutorial.step.1": "добавь команды-компаньоны:",
"tutorial.step.2": "нажми кнопку \"поделиться\" в диалоге сохранения кобальта.",
"tutorial.step.3": "выбери нужную команду в окне обмена.",
"tutorial.shortcut.photos": "в фото",
"tutorial.shortcut.files": "в файлы",
"tutorial.title": "как сохранить на ios?",
"tutorial.intro": "чтобы удобно сохранять файлы на ios, придётся использовать команду siri в меню обмена.",
"tutorial.outro": "эти команды siri будут работать только из приложения кобальта, использовать их из других приложений не получится.",
"tooltip.captcha": "cloudflare turnstile проверяет, что ты не бот. подожди, пожалуйста!",
"label.community_instance": "инстанс сообщества"
}

131
web/i18n/ru/settings.json Normal file
View File

@ -0,0 +1,131 @@
{
"theme.auto": "авто",
"theme.light": "светлая",
"audio.bitrate.kbps": "кб/с",
"theme.dark": "тёмная",
"audio.youtube.dub": "звуковая дорожка youtube",
"video.quality.max": "8k+",
"page.video": "видео",
"page.audio": "аудио",
"video.quality.1440": "1440p",
"video.quality.1080": "1080p",
"video.quality.720": "720p",
"video.quality.480": "480p",
"video.quality.360": "360p",
"video.quality.240": "240p",
"video.quality.144": "144p",
"metadata.file": "метаданные файла",
"saving.title": "метод сохранения",
"saving.ask": "спросить",
"saving.download": "скачать",
"saving.share": "поделиться",
"saving.copy": "скопировать",
"language": "язык",
"language.preferred.title": "предпочитаемый язык",
"privacy.analytics": "анонимная аналитика трафика",
"audio.tiktok.original.title": "скачивать оригинальный звук",
"privacy.tunnel": "туннелирование",
"privacy.tunnel.title": "всегда туннелировать файлы",
"audio.format.mp3": "mp3",
"audio.format.ogg": "ogg",
"audio.format.wav": "wav",
"audio.format.opus": "opus",
"page.privacy": "приватность",
"theme": "тема",
"video.quality": "качество видео",
"video.twitter.gif": "twitter/x",
"video.quality.2160": "4k",
"audio.format": "формат аудио",
"audio.bitrate": "битрейт аудио",
"audio.tiktok.original": "tiktok",
"metadata.disable.title": "отключить метаданные",
"language.auto.title": "автоматический выбор",
"metadata.disable.description": "название, исполнитель и другая информация не будут добавлены в файл.",
"language.preferred.description": "этот язык будет использоваться когда автоматический выбор отключен. любой непереведённый текст будет отображаться на английском языке.\n\nмы используем переводы, предоставленные сообществом. они могут быть неточными или неполными.",
"audio.youtube.dub.description": "cobalt будет использовать дублированную аудиодорожку для выбранного языка, если она доступна. в противном случае будет использоваться оригинальная.",
"language.auto.description": "если доступен перевод, то кобальт будет использовать язык твоего браузера. в ином случае будет использоваться английский.",
"theme.description": "авто тема переключается между светлой и тёмной темой в зависимости от системной темы.",
"page.debug": "инфа для зануд",
"page.appearance": "внешний вид",
"page.instances": "инстансы",
"page.advanced": "продвинутые",
"page.accessibility": "общедоступность",
"page.metadata": "метаданные",
"page.local": "локальная обработка",
"video.youtube.codec": "предпочитаемый кодек для youtube",
"audio.youtube.dub.title": "предпочитаемый язык озвучки",
"metadata.filename.basic": "базовый",
"video.twitter.gif.title": "конвертировать зацикленные видео в GIF",
"metadata.filename.description": "стиль названий файлов используется только для файлов, туннелированных через кобальт. некоторые сервисы поддерживают только классический стиль.",
"youtube.dub.original": "оригинальный",
"metadata.filename.pretty": "красивый",
"metadata.filename.nerdy": "занудный",
"audio.tiktok.original.description": "кобальт будет скачивать оригинальный звук из видео без каких-либо изменений от автора поста.",
"metadata.filename": "стиль названий файлов",
"metadata.filename.classic": "классический",
"video.twitter.gif.description": "GIF конвертация неэффективна, финальный файл может быть огромным и в плохом качестве.",
"audio.youtube.better_audio.title": "предпочитать лучшее качество",
"audio.format.description": "все форматы кроме \"лучшего\" конвертируются из исходного формата, поэтому возможна небольшая потеря качества. когда выбран \"лучший\" формат, аудио остаётся в оригинальном формате, если это возможно.",
"audio.youtube.better_audio.description": "кобальт будет пытаться выбрать самое качественное аудио в режиме скачивания аудио. оно может быть недоступно в зависимости от ответа youtube, текущей нагрузки и состояния сервера. на кастомных инстансах эта опция может не поддерживаться.",
"audio.youtube.better_audio": "качество аудио с youtube",
"video.quality.description": "если предпочитаемое качество недоступно, то выбирается следующий лучший вариант.",
"video.youtube.codec.description": "h264: наилучшая совместимость, среднее качество. максимальное качество — 1080p.\nav1: наилучшее качество и сжатие. поддерживает 8k и HDR.\nvp9: то же качество, что и у av1, но файл в ~2x больше. поддерживает 4k & HDR.\n\nav1 и vp9 не очень широко поддерживаются, возможно придётся использовать дополнительное ПО для их проигрывания/обработки. кобальт выбирает следующий лучший кодек, если предпочитаемый недоступен.",
"audio.bitrate.description": "битрейт применяется только при конвертации аудио в формат с потерями. кобальт не может улучшить качество исходного аудио, поэтому выбор битрейта выше 128 кб/с может увеличить размер файла без заметной разницы в звуке. воспринимаемое качество может различаться в зависимости от формата.",
"video.h265": "high efficiency video codec",
"video.h265.title": "использовать h265 для видео",
"video.h265.description": "позволяет скачивать видео с tiktok и xiaohongshu в более высоком качестве, но с потерей совместимости.",
"video.youtube.hls": "форматы hls для youtube",
"video.youtube.hls.description": "в этом режиме доступны только кодеки h264 и vp9. оригинальный аудио кодек aac перекодируется для совместимости, поэтому качество аудио может быть хуже чем у варианта без HLS.\n\nэта функция экспериментальна, поэтому может быть убрана или изменена в будущем.",
"audio.format.best": "лучший",
"video.youtube.hls.title": "предпочитать hls для видео и аудио",
"metadata.filename.preview.video": "Название Видео - Автор Видео",
"metadata.filename.preview.audio": "Название Аудио - Автор Аудио",
"filename.preview_desc.video": "превью видео файла",
"filename.preview_desc.audio": "превью аудио файла",
"saving.description": "предпочтительный способ сохранения файла или ссылки с кобальта. если предпочитаемый метод недоступен или что-то пойдёт не так, кобальт спросит тебя как поступить.",
"accessibility.transparency.description": "уменьшает прозрачность поверхностей и выключает эффекты размытия. также может улучшить работу интерфейса на менее мощных устройствах.",
"accessibility.transparency.title": "уменьшить визуальную прозрачность",
"accessibility.visual": "интерфейс",
"accessibility.haptics": "вибрация",
"accessibility.behavior": "поведение",
"accessibility.auto_queue.description": "очередь обработки не будет открываться автоматически при добавлении новой задачи. прогресс всё равно будет отображаться, и ты всё равно сможешь открыть её вручную.",
"privacy.analytics.learnmore": "узнай больше о преданности plausible к приватности.",
"accessibility.motion.description": "анимации и переходы будут отключены, когда это возможно.",
"accessibility.haptics.title": "отключить вибрацию",
"accessibility.haptics.description": "вся вибрация будет отключена.",
"accessibility.auto_queue.title": "не открывать очередь обработки",
"privacy.analytics.description": "анонимная аналитика трафика нужна, чтобы знать приблизительное количество активных пользователей кобальта. идентифицирующая информация о тебе никогда не сохраняется. все обрабатываемые данные анонимизированы и агрегированы.\n\nмы используем собственный инстанс plausible, который не использует куки и полностью соответствует требованиям GDPR, CCPA и PECR.",
"privacy.tunnel.description": "cobalt скроет твой ip адрес, информацию о браузере и обойдёт местные сетевые ограничения. когда включено, у всех файлов будут читаемые названия вместо абракадабры.",
"accessibility.motion.title": "уменьшить движение",
"privacy.analytics.title": "не участвовать в аналитике",
"advanced.debug": "отладка",
"advanced.debug.description": "даёт доступ к странице с различной информацией, которая может быть полезна для отладки. никак не меняет поведение кобальта.",
"advanced.debug.title": "включить функции для зануд",
"processing.community": "инстансы сообщества",
"processing.enable_custom.description": "кобальт будет использовать сторонний инстанс обработки, если ты так решишь. несмотря на то, что у кобальта есть некоторые меры безопасности, мы не несём ответственности за любой ущерб, причинённый сторонним инстансом, так как мы его не контролируем.\n\nбудь осторожен с тем, какие инстансы ты используешь, и убедись, что их хостят люди, которым ты доверяешь.",
"processing.enable_custom.title": "использовать сторонний инстанс",
"local.saving": "локальная обработка медиа",
"local.saving.description": "при скачивании медиа, ремуксинг и транскодирование будут выполняться на устройстве, а не в облаке. ты увидишь подробный прогресс в очереди обработки.\n\nникогда: локальная обработка не будет использоваться. инстансы обработки могут принудительно включать эту функцию, поэтому эта опция может не иметь эффекта.\nиногда: медиафайлы, требующие дополнительной обработки, будут загружаться через очередь обработки, но остальные медиафайлы будут загружаться менеджером загрузок твоего браузера.\nвсегда: все медиафайлы всегда будут проксироваться и загружаться через очередь обработки.\n\nэксклюзивные функции на устройстве не зависят от этой настройки, они всегда работают локально.",
"advanced.settings_data": "данные настроек",
"local.webcodecs.description": "при декодировании или кодировании файлов кобальт будет пытаться использовать webcodecs. эта функция позволяет обрабатывать медиафайлы с ускорением на GPU, так что всё декодирование и кодирование будет намного быстрее.\n\nдоступность и стабильность этой функции зависят от возможностей твоего устройства и браузера. что-то может сломаться или работать некорректно.",
"processing.access_key": "ключ доступа к инстансу",
"advanced.local_storage": "локальное хранилище",
"local.webcodecs": "webcodecs",
"local.webcodecs.title": "использовать webcodecs для локальной обработки",
"processing.access_key.title": "использовать ключ доступа",
"processing.custom_instance.input.alt_text": "домен стороннего инстанса",
"tabs": "навигация",
"tabs.hide_remux": "скрыть страницу ремукса",
"tabs.hide_remux.description": "если ты не пользуешься ремуксом, то его можно скрыть из панели навигации.",
"processing.access_key.description": "кобальт будет использовать этот ключ для запросов к инстансу обработки вместо других методов аутентификации. убедись, что инстанс поддерживает api ключи!",
"processing.access_key.input.alt_text": "ключ доступа u-u-i-d",
"video.youtube.container": "контейнер файла для youtube",
"video.youtube.container.description": "когда выбран \"авто\" контейнер, кобальт автоматически подберёт оптимальный контейнер в зависимости от выбранного кодека: mp4 для h264; webm для vp9/av1.",
"subtitles.description": "кобальт добавит субтитры к скачанному файлу на предпочитаемом языке, если они доступны.\n\nнекоторые сервисы не имеют выбора языка, и в таком случае кобальт добавит единственную доступную дорожку субтитров, если выбран любой язык.",
"subtitles": "субтитры",
"subtitles.title": "язык субтитров",
"subtitles.none": "никакой",
"local.saving.disabled": "никогда",
"local.saving.preferred": "иногда",
"local.saving.forced": "всегда"
}

View File

@ -3,6 +3,6 @@
"settings": "настройки",
"updates": "новости",
"donate": "донаты",
"about": "инфа",
"about": "инфо",
"remux": "ремукс"
}

4
web/i18n/ru/updates.json Normal file
View File

@ -0,0 +1,4 @@
{
"button.next": "перейти к предыдущему обновлению ({{ value }})",
"button.previous": "перейти к следующему обновлению ({{ value }})"
}

View File

@ -1,6 +1,6 @@
{
"name": "@imput/cobalt-web",
"version": "11.0.2",
"version": "11.3",
"type": "module",
"private": true,
"scripts": {
@ -27,8 +27,8 @@
"@eslint/js": "^9.5.0",
"@fontsource/ibm-plex-mono": "^5.0.13",
"@fontsource/redaction-10": "^5.0.2",
"@imput/libav.js-encode-cli": "6.7.7",
"@imput/libav.js-remux-cli": "^6.5.7",
"@imput/libav.js-encode-cli": "6.8.7",
"@imput/libav.js-remux-cli": "^6.8.7",
"@imput/version-info": "workspace:^",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.20.7",

View File

@ -46,7 +46,7 @@
padding: 8px 12px 8px 8px;
pointer-events: all;
gap: 8px;
margin-right: 71px;
margin: 0 64px;
margin-top: calc(env(safe-area-inset-top) + 8px);
box-shadow:
var(--button-box-shadow),
@ -81,7 +81,7 @@
display: flex;
flex-direction: column;
text-align: start;
font-size: 13px;
font-size: 12.5px;
}
.subtext {

View File

@ -21,11 +21,13 @@
import IconDownload from "@tabler/icons-svelte/IconDownload.svelte";
import IconExclamationCircle from "@tabler/icons-svelte/IconExclamationCircle.svelte";
import IconFile from "@tabler/icons-svelte/IconFile.svelte";
import IconMovie from "@tabler/icons-svelte/IconMovie.svelte";
import IconMusic from "@tabler/icons-svelte/IconMusic.svelte";
import IconPhoto from "@tabler/icons-svelte/IconPhoto.svelte";
const itemIcons = {
file: IconFile,
video: IconMovie,
audio: IconMusic,
image: IconPhoto,

View File

@ -40,7 +40,7 @@
}
.tooltip-body {
max-width: 180px;
max-width: 190px;
position: relative;
pointer-events: none;

View File

@ -75,7 +75,7 @@
</div>
<div class="item-text">
<div class="preview">{`${videoFilePreview}.${youtubeVideoExt}`}</div>
<div class="subtext description">video file preview</div>
<div class="subtext description">{$t("settings.filename.preview_desc.video")}</div>
</div>
</div>
<div id="filename-preview-audio" class="filename-preview-item">
@ -84,7 +84,7 @@
</div>
<div class="item-text">
<div class="preview">{`${audioFilePreview}.${audioFormat}`}</div>
<div class="subtext description">audio file preview</div>
<div class="subtext description">{$t("settings.filename.preview_desc.audio")}</div>
</div>
</div>
</div>

View File

@ -42,7 +42,7 @@
</h4>
<div class="right-side">
<span class="selector-current" aria-hidden="true">
{selectedTitle.split("(", 2)[0]}
{selectedTitle?.split("(", 2)[0]}
</span>
<IconSelector />
</div>

View File

@ -17,7 +17,7 @@
let screenWidth: number;
$: currentPageTitle = $page.url.pathname.split("/").at(-1);
$: currentPageTitle = $page.url.pathname.split("/").pop();
$: stringPageTitle =
currentPageTitle !== pageName
? ` / ${$t(`${pageName}.page.${currentPageTitle}`)}`

View File

@ -1,3 +1,4 @@
import env from "$lib/env";
import API from "$lib/api/api";
import settings from "$lib/state/settings";
import lazySettingGetter from "$lib/settings/lazy-get";
@ -49,21 +50,23 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
alwaysProxy: getSetting("save", "alwaysProxy"),
downloadMode: getSetting("save", "downloadMode"),
subtitleLang: getSetting("save", "subtitleLang"),
filenameStyle: getSetting("save", "filenameStyle"),
disableMetadata: getSetting("save", "disableMetadata"),
audioBitrate: getSetting("save", "audioBitrate"),
audioFormat: getSetting("save", "audioFormat"),
audioBitrate: getSetting("save", "audioBitrate"),
tiktokFullAudio: getSetting("save", "tiktokFullAudio"),
youtubeDubLang: getSetting("save", "youtubeDubLang"),
youtubeBetterAudio: getSetting("save", "youtubeBetterAudio"),
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
videoQuality: getSetting("save", "videoQuality"),
youtubeHLS: getSetting("save", "youtubeHLS"),
youtubeVideoCodec: getSetting("save", "youtubeVideoCodec"),
youtubeVideoContainer: getSetting("save", "youtubeVideoContainer"),
youtubeHLS: env.ENABLE_DEPRECATED_YOUTUBE_HLS ? getSetting("save", "youtubeHLS") : undefined,
convertGif: getSetting("save", "convertGif"),
allowH265: getSetting("save", "allowH265"),
convertGif: getSetting("save", "convertGif"),
}
const response = await API.request(selectedRequest);

View File

@ -28,6 +28,7 @@ const device = {
directDownload: false,
haptics: false,
defaultLocalProcessing: false,
multithreading: false,
},
userAgent: "sveltekit server",
}
@ -38,7 +39,7 @@ if (browser) {
const iPhone = ua.includes("iphone os");
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
const iosVersion = Number(ua.match(/iphone os (\d+)_/)?.[1]);
const iosVersion = Number(ua.match(/version\/(\d+)/)?.[1]);
const modernIOS = iPhone && iosVersion >= 18;
const iOS = iPhone || iPad;
@ -83,10 +84,9 @@ if (browser) {
// so they're enabled only on ios 18+ for now
haptics: modernIOS,
// enable local processing by default on
// desktop, ios 18+, and firefox on android
defaultLocalProcessing: !device.is.mobile || modernIOS ||
(device.is.android && !device.browser.chrome),
// enable local processing by default everywhere but android chrome
defaultLocalProcessing: !(device.is.android && device.browser.chrome),
multithreading: !iOS || iosVersion >= 18,
};
device.userAgent = navigator.userAgent;

View File

@ -9,13 +9,18 @@ const getEnv = (_key: string) => {
}
}
const getEnvBool = (key: string) => {
const value = getEnv(key);
return value && ['1', 'true'].includes(value.toLowerCase());
}
const variables = {
HOST: getEnv('HOST'),
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
DEFAULT_API: getEnv('DEFAULT_API'),
// temporary variable until webcodecs features are ready for testing
ENABLE_WEBCODECS: !!getEnv('ENABLE_WEBCODECS'),
ENABLE_WEBCODECS: getEnvBool('ENABLE_WEBCODECS'),
ENABLE_DEPRECATED_YOUTUBE_HLS: getEnvBool('ENABLE_DEPRECATED_YOUTUBE_HLS'),
}
const contacts = {

View File

@ -32,7 +32,6 @@ export default class LibAVWrapper {
this.libav = constructor({
...options,
variant: undefined,
yesthreads: true,
base: '/_libav'
});
}

View File

@ -0,0 +1,86 @@
import { t as translation } from "$lib/i18n/translations";
import type { FromReadable } from "$lib/types/generic";
const languages = [
// most popular languages are first, according to
// https://en.wikipedia.org/wiki/List_of_languages_by_number_of_native_speakers
"en", "es", "pt", "fr", "ru",
"zh", "vi", "hi", "bn", "ja",
"af", "am", "ar", "as", "az",
"be", "bg", "bs", "ca", "cs",
"da", "de", "el", "et", "eu",
"fa", "fi", "fil", "gl", "gu",
"hr", "hu", "hy", "id", "is",
"it", "iw", "ka", "kk", "ko",
"km", "kn", "ky", "lo", "lt",
"lv", "mk", "ml", "mn", "mr",
"ms", "my", "no", "ne", "nl",
"or", "pa", "pl", "ro", "si",
"sk", "sl", "sq", "sr", "sv",
"sw", "ta", "te", "th", "tr",
"uk", "ur", "uz", "zh-Hans",
"zh-Hant", "zh-CN", "zh-HK",
"zh-TW", "zu"
];
export const youtubeDubLanguages = ["original", ...languages] as const;
export const subtitleLanguages = ["none", ...languages] as const;
export type YoutubeDubLang = typeof youtubeDubLanguages[number];
export type SubtitleLang = typeof subtitleLanguages[number];
type TranslationFunction = FromReadable<typeof translation>;
const namedLanguages = (
languages: typeof youtubeDubLanguages | typeof subtitleLanguages,
t: TranslationFunction,
) => {
return languages.reduce((obj, lang) => {
let name: string;
switch (lang) {
case "original":
name = t("settings.youtube.dub.original");
break;
case "none":
name = t("settings.subtitles.none");
break;
default: {
let intlName;
try {
intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang);
} catch { /* */ };
name = `${intlName || "unknown"} (${lang})`;
break;
}
}
return {
...obj,
[lang]: name,
};
}, {}) as Record<typeof languages[number], string>;
}
export const namedYoutubeDubLanguages = (t: TranslationFunction) => {
return namedLanguages(youtubeDubLanguages, t);
}
export const namedSubtitleLanguages = (t: TranslationFunction) => {
return namedLanguages(subtitleLanguages, t);
}
export const getBrowserLanguage = (): YoutubeDubLang => {
if (typeof navigator !== 'undefined') {
const browserLanguage = navigator.language as YoutubeDubLang;
if (youtubeDubLanguages.includes(browserLanguage)) {
return browserLanguage;
}
const shortened = browserLanguage.split('-')[0] as YoutubeDubLang;
if (youtubeDubLanguages.includes(shortened)) {
return shortened;
}
}
return "original";
}

View File

@ -3,7 +3,7 @@ import { defaultLocale } from "$lib/i18n/translations";
import type { CobaltSettings } from "$lib/types/settings";
const defaultSettings: CobaltSettings = {
schemaVersion: 5,
schemaVersion: 6,
advanced: {
debug: false,
useWebCodecs: false,
@ -22,7 +22,8 @@ const defaultSettings: CobaltSettings = {
},
save: {
alwaysProxy: false,
localProcessing: device.supports.defaultLocalProcessing || false,
localProcessing:
device.supports.defaultLocalProcessing ? "preferred" : "disabled",
audioBitrate: "128",
audioFormat: "mp3",
disableMetadata: false,
@ -33,7 +34,9 @@ const defaultSettings: CobaltSettings = {
tiktokFullAudio: false,
convertGif: true,
videoQuality: "1080",
subtitleLang: "none",
youtubeVideoCodec: "h264",
youtubeVideoContainer: "auto",
youtubeDubLang: "original",
youtubeHLS: false,
youtubeBetterAudio: false,

View File

@ -5,8 +5,9 @@ import type {
CobaltSettingsV3,
CobaltSettingsV4,
CobaltSettingsV5,
CobaltSettingsV6,
} from "$lib/types/settings";
import { getBrowserLanguage } from "$lib/settings/youtube-lang";
import { getBrowserLanguage } from "$lib/settings/audio-sub-language";
type Migrator = (s: AllPartialSettingsWithSchema) => AllPartialSettingsWithSchema;
@ -80,6 +81,20 @@ const migrations: Record<number, Migrator> = {
return out as AllPartialSettingsWithSchema;
},
[6]: (settings: AllPartialSettingsWithSchema) => {
const out = settings as RecursivePartial<CobaltSettingsV6>;
out.schemaVersion = 6;
if (settings?.save) {
if ("localProcessing" in settings.save) {
out.save!.localProcessing =
settings.save.localProcessing ? "preferred" : "disabled";
}
}
return out as AllPartialSettingsWithSchema;
},
};
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {

View File

@ -1,5 +1,5 @@
import type { Optional } from '$lib/types/generic';
import defaultSettings from './defaults'
import defaultSettings from '$lib/settings/defaults';
import {
downloadModeOptions,
filenameStyleOptions,
@ -9,7 +9,7 @@ import {
youtubeVideoCodecOptions,
type PartialSettings,
} from '$lib/types/settings';
import { youtubeLanguages } from './youtube-lang';
import { youtubeDubLanguages } from '$lib/settings/audio-sub-language';
function validateTypes(input: unknown, reference = defaultSettings as unknown) {
if (typeof input === 'undefined')
@ -81,7 +81,7 @@ export function validateSettings(settings: PartialSettings) {
[ settings?.save?.videoQuality , videoQualityOptions ],
[ settings?.save?.youtubeVideoCodec, youtubeVideoCodecOptions ],
[ settings?.save?.savingMethod , savingMethodOptions ],
[ settings?.save?.youtubeDubLang , youtubeLanguages ]
[ settings?.save?.youtubeDubLang , youtubeDubLanguages ]
])
);
}

View File

@ -1,115 +0,0 @@
export const youtubeLanguages = [
"original",
"af",
"am",
"ar",
"as",
"az",
"be",
"bg",
"bn",
"bs",
"ca",
"cs",
"da",
"de",
"el",
"en",
"es",
"et",
"eu",
"fa",
"fi",
"fil",
"fr",
"gl",
"gu",
"hi",
"hr",
"hu",
"hy",
"id",
"is",
"it",
"iw",
"ja",
"ka",
"kk",
"km",
"kn",
"ko",
"ky",
"lo",
"lt",
"lv",
"mk",
"ml",
"mn",
"mr",
"ms",
"my",
"no",
"ne",
"nl",
"or",
"pa",
"pl",
"pt",
"ro",
"ru",
"si",
"sk",
"sl",
"sq",
"sr",
"sv",
"sw",
"ta",
"te",
"th",
"tr",
"uk",
"ur",
"uz",
"vi",
"zh",
"zh-Hans",
"zh-Hant",
"zh-CN",
"zh-HK",
"zh-TW",
"zu"
] as const;
export type YoutubeLang = typeof youtubeLanguages[number];
export const namedYoutubeLanguages = () => {
return youtubeLanguages.reduce((obj, lang) => {
const intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang);
let name = `${intlName} (${lang})`;
if (lang === "original") {
name = lang;
}
return {
...obj,
[lang]: name,
};
}, {}) as Record<YoutubeLang, string>;
}
export const getBrowserLanguage = (): YoutubeLang => {
if (typeof navigator === 'undefined')
return "original";
const browserLanguage = navigator.language as YoutubeLang;
if (youtubeLanguages.includes(browserLanguage))
return browserLanguage;
const shortened = browserLanguage.split('-')[0] as YoutubeLang;
if (youtubeLanguages.includes(shortened))
return shortened;
return "original";
}

View File

@ -1,4 +1,5 @@
import { AbstractStorage } from "./storage";
import { uuid } from "$lib/util";
export class MemoryStorage extends AbstractStorage {
#chunkSize: number;
@ -48,7 +49,7 @@ export class MemoryStorage extends AbstractStorage {
}
}
return new File(outputView, crypto.randomUUID());
return new File(outputView, uuid());
}
#expand(size: number) {

View File

@ -1,4 +1,5 @@
import { AbstractStorage } from "./storage";
import { uuid } from "$lib/util";
const COBALT_PROCESSING_DIR = "cobalt-processing-data";
@ -19,7 +20,7 @@ export class OPFSStorage extends AbstractStorage {
static async init() {
const root = await navigator.storage.getDirectory();
const cobaltDir = await root.getDirectoryHandle(COBALT_PROCESSING_DIR, { create: true });
const handle = await cobaltDir.getFileHandle(crypto.randomUUID(), { create: true });
const handle = await cobaltDir.getFileHandle(uuid(), { create: true });
const reader = await handle.createSyncAccessHandle();
return new this(cobaltDir, handle, reader);
@ -41,16 +42,29 @@ export class OPFSStorage extends AbstractStorage {
}
static async #computeIsAvailable() {
let tempFile = uuid(), ok = true;
if (typeof navigator === 'undefined')
return false;
if ('storage' in navigator && 'getDirectory' in navigator.storage) {
try {
await navigator.storage.getDirectory();
return true;
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle(tempFile, { create: true });
const syncAccess = await handle.createSyncAccessHandle();
syncAccess.close();
} catch {
return false;
ok = false;
}
try {
const root = await navigator.storage.getDirectory();
await root.removeEntry(tempFile, { recursive: true });
} catch {
ok = false;
}
return ok;
}
return false;

View File

@ -4,28 +4,28 @@ import { ffmpegMetadataArgs } from "$lib/util";
import { createDialog } from "$lib/state/dialogs";
import { addItem } from "$lib/state/task-manager/queue";
import { openQueuePopover } from "$lib/state/queue-visibility";
import { uuid } from "$lib/util";
import type { CobaltQueueItem } from "$lib/types/queue";
import type { CobaltCurrentTasks } from "$lib/types/task-manager";
import type { CobaltPipelineItem, CobaltPipelineResultFileType } from "$lib/types/workers";
import { resultFileTypes, type CobaltPipelineItem, type CobaltPipelineResultFileType } from "$lib/types/workers";
import type { CobaltLocalProcessingResponse, CobaltSaveRequestBody } from "$lib/types/api";
export const getMediaType = (type: string) => {
const kind = type.split('/')[0];
const kind = type.split('/')[0] as CobaltPipelineResultFileType;
// can't use .includes() here for some reason
if (kind === "video" || kind === "audio" || kind === "image") {
if (resultFileTypes.includes(kind)) {
return kind;
}
}
export const createRemuxPipeline = (file: File) => {
const parentId = crypto.randomUUID();
const parentId = uuid();
const mediaType = getMediaType(file.type);
const pipeline: CobaltPipelineItem[] = [{
worker: "remux",
workerId: crypto.randomUUID(),
workerId: uuid(),
parentId,
workerArgs: {
files: [file],
@ -54,14 +54,6 @@ export const createRemuxPipeline = (file: File) => {
}
}
const mediaIcons: { [key: string]: CobaltPipelineResultFileType } = {
merge: "video",
mute: "video",
audio: "audio",
gif: "image",
remux: "video"
}
const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => {
const ffargs = ["-c:v", "copy"];
@ -71,6 +63,13 @@ const makeRemuxArgs = (info: CobaltLocalProcessingResponse) => {
ffargs.push("-an");
}
if (info.output.subtitles) {
ffargs.push(
"-c:s",
info.output.filename.endsWith(".mp4") ? "mov_text" : "webvtt"
);
}
ffargs.push(
...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : [])
);
@ -83,11 +82,27 @@ const makeAudioArgs = (info: CobaltLocalProcessingResponse) => {
return;
}
const ffargs = [
"-vn",
const ffargs = [];
if (info.audio.cover && info.audio.format === "mp3") {
ffargs.push(
"-map", "0",
"-map", "1",
...(info.audio.cropCover ? [
"-c:v", "mjpeg",
"-vf", "scale=-1:720,crop=720:720",
] : [
"-c:v", "copy",
]),
);
} else {
ffargs.push("-vn");
}
ffargs.push(
...(info.audio.copy ? ["-c:a", "copy"] : ["-b:a", `${info.audio.bitrate}k`]),
...(info.output.metadata ? ffmpegMetadataArgs(info.output.metadata) : [])
];
);
if (info.audio.format === "mp3" && info.audio.bitrate === "8") {
ffargs.push("-ar", "12000");
@ -140,7 +155,7 @@ export const createSavePipeline = (
return showError("pipeline.missing_response_data");
}
const parentId = oldTaskId || crypto.randomUUID();
const parentId = oldTaskId || uuid();
const pipeline: CobaltPipelineItem[] = [];
// reverse is needed for audio (second item) to be downloaded first
@ -149,7 +164,7 @@ export const createSavePipeline = (
for (const tunnel of tunnels) {
pipeline.push({
worker: "fetch",
workerId: crypto.randomUUID(),
workerId: uuid(),
parentId,
workerArgs: {
url: tunnel,
@ -157,43 +172,45 @@ export const createSavePipeline = (
});
}
let ffargs: string[];
let workerType: 'encode' | 'remux';
if (info.type !== "proxy") {
let ffargs: string[];
let workerType: 'encode' | 'remux';
if (["merge", "mute", "remux"].includes(info.type)) {
workerType = "remux";
ffargs = makeRemuxArgs(info);
} else if (info.type === "audio") {
const args = makeAudioArgs(info);
if (["merge", "mute", "remux"].includes(info.type)) {
workerType = "remux";
ffargs = makeRemuxArgs(info);
} else if (info.type === "audio") {
const args = makeAudioArgs(info);
if (!args) {
if (!args) {
return showError("pipeline.missing_response_data");
}
workerType = "encode";
ffargs = args;
} else if (info.type === "gif") {
workerType = "encode";
ffargs = makeGifArgs();
} else {
console.error("unknown work type: " + info.type);
return showError("pipeline.missing_response_data");
}
workerType = "encode";
ffargs = args;
} else if (info.type === "gif") {
workerType = "encode";
ffargs = makeGifArgs();
} else {
console.error("unknown work type: " + info.type);
return showError("pipeline.missing_response_data");
}
pipeline.push({
worker: workerType,
workerId: crypto.randomUUID(),
parentId,
dependsOn: pipeline.map(w => w.workerId),
workerArgs: {
files: [],
ffargs,
output: {
type: info.output.type,
format: info.output.filename.split(".").pop(),
pipeline.push({
worker: workerType,
workerId: uuid(),
parentId,
dependsOn: pipeline.map(w => w.workerId),
workerArgs: {
files: [],
ffargs,
output: {
type: info.output.type,
format: info.output.filename.split(".").pop(),
},
},
},
});
});
}
addItem({
id: parentId,
@ -203,7 +220,7 @@ export const createSavePipeline = (
originalRequest: request,
filename: info.output.filename,
mimeType: info.output.type,
mediaType: mediaIcons[info.type],
mediaType: getMediaType(info.output.type) || "file",
});
openQueuePopover();

View File

@ -1,4 +1,5 @@
import { get } from "svelte/store";
import { device } from "$lib/device";
import { queue, itemError } from "$lib/state/task-manager/queue";
import { runFFmpegWorker } from "$lib/task-manager/runners/ffmpeg";
@ -42,6 +43,7 @@ export const startWorker = async ({ worker, workerId, dependsOn, parentId, worke
workerArgs.ffargs,
workerArgs.output,
worker,
device.supports.multithreading,
/*resetStartCounter=*/true,
);
} else {

View File

@ -16,7 +16,8 @@ export const runFFmpegWorker = async (
args: string[],
output: FileInfo,
variant: 'remux' | 'encode',
resetStartCounter = false
yesthreads: boolean,
resetStartCounter = false,
) => {
const worker = new FFmpegWorker();
@ -34,7 +35,11 @@ export const runFFmpegWorker = async (
startAttempts++;
if (startAttempts <= 10) {
killWorker(worker, unsubscribe, startCheck);
return await runFFmpegWorker(workerId, parentId, files, args, output, variant);
return await runFFmpegWorker(
workerId, parentId,
files, args, output,
variant, yesthreads
);
} else {
killWorker(worker, unsubscribe, startCheck);
return itemError(parentId, workerId, "queue.worker_didnt_start");
@ -54,6 +59,7 @@ export const runFFmpegWorker = async (
files,
args,
output,
yesthreads,
}
});

View File

@ -1,7 +1,13 @@
import LibAVWrapper from "$lib/libav";
import type { FileInfo } from "$lib/types/libav";
const ffmpeg = async (variant: string, files: File[], args: string[], output: FileInfo) => {
const ffmpeg = async (
variant: string,
files: File[],
args: string[],
output: FileInfo,
yesthreads: boolean = false,
) => {
if (!(files && output && args)) {
self.postMessage({
cobaltFFmpegWorker: {
@ -25,7 +31,7 @@ const ffmpeg = async (variant: string, files: File[], args: string[], output: Fi
})
});
ff.init({ variant });
ff.init({ variant, yesthreads });
const error = (code: string) => {
self.postMessage({
@ -64,6 +70,14 @@ const ffmpeg = async (variant: string, files: File[], args: string[], output: Fi
return error("queue.ffmpeg.no_input_format");
}
// handle the edge case when a video doesn't have an audio track
// but user still tries to extract it
if (files.length === 1 && file_info.streams?.length === 1) {
if (output.type?.startsWith("audio") && file_info.streams[0].codec_type !== "audio") {
return error("queue.ffmpeg.no_audio_channel");
}
}
self.postMessage({
cobaltFFmpegWorker: {
progress: {
@ -114,6 +128,6 @@ const ffmpeg = async (variant: string, files: File[], args: string[], output: Fi
self.onmessage = async (event: MessageEvent) => {
const ed = event.data.cobaltFFmpegWorker;
if (ed?.variant && ed?.files && ed?.args && ed?.output) {
await ffmpeg(ed.variant, ed.files, ed.args, ed.output);
await ffmpeg(ed.variant, ed.files, ed.args, ed.output, ed.yesthreads);
}
}

View File

@ -52,14 +52,15 @@ export const CobaltFileMetadataKeys = [
'artist',
'album_artist',
'track',
'date'
'date',
'sublanguage',
];
export type CobaltFileMetadata = Record<
typeof CobaltFileMetadataKeys[number], string | undefined
>;
export type CobaltLocalProcessingType = 'merge' | 'mute' | 'audio' | 'gif' | 'remux';
export type CobaltLocalProcessingType = 'merge' | 'mute' | 'audio' | 'gif' | 'remux' | 'proxy';
export type CobaltLocalProcessingResponse = {
status: CobaltResponseType.LocalProcessing,
@ -72,12 +73,15 @@ export type CobaltLocalProcessingResponse = {
type: string, // mimetype
filename: string,
metadata?: CobaltFileMetadata,
subtitles?: boolean,
},
audio?: {
copy: boolean,
format: string,
bitrate: string,
cover?: boolean,
cropCover?: boolean,
},
isHLS?: boolean,

View File

@ -1,3 +1,5 @@
import type { Readable } from "svelte/store";
// more readable version of recursive partial taken from stackoverflow:
// https://stackoverflow.com/a/51365037
export type RecursivePartial<Type> = {
@ -10,3 +12,4 @@ export type RecursivePartial<Type> = {
export type DefaultImport<T> = () => Promise<{ default: T }>;
export type Optional<T> = T | undefined;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type FromReadable<T> = T extends Readable<infer U> ? U : never;

View File

@ -3,15 +3,17 @@ import type { CobaltSettingsV2 } from "$lib/types/settings/v2";
import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
import type { CobaltSettingsV4 } from "$lib/types/settings/v4";
import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
export * from "$lib/types/settings/v2";
export * from "$lib/types/settings/v3";
export * from "$lib/types/settings/v4";
export * from "$lib/types/settings/v5";
export * from "$lib/types/settings/v6";
export type CobaltSettings = CobaltSettingsV5;
export type CobaltSettings = CobaltSettingsV6;
export type AnyCobaltSettings = CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
export type AnyCobaltSettings = CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
export type PartialSettings = RecursivePartial<CobaltSettings>;

View File

@ -1,9 +1,9 @@
import type { YoutubeLang } from "$lib/settings/youtube-lang";
import type { YoutubeDubLang } from "$lib/settings/audio-sub-language";
import { type CobaltSettingsV2 } from "$lib/types/settings/v2";
export type CobaltSettingsV3 = Omit<CobaltSettingsV2, 'schemaVersion' | 'save'> & {
schemaVersion: 3,
save: Omit<CobaltSettingsV2['save'], 'youtubeDubBrowserLang'> & {
youtubeDubLang: YoutubeLang;
youtubeDubLang: YoutubeDubLang;
};
};

View File

@ -0,0 +1,14 @@
import type { SubtitleLang } from "$lib/settings/audio-sub-language";
import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
export const youtubeVideoContainerOptions = ["auto", "mp4", "webm", "mkv"] as const;
export const localProcessingOptions = ["disabled", "preferred", "forced"] as const;
export type CobaltSettingsV6 = Omit<CobaltSettingsV5, 'schemaVersion' | 'save'> & {
schemaVersion: 6,
save: Omit<CobaltSettingsV5['save'], 'localProcessing'> & {
localProcessing: typeof localProcessingOptions[number],
youtubeVideoContainer: typeof youtubeVideoContainerOptions[number];
subtitleLang: SubtitleLang,
},
};

Some files were not shown because too many files have changed in this diff Show More