update: cobalt 11.7 (#1535)

This commit is contained in:
wukko 2026-04-06 17:32:24 +06:00 committed by GitHub
commit 9f7953c4c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1897 additions and 1953 deletions

View File

@ -15,6 +15,9 @@ jobs:
services: ${{ steps.checkServices.outputs.service_list }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v4
- id: checkServices
run: pnpm i --frozen-lockfile && echo "service_list=$(node api/src/util/test get-services)" >> "$GITHUB_OUTPUT"
@ -29,6 +32,9 @@ jobs:
name: "test service: ${{ matrix.service }}"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v4
- run: pnpm i --frozen-lockfile
- run: node api/src/util/test run-tests-for ${{ matrix.service }}

View File

@ -10,6 +10,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v4
- name: Check that lockfile does not need an update
run: pnpm install --frozen-lockfile
@ -32,5 +35,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v4
- run: .github/test.sh api

View File

@ -37,7 +37,6 @@ if the desired service isn't supported yet, feel free to create an appropriate i
| twitter/x | ✅ | ✅ | ✅ | | |
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
| xiaohongshu | ✅ | ✅ | ✅ | | |
| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
| emoji | meaning |

View File

@ -1,12 +1,12 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
"version": "11.5",
"version": "11.7",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
"engines": {
"node": ">=18"
"node": ">=18.17"
},
"scripts": {
"start": "node src/cobalt",
@ -23,28 +23,29 @@
},
"homepage": "https://github.com/imputnet/cobalt#readme",
"dependencies": {
"@datastructures-js/priority-queue": "^6.3.1",
"@datastructures-js/priority-queue": "^6.3.5",
"@imput/psl": "^2.0.4",
"@imput/version-info": "workspace:^",
"content-disposition-header": "0.6.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
"express": "^4.21.2",
"express-rate-limit": "^7.4.1",
"ffmpeg-static": "^5.1.0",
"hls-parser": "^0.10.7",
"ipaddr.js": "2.2.0",
"mime": "^4.0.4",
"nanoid": "^5.0.9",
"set-cookie-parser": "2.6.0",
"undici": "^6.21.3",
"dotenv": "^17.2.3",
"express": "^4.22.1",
"express-rate-limit": "^8.3.2",
"ffmpeg-static": "^5.3.0",
"hls-parser": "^0.16.0",
"ipaddr.js": "2.3.0",
"isolated-vm": "^6.0.2",
"mime": "^4.1.0",
"nanoid": "^5.1.6",
"set-cookie-parser": "2.7.2",
"undici": "^6.24.1",
"url-pattern": "1.0.3",
"youtubei.js": "15.1.1",
"zod": "^3.23.8"
"youtubei.js": "17.0.1",
"zod": "^4.2.1"
},
"optionalDependencies": {
"freebind": "^0.2.2",
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0"
"freebind": "^0.2.3",
"rate-limit-redis": "^4.3.1",
"redis": "^5.11.0"
}
}

View File

@ -121,6 +121,7 @@ export const loadEnvs = (env = process.env) => {
ytSessionReloadInterval: 300,
ytSessionInnertubeClient: env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
ytAllowBetterAudio: env.YOUTUBE_ALLOW_BETTER_AUDIO !== "0",
ytPlayerIds: env.YOUTUBE_PLAYER_ID?.split(',')?.map(p => p.trim()),
// "never" | "session" | "always"
forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never",

View File

@ -9,6 +9,10 @@ const defaultAgent = new Agent();
let session;
const validateSession = (sessionResponse) => {
sessionResponse.visitor_data ??= sessionResponse.contentBinding;
sessionResponse.potoken ??= sessionResponse.poToken;
sessionResponse.updated ??= new Date().getTime();
if (!sessionResponse.potoken) {
throw "no poToken in session response";
}
@ -33,11 +37,11 @@ const updateSession = (newSession) => {
const loadSession = async () => {
const sessionServerUrl = new URL(env.ytSessionServer);
sessionServerUrl.pathname = "/token";
sessionServerUrl.pathname = "/get_pot";
const newSession = await fetch(
sessionServerUrl,
{ dispatcher: defaultAgent }
{ method: 'POST', dispatcher: defaultAgent }
).then(a => a.json());
validateSession(newSession);

View File

@ -103,7 +103,6 @@ export default function({
case "twitter":
case "snapchat":
case "bsky":
case "xiaohongshu":
params = { picker: r.picker };
break;
@ -145,6 +144,7 @@ export default function({
params = { type: r.type };
break;
case "rutube":
case "vimeo":
if (Array.isArray(r.urls)) {
params = { type: "merge" };
@ -179,7 +179,6 @@ export default function({
break;
case "ok":
case "xiaohongshu":
case "newgrounds":
params = { type: "proxy" };
break;

View File

@ -28,7 +28,6 @@ import snapchat from "./services/snapchat.js";
import loom from "./services/loom.js";
import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js";
import xiaohongshu from "./services/xiaohongshu.js";
import newgrounds from "./services/newgrounds.js";
let freebind;
@ -260,15 +259,6 @@ export default async function({ host, patternMatch, params, authType }) {
});
break;
case "xiaohongshu":
r = await xiaohongshu({
...patternMatch,
h265: params.allowH265,
isAudioOnly,
dispatcher,
});
break;
case "newgrounds":
r = await newgrounds({
...patternMatch,

View File

@ -205,14 +205,6 @@ export const services = {
subdomains: ["m"],
altDomains: ["vkvideo.ru", "vk.ru"],
},
xiaohongshu: {
patterns: [
"explore/:id?xsec_token=:token",
"discovery/item/:id?xsec_token=:token",
":shareType/:shareId",
],
altDomains: ["xhslink.com"],
},
youtube: {
patterns: [
"watch?v=:id",

View File

@ -81,10 +81,6 @@ export const testers = {
(pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||
(pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),
"xiaohongshu": pattern =>
pattern.id?.length <= 24 && pattern.token?.length <= 64 ||
pattern.shareId?.length <= 24 && pattern.shareType?.length === 1,
"youtube": pattern =>
pattern.id?.length <= 11,
}

View File

@ -1,4 +1,4 @@
import { genericUserAgent, env } from "../../config.js";
import { env } from "../../config.js";
import { resolveRedirectingURL } from "../url.js";
// TO-DO: higher quality downloads (currently requires an account)
@ -26,7 +26,7 @@ async function com_download(id, partId) {
const html = await fetch(url, {
headers: {
"user-agent": genericUserAgent
"user-agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
}
})
.then(r => r.text())

View File

@ -24,9 +24,9 @@ const resolveUrl = (url, dispatcher) => {
}
export default async function({ id, shareType, shortLink, dispatcher }) {
let url = `https://web.facebook.com/i/videos/${id}`;
let url = `https://www.facebook.com/reel/${id}`;
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
if (shareType) url = `https://www.facebook.com/share/${shareType}/${id}`;
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`, dispatcher);
const html = await fetch(url, { headers, dispatcher })

View File

@ -13,7 +13,7 @@ const delta = (a, b) => Math.abs(a - b);
export default async function(obj) {
if (obj.yappyId) {
const yappy = await requestJSON(
`https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`
`https://rutube.ru/pangolin/api/web/yappy/v4/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=1`
)
const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;
if (!yappyURL) return { error: "fetch.empty" };

View File

@ -15,24 +15,27 @@ async function findClientID() {
return cachedID.id;
}
const scripts = sc.matchAll(/<script.+src="(.+)">/g);
let clientid = sc.match(/"hydratable"\s*:\s*"apiClient"\s*,\s*"data"\s*:\s*\{\s*"id"\s*:\s*"([^"]+)"/)?.[1];
if (!clientid) {
const scripts = sc.matchAll(/<script.+src="(.+)">/g);
let clientid;
for (let script of scripts) {
const url = script[1];
for (let script of scripts) {
const url = script[1];
if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
return;
}
if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
return;
}
const scrf = await fetch(url).then(r => r.text()).catch(() => {});
const id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
const scrf = await fetch(url).then(r => r.text()).catch(() => {});
const id = scrf.match(/,client_id:"([A-Za-z0-9]{32})",/);
if (id && typeof id[0] === 'string') {
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
break;
if (id && id.length >= 2) {
clientid = id[1];
break;
}
}
}
cachedID.version = scVersion;
cachedID.id = clientid;

View File

@ -37,9 +37,19 @@ function needsFixing(media) {
}
function bestQuality(arr) {
return arr.filter(v => v.content_type === "video/mp4")
return stripVideoURL(
arr.filter(v => v.content_type === "video/mp4")
.reduce((a, b) => Number(a?.bitrate) > Number(b?.bitrate) ? a : b)
.url
);
}
function stripVideoURL(maybeUrl) {
if (maybeUrl) {
const url = new URL(maybeUrl);
url.searchParams.delete('tag');
return url.toString();
}
}
let _cachedToken;

View File

@ -2,14 +2,15 @@ import { env } from "../../config.js";
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token";
const apiUrl = "https://api.vk.com/method";
const authUrl = "https://api.vk.ru/method/auth.getAnonymToken";
const videoApiUrl = "https://api.vkvideo.ru/method/video.get";
const clientId = "51552953";
const clientSecret = "qgr0yWwXCrsxA1jnRtRX";
const clientVersion = "5.274";
// used in stream/shared.js for accessing media files
export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119";
export const vkClientAgent = "com.vk.vkvideo.prod/1955 (iPhone, iOS 16.7.15, iPhone10,4, Scale/2.0) SAK/1.135";
const cachedToken = {
token: "",
@ -24,10 +25,11 @@ const getToken = async () => {
const randomDeviceId = crypto.randomUUID().toUpperCase();
const anonymOauth = new URL(oauthUrl);
const anonymOauth = new URL(authUrl);
anonymOauth.searchParams.set("client_id", clientId);
anonymOauth.searchParams.set("client_secret", clientSecret);
anonymOauth.searchParams.set("device_id", randomDeviceId);
anonymOauth.searchParams.set("v", clientVersion);
const oauthResponse = await fetch(anonymOauth.toString(), {
headers: {
@ -39,11 +41,13 @@ const getToken = async () => {
}
});
if (!oauthResponse) return;
if (!oauthResponse || !oauthResponse.response) return;
if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") {
cachedToken.token = oauthResponse.token;
cachedToken.expiry = oauthResponse.expired_at;
const res = oauthResponse.response;
if (res.token && res.expired_at && typeof res.expired_at === "number") {
cachedToken.token = res.token;
cachedToken.expiry = res.expired_at;
cachedToken.device_id = randomDeviceId;
}
@ -53,7 +57,7 @@ const getToken = async () => {
}
const getVideo = async (ownerId, videoId, accessKey) => {
const video = await fetch(`${apiUrl}/video.get`, {
const video = await fetch(videoApiUrl, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded; charset=utf-8",
@ -63,7 +67,7 @@ const getVideo = async (ownerId, videoId, accessKey) => {
anonymous_token: cachedToken.token,
device_id: cachedToken.device_id,
lang: "en",
v: "5.244",
v: clientVersion,
videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`
}).toString()
})

View File

@ -1,109 +0,0 @@
import { resolveRedirectingURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
const https = (url) => {
return url.replace(/^http:/i, 'https:');
}
export default async function ({ id, token, shareType, shareId, h265, isAudioOnly, dispatcher }) {
let noteId = id;
let xsecToken = token;
if (!noteId) {
const patternMatch = await resolveRedirectingURL(
`https://xhslink.com/${shareType}/${shareId}`,
dispatcher
);
noteId = patternMatch?.id;
xsecToken = patternMatch?.token;
}
if (!noteId || !xsecToken) return { error: "fetch.short_link" };
const res = await fetch(`https://www.xiaohongshu.com/explore/${noteId}?xsec_token=${xsecToken}`, {
headers: {
"user-agent": genericUserAgent,
},
dispatcher,
});
const html = await res.text();
let note;
try {
const initialState = html
.split('<script>window.__INITIAL_STATE__=')[1]
.split('</script>')[0]
.replace(/:\s*undefined/g, ":null");
const data = JSON.parse(initialState);
const noteInfo = data?.note?.noteDetailMap;
if (!noteInfo) throw "no note detail map";
const currentNote = noteInfo[noteId];
if (!currentNote) throw "no current note in detail map";
note = currentNote.note;
} catch {}
if (!note) return { error: "fetch.empty" };
const video = note.video;
const images = note.imageList;
const filenameBase = `xiaohongshu_${noteId}`;
if (video) {
const videoFilename = `${filenameBase}.mp4`;
const audioFilename = `${filenameBase}_audio`;
let videoURL;
if (h265 && !isAudioOnly && video.consumer?.originVideoKey) {
videoURL = `https://sns-video-bd.xhscdn.com/${video.consumer.originVideoKey}`;
} else {
const h264Streams = video.media?.stream?.h264;
if (h264Streams?.length) {
videoURL = h264Streams.reduce((a, b) => Number(a?.videoBitrate) > Number(b?.videoBitrate) ? a : b).masterUrl;
}
}
if (!videoURL) return { error: "fetch.empty" };
return {
urls: https(videoURL),
filename: videoFilename,
audioFilename: audioFilename,
}
}
if (!images || images.length === 0) {
return { error: "fetch.empty" };
}
if (images.length === 1) {
return {
isPhoto: true,
urls: https(images[0].urlDefault),
filename: `${filenameBase}.jpg`,
}
}
const picker = images.map((image, i) => {
return {
type: "photo",
url: createStream({
service: "xiaohongshu",
type: "proxy",
url: https(image.urlDefault),
filename: `${filenameBase}_${i + 1}.jpg`,
})
}
});
return { picker };
}

View File

@ -1,12 +1,27 @@
import HLS from "hls-parser";
import ivm from "isolated-vm";
import { fetch } from "undici";
import { Innertube, Session } from "youtubei.js";
import { fetch, Request } from "undici";
import { Innertube, Platform, Session } from "youtubei.js";
import { env } from "../../config.js";
import { getCookie } from "../cookie/manager.js";
import { getYouTubeSession } from "../helpers/youtube-session.js";
// https://github.com/LuanRT/YouTube.js/pull/1052
Platform.shim.eval = async (data) => {
const isolate = new ivm.Isolate();
try {
const context = await isolate.createContext();
const code = `(() => { ${data.output} })()`;
const script = await isolate.compileScript(code);
return await script.run(context, { copy: true, timeout: 5000 });
} finally {
isolate.dispose();
}
}
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
let innertube, lastRefreshedAt;
@ -60,12 +75,20 @@ const cloneInnertube = async (customFetch, useSession) => {
}
if (!innertube || shouldRefreshPlayer) {
let player_id;
if (env.ytPlayerIds) {
player_id = env.ytPlayerIds[
Math.floor(Math.random() * env.ytPlayerIds.length)
];
}
innertube = await Innertube.create({
fetch: customFetch,
retrieve_player,
cookie,
po_token: useSession ? sessionTokens?.potoken : undefined,
visitor_data: useSession ? sessionTokens?.visitor_data : undefined,
player_id,
});
lastRefreshedAt = +new Date();
}
@ -206,10 +229,24 @@ export default async function (o) {
let yt;
try {
yt = await cloneInnertube(
(input, init) => fetch(input, {
...init,
dispatcher: o.dispatcher
}),
(input, init) => {
const url = typeof input === 'string'
? new URL(input)
: input instanceof URL
? input
: new URL(input.url);
const request = new Request(
url,
input instanceof Platform.shim.Request
? input : undefined
);
return fetch(request, {
...init,
dispatcher: o.dispatcher
});
},
useSession
);
} catch (e) {
@ -529,7 +566,7 @@ export default async function (o) {
}
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
urls = audio.decipher(innertube.session.player);
urls = await audio.decipher(innertube.session.player);
}
let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
@ -576,8 +613,8 @@ export default async function (o) {
filenameAttributes.extension = o.container === "auto" ? codecList[codec].container : o.container;
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
video = video.decipher(innertube.session.player);
audio = audio.decipher(innertube.session.player);
video = await video.decipher(innertube.session.player);
audio = await audio.decipher(innertube.session.player);
} else {
video = video.url;
audio = audio.url;

View File

@ -97,12 +97,6 @@ function aliasURL(url) {
}
break;
case "xhslink":
if (url.hostname === 'xhslink.com' && parts.length === 3) {
url = new URL(`https://www.xiaohongshu.com/${parts[1]}/${parts[2]}`);
}
break;
case "loom":
const idPart = parts[parts.length - 1];
if (idPart.length > 32) {
@ -158,11 +152,6 @@ function cleanURL(url) {
limitQuery('post_id');
}
break;
case "xiaohongshu":
if (url.searchParams.get('xsec_token')) {
limitQuery('xsec_token');
}
break;
}
if (stripQuery) {
@ -215,7 +204,7 @@ export function extract(url, enabledServices = env.enabledServices) {
// 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") {
return { error: "youtube.temporary_disabled" };
return { error: "youtube.disabled_main_instance" };
}
return { error: "service.disabled" };
}

View File

@ -59,7 +59,7 @@
},
{
"name": "bilibili.com link with part id",
"url": "https://www.bilibili.com/video/BV1uo4y1K72s?spm_id_from=333.788.videopod.episodes&p=6",
"url": "https://www.bilibili.com/video/BV1bK411W797?p=3&spm_id_from=333.788.videopod.episodes",
"params": {},
"expected": {
"code": 200,

View File

@ -26,15 +26,6 @@
"status": "redirect"
}
},
{
"name": "shortlink video",
"url": "https://fb.watch/r1K6XHMfGT/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "reel video",
"url": "https://web.facebook.com/reel/730293269054758",
@ -46,15 +37,6 @@
},
{
"name": "shared video link",
"url": "https://www.facebook.com/share/v/6EJK4Z8EAEAHtz8K/",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "shared video link v2",
"url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/",
"params": {},
"expected": {

View File

@ -19,15 +19,6 @@
"status": "tunnel"
}
},
{
"name": "russian region lock",
"url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "vertical video",
"url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
@ -88,7 +79,17 @@
}
},
{
"name": "region locked video, should fail",
"name": "russian region lock",
"url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
"canFail": true,
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "region locked video",
"canFail": true,
"url": "https://rutube.ru/video/e7ac82708cc22bd068a3bf6a7004d1b1/",
"params": {},
@ -97,4 +98,4 @@
"status": "error"
}
}
]
]

View File

@ -8,15 +8,6 @@
"status": "redirect"
}
},
{
"name": "shortlinked spotlight",
"url": "https://t.snapchat.com/4ZsiBLDi",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "story",
"url": "https://www.snapchat.com/add/bazerkmakane",

View File

@ -28,7 +28,7 @@
},
{
"name": "tumblr audio",
"url": "https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share",
"url": "https://www.tumblr.com/firebuug/770611154490998784/bing",
"params": {},
"expected": {
"code": 200,

View File

@ -1,60 +0,0 @@
[
{
"name": "video (might have expired)",
"url": "https://www.xiaohongshu.com/explore/685e63e1000000000b02ee3b?xsec_token=ABN8EQJCDMPcFX9RRggeIPSHLIJ8zkGceFDyBewLGUz30=",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "picker with multiple live photos (might have expired)",
"url": "https://www.xiaohongshu.com/explore/687128a2000000001203d94c?xsec_token=CBlDi5QDXDWZu2uUmbUrpKwg8lEL3uC10mc59lGf43r9w=",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "one photo (might have expired)",
"url": "https://www.xiaohongshu.com/explore/64726b99000000000800e115?xsec_token=ABoD3qPHqVZolCfS-J8UP9QQaPXZ6Z6PVyODrhaiUg27U=",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "short link (might have expired)",
"url": "https://xhslink.com/m/2wAnaTkLRc1",
"canFail": true,
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "wrong note id",
"url": "https://www.xiaohongshu.com/discovery/item/6789065911100000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
},
{
"name": "short link, wrong id",
"url": "https://xhslink.com/a/aaaaaa",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]

View File

@ -68,6 +68,7 @@ this document is not final and will expand over time. feel free to improve it!
| YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED` |
| YOUTUBE_ALLOW_BETTER_AUDIO | `1` |
| ENABLE_DEPRECATED_YOUTUBE_HLS | `key` |
| YOUTUBE_PLAYER_ID | `abcdefff` |
[*view details*](#service-specific)
@ -271,6 +272,13 @@ innertube client that's compatible with botguard's (web) `poToken` and `visitor_
the value is a string.
### YOUTUBE_PLAYER_ID
a comma-separated-list of player IDs to use for youtube fetching.
if defined, cobalt chooses one of them at each client initialization, otherwise
defaults to the current latest player ID.
the value is a string.
### YOUTUBE_ALLOW_BETTER_AUDIO
when set to `1`, cobalt will try to use higher quality audio if user requests it via `youtubeBetterAudio`. will negatively impact the rate limit of a secondary youtube client with a session.

View File

@ -88,7 +88,7 @@ all keys except for `url` are optional. value options are separated by `/`.
| `youtubeVideoContainer` | `string` | `auto / mp4 / webm / mkv` | `auto` |
| `youtubeDubLang` | `string` | any valid ISO 639-1 language code | *none* |
| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` |
| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` |
| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok | `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` |

View File

@ -8,8 +8,8 @@
"author": "imput <meow@imput.net>",
"license": "MIT",
"devDependencies": {
"prettier": "3.3.3",
"tsup": "^8.3.0",
"typescript": "^5.4.5"
"prettier": "3.7.4",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
}
}

3290
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,7 @@
"youtube.token_expired": "couldn't get this video because the youtube token expired and wasn't refreshed. try again in a few seconds, but if it still doesn't work, please report this issue!",
"youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
"youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
"youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
"youtube.disabled_main_instance": "youtube downloading is disabled on the main instance due to restrictions from youtube's side and infinite maintenance cost at scale.\n\nwe apologize for the inconvenience and encourage you to host your own API instance for this.",
"youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
"youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
}

View File

@ -44,7 +44,7 @@
"video.h265": "high efficiency video codec",
"video.h265.title": "allow h265 for videos",
"video.h265.description": "allows downloading videos from platforms like tiktok and xiaohongshu in higher quality at cost of compatibility.",
"video.h265.description": "allows downloading videos from platforms like tiktok in higher quality at cost of compatibility.",
"audio.format": "audio format",
"audio.format.best": "best",

View File

@ -35,7 +35,7 @@
"youtube.api_error": "youtube что-то обновил в своём api, и я не смог получить инфу об этом видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!",
"youtube.drm": "это youtube-видео защищено widevine DRM, так что я не могу его скачать. попробуй другую ссылку!",
"fetch.rate": "{{ service }} ограничил частоту запросов от инстанса обработки. попробуй ещё раз через пару секунд!",
"youtube.temporary_disabled": "скачивание с youtube временно отключено из-за ограничений со стороны youtube. мы уже ищем способы их обойти.\n\nприносим извинения за неудобства и делаем всё возможное, чтобы восстановить эту функциональность. следи за обновлениями в соцсетях или на github!",
"youtube.disabled_main_instance": "скачивание с youtube отключено на главном инстансе из-за ограничений со стороны youtube и бесконечных затрат на содержание его поддержки.\n\nприносим извинения за неудобства и советуем хостить свой инстанс для этой цели.",
"content.video.age": "это видео ограничено по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!",
"content.region": "этот контент ограничен по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!",
"youtube.no_matching_format": "youtube не вернул ни одного подходящего формата. возможно, кобальт их не поддерживает или же они перекодируются на стороне youtube. попробуй ещё раз чуть позже, а если проблема останется, сообщи о ней!",

View File

@ -73,7 +73,7 @@
"audio.bitrate.description": "битрейт применяется только при конвертации аудио в формат с потерями. кобальт не может улучшить качество исходного аудио, поэтому выбор битрейта выше 128 кб/с может увеличить размер файла без заметной разницы в звуке. воспринимаемое качество может различаться в зависимости от формата.",
"video.h265": "high efficiency video codec",
"video.h265.title": "использовать h265 для видео",
"video.h265.description": "позволяет скачивать видео с tiktok и xiaohongshu в более высоком качестве, но с потерей совместимости.",
"video.h265.description": "позволяет скачивать видео с платформ как tiktok в более высоком качестве, но с потерей совместимости.",
"video.youtube.hls": "форматы hls для youtube",
"video.youtube.hls.description": "в этом режиме доступны только кодеки h264 и vp9. оригинальный аудио кодек aac перекодируется для совместимости, поэтому качество аудио может быть хуже чем у варианта без HLS.\n\nэта функция экспериментальна, поэтому может быть убрана или изменена в будущем.",
"audio.format.best": "лучший",

View File

@ -1,6 +1,6 @@
{
"name": "@imput/cobalt-web",
"version": "11.3",
"version": "11.7",
"type": "module",
"private": true,
"scripts": {
@ -24,36 +24,35 @@
},
"homepage": "https://cobalt.tools/",
"devDependencies": {
"@eslint/js": "^9.5.0",
"@fontsource/ibm-plex-mono": "^5.0.13",
"@fontsource/redaction-10": "^5.0.2",
"@eslint/js": "^9.39.4",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/redaction-10": "^5.2.5",
"@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",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.55.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tabler/icons-svelte": "3.6.0",
"@types/eslint__js": "^8.42.3",
"@types/fluent-ffmpeg": "^2.1.25",
"@types/node": "^20.14.10",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"compare-versions": "^6.1.0",
"dotenv": "^16.0.1",
"eslint": "^9.16.0",
"glob": "^11.0.0",
"mdsvex": "^0.11.2",
"mime": "^4.0.4",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.2",
"svelte-sitemap": "2.6.0",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.0.3",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"compare-versions": "^6.1.1",
"dotenv": "^17.2.3",
"eslint": "^9.39.4",
"glob": "^13.0.6",
"mdsvex": "^0.12.6",
"mime": "^4.1.0",
"svelte": "^5.55.1",
"svelte-check": "^4.3.5",
"svelte-preprocess": "^6.0.3",
"svelte-sitemap": "2.7.1",
"sveltekit-i18n": "^2.4.2",
"ts-deepmerge": "^7.0.1",
"tslib": "^2.4.1",
"turnstile-types": "^1.2.2",
"typescript": "^5.5.0",
"typescript-eslint": "^8.18.0",
"vite": "^5.4.4"
"ts-deepmerge": "^7.0.3",
"tslib": "^2.8.1",
"turnstile-types": "^1.2.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.58.0",
"vite": "^7.3.1"
}
}

View File

@ -22,13 +22,16 @@
let imageLoaded = $state(false);
let hideSkeleton = $state(false);
let validUrl = false;
try {
new URL(item.url);
validUrl = true;
} catch {}
const validUrl = $derived.by(() => {
try {
new URL(item.url);
return true;
} catch {
return false;
}
});
const isTunnel = validUrl && new URL(item.url).pathname === "/tunnel";
const isTunnel = $derived(validUrl && new URL(item.url).pathname === "/tunnel");
const loaded = () => {
imageLoaded = true;

View File

@ -69,7 +69,7 @@
if (file) {
return openFile(file);
} else if (url) {
return openURL(url);
return openURL(url, true);
}
}}
>

View File

@ -23,7 +23,7 @@
onImport,
}: Props = $props();
let selectorStringMultiple = maxFileNumber > 1 ? ".multiple" : "";
const selectorStringMultiple = $derived(maxFileNumber > 1 ? ".multiple" : "");
let fileInput: HTMLInputElement;

View File

@ -22,7 +22,7 @@
copyData = "",
}: Props = $props();
const sectionURL = `${page.url.origin}${page.url.pathname}#${sectionId}`;
const sectionURL = $derived(`${page.url.origin}${page.url.pathname}#${sectionId}`);
let copied = $state(false);
</script>

View File

@ -8,7 +8,11 @@
let { loading }: Props = $props();
let animated = $state(loading);
let animated = $state(false);
$effect(() => {
animated = loading;
});
/*
initial spinner state is equal to loading state,

View File

@ -51,7 +51,7 @@ export const shareFile = async (file: File) => {
});
}
export const openURL = (url: string) => {
export const openURL = (url: string, hasDialog = false) => {
if (!['http:', 'https:'].includes(new URL(url).protocol)) {
return alert('error: invalid url!');
}
@ -59,7 +59,7 @@ export const openURL = (url: string) => {
const open = window.open(url, "_blank", "noopener,noreferrer");
/* if new tab got blocked by user agent, show a saving dialog */
if (!open) {
if (!open && !hasDialog) {
return openSavingDialog({
url,
body: get(t)("dialog.saving.blocked")

View File

@ -4,7 +4,7 @@ import { uuid } from "$lib/util";
export class MemoryStorage extends AbstractStorage {
#chunkSize: number;
#actualSize: number = 0;
#chunks: Uint8Array[] = [];
#chunks: Uint8Array<ArrayBuffer>[] = [];
constructor(chunkSize: number) {
super();
@ -33,7 +33,7 @@ export class MemoryStorage extends AbstractStorage {
async res() {
// if we didn't need as much space as we allocated for some reason,
// shrink the buffers so that we don't inflate the file with zeroes
const outputView: Uint8Array[] = [];
const outputView: Uint8Array<ArrayBuffer>[] = [];
for (let i = 0; i < this.#chunks.length; ++i) {
outputView.push(