diff --git a/api/README.md b/api/README.md index 36c1dc89..3bdfb519 100644 --- a/api/README.md +++ b/api/README.md @@ -23,6 +23,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i | instagram | ✅ | ✅ | ✅ | ➖ | ➖ | | facebook | ✅ | ❌ | ✅ | ➖ | ➖ | | loom | ✅ | ❌ | ✅ | ✅ | ➖ | +| newgrounds | ✅ | ✅ | ✅ | ✅ | ✅ | | ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ | | pinterest | ✅ | ✅ | ✅ | ➖ | ➖ | | reddit | ✅ | ✅ | ✅ | ❌ | ❌ | diff --git a/api/package.json b/api/package.json index de1e170f..c03064a5 100644 --- a/api/package.json +++ b/api/package.json @@ -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": { diff --git a/api/src/config.js b/api/src/config.js index 8f1579b8..2d539c0d 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -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)); diff --git a/api/src/core/api.js b/api/src/core/api.js index eb5cf4ff..248f9357 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -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); diff --git a/api/src/core/env.js b/api/src/core/env.js index 37ab36c1..5b05ad76 100644 --- a/api/src/core/env.js +++ b/api/src/core/env.js @@ -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') } diff --git a/api/src/misc/language-codes.js b/api/src/misc/language-codes.js new file mode 100644 index 00000000..1f692601 --- /dev/null +++ b/api/src/misc/language-codes.js @@ -0,0 +1,54 @@ +// converted from this file https://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt +const iso639_1to2 = { + 'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'sqi', + 'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'hye', 'as': 'asm', + 'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak', + 'bm': 'bam', 'eu': 'eus', 'be': 'bel', 'bn': 'ben', 'bi': 'bis', + 'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'mya', 'ca': 'cat', + 'ch': 'cha', 'ce': 'che', 'zh': 'zho', 'cu': 'chu', 'cv': 'chv', + 'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'ces', 'da': 'dan', + 'dv': 'div', 'nl': 'nld', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo', + 'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin', + 'fr': 'fra', 'fy': 'fry', 'ff': 'ful', 'ka': 'kat', 'de': 'deu', + 'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell', + 'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb', + 'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun', + 'ig': 'ibo', 'is': 'isl', 'io': 'ido', 'ii': 'iii', 'iu': 'iku', + 'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita', + 'jv': 'jav', 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas', + 'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin', + 'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua', + 'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim', + 'ln': 'lin', 'lt': 'lit', 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug', + 'mk': 'mkd', 'mh': 'mah', 'ml': 'mal', 'mi': 'mri', 'mr': 'mar', + 'ms': 'msa', 'mg': 'mlg', 'mt': 'mlt', 'mn': 'mon', 'na': 'nau', + 'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep', + 'nn': 'nno', 'nb': 'nob', 'no': 'nor', 'ny': 'nya', 'oc': 'oci', + 'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan', + 'fa': 'fas', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus', + 'qu': 'que', 'rm': 'roh', 'ro': 'ron', 'rn': 'run', 'ru': 'rus', + 'sg': 'sag', 'sa': 'san', 'si': 'sin', 'sk': 'slk', 'sl': 'slv', + 'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som', + 'st': 'sot', 'es': 'spa', 'sc': 'srd', 'sr': 'srp', 'ss': 'ssw', + 'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam', + 'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha', + 'bo': 'bod', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso', + 'tk': 'tuk', 'tr': 'tur', 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr', + 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol', + 'cy': 'cym', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid', + 'yo': 'yor', 'za': 'zha', 'zu': 'zul', +} + +const iso639_2to1 = Object.fromEntries( + Object.entries(iso639_1to2).map(([k, v]) => [v, k]) +); + +const maps = { + 2: iso639_1to2, + 3: iso639_2to1, +} + +export const convertLanguageCode = (code) => { + code = code?.split("-")[0]?.split("_")[0] || ""; + return maps[code.length]?.[code.toLowerCase()] || null; +} diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index e8933784..5852b19d 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -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( diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 65a021b0..1265297c 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -29,10 +29,11 @@ import loom from "./services/loom.js"; import facebook from "./services/facebook.js"; import bluesky from "./services/bluesky.js"; import xiaohongshu from "./services/xiaohongshu.js"; +import newgrounds from "./services/newgrounds.js"; let freebind; -export default async function({ host, patternMatch, params, 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 { diff --git a/api/src/processing/request.js b/api/src/processing/request.js index 697e67fc..b9ebca05 100644 --- a/api/src/processing/request.js +++ b/api/src/processing/request.js @@ -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 } )); diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index be895efd..7570a8b0 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -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(); diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 1c77c7bb..906c23da 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -1,7 +1,7 @@ import UrlPattern from "url-pattern"; -export const audioIgnore = ["vk", "ok", "loom"]; -export const hlsExceptions = ["dailymotion", "vimeo", "rutube", "bsky", "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"], diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index 989cfa63..e68a20be 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -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, } diff --git a/api/src/processing/services/bilibili.js b/api/src/processing/services/bilibili.js index 8747a781..a77ce10c 100644 --- a/api/src/processing/services/bilibili.js +++ b/api/src/processing/services/bilibili.js @@ -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('')[0]); + const streamData = JSON.parse( + html.split('')[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 }; } diff --git a/api/src/processing/services/loom.js b/api/src/processing/services/loom.js index 749f6e8f..64382108 100644 --- a/api/src/processing/services/loom.js +++ b/api/src/processing/services/loom.js @@ -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` } diff --git a/api/src/processing/services/newgrounds.js b/api/src/processing/services/newgrounds.js new file mode 100644 index 00000000..7519a6cf --- /dev/null +++ b/api/src/processing/services/newgrounds.js @@ -0,0 +1,103 @@ +import { genericUserAgent } from "../../config.js"; + +const getVideo = async ({ id, quality }) => { + const json = await fetch(`https://www.newgrounds.com/portal/video/${id}`, { + headers: { + "User-Agent": genericUserAgent, + "X-Requested-With": "XMLHttpRequest", // required to get the JSON response + } + }) + .then(r => r.json()) + .catch(() => {}); + + if (!json) return { error: "fetch.empty" }; + + const videoSources = json.sources; + const videoQualities = Object.keys(videoSources); + + if (videoQualities.length === 0) { + return { error: "fetch.empty" }; + } + + const bestVideo = videoSources[videoQualities[0]]?.[0], + userQuality = quality === "2160" ? "4k" : `${quality}p`, + preferredVideo = videoSources[userQuality]?.[0], + video = preferredVideo || bestVideo, + videoQuality = preferredVideo ? userQuality : videoQualities[0]; + + if (!bestVideo || !video.type.includes("mp4")) { + return { error: "fetch.empty" }; + } + + const fileMetadata = { + title: decodeURIComponent(json.title), + artist: decodeURIComponent(json.author), + } + + return { + urls: video.src, + filenameAttributes: { + service: "newgrounds", + id, + title: fileMetadata.title, + author: fileMetadata.artist, + extension: "mp4", + qualityLabel: videoQuality, + resolution: videoQuality, + }, + fileMetadata, + } +} + +const getMusic = async ({ id }) => { + const html = await fetch(`https://www.newgrounds.com/audio/listen/${id}`, { + headers: { + "User-Agent": genericUserAgent, + } + }) + .then(r => r.text()) + .catch(() => {}); + + if (!html) return { error: "fetch.fail" }; + + const params = JSON.parse( + `{${html.split(',"params":{')[1]?.split(',"images":')[0]}}` + ); + if (!params) return { error: "fetch.empty" }; + + if (!params.name || !params.artist || !params.filename || !params.icon) { + return { error: "fetch.empty" }; + } + + const fileMetadata = { + title: decodeURIComponent(params.name), + artist: decodeURIComponent(params.artist), + } + + return { + urls: params.filename, + filenameAttributes: { + service: "newgrounds", + id, + title: fileMetadata.title, + author: fileMetadata.artist, + }, + fileMetadata, + cover: + params.icon.includes(".png?") || params.icon.includes(".jpg?") + ? params.icon + : undefined, + isAudioOnly: true, + bestAudio: "mp3", + } +} + +export default function({ id, audioId, quality }) { + if (id) { + return getVideo({ id, quality }); + } else if (audioId) { + return getMusic({ id: audioId }); + } + + return { error: "fetch.empty" }; +} diff --git a/api/src/processing/services/rutube.js b/api/src/processing/services/rutube.js index 5b502452..9fe37d34 100644 --- a/api/src/processing/services/rutube.js +++ b/api/src/processing/services/rutube.js @@ -65,8 +65,21 @@ export default async function(obj) { artist: play.author.name.trim(), } + let subtitles; + if (obj.subtitleLang && play.captions?.length) { + const subtitle = play.captions.find( + s => ["webvtt", "srt"].includes(s.format) && s.code.startsWith(obj.subtitleLang) + ); + + if (subtitle) { + subtitles = subtitle.file; + fileMetadata.sublanguage = obj.subtitleLang; + } + } + return { urls: matchingQuality.uri, + subtitles, isHLS: true, filenameAttributes: { service: "rutube", diff --git a/api/src/processing/services/soundcloud.js b/api/src/processing/services/soundcloud.js index 046ebd79..a73e6474 100644 --- a/api/src/processing/services/soundcloud.js +++ b/api/src/processing/services/soundcloud.js @@ -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, diff --git a/api/src/processing/services/tiktok.js b/api/src/processing/services/tiktok.js index 93e07c50..1e1605e0 100644 --- a/api/src/processing/services/tiktok.js +++ b/api/src/processing/services/tiktok.js @@ -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(' 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" }; } diff --git a/api/src/processing/services/twitter.js b/api/src/processing/services/twitter.js index faf2406b..8bbe0eeb 100644 --- a/api/src/processing/services/twitter.js +++ b/api/src/processing/services/twitter.js @@ -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) => diff --git a/api/src/processing/services/vimeo.js b/api/src/processing/services/vimeo.js index 8d704771..8c51c026 100644 --- a/api/src/processing/services/vimeo.js +++ b/api/src/processing/services/vimeo.js @@ -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, diff --git a/api/src/processing/services/vk.js b/api/src/processing/services/vk.js index 33224d69..c07d964a 100644 --- a/api/src/processing/services/vk.js +++ b/api/src/processing/services/vk.js @@ -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", diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index 74959063..dbf93bb8 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -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, diff --git a/api/src/processing/url.js b/api/src/processing/url.js index 86c333f6..dbbda1cd 100644 --- a/api/src/processing/url.js +++ b/api/src/processing/url.js @@ -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": @@ -106,7 +109,7 @@ function aliasURL(url) { url.pathname = `/share/${idPart.slice(-32)}`; } break; - + case "redd": /* reddit short video links can be treated by changing https://v.redd.it/ to https://reddit.com/video/.*/ @@ -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") { diff --git a/api/src/security/api-keys.js b/api/src/security/api-keys.js index 37ec66fb..f2e9e6dc 100644 --- a/api/src/security/api-keys.js +++ b/api/src/security/api-keys.js @@ -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; +} diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js new file mode 100644 index 00000000..4a72a309 --- /dev/null +++ b/api/src/stream/ffmpeg.js @@ -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, +} diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js index 6d4ce318..aa8333ae 100644 --- a/api/src/stream/internal.js +++ b/api/src/stream/internal.js @@ -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); diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index 6adcd553..93e9e652 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -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; } diff --git a/api/src/stream/proxy.js b/api/src/stream/proxy.js new file mode 100644 index 00000000..d51927a9 --- /dev/null +++ b/api/src/stream/proxy.js @@ -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(); + } +} diff --git a/api/src/stream/shared.js b/api/src/stream/shared.js index ec06339d..6d268564 100644 --- a/api/src/stream/shared.js +++ b/api/src/stream/shared.js @@ -19,6 +19,9 @@ const serviceHeaders = { }, vk: { 'user-agent': vkClientAgent + }, + tiktok: { + referer: 'https://www.tiktok.com/', } } diff --git a/api/src/stream/stream.js b/api/src/stream/stream.js index e714f38e..1290c029 100644 --- a/api/src/stream/stream.js +++ b/api/src/stream/stream.js @@ -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); diff --git a/api/src/stream/types.js b/api/src/stream/types.js deleted file mode 100644 index 6b493efa..00000000 --- a/api/src/stream/types.js +++ /dev/null @@ -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, -} diff --git a/api/src/util/tests/facebook.json b/api/src/util/tests/facebook.json index 70e2db68..a8b9607e 100644 --- a/api/src/util/tests/facebook.json +++ b/api/src/util/tests/facebook.json @@ -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, diff --git a/api/src/util/tests/newgrounds.json b/api/src/util/tests/newgrounds.json new file mode 100644 index 00000000..e0c9c83d --- /dev/null +++ b/api/src/util/tests/newgrounds.json @@ -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" + } + } +] diff --git a/api/src/util/tests/vimeo.json b/api/src/util/tests/vimeo.json index 6c44a47d..e28af888 100644 --- a/api/src/util/tests/vimeo.json +++ b/api/src/util/tests/vimeo.json @@ -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" diff --git a/docs/api-env-variables.md b/docs/api-env-variables.md index 92e7a6cf..5a836bfa 100644 --- a/docs/api-env-variables.md +++ b/docs/api-env-variables.md @@ -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. diff --git a/docs/api.md b/docs/api.md index f093e148..47fc7115 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/docs/protect-an-instance.md b/docs/protect-an-instance.md index 30584102..0271375e 100644 --- a/docs/protect-an-instance.md +++ b/docs/protect-an-instance.md @@ -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. diff --git a/packages/version-info/index.js b/packages/version-info/index.js index 3ac1bd3e..a1f74968 100644 --- a/packages/version-info/index.js +++ b/packages/version-info/index.js @@ -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(); diff --git a/packages/version-info/package.json b/packages/version-info/package.json index e82a6201..018b6a2e 100644 --- a/packages/version-info/package.json +++ b/packages/version-info/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32364730..ef027b1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/web/README.md b/web/README.md index a04ff9a4..8c7c42ac 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/changelogs/11.0.md b/web/changelogs/11.0.md index 06d3168a..82835549 100644 --- a/web/changelogs/11.0.md +++ b/web/changelogs/11.0.md @@ -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. diff --git a/web/changelogs/11.2.md b/web/changelogs/11.2.md new file mode 100644 index 00000000..f1ee42b7 --- /dev/null +++ b/web/changelogs/11.2.md @@ -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 ❤️ diff --git a/web/i18n/en/about.json b/web/i18n/en/about.json index 0a794c77..638fd7f1 100644 --- a/web/i18n/en/about.json +++ b/web/i18n/en/about.json @@ -1,6 +1,5 @@ { "page.general": "what's cobalt?", - "page.faq": "FAQ", "page.community": "community & support", diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json index 35a15a8c..44e92dab 100644 --- a/web/i18n/en/dialog.json +++ b/web/i18n/en/dialog.json @@ -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." } diff --git a/web/i18n/en/error/api.json b/web/i18n/en/error/api.json index 70c996e6..9e922745 100644 --- a/web/i18n/en/error/api.json +++ b/web/i18n/en/error/api.json @@ -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!", diff --git a/web/i18n/en/error/queue.json b/web/i18n/en/error/queue.json index 064dce5a..5df31caf 100644 --- a/web/i18n/en/error/queue.json +++ b/web/i18n/en/error/queue.json @@ -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" } diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json index 361361f8..91716ab4 100644 --- a/web/i18n/en/save.json +++ b/web/i18n/en/save.json @@ -10,7 +10,7 @@ "services.title": "supported services", "services.title_show": "show supported services", "services.title_hide": "hide supported services", - "services.disclaimer": "cobalt is not affiliated with any of the services listed above.", + "services.disclaimer": "support for a service does not imply affiliation, endorsement, or any form of support other than technical compatibility.", "tutorial.title": "how to save on ios?", "tutorial.intro": "to save media conveniently on ios, you'll need to use a companion siri shortcut from the share sheet.", diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index 7ab13fa0..5caf67f5 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -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", diff --git a/web/i18n/ru/a11y/dialog.json b/web/i18n/ru/a11y/dialog.json new file mode 100644 index 00000000..ea7c32ce --- /dev/null +++ b/web/i18n/ru/a11y/dialog.json @@ -0,0 +1,5 @@ +{ + "picker.item.photo": "превью фотографии", + "picker.item.video": "превью видео", + "picker.item.gif": "превью gif" +} diff --git a/web/i18n/ru/a11y/donate.json b/web/i18n/ru/a11y/donate.json new file mode 100644 index 00000000..93561b71 --- /dev/null +++ b/web/i18n/ru/a11y/donate.json @@ -0,0 +1,4 @@ +{ + "share.qr.expand": "qr-код. нажми, чтобы развернуть.", + "share.qr.collapse": "развёрнутый qr-код. нажми, чтобы свернуть." +} diff --git a/web/i18n/ru/a11y/queue.json b/web/i18n/ru/a11y/queue.json new file mode 100644 index 00000000..3921c99b --- /dev/null +++ b/web/i18n/ru/a11y/queue.json @@ -0,0 +1,5 @@ +{ + "status.completed": "очередь обработки. все задачи завершены.", + "status.ongoing": "очередь обработки. есть текущие задачи.", + "status.default": "очередь обработки" +} diff --git a/web/i18n/ru/a11y/save.json b/web/i18n/ru/a11y/save.json index d6def5e5..83148125 100644 --- a/web/i18n/ru/a11y/save.json +++ b/web/i18n/ru/a11y/save.json @@ -1,9 +1,12 @@ { - "link_area": "зона вставки ссылки", - "clear_input": "clear input", + "link_area": "поле ввода ссылки", + "clear_input": "очистить поле ввода", "download": "скачать", "download.think": "обрабатываю ссылку...", "download.check": "проверяю загрузку...", - "download.done": "загрузка завершена!", - "download.error": "ошибка загрузки" + "download.done": "загрузка завершена", + "download.error": "ошибка загрузки", + "link_area.turnstile": "поле ввода ссылки. проверяю, что ты не робот.", + "tutorial.shortcut.photos": "добавить команду \"в фото\"", + "tutorial.shortcut.files": "добавить команду \"в файлы\"" } diff --git a/web/i18n/ru/about.json b/web/i18n/ru/about.json new file mode 100644 index 00000000..87dacd5a --- /dev/null +++ b/web/i18n/ru/about.json @@ -0,0 +1,30 @@ +{ + "page.general": "что такое кобальт?", + "heading.general": "общие условия", + "heading.saving": "скачивание", + "heading.encryption": "шифрование", + "heading.abuse": "сообщение о злоупотреблении", + "heading.motivation": "мотивация", + "heading.licenses": "лицензии", + "heading.summary": "лучший способ сохранять то, что ты любишь", + "page.community": "сообщество и поддержка", + "page.privacy": "конфиденциальность", + "page.terms": "условия и этика", + "page.credits": "благодарности и лицензии", + "heading.testers": "бета-тестеры", + "heading.community": "открытое сообщество", + "heading.local": "обработка на устройстве", + "heading.plausible": "анонимная аналитика трафика", + "heading.cloudflare": "веб-приватность и безопасность", + "heading.responsibility": "ответственности пользователя", + "support.github": "смотри исходный код кобальта, вноси свой вклад или сообщай о проблемах", + "support.discord": "общайся с сообществом и разработчиками кобальта или попроси о помощи", + "support.description.issue": "если ты хочешь сообщить о баге или какой-то другой повторяющейся проблеме, то делай это на github.", + "support.description.help": "используй discord для любых других вопросов. чётко опиши проблему в #cobalt-support, иначе никто не сможет тебе помочь.", + "support.twitter": "следи за обновлениями и разработкой кобальта в своей ленте твиттера", + "support.telegram": "следи за обновлениями кобальта в телеграм-канале", + "support.description.best-effort": "вся поддержка осуществляется по мере возможности и не гарантируется, а ответ может занять какое-то время.", + "heading.privacy_efficiency": "лучшая приватность и эффективность", + "heading.partners": "партнёры", + "support.bluesky": "следи за обновлениями и разработкой кобальта в своей ленте bluesky" +} diff --git a/web/i18n/ru/about/credits.md b/web/i18n/ru/about/credits.md new file mode 100644 index 00000000..a58bd223 --- /dev/null +++ b/web/i18n/ru/about/credits.md @@ -0,0 +1,94 @@ + + +
+ + +кобальт сделан с любовью и заботой руками [imput](https://imput.net/) ❤️ + +мы маленькая команда из двух человек, но мы очень усердно работаем, чтобы делать +классный софт, который приносит пользу всем. если тебе нравится то, что мы +делаем, поддержи нас на [странице донатов](/donate)! +
+ +
+ + +огромное спасибо нашим тестерам за то, что они тестировали обновления заранее и +следили за их стабильностью. они ещё помогли нам выпустить cobalt 10! + + +все ссылки внешние и ведут на их личные сайты или соцсети. +
+ +
+ + +часть инфраструктуры кобальта предоставлена нашим давним партнёром, +[royalehosting.net]({partners.royalehosting})! +
+ +
+ + +мяубальт — это шустрый маскот кобальта, очень выразительный кот, который любит +быстрый интернет. + +весь потрясающий арт мяубальта, который ты видишь в кобальте, был сделан +[GlitchyPSI](https://glitchypsi.xyz/). он ещё и оригинальный создатель этого +персонажа. + +imput владеет юридическими правами на дизайн персонажа мяубальта, но не на +конкретные арты, которые были созданы GlitchyPSI. + +мы любим мяубальта, поэтому мы вынуждены установить пару правил, чтобы его +защитить: +- ты не можешь использовать дизайн персонажа мяубальта ни в какой форме, кроме + фанарта. +- ты не можешь использовать дизайн или арты мяубальта в коммерческих целях. +- ты не можешь использовать дизайн или арты мяубальта в своих проектах. +- ты не можешь использовать или изменять работы GlitchyPSI с мяубальтом ни в + каком виде. + +если ты нарисуешь фанарт мяубальта, не стесняйся делиться им в [нашем +дискорд-сервере](/about/community), мы с нетерпением ждём! +
+ +
+ + +код api (сервера обработки) кобальта — open source и распространяется по +лицензии [AGPL-3.0]({docs.apiLicense}). + +код фронтенда кобальта — [source first](https://sourcefirst.com/) и +распространяется по лицензии [CC-BY-NC-SA 4.0]({docs.webLicense}). + +нам пришлось сделать фронтенд source first, чтобы грифтеры не наживались на +нашем труде и не создавали вредоносные клоны для обмана людей и порче нашей +репутации. кроме коммерческого использования, у этого типа лицензии те же +принципы, что и у многих open source лицензий. + +мы используем много опенсорсных библиотек, но также создаём и распространяем +свои собственные. полный список зависимостей можно посмотреть на +[github]({contacts.github})! +
diff --git a/web/i18n/ru/about/general.md b/web/i18n/ru/about/general.md new file mode 100644 index 00000000..1657ca7b --- /dev/null +++ b/web/i18n/ru/about/general.md @@ -0,0 +1,79 @@ + + +
+ + +кобальт помогает сохранять что угодно с твоих любимых сайтов: видео, аудио, фото +или гифки. просто вставь ссылку и вперёд! + +никакой рекламы, трекеров, платных подписок и прочей ерунды. просто удобное +веб-приложение, которое работает где угодно и когда угодно. +
+ +
+ + +кобальт был создан для всеобщего блага, чтобы защитить людей от рекламы и +вредоносных программ, которые навязывают альтернативные загрузчики. мы верим, +что лучший софт — безопасный, открытый и доступный. все проекты imput следуют +этим принципам. +
+ +
+ + +все запросы к бэкенду анонимны, и вся инфа о потенциальных файловых туннелях +зашифрована. у нас строгая политика нулевых логов, мы *никогда* не храним +идентифицирующую инфу о людях и никого не отслеживаем. + +если запрос требует дополнительной обработки, например ремукса или +транскодирования, то кобальт обрабатывает медиафайлы прямо на твоём устройстве. +это обеспечивает максимальную эффективность и приватность. + +если твоё устройство не поддерживает локальную обработку, то вместо неё +используется серверная обработка в реальном времени. в этом сценарии +обработанные медиаданные передаются напрямую клиенту, никогда не сохраняясь на +диске сервера. + +ты можешь [включить принудительное туннелирование](/settings/privacy#tunnel), +чтобы ещё сильнее повысить приватность. когда оно включено, кобальт будет +туннелировать все скачиваемые файлы, а не только те, которым это необходимо. +никто не узнает, откуда и что ты скачиваешь, даже твой провайдер. всё, что они +увидят, это то, что ты используешь инстанс кобальта. +
+ +
+ + +кобальт используют бесчисленные артисты, преподаватели и прочие создатели +контента, чтобы заниматься любимым делом. мы всегда на связи с нашим сообществом +и работаем вместе, чтобы делать кобальт ещё полезнее. не стесняйся +[присоединиться к разговору](/about/community)! + +мы верим, что будущее интернета — открытое и свободное, поэтому кобальт +опубликован с [открытым исходным кодом](https://sourcefirst.com/) и его можно +легко [захостить самому]({docs.instanceHosting}). + +если твой друг хостит инстанс обработки, просто попроси у него домен и [добавь +его в настройках инстанса](/settings/instances#community). + +ты можешь посмотреть исходный код и внести свой вклад [на +github]({contacts.github}) в любое время. мы рады любым предложениям и помощи! +
diff --git a/web/i18n/ru/about/privacy.md b/web/i18n/ru/about/privacy.md new file mode 100644 index 00000000..d8522f99 --- /dev/null +++ b/web/i18n/ru/about/privacy.md @@ -0,0 +1,129 @@ + + +
+ + +политика конфиденциальности кобальта проста: мы ничего не собираем и не храним о +тебе. то, что ты делаешь, — это исключительно твоё дело, а не наше или чьё-либо +ещё. + +эти условия применяются только при использовании официального инстанса кобальта. +в других случаях, возможно, придётся обратиться к хостеру инстанса за точной +информацией. +
+ +
+ + +инструменты, которые используют обработку на устройстве, работают офлайн, +локально и никогда никуда не отправляют обработанные данные. они явно помечены +как таковые, когда это применимо. +
+ +
+ + +при использовании функции сохранения, кобальту может понадобиться проксировать +или ремуксировать/транскодировать файлы. если это так, то для этой цели +создаётся временный туннель, и минимально необходимая информация о медиа +хранится в течение 90 секунд. + +на неизменённом и официальном инстансе кобальта **все данные туннеля шифруются +ключом, к которому имеет доступ только конечный пользователь**. + +зашифрованные данные туннеля могут включать: +- название исходного сервиса. +- исходные ссылки на медиафайлы. +- необходимые внутренние аргументы для различения типов обработки. +- ключевые метаданные файла (сгенерированное имя, заголовок, автор, год + создания, данные об авторских правах). +- минимальная информация об исходном запросе, которая может быть использована + для восстановления туннеля после ошибки ссылки во время скачивания. + +эти данные безвозвратно удаляются из оперативной памяти сервера через 90 секунд. +никто не имеет доступа к кэшированным данным туннеля, даже владельцы инстансов, +если исходный код кобальта не изменён. + +медиаданные из туннелей нигде не хранятся/кэшируются. всё обрабатывается в +реальном времени, даже при ремуксинге и транскодировании. туннели кобальта +работают как анонимный прокси. + +если твоё устройство поддерживает локальную обработку, то зашифрованный туннель +содержит намного меньше информации, потому что она возвращается клиенту. + +смотри [соответствующий исходный код на +github](https://github.com/imputnet/cobalt/tree/main/api/src/stream), чтобы +узнать больше о том, как это работает. +
+ +
+ + +временно хранящиеся данные туннеля шифруются с использованием стандарта AES-256. +ключи расшифровки включены только в ссылку доступа и никогда не +логируются/кэшируются/хранятся где-либо. только конечный пользователь имеет +доступ к ссылке и ключам шифрования. ключи генерируются уникально для каждого +запрошенного туннеля. +
+ +{#if env.PLAUSIBLE_ENABLED} +
+ + +мы используем [plausible](https://plausible.io/), чтобы знать приблизительное +число активных пользователей кобальта, полностью анонимно. никакая +идентифицирующая информация о тебе или твоих запросах никогда не хранится. все +данные анонимизированы и агрегированы. мы сами хостим и управляем [инстансом +plausible](https://{env.PLAUSIBLE_HOST}/), который использует кобальт. + +plausible не использует куки и полностью соответствует GDPR, CCPA и PECR. + +если ты хочешь отказаться от анонимной аналитики, то это можно сделать в +[настройках приватности](/settings/privacy#analytics). после отказа скрипт +plausible не будет загружаться. + +[узнай больше о преданности plausible к +приватности](https://plausible.io/privacy-focused-web-analytics). +
+{/if} + +
+ + +мы используем сервисы cloudflare для: +- защиты от ddos и абьюза. +- защиты от ботов (cloudflare turnstile). +- хостинга и деплоя статического веб-приложения (cloudflare workers). + +всё это необходимо для обеспечения лучшего опыта для всех. cloudflare — наиболее +приватный и надёжный провайдер всех упомянутых решений из всех известных нам +провайдеров. + +cloudflare полностью соответствует требованиям GDPR и HIPAA. + +[узнай больше о преданности cloudflare к +приватности](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/). +
diff --git a/web/i18n/ru/about/terms.md b/web/i18n/ru/about/terms.md new file mode 100644 index 00000000..edb6df38 --- /dev/null +++ b/web/i18n/ru/about/terms.md @@ -0,0 +1,69 @@ + + +
+ + +эти условия применяются только при использовании официального инстанса кобальта. +в других случаях, возможно, придётся обратиться к хостеру инстанса за точной +информацией. +
+ +
+ + +функция сохранения упрощает скачивание контента из интернета, и мы не несём +никакой ответственности за то, как будет использоваться сохранённый контент. + +серверы обработки работают как продвинутые прокси и никогда не записывают +запрошенный контент на диск. всё происходит в оперативной памяти и полностью +удаляется после завершения туннеля. у нас нет логов загрузок, и мы не можем +никого идентифицировать. + +подробнее о том, как работают туннели, можно узнать в [политике +конфиденциальности](/about/privacy). +
+ +
+ + +ты (конечный пользователь) несёшь ответственность за то, что делаешь с нашими +инструментами, как используешь и распространяешь полученный контент. пожалуйста, +уважай чужой труд и всегда указывай авторов. убедись, что ты не нарушаешь +никаких условий или лицензий. + +при использовании в образовательных целях всегда ссылайся на источники и +указывай авторов. + +добросовестное использование и указание авторства приносят пользу всем. +
+ +
+ + +у нас нет возможности автоматически выявлять злоупотребления, так как кобальт +полностью анонимен. однако, есть возможность сообщить нам о такой деятельности +по почте, и мы сделаем всё возможное, чтобы принять нужные меры вручную: +abuse[at]imput.net + +**этот адрес не предназначен для поддержки пользователей. ты не получишь ответ, +если твой запрос не связан со злоупотреблениями.** + +если у тебя возникли проблемы с работой кобальта, то ты можешь обратиться за +помощью любым удобным способом на [странице поддержки и +сообщества](/about/community). +
diff --git a/web/i18n/ru/button.json b/web/i18n/ru/button.json new file mode 100644 index 00000000..d87f75b8 --- /dev/null +++ b/web/i18n/ru/button.json @@ -0,0 +1,27 @@ +{ + "download.audio": "скачать аудио", + "import": "импортировать", + "copied": "скопировано", + "copy": "скопировать", + "share": "поделиться", + "download": "скачать", + "no": "нет", + "yes": "да", + "save": "скачать", + "continue": "продолжить", + "done": "готово", + "reset": "сбросить", + "cancel": "отменить", + "export": "экспортировать", + "gotit": "понятно", + "copy.section": "скопировать ссылку на раздел", + "clear_input": "очистить поле ввода", + "show_input": "показать ввод", + "hide_input": "скрыть ввод", + "restore_input": "восстановить ввод", + "clear": "очистить", + "remove": "убрать", + "clear_cache": "очистить кэш", + "retry": "повторить", + "delete": "удалить" +} diff --git a/web/i18n/ru/dialog.json b/web/i18n/ru/dialog.json new file mode 100644 index 00000000..8e9d538b --- /dev/null +++ b/web/i18n/ru/dialog.json @@ -0,0 +1,16 @@ +{ + "picker.title": "что сохранить?", + "saving.title": "как сохранить?", + "saving.timeout": "кобальт попытался сохранить файл автоматически, но твой браузер остановил это. выбери способ вручную.", + "reset_settings.title": "сбросить все настройки?", + "reset_settings.body": "ты точно хочешь сбросить все настройки? это действие мгновенное и необратимое.", + "picker.description.phone": "нажми на то, что хочешь скачать. картинки также можно скачать долгим нажатием.", + "picker.description.desktop": "кликни на то, что хочешь скачать. картинки также можно скачать через контекстное меню.", + "picker.description.ios": "нажми на то, что хочешь скачать через команду siri. картинки также можно скачать долгим нажатием.", + "saving.blocked": "кобальт попытался открыть файл в новой вкладке, но твой браузер заблокировал это. разреши всплывающие окна для кобальта, чтобы избежать этого в следующий раз.", + "clear_cache.title": "очистить весь кэш?", + "import.body": "импорт неизвестных или повреждённых файлов может неожиданно изменить или сломать работу кобальта. импортируй только те файлы, которые ты экспортировал сам и не изменял. если кто-то попросил тебя импортировать этот файл — не делай этого.\n\nмы не несём ответственности за любой вред, причинённый импортом неизвестных файлов настроек.", + "safety.custom_instance.body": "сторонние инстансы могут быть опасны для твоей приватности и безопасности.\n\nвредоносные инстансы могут:\n1. перенаправлять тебя с кобальта и пытаться обмануть.\n2. записывать всю информацию о твоих запросах, хранить её вечно и использовать для слежки за тобой.\n3. скачивать вредоносные файлы (например, вирусы).\n4. заставлять тебя смотреть рекламу или платить за скачивание.\n\nпосле этого момента мы не сможем тебя защитить. пожалуйста, будь осторожен с выбором инстанса и всегда доверяй своей интуиции. если что-то кажется странным, то вернись на эту страницу, сбрось пользовательский инстанс и сообщи нам об этом на github.", + "clear_cache.body": "все файлы из очереди обработки будут удалены и локальные фичи займут больше времени на загрузку. это действие мгновенное и необратимое.", + "safety.title": "важное предупреждение о безопасности" +} diff --git a/web/i18n/ru/donate.json b/web/i18n/ru/donate.json new file mode 100644 index 00000000..9b88340a --- /dev/null +++ b/web/i18n/ru/donate.json @@ -0,0 +1,29 @@ +{ + "card.once": "одноразовый донат", + "card.option.30": "обед для двоих", + "body.no_bullshit": "мы считаем, что интернет не должен быть страшным. поэтому в кобальте никогда не будет рекламы или другого вредоносного контента. это обещание, за которым мы стоим горой. всё, что мы делаем, создаётся с учётом конфиденциальности, доступности и простоты использования, что делает кобальт доступным для всех.", + "card.custom": "своя сумма (от $2)", + "card.processor": "через {{value}}", + "card.option.5": "чашка кофе", + "card.option.50": "10кг кошачьего корма", + "card.option.1599": "базовый макбук", + "card.option.4900": "10,000 яблок", + "share.title": "поделись кобальтом с другом", + "alternative.title": "альтернативные способы доната", + "alt.copy": "{{ value }}. адрес криптокошелька. нажми, чтобы скопировать.", + "alt.open": "{{ value }}. нажми, чтобы открыть.", + "body.motivation": "кобальт помогает продюсерам, преподавателям, видеомейкерам и многим другим заниматься тем, что они любят. это особый сервис, создающийся с любовью, а не ради прибыли.", + "body.keep_going": "если кобальт помог тебе, пожалуйста, подумай над тем, чтобы поддержать нашу работу! ты можешь поддержать нас донатом, либо поделившись кобальтом с другом. каждый донат очень ценится и помогает нам продолжать работу над кобальтом и другими проектами.", + "card.recurring": "регулярный донат", + "card.option.10": "большая пицца", + "card.option.15": "полный обед", + "card.custom.submit": "своя сумма", + "banner.title": "Поддержи безопасный\nи открытый Интернет", + "banner.subtitle": "поддержи imput или поделись\nкобальтом с другом", + "card.option.100": "один год доменов", + "card.option.200": "аэрогриль", + "card.option.500": "крутое офисное кресло", + "card.option.7398": "флагманский макбук", + "card.option.8629": "маленький земельный участок", + "card.option.9433": "джакузи класса люкс" +} diff --git a/web/i18n/ru/error.json b/web/i18n/ru/error.json new file mode 100644 index 00000000..a8f0d643 --- /dev/null +++ b/web/i18n/ru/error.json @@ -0,0 +1,8 @@ +{ + "pipeline.missing_response_data": "инстанс обработки не ответил с нужной информацией о файле, поэтому я не могу создать задачи для локальной обработки. попробуй ещё раз через несколько секунд и сообщи о проблеме, если она не исчезнет!", + "captcha_too_long": "cloudflare turnstile слишком долго проверяет, что ты не бот. попробуй ещё раз, но если снова появится эта ошибка, то можно попробовать: отключить странные расширения браузера, сменить сеть, использовать другой браузер или проверить устройство на наличие вредоносных программ.", + "import.invalid": "в этом файле нет совместимых настроек кобальта для импорта. ты уверен, что это тот файл?", + "tunnel.probe": "не удалось протестировать этот туннель. возможно, твой браузер или настройки сети блокируют доступ к одному из серверов кобальта. ты уверен, что у тебя нет каких-то странных расширений для браузера?", + "import.unknown": "не удалось загрузить данные из файла. возможно, он повреждён или не того формата. вот ошибка, которую я получил:\n\n{{ value }}", + "import.no_data": "из этого файла нечего загружать. ты уверен, что это тот файл?" +} diff --git a/web/i18n/ru/error/api.json b/web/i18n/ru/error/api.json new file mode 100644 index 00000000..535a100a --- /dev/null +++ b/web/i18n/ru/error/api.json @@ -0,0 +1,51 @@ +{ + "auth.jwt.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что токен доступа недействителен. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.turnstile.invalid": "не удалось пройти аутентификацию с инстансом обработки, потому что решение капчи недействительно. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.key.not_api_key": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!", + "auth.key.invalid": "ключ доступа недействителен. сбрось его в настройках инстанса и используй правильный!", + "auth.key.ua_not_allowed": "ты не можешь использовать этот ключ доступа с текущего юзер агента. попробуй другой клиент или устройство!", + "unreachable": "не удалось подключиться к инстансу обработки. проверь своё интернет-соединение и попробуй ещё раз!", + "rate_exceeded": "ты делаешь слишком много запросов. попробуй снова через {{ limit }}с.", + "capacity": "кобальт сейчас перегружен и не может обработать твой запрос. попробуй ещё раз через пару секунд!", + "service.unsupported": "этот сервис ещё не поддерживается. ты уверен, что вставил правильную ссылку?", + "service.audio_not_supported": "этот сервис не поддерживает извлечение аудио. попробуй ссылку с другого сервиса!", + "link.invalid": "твоя ссылка недействительна или этот сервис ещё не поддерживается. ты точно вставил правильную ссылку?", + "fetch.fail": "что-то пошло не так при получении инфы из {{ service }}, и я ничего не смог для тебя достать. если эта проблема не исчезнет, пожалуйста, сообщи о ней!", + "auth.jwt.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует токен доступа. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "auth.key.missing": "для использования этого инстанса нужен ключ доступа, но его нет. добавь его в настройках!", + "generic": "что-то пошло не так, и я не смог ничего найти для тебя. попробуй ещё раз через пару секунд. если проблема останется, пожалуйста, сообщи об этом!", + "auth.turnstile.missing": "не удалось пройти аутентификацию с инстансом обработки, потому что отсутствует решение капчи. попробуй ещё раз через пару секунд или перезагрузи страницу!", + "unknown_response": "не удалось прочитать ответ от инстанса обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!", + "auth.key.not_found": "использованный тобой ключ доступа не найден. ты уверен, что у этого инстанса есть твой ключ?", + "invalid_body": "не удалось отправить запрос на инстанс обработки. скорее всего, причина в том, что веб-приложение устарело. перезагрузи его и попробуй снова!", + "auth.key.invalid_ip": "не удалось распарсить твой ip-адрес. что-то пошло совсем не так, пожалуйста, сообщи об этой ошибке!", + "auth.key.ip_not_allowed": "ты не можешь использовать этот ключ доступа с текущего ip-адреса. попробуй другой инстанс или сеть!", + "timed_out": "инстанс обработки слишком долго не отвечал. возможно, он сейчас перегружен, попробуй ещё раз через пару секунд!", + "service.disabled": "этот сервис обычно поддерживается кобальтом, но он отключён на этом инстансе. попробуй ссылку с другого сервиса!", + "link.unsupported": "{{ service }} поддерживается, но я не смог распознать твою ссылку. ты точно вставил правильную?", + "fetch.critical": "модуль {{ service }} вернул ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "content.too_long": "запрошенное медиа слишком длинное. лимит длительности на этом инстансе — {{ limit }}мин. попробуй что-нибудь покороче!", + "content.video.unavailable": "я не могу получить доступ к этому видео. оно может быть ограничено со стороны {{ service }}. попробуй другую ссылку!", + "content.video.private": "это видео приватное, поэтому я не могу получить к нему доступ. измени его видимость или попробуй другое!", + "content.video.region": "это видео ограничено по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!", + "content.paid": "этот контент требует покупки. кобальт не может скачивать платный контент. попробуй другую ссылку!", + "content.post.private": "не удалось получить инфу об этом посте, потому что он от закрытого аккаунта. попробуй другую ссылку!", + "youtube.token_expired": "не удалось получить это видео, потому что токен youtube истёк и не был обновлён. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!", + "youtube.no_hls_streams": "не удалось найти ни одного подходящего HLS-потока для этого видео. попробуй скачать его без HLS!", + "youtube.api_error": "youtube что-то обновил в своём api, и я не смог получить инфу об этом видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "youtube.drm": "это youtube-видео защищено widevine DRM, так что я не могу его скачать. попробуй другую ссылку!", + "fetch.rate": "{{ service }} ограничил частоту запросов от инстанса обработки. попробуй ещё раз через пару секунд!", + "youtube.temporary_disabled": "скачивание с youtube временно отключено из-за ограничений со стороны youtube. мы уже ищем способы их обойти.\n\nприносим извинения за неудобства и делаем всё возможное, чтобы восстановить эту функциональность. следи за обновлениями в соцсетях или на github!", + "content.video.age": "это видео ограничено по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!", + "content.region": "этот контент ограничен по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!", + "youtube.no_matching_format": "youtube не вернул ни одного подходящего формата. возможно, кобальт их не поддерживает или же они перекодируются на стороне youtube. попробуй ещё раз чуть позже, а если проблема останется, сообщи о ней!", + "youtube.no_session_tokens": "не удалось получить необходимые токены сессии для ютуба. это может быть вызвано ограничением со стороны ютуба. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "youtube.decipher": "youtube обновил свой алгоритм расшифровки, и из-за этого мне не удалось получить информацию о видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!", + "fetch.short_link": "не удалось получить инфу по короткой ссылке. ты уверен, что она работает? если да, а ты всё равно видишь эту ошибку, пожалуйста, сообщи о ней!", + "fetch.empty": "не смог найти медиа, которое я мог бы скачать для тебя. ты уверен, что вставил правильную ссылку?", + "content.post.age": "этот пост ограничен по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!", + "youtube.login": "не удалось получить это видео, потому что youtube попросил доказать, что инстанс обработки — не бот. попробуй ещё раз через пару секунд, но если так и не заработает, пожалуйста, сообщи об этой проблеме!", + "content.video.live": "это видео сейчас идёт в прямом эфире, поэтому я ещё не могу его скачать. подожди, пока стрим закончится, и попробуй снова!", + "content.post.unavailable": "не удалось ничего найти об этом посте. его видимость может быть ограничена или он может не существовать. убедись, что твоя ссылка работает, и попробуй снова через пару секунд!", + "fetch.critical.core": "один из основных модулей выдал ошибку, которую я не узнаю. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!" +} diff --git a/web/i18n/ru/error/queue.json b/web/i18n/ru/error/queue.json new file mode 100644 index 00000000..5c3df793 --- /dev/null +++ b/web/i18n/ru/error/queue.json @@ -0,0 +1,19 @@ +{ + "fetch.no_file_reader": "не смог записать файл в кэш", + "worker_didnt_start": "не смог запустить воркер обработки", + "ffmpeg.probe_failed": "не удалось проверить этот файл, возможно, он повреждён или не поддерживается", + "fetch.network_error": "скачивание было прервано из-за проблем с сетью", + "no_final_file": "финальный файл пропал", + "fetch.corrupted_file": "файл был скачан не полностью, попробуй ещё раз", + "fetch.crashed": "воркер скачивания вылетел, смотри детали в консоли", + "fetch.bad_response": "не смог получить туннель файла", + "fetch.empty_tunnel": "туннель файла пустой, попробуй ещё раз через несколько минут", + "ffmpeg.no_input_type": "тип этого файла не поддерживается", + "ffmpeg.crashed": "воркер ffmpeg вылетел, смотри детали в консоли", + "ffmpeg.no_input_format": "формат этого файла не поддерживается", + "ffmpeg.out_of_memory": "не хватает памяти, не могу продолжить", + "ffmpeg.no_render": "рендер ffmpeg пустой, произошло что-то очень странное", + "ffmpeg.no_args": "воркер ffmpeg не получил нужные аргументы", + "generic_error": "воркер обработки вылетел, смотри детали в консоли", + "ffmpeg.no_audio_channel": "у этого видео нет аудиодорожки, ничего нельзя сделать" +} diff --git a/web/i18n/ru/general.json b/web/i18n/ru/general.json index 90cbfef5..d10e39a2 100644 --- a/web/i18n/ru/general.json +++ b/web/i18n/ru/general.json @@ -2,6 +2,5 @@ "cobalt": "кобальт", "meowbalt": "мяубальт", "beta": "бета", - - "embed.description": "сохраняй то, что любишь: без рекламы, трекеров и прочей чепухи. кобальт создан с любовью, а не с целью заработать." + "embed.description": "кобальт помогает тебе сохранять то, что ты любишь, без рекламы, трекеров и прочей ерунды. просто вставь ссылку!" } diff --git a/web/i18n/ru/notification.json b/web/i18n/ru/notification.json new file mode 100644 index 00000000..14822cf3 --- /dev/null +++ b/web/i18n/ru/notification.json @@ -0,0 +1,4 @@ +{ + "update.title": "доступно обновление!", + "update.subtext": "нажми, чтобы обновить" +} diff --git a/web/i18n/ru/queue.json b/web/i18n/ru/queue.json new file mode 100644 index 00000000..62a8102a --- /dev/null +++ b/web/i18n/ru/queue.json @@ -0,0 +1,13 @@ +{ + "state.waiting": "в очереди", + "state.starting.fetch": "начинаю скачивание", + "state.running.remux": "ремуксирую", + "state.retrying": "повторяю", + "state.starting.encode": "начинаю транскодирование", + "title": "очередь обработки", + "state.starting": "начинаю", + "state.starting.remux": "начинаю ремуксинг", + "state.running.fetch": "скачиваю", + "state.running.encode": "транскодирую", + "stub": "тут пока что ничего нет, только мы вдвоём.\nпопробуй скачать что-нибудь!" +} diff --git a/web/i18n/ru/receiver.json b/web/i18n/ru/receiver.json new file mode 100644 index 00000000..2f808fac --- /dev/null +++ b/web/i18n/ru/receiver.json @@ -0,0 +1,7 @@ +{ + "accept": "поддерживаемые форматы: {{ formats }}.", + "title": "перетащи или выбери файл", + "title.drop": "скинь файл сюда!", + "title.multiple": "перетащи или выбери файлы", + "title.drop.multiple": "скинь файлы сюда!" +} diff --git a/web/i18n/ru/remux.json b/web/i18n/ru/remux.json new file mode 100644 index 00000000..1ee7fa6d --- /dev/null +++ b/web/i18n/ru/remux.json @@ -0,0 +1,8 @@ +{ + "bullet.purpose.description": "ремукс исправляет любые проблемы с файлом, например, отсутствие информации о времени. он помогает повысить совместимость со старыми программами, такими как vegas pro и windows media player.", + "bullet.purpose.title": "что делает ремукс?", + "bullet.explainer.title": "как он работает?", + "bullet.explainer.description": "ремукс берёт существующие данные кодека и копирует их в новый медиаконтейнер. это происходит без потери качества, так как медиаданные не перекодируются.", + "bullet.privacy.title": "локальная обработка", + "bullet.privacy.description": "кобальт ремуксирует файлы локально. файлы никогда не покидают твоё устройство, поэтому обработка происходит практически мгновенно." +} diff --git a/web/i18n/ru/save.json b/web/i18n/ru/save.json index ce64917a..26dedbd6 100644 --- a/web/i18n/ru/save.json +++ b/web/i18n/ru/save.json @@ -10,5 +10,15 @@ "services.title": "поддерживаемые сервисы", "services.title_show": "показать поддерживаемые сервисы", "services.title_hide": "скрыть поддерживаемые сервисы", - "services.disclaimer": "кобальт не аффилирован ни с одним из перечисленных выше сервисов.\n\nдеятельность meta platforms (владельца facebook и instagram) запрещена на территории РФ и признана экстремистской." + "services.disclaimer": "поддержка сервиса не означает аффилированность, одобрение или любую другую форму поддержки, кроме технической совместимости.\n\nдеятельность владельца facebook и instagram запрещена на территории РФ и признана экстремистской.", + "tutorial.step.1": "добавь команды-компаньоны:", + "tutorial.step.2": "нажми кнопку \"поделиться\" в диалоге сохранения кобальта.", + "tutorial.step.3": "выбери нужную команду в окне обмена.", + "tutorial.shortcut.photos": "в фото", + "tutorial.shortcut.files": "в файлы", + "tutorial.title": "как сохранить на ios?", + "tutorial.intro": "чтобы удобно сохранять файлы на ios, придётся использовать команду siri в меню обмена.", + "tutorial.outro": "эти команды siri будут работать только из приложения кобальта, использовать их из других приложений не получится.", + "tooltip.captcha": "cloudflare turnstile проверяет, что ты не бот. подожди, пожалуйста!", + "label.community_instance": "инстанс сообщества" } diff --git a/web/i18n/ru/settings.json b/web/i18n/ru/settings.json new file mode 100644 index 00000000..044087c2 --- /dev/null +++ b/web/i18n/ru/settings.json @@ -0,0 +1,131 @@ +{ + "theme.auto": "авто", + "theme.light": "светлая", + "audio.bitrate.kbps": "кб/с", + "theme.dark": "тёмная", + "audio.youtube.dub": "звуковая дорожка youtube", + "video.quality.max": "8k+", + "page.video": "видео", + "page.audio": "аудио", + "video.quality.1440": "1440p", + "video.quality.1080": "1080p", + "video.quality.720": "720p", + "video.quality.480": "480p", + "video.quality.360": "360p", + "video.quality.240": "240p", + "video.quality.144": "144p", + "metadata.file": "метаданные файла", + "saving.title": "метод сохранения", + "saving.ask": "спросить", + "saving.download": "скачать", + "saving.share": "поделиться", + "saving.copy": "скопировать", + "language": "язык", + "language.preferred.title": "предпочитаемый язык", + "privacy.analytics": "анонимная аналитика трафика", + "audio.tiktok.original.title": "скачивать оригинальный звук", + "privacy.tunnel": "туннелирование", + "privacy.tunnel.title": "всегда туннелировать файлы", + "audio.format.mp3": "mp3", + "audio.format.ogg": "ogg", + "audio.format.wav": "wav", + "audio.format.opus": "opus", + "page.privacy": "приватность", + "theme": "тема", + "video.quality": "качество видео", + "video.twitter.gif": "twitter/x", + "video.quality.2160": "4k", + "audio.format": "формат аудио", + "audio.bitrate": "битрейт аудио", + "audio.tiktok.original": "tiktok", + "metadata.disable.title": "отключить метаданные", + "language.auto.title": "автоматический выбор", + "metadata.disable.description": "название, исполнитель и другая информация не будут добавлены в файл.", + "language.preferred.description": "этот язык будет использоваться когда автоматический выбор отключен. любой непереведённый текст будет отображаться на английском языке.\n\nмы используем переводы, предоставленные сообществом. они могут быть неточными или неполными.", + "audio.youtube.dub.description": "cobalt будет использовать дублированную аудиодорожку для выбранного языка, если она доступна. в противном случае будет использоваться оригинальная.", + "language.auto.description": "если доступен перевод, то кобальт будет использовать язык твоего браузера. в ином случае будет использоваться английский.", + "theme.description": "авто тема переключается между светлой и тёмной темой в зависимости от системной темы.", + "page.debug": "инфа для зануд", + "page.appearance": "внешний вид", + "page.instances": "инстансы", + "page.advanced": "продвинутые", + "page.accessibility": "общедоступность", + "page.metadata": "метаданные", + "page.local": "локальная обработка", + "video.youtube.codec": "предпочитаемый кодек для youtube", + "audio.youtube.dub.title": "предпочитаемый язык озвучки", + "metadata.filename.basic": "базовый", + "video.twitter.gif.title": "конвертировать зацикленные видео в GIF", + "metadata.filename.description": "стиль названий файлов используется только для файлов, туннелированных через кобальт. некоторые сервисы поддерживают только классический стиль.", + "youtube.dub.original": "оригинальный", + "metadata.filename.pretty": "красивый", + "metadata.filename.nerdy": "занудный", + "audio.tiktok.original.description": "кобальт будет скачивать оригинальный звук из видео без каких-либо изменений от автора поста.", + "metadata.filename": "стиль названий файлов", + "metadata.filename.classic": "классический", + "video.twitter.gif.description": "GIF конвертация неэффективна, финальный файл может быть огромным и в плохом качестве.", + "audio.youtube.better_audio.title": "предпочитать лучшее качество", + "audio.format.description": "все форматы кроме \"лучшего\" конвертируются из исходного формата, поэтому возможна небольшая потеря качества. когда выбран \"лучший\" формат, аудио остаётся в оригинальном формате, если это возможно.", + "audio.youtube.better_audio.description": "кобальт будет пытаться выбрать самое качественное аудио в режиме скачивания аудио. оно может быть недоступно в зависимости от ответа youtube, текущей нагрузки и состояния сервера. на кастомных инстансах эта опция может не поддерживаться.", + "audio.youtube.better_audio": "качество аудио с youtube", + "video.quality.description": "если предпочитаемое качество недоступно, то выбирается следующий лучший вариант.", + "video.youtube.codec.description": "h264: наилучшая совместимость, среднее качество. максимальное качество — 1080p.\nav1: наилучшее качество и сжатие. поддерживает 8k и HDR.\nvp9: то же качество, что и у av1, но файл в ~2x больше. поддерживает 4k & HDR.\n\nav1 и vp9 не очень широко поддерживаются, возможно придётся использовать дополнительное ПО для их проигрывания/обработки. кобальт выбирает следующий лучший кодек, если предпочитаемый недоступен.", + "audio.bitrate.description": "битрейт применяется только при конвертации аудио в формат с потерями. кобальт не может улучшить качество исходного аудио, поэтому выбор битрейта выше 128 кб/с может увеличить размер файла без заметной разницы в звуке. воспринимаемое качество может различаться в зависимости от формата.", + "video.h265": "high efficiency video codec", + "video.h265.title": "использовать h265 для видео", + "video.h265.description": "позволяет скачивать видео с tiktok и xiaohongshu в более высоком качестве, но с потерей совместимости.", + "video.youtube.hls": "форматы hls для youtube", + "video.youtube.hls.description": "в этом режиме доступны только кодеки h264 и vp9. оригинальный аудио кодек aac перекодируется для совместимости, поэтому качество аудио может быть хуже чем у варианта без HLS.\n\nэта функция экспериментальна, поэтому может быть убрана или изменена в будущем.", + "audio.format.best": "лучший", + "video.youtube.hls.title": "предпочитать hls для видео и аудио", + "metadata.filename.preview.video": "Название Видео - Автор Видео", + "metadata.filename.preview.audio": "Название Аудио - Автор Аудио", + "filename.preview_desc.video": "превью видео файла", + "filename.preview_desc.audio": "превью аудио файла", + "saving.description": "предпочтительный способ сохранения файла или ссылки с кобальта. если предпочитаемый метод недоступен или что-то пойдёт не так, кобальт спросит тебя как поступить.", + "accessibility.transparency.description": "уменьшает прозрачность поверхностей и выключает эффекты размытия. также может улучшить работу интерфейса на менее мощных устройствах.", + "accessibility.transparency.title": "уменьшить визуальную прозрачность", + "accessibility.visual": "интерфейс", + "accessibility.haptics": "вибрация", + "accessibility.behavior": "поведение", + "accessibility.auto_queue.description": "очередь обработки не будет открываться автоматически при добавлении новой задачи. прогресс всё равно будет отображаться, и ты всё равно сможешь открыть её вручную.", + "privacy.analytics.learnmore": "узнай больше о преданности plausible к приватности.", + "accessibility.motion.description": "анимации и переходы будут отключены, когда это возможно.", + "accessibility.haptics.title": "отключить вибрацию", + "accessibility.haptics.description": "вся вибрация будет отключена.", + "accessibility.auto_queue.title": "не открывать очередь обработки", + "privacy.analytics.description": "анонимная аналитика трафика нужна, чтобы знать приблизительное количество активных пользователей кобальта. идентифицирующая информация о тебе никогда не сохраняется. все обрабатываемые данные анонимизированы и агрегированы.\n\nмы используем собственный инстанс plausible, который не использует куки и полностью соответствует требованиям GDPR, CCPA и PECR.", + "privacy.tunnel.description": "cobalt скроет твой ip адрес, информацию о браузере и обойдёт местные сетевые ограничения. когда включено, у всех файлов будут читаемые названия вместо абракадабры.", + "accessibility.motion.title": "уменьшить движение", + "privacy.analytics.title": "не участвовать в аналитике", + "advanced.debug": "отладка", + "advanced.debug.description": "даёт доступ к странице с различной информацией, которая может быть полезна для отладки. никак не меняет поведение кобальта.", + "advanced.debug.title": "включить функции для зануд", + "processing.community": "инстансы сообщества", + "processing.enable_custom.description": "кобальт будет использовать сторонний инстанс обработки, если ты так решишь. несмотря на то, что у кобальта есть некоторые меры безопасности, мы не несём ответственности за любой ущерб, причинённый сторонним инстансом, так как мы его не контролируем.\n\nбудь осторожен с тем, какие инстансы ты используешь, и убедись, что их хостят люди, которым ты доверяешь.", + "processing.enable_custom.title": "использовать сторонний инстанс", + "local.saving": "локальная обработка медиа", + "local.saving.description": "при скачивании медиа, ремуксинг и транскодирование будут выполняться на устройстве, а не в облаке. ты увидишь подробный прогресс в очереди обработки.\n\nникогда: локальная обработка не будет использоваться. инстансы обработки могут принудительно включать эту функцию, поэтому эта опция может не иметь эффекта.\nиногда: медиафайлы, требующие дополнительной обработки, будут загружаться через очередь обработки, но остальные медиафайлы будут загружаться менеджером загрузок твоего браузера.\nвсегда: все медиафайлы всегда будут проксироваться и загружаться через очередь обработки.\n\nэксклюзивные функции на устройстве не зависят от этой настройки, они всегда работают локально.", + "advanced.settings_data": "данные настроек", + "local.webcodecs.description": "при декодировании или кодировании файлов кобальт будет пытаться использовать webcodecs. эта функция позволяет обрабатывать медиафайлы с ускорением на GPU, так что всё декодирование и кодирование будет намного быстрее.\n\nдоступность и стабильность этой функции зависят от возможностей твоего устройства и браузера. что-то может сломаться или работать некорректно.", + "processing.access_key": "ключ доступа к инстансу", + "advanced.local_storage": "локальное хранилище", + "local.webcodecs": "webcodecs", + "local.webcodecs.title": "использовать webcodecs для локальной обработки", + "processing.access_key.title": "использовать ключ доступа", + "processing.custom_instance.input.alt_text": "домен стороннего инстанса", + "tabs": "навигация", + "tabs.hide_remux": "скрыть страницу ремукса", + "tabs.hide_remux.description": "если ты не пользуешься ремуксом, то его можно скрыть из панели навигации.", + "processing.access_key.description": "кобальт будет использовать этот ключ для запросов к инстансу обработки вместо других методов аутентификации. убедись, что инстанс поддерживает api ключи!", + "processing.access_key.input.alt_text": "ключ доступа u-u-i-d", + "video.youtube.container": "контейнер файла для youtube", + "video.youtube.container.description": "когда выбран \"авто\" контейнер, кобальт автоматически подберёт оптимальный контейнер в зависимости от выбранного кодека: mp4 для h264; webm для vp9/av1.", + "subtitles.description": "кобальт добавит субтитры к скачанному файлу на предпочитаемом языке, если они доступны.\n\nнекоторые сервисы не имеют выбора языка, и в таком случае кобальт добавит единственную доступную дорожку субтитров, если выбран любой язык.", + "subtitles": "субтитры", + "subtitles.title": "язык субтитров", + "subtitles.none": "никакой", + "local.saving.disabled": "никогда", + "local.saving.preferred": "иногда", + "local.saving.forced": "всегда" +} diff --git a/web/i18n/ru/tabs.json b/web/i18n/ru/tabs.json index 0b93cc7f..afe0d693 100644 --- a/web/i18n/ru/tabs.json +++ b/web/i18n/ru/tabs.json @@ -3,6 +3,6 @@ "settings": "настройки", "updates": "новости", "donate": "донаты", - "about": "инфа", + "about": "инфо", "remux": "ремукс" } diff --git a/web/i18n/ru/updates.json b/web/i18n/ru/updates.json new file mode 100644 index 00000000..f6ab7698 --- /dev/null +++ b/web/i18n/ru/updates.json @@ -0,0 +1,4 @@ +{ + "button.next": "перейти к предыдущему обновлению ({{ value }})", + "button.previous": "перейти к следующему обновлению ({{ value }})" +} diff --git a/web/package.json b/web/package.json index dbd1612a..7de6f75d 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/misc/UpdateNotification.svelte b/web/src/components/misc/UpdateNotification.svelte index 8e8e1b54..77f3a966 100644 --- a/web/src/components/misc/UpdateNotification.svelte +++ b/web/src/components/misc/UpdateNotification.svelte @@ -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 { diff --git a/web/src/components/queue/ProcessingQueueItem.svelte b/web/src/components/queue/ProcessingQueueItem.svelte index 5ab4e03d..c8c2e79c 100644 --- a/web/src/components/queue/ProcessingQueueItem.svelte +++ b/web/src/components/queue/ProcessingQueueItem.svelte @@ -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, diff --git a/web/src/components/save/CaptchaTooltip.svelte b/web/src/components/save/CaptchaTooltip.svelte index f114090d..af769e6f 100644 --- a/web/src/components/save/CaptchaTooltip.svelte +++ b/web/src/components/save/CaptchaTooltip.svelte @@ -40,7 +40,7 @@ } .tooltip-body { - max-width: 180px; + max-width: 190px; position: relative; pointer-events: none; diff --git a/web/src/components/settings/FilenamePreview.svelte b/web/src/components/settings/FilenamePreview.svelte index 4af25be9..35a7612f 100644 --- a/web/src/components/settings/FilenamePreview.svelte +++ b/web/src/components/settings/FilenamePreview.svelte @@ -75,7 +75,7 @@
{`${videoFilePreview}.${youtubeVideoExt}`}
-
video file preview
+
{$t("settings.filename.preview_desc.video")}
@@ -84,7 +84,7 @@
{`${audioFilePreview}.${audioFormat}`}
-
audio file preview
+
{$t("settings.filename.preview_desc.audio")}
diff --git a/web/src/components/settings/SettingsDropdown.svelte b/web/src/components/settings/SettingsDropdown.svelte index 07d4585a..9fe67666 100644 --- a/web/src/components/settings/SettingsDropdown.svelte +++ b/web/src/components/settings/SettingsDropdown.svelte @@ -42,7 +42,7 @@
diff --git a/web/src/components/subnav/PageNav.svelte b/web/src/components/subnav/PageNav.svelte index 85e18d70..a4e4c7b2 100644 --- a/web/src/components/subnav/PageNav.svelte +++ b/web/src/components/subnav/PageNav.svelte @@ -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}`)}` diff --git a/web/src/lib/api/saving-handler.ts b/web/src/lib/api/saving-handler.ts index b2dfbbb4..94c0319e 100644 --- a/web/src/lib/api/saving-handler.ts +++ b/web/src/lib/api/saving-handler.ts @@ -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); diff --git a/web/src/lib/device.ts b/web/src/lib/device.ts index 9b931713..2b8dc894 100644 --- a/web/src/lib/device.ts +++ b/web/src/lib/device.ts @@ -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; diff --git a/web/src/lib/env.ts b/web/src/lib/env.ts index 1ba6a843..5821699c 100644 --- a/web/src/lib/env.ts +++ b/web/src/lib/env.ts @@ -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 = { diff --git a/web/src/lib/libav.ts b/web/src/lib/libav.ts index e888926a..6c154022 100644 --- a/web/src/lib/libav.ts +++ b/web/src/lib/libav.ts @@ -32,7 +32,6 @@ export default class LibAVWrapper { this.libav = constructor({ ...options, variant: undefined, - yesthreads: true, base: '/_libav' }); } diff --git a/web/src/lib/settings/audio-sub-language.ts b/web/src/lib/settings/audio-sub-language.ts new file mode 100644 index 00000000..b2cfe7bc --- /dev/null +++ b/web/src/lib/settings/audio-sub-language.ts @@ -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; + +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; +} + +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"; +} diff --git a/web/src/lib/settings/defaults.ts b/web/src/lib/settings/defaults.ts index 54a96994..ce3114ec 100644 --- a/web/src/lib/settings/defaults.ts +++ b/web/src/lib/settings/defaults.ts @@ -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, diff --git a/web/src/lib/settings/migrate.ts b/web/src/lib/settings/migrate.ts index 3d372823..d033a6f9 100644 --- a/web/src/lib/settings/migrate.ts +++ b/web/src/lib/settings/migrate.ts @@ -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 = { return out as AllPartialSettingsWithSchema; }, + + [6]: (settings: AllPartialSettingsWithSchema) => { + const out = settings as RecursivePartial; + 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 => { diff --git a/web/src/lib/settings/validate.ts b/web/src/lib/settings/validate.ts index 02467d2e..a711f9ae 100644 --- a/web/src/lib/settings/validate.ts +++ b/web/src/lib/settings/validate.ts @@ -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 ] ]) ); } diff --git a/web/src/lib/settings/youtube-lang.ts b/web/src/lib/settings/youtube-lang.ts deleted file mode 100644 index 9c470547..00000000 --- a/web/src/lib/settings/youtube-lang.ts +++ /dev/null @@ -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; -} - -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"; -} diff --git a/web/src/lib/storage/memory.ts b/web/src/lib/storage/memory.ts index 45bebc3b..fc462f0b 100644 --- a/web/src/lib/storage/memory.ts +++ b/web/src/lib/storage/memory.ts @@ -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) { diff --git a/web/src/lib/storage/opfs.ts b/web/src/lib/storage/opfs.ts index 96a1e1f8..d745cb34 100644 --- a/web/src/lib/storage/opfs.ts +++ b/web/src/lib/storage/opfs.ts @@ -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; diff --git a/web/src/lib/task-manager/queue.ts b/web/src/lib/task-manager/queue.ts index bd79269f..2d439333 100644 --- a/web/src/lib/task-manager/queue.ts +++ b/web/src/lib/task-manager/queue.ts @@ -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(); diff --git a/web/src/lib/task-manager/run-worker.ts b/web/src/lib/task-manager/run-worker.ts index 021fda0d..b9994661 100644 --- a/web/src/lib/task-manager/run-worker.ts +++ b/web/src/lib/task-manager/run-worker.ts @@ -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 { diff --git a/web/src/lib/task-manager/runners/ffmpeg.ts b/web/src/lib/task-manager/runners/ffmpeg.ts index 43d75cbe..0a83f3fb 100644 --- a/web/src/lib/task-manager/runners/ffmpeg.ts +++ b/web/src/lib/task-manager/runners/ffmpeg.ts @@ -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, } }); diff --git a/web/src/lib/task-manager/workers/ffmpeg.ts b/web/src/lib/task-manager/workers/ffmpeg.ts index 7568fb8a..7f7577f2 100644 --- a/web/src/lib/task-manager/workers/ffmpeg.ts +++ b/web/src/lib/task-manager/workers/ffmpeg.ts @@ -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); } } diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts index 13d2b8b7..f127d02e 100644 --- a/web/src/lib/types/api.ts +++ b/web/src/lib/types/api.ts @@ -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, diff --git a/web/src/lib/types/generic.ts b/web/src/lib/types/generic.ts index 59844106..19f78db2 100644 --- a/web/src/lib/types/generic.ts +++ b/web/src/lib/types/generic.ts @@ -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 = { @@ -10,3 +12,4 @@ export type RecursivePartial = { export type DefaultImport = () => Promise<{ default: T }>; export type Optional = T | undefined; export type Writeable = { -readonly [P in keyof T]: T[P] }; +export type FromReadable = T extends Readable ? U : never; diff --git a/web/src/lib/types/settings.ts b/web/src/lib/types/settings.ts index 37454183..1d184eec 100644 --- a/web/src/lib/types/settings.ts +++ b/web/src/lib/types/settings.ts @@ -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; diff --git a/web/src/lib/types/settings/v3.ts b/web/src/lib/types/settings/v3.ts index 7d02f2da..31e223c9 100644 --- a/web/src/lib/types/settings/v3.ts +++ b/web/src/lib/types/settings/v3.ts @@ -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 & { schemaVersion: 3, save: Omit & { - youtubeDubLang: YoutubeLang; + youtubeDubLang: YoutubeDubLang; }; }; diff --git a/web/src/lib/types/settings/v6.ts b/web/src/lib/types/settings/v6.ts new file mode 100644 index 00000000..0a933e67 --- /dev/null +++ b/web/src/lib/types/settings/v6.ts @@ -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 & { + schemaVersion: 6, + save: Omit & { + localProcessing: typeof localProcessingOptions[number], + youtubeVideoContainer: typeof youtubeVideoContainerOptions[number]; + subtitleLang: SubtitleLang, + }, +}; diff --git a/web/src/lib/types/workers.ts b/web/src/lib/types/workers.ts index 56e44baa..b65abf69 100644 --- a/web/src/lib/types/workers.ts +++ b/web/src/lib/types/workers.ts @@ -1,7 +1,7 @@ import type { FileInfo } from "$lib/types/libav"; import type { UUID } from "./queue"; -export const resultFileTypes = ["video", "audio", "image"] as const; +export const resultFileTypes = ["video", "audio", "image", "file"] as const; export type CobaltPipelineResultFileType = typeof resultFileTypes[number]; diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index 609f651e..4e833166 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -18,6 +18,13 @@ export const formatFileSize = (size: number | undefined) => { export const ffmpegMetadataArgs = (metadata: CobaltFileMetadata) => Object.entries(metadata).flatMap(([name, value]) => { if (CobaltFileMetadataKeys.includes(name) && typeof value === "string") { + if (name === "sublanguage") { + return [ + '-metadata:s:s:0', + // eslint-disable-next-line no-control-regex + `language=${value.replace(/[\u0000-\u0009]/g, "")}` + ] + } return [ '-metadata', // eslint-disable-next-line no-control-regex @@ -26,3 +33,20 @@ export const ffmpegMetadataArgs = (metadata: CobaltFileMetadata) => } return []; }); + +const digit = () => '0123456789abcdef'[Math.random() * 16 | 0]; +export const uuid = () => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + + const digits = Array.from({length: 32}, digit); + digits[12] = '4'; + digits[16] = '89ab'[Math.random() * 4 | 0]; + + return digits + .join('') + .match(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/)! + .slice(1) + .join('-'); +} diff --git a/web/src/routes/settings/audio/+page.svelte b/web/src/routes/settings/audio/+page.svelte index 932cfc20..f4227daa 100644 --- a/web/src/routes/settings/audio/+page.svelte +++ b/web/src/routes/settings/audio/+page.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/routes/settings/local/+page.svelte b/web/src/routes/settings/local/+page.svelte index babff20e..656e7fa3 100644 --- a/web/src/routes/settings/local/+page.svelte +++ b/web/src/routes/settings/local/+page.svelte @@ -1,18 +1,26 @@ - + + {#each localProcessingOptions as value} + + {$t(`settings.local.saving.${value}`)} + + {/each} + {#if env.ENABLE_WEBCODECS} diff --git a/web/src/routes/settings/metadata/+page.svelte b/web/src/routes/settings/metadata/+page.svelte index 48a2b433..dfe02eef 100644 --- a/web/src/routes/settings/metadata/+page.svelte +++ b/web/src/routes/settings/metadata/+page.svelte @@ -1,6 +1,8 @@ @@ -44,6 +49,21 @@ + + + + + import env from "$lib/env"; import settings from "$lib/state/settings"; import { t } from "$lib/i18n/translations"; - import { videoQualityOptions } from "$lib/types/settings"; + import { videoQualityOptions, youtubeVideoContainerOptions } from "$lib/types/settings"; import { youtubeVideoCodecOptions } from "$lib/types/settings"; import SettingsCategory from "$components/settings/SettingsCategory.svelte"; @@ -11,9 +12,9 @@ import SettingsToggle from "$components/buttons/SettingsToggle.svelte"; const codecTitles = { - h264: "h264 (mp4)", - av1: "av1 (webm)", - vp9: "vp9 (webm)", + h264: "h264 + aac", + av1: "av1 + opus", + vp9: "vp9 + opus", } @@ -55,20 +56,42 @@ - + + {#each youtubeVideoContainerOptions as value} + + {value} + + {/each} + +{#if env.ENABLE_DEPRECATED_YOUTUBE_HLS} + + + +{/if} +