mirror of
https://github.com/imputnet/cobalt.git
synced 2025-06-28 01:18:27 +00:00
Merge 900c6f27ca
into 35530459b6
This commit is contained in:
commit
e01c47972b
@ -39,7 +39,7 @@
|
||||
"set-cookie-parser": "2.6.0",
|
||||
"undici": "^5.19.1",
|
||||
"url-pattern": "1.0.3",
|
||||
"youtubei.js": "^13.4.0",
|
||||
"youtubei.js": "^14.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
@ -156,6 +156,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
return fail(res, `error.api.auth.key.${error}`);
|
||||
}
|
||||
|
||||
req.isApiKey = true;
|
||||
return next();
|
||||
});
|
||||
|
||||
@ -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");
|
||||
@ -264,6 +268,7 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
|
||||
patternMatch: parsed.patternMatch,
|
||||
params: normalizedRequest,
|
||||
isSession: req.isSession ?? false,
|
||||
isApiKey: req.isApiKey ?? false,
|
||||
});
|
||||
|
||||
res.status(result.status).json(result.body);
|
||||
|
@ -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,7 @@ 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: (env.SESSION_RATELIMIT_MAX && parseInt(env.SESSION_RATELIMIT_MAX)) || 10,
|
||||
|
||||
durationLimit: (env.DURATION_LIMIT && parseInt(env.DURATION_LIMIT)) || 10800,
|
||||
streamLifespan: (env.TUNNEL_LIFESPAN && parseInt(env.TUNNEL_LIFESPAN)) || 90,
|
||||
@ -63,6 +65,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 +77,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 +112,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')
|
||||
}
|
||||
|
53
api/src/misc/language-codes.js
Normal file
53
api/src/misc/language-codes.js
Normal file
@ -0,0 +1,53 @@
|
||||
// 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) => {
|
||||
return maps[code.length]?.[code.toLowerCase()] || null;
|
||||
}
|
@ -4,6 +4,7 @@ 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"];
|
||||
|
||||
@ -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,9 +163,22 @@ 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":
|
||||
params = { type: "proxy" };
|
||||
break;
|
||||
@ -170,7 +189,6 @@ export default function({
|
||||
case "pinterest":
|
||||
case "streamable":
|
||||
case "snapchat":
|
||||
case "loom":
|
||||
case "twitch":
|
||||
responseType = "redirect";
|
||||
break;
|
||||
@ -178,7 +196,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 +244,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 +252,26 @@ 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.includes(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
|
||||
if (defaultParams.fileMetadata?.sublanguage?.length === 2) {
|
||||
const code = convertLanguageCode(defaultParams.fileMetadata.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(
|
||||
|
@ -32,7 +32,7 @@ import xiaohongshu from "./services/xiaohongshu.js";
|
||||
|
||||
let freebind;
|
||||
|
||||
export default async function({ host, patternMatch, params, isSession }) {
|
||||
export default async function({ host, patternMatch, params, isSession, isApiKey }) {
|
||||
const { url } = params;
|
||||
assert(url instanceof URL);
|
||||
let dispatcher, requestIP;
|
||||
@ -65,6 +65,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" && !isApiKey)) {
|
||||
youtubeHLS = false;
|
||||
}
|
||||
|
||||
const subtitleLang =
|
||||
params.subtitleLang !== "none" ? params.subtitleLang : undefined;
|
||||
|
||||
switch (host) {
|
||||
case "twitter":
|
||||
r = await twitter({
|
||||
@ -81,7 +92,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 +113,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 +151,7 @@ export default async function({ host, patternMatch, params, isSession }) {
|
||||
isAudioOnly,
|
||||
h265: params.allowH265,
|
||||
alwaysProxy: params.alwaysProxy,
|
||||
subtitleLang,
|
||||
});
|
||||
break;
|
||||
|
||||
@ -154,6 +169,7 @@ export default async function({ host, patternMatch, params, isSession }) {
|
||||
password: patternMatch.password,
|
||||
quality: params.videoQuality,
|
||||
isAudioOnly,
|
||||
subtitleLang,
|
||||
});
|
||||
break;
|
||||
|
||||
@ -205,6 +221,7 @@ export default async function({ host, patternMatch, params, isSession }) {
|
||||
key: patternMatch.key,
|
||||
quality: params.videoQuality,
|
||||
isAudioOnly,
|
||||
subtitleLang,
|
||||
});
|
||||
break;
|
||||
|
||||
@ -221,7 +238,8 @@ export default async function({ host, patternMatch, params, isSession }) {
|
||||
|
||||
case "loom":
|
||||
r = await loom({
|
||||
id: patternMatch.id
|
||||
id: patternMatch.id,
|
||||
subtitleLang,
|
||||
});
|
||||
break;
|
||||
|
||||
@ -293,11 +311,12 @@ export default async function({ host, patternMatch, params, isSession }) {
|
||||
}
|
||||
|
||||
let localProcessing = params.localProcessing;
|
||||
|
||||
const lpEnv = env.forceLocalProcessing;
|
||||
const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && isSession);
|
||||
const localDisabled = (!localProcessing || localProcessing === "none");
|
||||
|
||||
if (lpEnv === "always" || (lpEnv === "session" && isSession)) {
|
||||
localProcessing = true;
|
||||
if (shouldForceLocal && localDisabled) {
|
||||
localProcessing = "preferred";
|
||||
}
|
||||
|
||||
return matchAction({
|
||||
@ -311,7 +330,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 {
|
||||
|
@ -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 }
|
||||
));
|
||||
|
@ -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();
|
||||
|
@ -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: {
|
||||
@ -186,12 +186,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"],
|
||||
|
@ -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`
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -146,8 +146,14 @@ export default async function(obj) {
|
||||
copyright: json.license?.trim(),
|
||||
}
|
||||
|
||||
let cover;
|
||||
if (json.artwork_url) {
|
||||
cover = json.artwork_url.replace(/-large/, "-t1080x1080");
|
||||
}
|
||||
|
||||
return {
|
||||
urls: file.toString(),
|
||||
cover,
|
||||
filenameAttributes: {
|
||||
service: "soundcloud",
|
||||
id: json.id,
|
||||
|
@ -4,6 +4,7 @@ import { extract, normalizeURL } from "../url.js";
|
||||
import { genericUserAgent } from "../../config.js";
|
||||
import { updateCookie } from "../cookie/manager.js";
|
||||
import { createStream } from "../../stream/manage.js";
|
||||
import { convertLanguageCode } from "../../misc/language-codes.js";
|
||||
|
||||
const shortDomain = "https://vt.tiktok.com/";
|
||||
|
||||
@ -23,8 +24,10 @@ export default async function(obj) {
|
||||
|
||||
if (html.startsWith('<a href="https://')) {
|
||||
const extractedURL = html.split('<a href="')[1].split('?')[0];
|
||||
const { patternMatch } = extract(normalizeURL(extractedURL));
|
||||
postId = patternMatch?.postId;
|
||||
const { host, patternMatch } = extract(normalizeURL(extractedURL));
|
||||
if (host === "tiktok") {
|
||||
postId = patternMatch?.postId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!postId) return { error: "fetch.short_link" };
|
||||
@ -97,8 +100,23 @@ export default async function(obj) {
|
||||
}
|
||||
|
||||
if (video) {
|
||||
let subtitles, fileMetadata;
|
||||
if (obj.subtitleLang && detail?.video?.subtitleInfos?.length) {
|
||||
const langCode = convertLanguageCode(obj.subtitleLang);
|
||||
const subtitle = detail?.video?.subtitleInfos.find(
|
||||
s => s.LanguageCodeName.startsWith(langCode) && s.Format === "webvtt"
|
||||
)
|
||||
if (subtitle) {
|
||||
subtitles = subtitle.Url;
|
||||
fileMetadata = {
|
||||
sublanguage: langCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
urls: video,
|
||||
subtitles,
|
||||
fileMetadata,
|
||||
filename: videoFilename,
|
||||
headers: { cookie }
|
||||
}
|
||||
|
@ -40,7 +40,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 +56,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,
|
||||
@ -143,7 +158,7 @@ export default async function(obj) {
|
||||
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 +170,10 @@ export default async function(obj) {
|
||||
artist: info.user.name,
|
||||
};
|
||||
|
||||
if (response.subtitles) {
|
||||
fileMetadata.sublanguage = obj.subtitleLang;
|
||||
}
|
||||
|
||||
return merge(
|
||||
{
|
||||
fileMetadata,
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -17,7 +17,7 @@ function aliasURL(url) {
|
||||
if (url.pathname.startsWith('/live/') || url.pathname.startsWith('/shorts/')) {
|
||||
url.pathname = '/watch';
|
||||
// parts := ['', 'live' || 'shorts', id, ...rest]
|
||||
url.search = `?v=${encodeURIComponent(parts[2])}`
|
||||
url.search = `?v=${encodeURIComponent(parts[2])}`;
|
||||
}
|
||||
break;
|
||||
|
||||
@ -61,23 +61,23 @@ function aliasURL(url) {
|
||||
|
||||
case "b23":
|
||||
if (url.hostname === 'b23.tv' && parts.length === 2) {
|
||||
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
|
||||
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "dai":
|
||||
if (url.hostname === 'dai.ly' && parts.length === 2) {
|
||||
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
|
||||
url = new URL(`https://dailymotion.com/video/${parts[1]}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "facebook":
|
||||
case "fb":
|
||||
if (url.searchParams.get('v')) {
|
||||
url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`)
|
||||
url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`);
|
||||
}
|
||||
if (url.hostname === 'fb.watch') {
|
||||
url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`)
|
||||
url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -92,6 +92,9 @@ function aliasURL(url) {
|
||||
if (services.vk.altDomains.includes(url.hostname)) {
|
||||
url.hostname = 'vk.com';
|
||||
}
|
||||
if (url.searchParams.get('z')) {
|
||||
url = new URL(`https://vk.com/${url.searchParams.get('z')}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "xhslink":
|
||||
@ -196,7 +199,7 @@ export function normalizeURL(url) {
|
||||
);
|
||||
}
|
||||
|
||||
export function extract(url) {
|
||||
export function extract(url, enabledServices = env.enabledServices) {
|
||||
if (!(url instanceof URL)) {
|
||||
url = new URL(url);
|
||||
}
|
||||
@ -207,7 +210,7 @@ export function extract(url) {
|
||||
return { error: "link.invalid" };
|
||||
}
|
||||
|
||||
if (!env.enabledServices.has(host)) {
|
||||
if (!enabledServices.has(host)) {
|
||||
// show a different message when youtube is disabled on official instances
|
||||
// as it only happens when shit hits the fan
|
||||
if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
|
||||
|
@ -15,7 +15,7 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12
|
||||
|
||||
let keys = {}, reader = null;
|
||||
|
||||
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
|
||||
const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit', 'allowedServices']);
|
||||
|
||||
/* Expected format pseudotype:
|
||||
** type KeyFileContents = Record<
|
||||
@ -24,7 +24,8 @@ const ALLOWED_KEYS = new Set(['name', 'ips', 'userAgents', 'limit']);
|
||||
** name?: string,
|
||||
** limit?: number | "unlimited",
|
||||
** ips?: CIDRString[],
|
||||
** userAgents?: string[]
|
||||
** userAgents?: string[],
|
||||
** allowedServices?: "all" | string[],
|
||||
** }
|
||||
** >;
|
||||
*/
|
||||
@ -77,6 +78,19 @@ const validateKeys = (input) => {
|
||||
throw "`userAgents` in details contains an invalid user agent: " + invalid_ua;
|
||||
}
|
||||
}
|
||||
|
||||
if (details.allowedServices) {
|
||||
if (Array.isArray(details.allowedServices)) {
|
||||
const invalid_services = details.allowedServices.some(
|
||||
service => !env.allServices.has(service)
|
||||
);
|
||||
if (invalid_services) {
|
||||
throw "`allowedServices` in details contains an invalid service";
|
||||
}
|
||||
} else if (details.allowedServices !== "all") {
|
||||
throw "details object contains value for `allowedServices` which is not an array or `all`";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -112,6 +126,14 @@ const formatKeys = (keyData) => {
|
||||
if (data.userAgents) {
|
||||
formatted[key].userAgents = data.userAgents.map(generateWildcardRegex);
|
||||
}
|
||||
|
||||
if (data.allowedServices) {
|
||||
if (Array.isArray(data.allowedServices)) {
|
||||
formatted[key].allowedServices = new Set(data.allowedServices);
|
||||
} else {
|
||||
formatted[key].allowedServices = data.allowedServices;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
@ -230,3 +252,15 @@ export const setup = (url) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const getAllowedServices = (key) => {
|
||||
if (typeof key !== "string") return;
|
||||
|
||||
const allowedServices = keys[key.toLowerCase()]?.allowedServices;
|
||||
if (!allowedServices) return;
|
||||
|
||||
if (allowedServices === "all") {
|
||||
return env.allServices;
|
||||
}
|
||||
return allowedServices;
|
||||
}
|
||||
|
215
api/src/stream/ffmpeg.js
Normal file
215
api/src/stream/ffmpeg.js
Normal file
@ -0,0 +1,215 @@
|
||||
import ffmpeg from "ffmpeg-static";
|
||||
import { spawn } from "child_process";
|
||||
import { create as contentDisposition } from "content-disposition-header";
|
||||
|
||||
import { env } from "../config.js";
|
||||
import { destroyInternalStream } from "./manage.js";
|
||||
import { hlsExceptions } from "../processing/service-config.js";
|
||||
import { closeResponse, pipe, estimateTunnelLength, estimateAudioMultiplier } from "./shared.js";
|
||||
|
||||
const metadataTags = new Set([
|
||||
"album",
|
||||
"composer",
|
||||
"genre",
|
||||
"copyright",
|
||||
"title",
|
||||
"artist",
|
||||
"album_artist",
|
||||
"track",
|
||||
"date",
|
||||
"sublanguage"
|
||||
]);
|
||||
|
||||
const convertMetadataToFFmpeg = (metadata) => {
|
||||
const args = [];
|
||||
|
||||
for (const [ name, value ] of Object.entries(metadata)) {
|
||||
if (metadataTags.has(name)) {
|
||||
if (name === "sublanguage") {
|
||||
args.push('-metadata:s:s:0', `language=${value}`);
|
||||
continue;
|
||||
}
|
||||
args.push('-metadata', `${name}=${value.replace(/[\u0000-\u0009]/g, '')}`); // skipcq: JS-0004
|
||||
} else {
|
||||
throw `${name} metadata tag is not supported.`;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
const killProcess = (p) => {
|
||||
p?.kill('SIGTERM'); // ask the process to terminate itself gracefully
|
||||
|
||||
setTimeout(() => {
|
||||
if (p?.exitCode === null)
|
||||
p?.kill('SIGKILL'); // brutally murder the process if it didn't quit
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
const getCommand = (args) => {
|
||||
if (typeof env.processingPriority === 'number' && !isNaN(env.processingPriority)) {
|
||||
return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]]
|
||||
}
|
||||
return [ffmpeg, args]
|
||||
}
|
||||
|
||||
const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
|
||||
let process;
|
||||
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
|
||||
const shutdown = () => (
|
||||
killProcess(process),
|
||||
closeResponse(res),
|
||||
urls.map(destroyInternalStream)
|
||||
);
|
||||
|
||||
try {
|
||||
const args = [
|
||||
'-loglevel', '-8',
|
||||
...ffargs,
|
||||
];
|
||||
|
||||
process = spawn(...getCommand(args), {
|
||||
windowsHide: true,
|
||||
stdio: [
|
||||
'inherit', 'inherit', 'inherit',
|
||||
'pipe'
|
||||
],
|
||||
});
|
||||
|
||||
const [,,, muxOutput] = process.stdio;
|
||||
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
|
||||
|
||||
res.setHeader(
|
||||
'Estimated-Content-Length',
|
||||
await estimateTunnelLength(streamInfo, estimateMultiplier)
|
||||
);
|
||||
|
||||
pipe(muxOutput, res, shutdown);
|
||||
|
||||
process.on('close', shutdown);
|
||||
res.on('finish', shutdown);
|
||||
} catch {
|
||||
shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
const remux = async (streamInfo, res) => {
|
||||
const format = streamInfo.filename.split('.').pop();
|
||||
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
|
||||
const args = urls.flatMap(url => ['-i', url]);
|
||||
|
||||
// if the stream type is merge, we expect two URLs
|
||||
if (streamInfo.type === 'merge' && urls.length !== 2) {
|
||||
return closeResponse(res);
|
||||
}
|
||||
|
||||
if (streamInfo.subtitles) {
|
||||
args.push(
|
||||
'-i', streamInfo.subtitles,
|
||||
'-map', `${urls.length}:s`,
|
||||
'-c:s', format === 'mp4' ? 'mov_text' : 'webvtt',
|
||||
);
|
||||
}
|
||||
|
||||
if (urls.length === 2) {
|
||||
args.push(
|
||||
'-map', '0:v',
|
||||
'-map', '1:a',
|
||||
);
|
||||
} else {
|
||||
args.push(
|
||||
'-map', '0:v:0',
|
||||
'-map', '0:a:0'
|
||||
);
|
||||
}
|
||||
|
||||
args.push(
|
||||
'-c:v', 'copy',
|
||||
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
|
||||
);
|
||||
|
||||
if (format === 'mp4') {
|
||||
args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
|
||||
}
|
||||
|
||||
if (streamInfo.type !== 'mute' && streamInfo.isHLS && hlsExceptions.has(streamInfo.service)) {
|
||||
if (streamInfo.service === 'youtube' && format === 'webm') {
|
||||
args.push('-c:a', 'libopus');
|
||||
} else {
|
||||
args.push('-c:a', 'aac', '-bsf:a', 'aac_adtstoasc');
|
||||
}
|
||||
}
|
||||
|
||||
if (streamInfo.metadata) {
|
||||
args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
|
||||
}
|
||||
|
||||
args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
|
||||
|
||||
await render(res, streamInfo, args);
|
||||
}
|
||||
|
||||
const convertAudio = async (streamInfo, res) => {
|
||||
const args = [
|
||||
'-i', streamInfo.urls,
|
||||
'-vn',
|
||||
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
|
||||
];
|
||||
|
||||
if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
|
||||
args.push('-ar', '12000');
|
||||
}
|
||||
|
||||
if (streamInfo.audioFormat === 'opus') {
|
||||
args.push('-vbr', 'off');
|
||||
}
|
||||
|
||||
if (streamInfo.audioFormat === 'mp4a') {
|
||||
args.push('-movflags', 'frag_keyframe+empty_moov');
|
||||
}
|
||||
|
||||
if (streamInfo.metadata) {
|
||||
args.push(...convertMetadataToFFmpeg(streamInfo.metadata));
|
||||
}
|
||||
|
||||
args.push(
|
||||
'-f',
|
||||
streamInfo.audioFormat === 'm4a' ? 'ipod' : streamInfo.audioFormat,
|
||||
'pipe:3',
|
||||
);
|
||||
|
||||
await render(
|
||||
res,
|
||||
streamInfo,
|
||||
args,
|
||||
estimateAudioMultiplier(streamInfo) * 1.1,
|
||||
);
|
||||
}
|
||||
|
||||
const convertGif = async (streamInfo, res) => {
|
||||
const args = [
|
||||
'-i', streamInfo.urls,
|
||||
|
||||
'-vf',
|
||||
'scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse',
|
||||
'-loop', '0',
|
||||
|
||||
'-f', 'gif', 'pipe:3',
|
||||
];
|
||||
|
||||
await render(
|
||||
res,
|
||||
streamInfo,
|
||||
args,
|
||||
60,
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
remux,
|
||||
convertAudio,
|
||||
convertGif,
|
||||
}
|
@ -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 = ["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.includes(streamInfo.service) && !streamInfo.isHLS) {
|
||||
return handleChunkedStream(streamInfo, res);
|
||||
}
|
||||
|
||||
return handleGenericStream(streamInfo, res);
|
||||
|
@ -41,7 +41,10 @@ export function createStream(obj) {
|
||||
audioFormat: obj.audioFormat,
|
||||
|
||||
isHLS: obj.isHLS || false,
|
||||
originalRequest: obj.originalRequest
|
||||
originalRequest: obj.originalRequest,
|
||||
|
||||
// url to a subtitle file
|
||||
subtitles: obj.subtitles,
|
||||
};
|
||||
|
||||
// FIXME: this is now a Promise, but it is not awaited
|
||||
@ -79,17 +82,39 @@ export function createProxyTunnels(info) {
|
||||
urls = [urls];
|
||||
}
|
||||
|
||||
const tunnelTemplate = {
|
||||
type: "proxy",
|
||||
headers: info?.headers,
|
||||
requestIP: info?.requestIP,
|
||||
}
|
||||
|
||||
for (const url of urls) {
|
||||
proxyTunnels.push(
|
||||
createStream({
|
||||
...tunnelTemplate,
|
||||
url,
|
||||
type: "proxy",
|
||||
|
||||
service: info?.service,
|
||||
headers: info?.headers,
|
||||
requestIP: info?.requestIP,
|
||||
originalRequest: info?.originalRequest,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
originalRequest: info?.originalRequest
|
||||
if (info.subtitles) {
|
||||
proxyTunnels.push(
|
||||
createStream({
|
||||
...tunnelTemplate,
|
||||
url: info.subtitles,
|
||||
service: `${info?.service}-subtitles`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (info.cover) {
|
||||
proxyTunnels.push(
|
||||
createStream({
|
||||
...tunnelTemplate,
|
||||
url: info.cover,
|
||||
service: `${info?.service}-cover`,
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -111,7 +136,7 @@ export function getInternalTunnelFromURL(url) {
|
||||
return getInternalTunnel(id);
|
||||
}
|
||||
|
||||
export function createInternalStream(url, obj = {}) {
|
||||
export function createInternalStream(url, obj = {}, isSubtitles) {
|
||||
assert(typeof url === 'string');
|
||||
|
||||
let dispatcher = obj.dispatcher;
|
||||
@ -132,9 +157,12 @@ export function createInternalStream(url, obj = {}) {
|
||||
headers = new Map(Object.entries(obj.headers));
|
||||
}
|
||||
|
||||
// subtitles don't need special treatment unlike big media files
|
||||
const service = isSubtitles ? `${obj.service}-subtitles` : obj.service;
|
||||
|
||||
internalStreamCache.set(streamID, {
|
||||
url,
|
||||
service: obj.service,
|
||||
service,
|
||||
headers,
|
||||
controller,
|
||||
dispatcher,
|
||||
@ -245,6 +273,14 @@ function wrapStream(streamInfo) {
|
||||
}
|
||||
} else throw 'invalid urls';
|
||||
|
||||
if (streamInfo.subtitles) {
|
||||
streamInfo.subtitles = createInternalStream(
|
||||
streamInfo.subtitles,
|
||||
streamInfo,
|
||||
/*isSubtitles=*/true
|
||||
);
|
||||
}
|
||||
|
||||
return streamInfo;
|
||||
}
|
||||
|
||||
|
43
api/src/stream/proxy.js
Normal file
43
api/src/stream/proxy.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { Agent, request } from "undici";
|
||||
import { create as contentDisposition } from "content-disposition-header";
|
||||
|
||||
import { destroyInternalStream } from "./manage.js";
|
||||
import { getHeaders, closeRequest, closeResponse, pipe } from "./shared.js";
|
||||
|
||||
const defaultAgent = new Agent();
|
||||
|
||||
export default async function (streamInfo, res) {
|
||||
const abortController = new AbortController();
|
||||
const shutdown = () => (
|
||||
closeRequest(abortController),
|
||||
closeResponse(res),
|
||||
destroyInternalStream(streamInfo.urls)
|
||||
);
|
||||
|
||||
try {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader('Content-disposition', contentDisposition(streamInfo.filename));
|
||||
|
||||
const { body: stream, headers, statusCode } = await request(streamInfo.urls, {
|
||||
headers: {
|
||||
...getHeaders(streamInfo.service),
|
||||
Range: streamInfo.range
|
||||
},
|
||||
signal: abortController.signal,
|
||||
maxRedirections: 16,
|
||||
dispatcher: defaultAgent,
|
||||
});
|
||||
|
||||
res.status(statusCode);
|
||||
|
||||
for (const headerName of ['accept-ranges', 'content-type', 'content-length']) {
|
||||
if (headers[headerName]) {
|
||||
res.setHeader(headerName, headers[headerName]);
|
||||
}
|
||||
}
|
||||
|
||||
pipe(stream, res, shutdown);
|
||||
} catch {
|
||||
shutdown();
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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,
|
||||
}
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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.
|
||||
|
42
docs/api.md
42
docs/api.md
@ -81,15 +81,16 @@ all keys except for `url` are optional. value options are separated by `/`.
|
||||
| `localProcessing` | `boolean` | remux/transcode files locally instead of the server | `false` |
|
||||
|
||||
#### service-specific options
|
||||
| key | type | description/value | default |
|
||||
|:-----------------------|:----------|:--------------------------------------------------|:--------|
|
||||
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` |
|
||||
| `youtubeDubLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* |
|
||||
| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` |
|
||||
| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` |
|
||||
| `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` |
|
||||
| `youtubeBetterAudio` | `boolean` | prefer higher quality youtube audio if possible | `false` |
|
||||
| `youtubeHLS` | `boolean` | use HLS formats when downloading from youtube | `false` |
|
||||
| key | type | description/value | default |
|
||||
|:------------------------|:----------|:--------------------------------------------------|:--------|
|
||||
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` |
|
||||
| `youtubeVideoContainer` | `string` | `auto / mp4 / webm / mkv` | `auto` |
|
||||
| `youtubeDubLang` | `string` | any valid language code, such as: `en` or `zh-CN` | *none* |
|
||||
| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` |
|
||||
| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` |
|
||||
| `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` |
|
||||
| `youtubeBetterAudio` | `boolean` | prefer higher quality youtube audio if possible | `false` |
|
||||
| `youtubeHLS` | `boolean` | use HLS formats when downloading from youtube | `false` |
|
||||
|
||||
### response
|
||||
body type: `application/json`
|
||||
@ -120,11 +121,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.
|
||||
@ -142,11 +144,13 @@ all keys in this table are optional.
|
||||
| `date` | `string` | release date or creation date |
|
||||
|
||||
#### audio object
|
||||
| key | type | value |
|
||||
|:----------|:----------|:-------------------------------------------|
|
||||
| `copy` | `boolean` | defines whether audio codec data is copied |
|
||||
| `format` | `string` | output audio format |
|
||||
| `bitrate` | `string` | preferred bitrate of audio format |
|
||||
| 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 |
|
||||
|
@ -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.
|
||||
|
||||
|
@ -59,8 +59,8 @@ importers:
|
||||
specifier: 1.0.3
|
||||
version: 1.0.3
|
||||
youtubei.js:
|
||||
specifier: ^13.4.0
|
||||
version: 13.4.0
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.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,11 +554,11 @@ 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==}
|
||||
@ -2181,8 +2181,8 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
youtubei.js@13.4.0:
|
||||
resolution: {integrity: sha512-+fmIZU/dWAjsROONrASy1REwVpy6umAPVuoNLr/4iNmZXl84LyBef0n3hrd1Vn9035EuINToGyQcBmifwUEemA==}
|
||||
youtubei.js@14.0.0:
|
||||
resolution: {integrity: sha512-KAFttOw+9fwwBUvBc1T7KzMNBLczDOuN/dfote8BA9CABxgx8MPgV+vZWlowdDB6DnHjSUYppv+xvJ4VNBLK9A==}
|
||||
|
||||
zimmerframe@1.1.2:
|
||||
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
|
||||
@ -2415,9 +2415,9 @@ 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:
|
||||
@ -4035,7 +4035,7 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
youtubei.js@13.4.0:
|
||||
youtubei.js@14.0.0:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 2.2.5
|
||||
jintr: 3.3.1
|
||||
|
@ -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.
|
||||
|
@ -1,6 +1,5 @@
|
||||
{
|
||||
"page.general": "what's cobalt?",
|
||||
"page.faq": "FAQ",
|
||||
|
||||
"page.community": "community & support",
|
||||
|
||||
|
@ -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."
|
||||
}
|
||||
|
@ -31,6 +31,7 @@
|
||||
|
||||
"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!",
|
||||
@ -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!",
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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.",
|
||||
@ -138,9 +143,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",
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -40,7 +40,7 @@
|
||||
}
|
||||
|
||||
.tooltip-body {
|
||||
max-width: 180px;
|
||||
max-width: 190px;
|
||||
position: relative;
|
||||
|
||||
pointer-events: none;
|
||||
|
@ -42,7 +42,7 @@
|
||||
</h4>
|
||||
<div class="right-side">
|
||||
<span class="selector-current" aria-hidden="true">
|
||||
{selectedTitle.split("(", 2)[0]}
|
||||
{selectedTitle?.split("(", 2)[0]}
|
||||
</span>
|
||||
<IconSelector />
|
||||
</div>
|
||||
|
@ -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}`)}`
|
||||
|
@ -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);
|
||||
|
@ -84,8 +84,8 @@ if (browser) {
|
||||
haptics: modernIOS,
|
||||
|
||||
// enable local processing by default on
|
||||
// desktop, ios 18+, and firefox on android
|
||||
defaultLocalProcessing: !device.is.mobile || modernIOS ||
|
||||
// desktop, ios, and firefox on android
|
||||
defaultLocalProcessing: !device.is.mobile || iOS ||
|
||||
(device.is.android && !device.browser.chrome),
|
||||
};
|
||||
|
||||
|
@ -9,13 +9,17 @@ const getEnv = (_key: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getEnvBool = (key: string) => {
|
||||
return getEnv(key) === "true";
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
@ -5,6 +5,12 @@ import EncodeLibAV from "@imput/libav.js-encode-cli";
|
||||
import type { FfprobeData } from "fluent-ffmpeg";
|
||||
import type { FFmpegProgressCallback, FFmpegProgressEvent, FFmpegProgressStatus, RenderParams } from "$lib/types/libav";
|
||||
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const iPhone = ua.includes("iphone os");
|
||||
const iPad = !iPhone && ua.includes("mac os") && navigator.maxTouchPoints > 0;
|
||||
const iOS = iPhone || iPad;
|
||||
const modernIOS = iOS && Number(ua.match(/iphone os (\d+)_/)?.[1]) >= 18;
|
||||
|
||||
export default class LibAVWrapper {
|
||||
libav: Promise<LibAVInstance> | null;
|
||||
concurrency: number;
|
||||
@ -32,7 +38,7 @@ export default class LibAVWrapper {
|
||||
this.libav = constructor({
|
||||
...options,
|
||||
variant: undefined,
|
||||
yesthreads: true,
|
||||
yesthreads: !iOS || modernIOS,
|
||||
base: '/_libav'
|
||||
});
|
||||
}
|
||||
|
84
web/src/lib/settings/audio-sub-language.ts
Normal file
84
web/src/lib/settings/audio-sub-language.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
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];
|
||||
|
||||
const namedLanguages = (
|
||||
languages: typeof youtubeDubLanguages | typeof subtitleLanguages
|
||||
) => {
|
||||
return languages.reduce((obj, lang) => {
|
||||
let name: string;
|
||||
|
||||
switch (lang) {
|
||||
case "original":
|
||||
name = get(t)("settings.youtube.dub.original");
|
||||
break;
|
||||
case "none":
|
||||
name = get(t)("settings.subtitles.none");
|
||||
break;
|
||||
default: {
|
||||
let intlName = "unknown";
|
||||
try {
|
||||
intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang) || "unknown";
|
||||
} catch { /* */ };
|
||||
name = `${intlName} (${lang})`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...obj,
|
||||
[lang]: name,
|
||||
};
|
||||
}, {}) as Record<typeof languages[number], string>;
|
||||
}
|
||||
|
||||
export const namedYoutubeDubLanguages = () => {
|
||||
return namedLanguages(youtubeDubLanguages);
|
||||
}
|
||||
|
||||
export const namedSubtitleLanguages = () => {
|
||||
return namedLanguages(subtitleLanguages);
|
||||
}
|
||||
|
||||
export const getBrowserLanguage = (): YoutubeDubLang => {
|
||||
if (typeof navigator === 'undefined')
|
||||
return "original";
|
||||
|
||||
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";
|
||||
}
|
@ -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,
|
||||
|
@ -5,8 +5,9 @@ import type {
|
||||
CobaltSettingsV3,
|
||||
CobaltSettingsV4,
|
||||
CobaltSettingsV5,
|
||||
CobaltSettingsV6,
|
||||
} from "$lib/types/settings";
|
||||
import { getBrowserLanguage } from "$lib/settings/youtube-lang";
|
||||
import { getBrowserLanguage } from "$lib/settings/audio-sub-language";
|
||||
|
||||
type Migrator = (s: AllPartialSettingsWithSchema) => AllPartialSettingsWithSchema;
|
||||
|
||||
@ -80,6 +81,20 @@ const migrations: Record<number, Migrator> = {
|
||||
|
||||
return out as AllPartialSettingsWithSchema;
|
||||
},
|
||||
|
||||
[6]: (settings: AllPartialSettingsWithSchema) => {
|
||||
const out = settings as RecursivePartial<CobaltSettingsV6>;
|
||||
out.schemaVersion = 6;
|
||||
|
||||
if (settings?.save) {
|
||||
if ("localProcessing" in settings.save) {
|
||||
out.save!.localProcessing =
|
||||
settings.save.localProcessing ? "preferred" : "disabled";
|
||||
}
|
||||
}
|
||||
|
||||
return out as AllPartialSettingsWithSchema;
|
||||
},
|
||||
};
|
||||
|
||||
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
|
||||
|
@ -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 ]
|
||||
])
|
||||
);
|
||||
}
|
||||
|
@ -1,115 +0,0 @@
|
||||
export const youtubeLanguages = [
|
||||
"original",
|
||||
"af",
|
||||
"am",
|
||||
"ar",
|
||||
"as",
|
||||
"az",
|
||||
"be",
|
||||
"bg",
|
||||
"bn",
|
||||
"bs",
|
||||
"ca",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"fa",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"gl",
|
||||
"gu",
|
||||
"hi",
|
||||
"hr",
|
||||
"hu",
|
||||
"hy",
|
||||
"id",
|
||||
"is",
|
||||
"it",
|
||||
"iw",
|
||||
"ja",
|
||||
"ka",
|
||||
"kk",
|
||||
"km",
|
||||
"kn",
|
||||
"ko",
|
||||
"ky",
|
||||
"lo",
|
||||
"lt",
|
||||
"lv",
|
||||
"mk",
|
||||
"ml",
|
||||
"mn",
|
||||
"mr",
|
||||
"ms",
|
||||
"my",
|
||||
"no",
|
||||
"ne",
|
||||
"nl",
|
||||
"or",
|
||||
"pa",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"si",
|
||||
"sk",
|
||||
"sl",
|
||||
"sq",
|
||||
"sr",
|
||||
"sv",
|
||||
"sw",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"ur",
|
||||
"uz",
|
||||
"vi",
|
||||
"zh",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"zh-CN",
|
||||
"zh-HK",
|
||||
"zh-TW",
|
||||
"zu"
|
||||
] as const;
|
||||
|
||||
export type YoutubeLang = typeof youtubeLanguages[number];
|
||||
|
||||
export const namedYoutubeLanguages = () => {
|
||||
return youtubeLanguages.reduce((obj, lang) => {
|
||||
const intlName = new Intl.DisplayNames([lang], { type: 'language' }).of(lang);
|
||||
|
||||
let name = `${intlName} (${lang})`;
|
||||
if (lang === "original") {
|
||||
name = lang;
|
||||
}
|
||||
|
||||
return {
|
||||
...obj,
|
||||
[lang]: name,
|
||||
};
|
||||
}, {}) as Record<YoutubeLang, string>;
|
||||
}
|
||||
|
||||
export const getBrowserLanguage = (): YoutubeLang => {
|
||||
if (typeof navigator === 'undefined')
|
||||
return "original";
|
||||
|
||||
const browserLanguage = navigator.language as YoutubeLang;
|
||||
if (youtubeLanguages.includes(browserLanguage))
|
||||
return browserLanguage;
|
||||
|
||||
const shortened = browserLanguage.split('-')[0] as YoutubeLang;
|
||||
if (youtubeLanguages.includes(shortened))
|
||||
return shortened;
|
||||
|
||||
return "original";
|
||||
}
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -64,6 +64,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: {
|
||||
|
@ -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,
|
||||
|
@ -3,15 +3,17 @@ import type { CobaltSettingsV2 } from "$lib/types/settings/v2";
|
||||
import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
|
||||
import type { CobaltSettingsV4 } from "$lib/types/settings/v4";
|
||||
import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
|
||||
import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
|
||||
|
||||
export * from "$lib/types/settings/v2";
|
||||
export * from "$lib/types/settings/v3";
|
||||
export * from "$lib/types/settings/v4";
|
||||
export * from "$lib/types/settings/v5";
|
||||
export * from "$lib/types/settings/v6";
|
||||
|
||||
export type CobaltSettings = CobaltSettingsV5;
|
||||
export type CobaltSettings = CobaltSettingsV6;
|
||||
|
||||
export type AnyCobaltSettings = CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
|
||||
export type AnyCobaltSettings = CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
|
||||
|
||||
export type PartialSettings = RecursivePartial<CobaltSettings>;
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
import type { YoutubeLang } from "$lib/settings/youtube-lang";
|
||||
import type { YoutubeDubLang } from "$lib/settings/audio-sub-language";
|
||||
import { type CobaltSettingsV2 } from "$lib/types/settings/v2";
|
||||
|
||||
export type CobaltSettingsV3 = Omit<CobaltSettingsV2, 'schemaVersion' | 'save'> & {
|
||||
schemaVersion: 3,
|
||||
save: Omit<CobaltSettingsV2['save'], 'youtubeDubBrowserLang'> & {
|
||||
youtubeDubLang: YoutubeLang;
|
||||
youtubeDubLang: YoutubeDubLang;
|
||||
};
|
||||
};
|
||||
|
14
web/src/lib/types/settings/v6.ts
Normal file
14
web/src/lib/types/settings/v6.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { SubtitleLang } from "$lib/settings/audio-sub-language";
|
||||
import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
|
||||
|
||||
export const youtubeVideoContainerOptions = ["auto", "mp4", "webm", "mkv"] as const;
|
||||
export const localProcessingOptions = ["disabled", "preferred", "forced"] as const;
|
||||
|
||||
export type CobaltSettingsV6 = Omit<CobaltSettingsV5, 'schemaVersion' | 'save'> & {
|
||||
schemaVersion: 6,
|
||||
save: Omit<CobaltSettingsV5['save'], 'localProcessing'> & {
|
||||
localProcessing: typeof localProcessingOptions[number],
|
||||
youtubeVideoContainer: typeof youtubeVideoContainerOptions[number];
|
||||
subtitleLang: SubtitleLang,
|
||||
},
|
||||
};
|
@ -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];
|
||||
|
||||
|
@ -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('-');
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import settings from "$lib/state/settings";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { namedYoutubeLanguages } from "$lib/settings/youtube-lang";
|
||||
import { namedYoutubeDubLanguages } from "$lib/settings/audio-sub-language";
|
||||
|
||||
import { audioFormatOptions, audioBitrateOptions } from "$lib/types/settings";
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
import SettingsToggle from "$components/buttons/SettingsToggle.svelte";
|
||||
import SettingsDropdown from "$components/settings/SettingsDropdown.svelte";
|
||||
|
||||
const displayLangs = namedYoutubeLanguages();
|
||||
const displayLangs = namedYoutubeDubLanguages();
|
||||
</script>
|
||||
|
||||
<SettingsCategory sectionId="format" title={$t("settings.audio.format")}>
|
||||
|
@ -1,18 +1,26 @@
|
||||
<script lang="ts">
|
||||
import env from "$lib/env";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
import { localProcessingOptions } from "$lib/types/settings";
|
||||
|
||||
import Switcher from "$components/buttons/Switcher.svelte";
|
||||
import SettingsButton from "$components/buttons/SettingsButton.svelte";
|
||||
import SettingsToggle from "$components/buttons/SettingsToggle.svelte";
|
||||
import SettingsCategory from "$components/settings/SettingsCategory.svelte";
|
||||
</script>
|
||||
|
||||
<SettingsCategory sectionId="media-processing" title={$t("settings.local.saving")} beta>
|
||||
<SettingsToggle
|
||||
settingContext="save"
|
||||
settingId="localProcessing"
|
||||
title={$t("settings.local.saving.title")}
|
||||
description={$t("settings.local.saving.description")}
|
||||
/>
|
||||
<Switcher big={true} description={$t("settings.local.saving.description")}>
|
||||
{#each localProcessingOptions as value}
|
||||
<SettingsButton
|
||||
settingContext="save"
|
||||
settingId="localProcessing"
|
||||
settingValue={value}
|
||||
>
|
||||
{$t(`settings.local.saving.${value}`)}
|
||||
</SettingsButton>
|
||||
{/each}
|
||||
</Switcher>
|
||||
</SettingsCategory>
|
||||
|
||||
{#if env.ENABLE_WEBCODECS}
|
||||
|
@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import settings from "$lib/state/settings";
|
||||
import { t } from "$lib/i18n/translations";
|
||||
|
||||
import { namedSubtitleLanguages } from "$lib/settings/audio-sub-language";
|
||||
import { filenameStyleOptions, savingMethodOptions } from "$lib/types/settings";
|
||||
|
||||
import SettingsCategory from "$components/settings/SettingsCategory.svelte";
|
||||
@ -8,6 +10,9 @@
|
||||
import SettingsButton from "$components/buttons/SettingsButton.svelte";
|
||||
import SettingsToggle from "$components/buttons/SettingsToggle.svelte";
|
||||
import FilenamePreview from "$components/settings/FilenamePreview.svelte";
|
||||
import SettingsDropdown from "$components/settings/SettingsDropdown.svelte";
|
||||
|
||||
const displayLangs = namedSubtitleLanguages();
|
||||
</script>
|
||||
|
||||
<SettingsCategory sectionId="filename" title={$t("settings.metadata.filename")}>
|
||||
@ -44,6 +49,21 @@
|
||||
</Switcher>
|
||||
</SettingsCategory>
|
||||
|
||||
<SettingsCategory
|
||||
sectionId="subtitles"
|
||||
title={$t("settings.subtitles")}
|
||||
>
|
||||
<SettingsDropdown
|
||||
title={$t("settings.subtitles.title")}
|
||||
description={$t("settings.subtitles.description")}
|
||||
items={displayLangs}
|
||||
settingContext="save"
|
||||
settingId="subtitleLang"
|
||||
selectedOption={$settings.save.subtitleLang}
|
||||
selectedTitle={displayLangs[$settings.save.subtitleLang]}
|
||||
/>
|
||||
</SettingsCategory>
|
||||
|
||||
<SettingsCategory
|
||||
sectionId="metadata"
|
||||
title={$t("settings.metadata.file")}
|
||||
|
@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
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",
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -55,20 +56,42 @@
|
||||
</SettingsCategory>
|
||||
|
||||
<SettingsCategory
|
||||
sectionId="youtube-hls"
|
||||
title={$t("settings.video.youtube.hls")}
|
||||
disabled={$settings.save.youtubeVideoCodec === "av1"}
|
||||
beta
|
||||
sectionId="youtube-container"
|
||||
title={$t("settings.video.youtube.container")}
|
||||
>
|
||||
<SettingsToggle
|
||||
settingContext="save"
|
||||
settingId="youtubeHLS"
|
||||
title={$t("settings.video.youtube.hls.title")}
|
||||
description={$t("settings.video.youtube.hls.description")}
|
||||
disabled={$settings.save.youtubeVideoCodec === "av1"}
|
||||
/>
|
||||
<Switcher
|
||||
big={true}
|
||||
description={$t("settings.video.youtube.container.description")}
|
||||
>
|
||||
{#each youtubeVideoContainerOptions as value}
|
||||
<SettingsButton
|
||||
settingContext="save"
|
||||
settingId="youtubeVideoContainer"
|
||||
settingValue={value}
|
||||
>
|
||||
{value}
|
||||
</SettingsButton>
|
||||
{/each}
|
||||
</Switcher>
|
||||
</SettingsCategory>
|
||||
|
||||
{#if env.ENABLE_DEPRECATED_YOUTUBE_HLS}
|
||||
<SettingsCategory
|
||||
sectionId="youtube-hls"
|
||||
title={$t("settings.video.youtube.hls")}
|
||||
disabled={$settings.save.youtubeVideoCodec === "av1"}
|
||||
beta
|
||||
>
|
||||
<SettingsToggle
|
||||
settingContext="save"
|
||||
settingId="youtubeHLS"
|
||||
title={$t("settings.video.youtube.hls.title")}
|
||||
description={$t("settings.video.youtube.hls.description")}
|
||||
disabled={$settings.save.youtubeVideoCodec === "av1"}
|
||||
/>
|
||||
</SettingsCategory>
|
||||
{/if}
|
||||
|
||||
<SettingsCategory sectionId="h265" title={$t("settings.video.h265")}>
|
||||
<SettingsToggle
|
||||
settingContext="save"
|
||||
|
Loading…
Reference in New Issue
Block a user