mirror of
https://github.com/imputnet/cobalt.git
synced 2025-06-28 17:38:31 +00:00
Merge branch 'main' into alec/update-proxy
This commit is contained in:
commit
fb1a1595b2
3
.github/workflows/test-services.yml
vendored
3
.github/workflows/test-services.yml
vendored
@ -31,3 +31,6 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- run: pnpm i --frozen-lockfile && node api/src/util/test run-tests-for ${{ matrix.service }}
|
- run: pnpm i --frozen-lockfile && node api/src/util/test run-tests-for ${{ matrix.service }}
|
||||||
|
env:
|
||||||
|
API_EXTERNAL_PROXY: ${{ secrets.API_EXTERNAL_PROXY }}
|
||||||
|
TEST_IGNORE_SERVICES: ${{ vars.TEST_IGNORE_SERVICES }}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@imput/cobalt-api",
|
"name": "@imput/cobalt-api",
|
||||||
"description": "save what you love",
|
"description": "save what you love",
|
||||||
"version": "10.7.2",
|
"version": "10.7.10",
|
||||||
"author": "imput",
|
"author": "imput",
|
||||||
"exports": "./src/cobalt.js",
|
"exports": "./src/cobalt.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -39,7 +39,7 @@
|
|||||||
"set-cookie-parser": "2.6.0",
|
"set-cookie-parser": "2.6.0",
|
||||||
"undici": "^7.3.0",
|
"undici": "^7.3.0",
|
||||||
"url-pattern": "1.0.3",
|
"url-pattern": "1.0.3",
|
||||||
"youtubei.js": "^13.0.0",
|
"youtubei.js": "^13.1.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Constants } from "youtubei.js";
|
||||||
import { getVersion } from "@imput/version-info";
|
import { getVersion } from "@imput/version-info";
|
||||||
import { services } from "./processing/service-config.js";
|
import { services } from "./processing/service-config.js";
|
||||||
import { supportsReusePort } from "./misc/cluster.js";
|
import { supportsReusePort } from "./misc/cluster.js";
|
||||||
@ -52,6 +53,7 @@ const env = {
|
|||||||
keyReloadInterval: 900,
|
keyReloadInterval: 900,
|
||||||
|
|
||||||
enabledServices,
|
enabledServices,
|
||||||
|
customInnertubeClient: process.env.CUSTOM_INNERTUBE_CLIENT,
|
||||||
}
|
}
|
||||||
|
|
||||||
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
|
const genericUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36";
|
||||||
@ -74,6 +76,12 @@ if (env.instanceCount > 1 && !env.redisURL) {
|
|||||||
throw new Error('SO_REUSEPORT is not supported');
|
throw new Error('SO_REUSEPORT is not supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (env.customInnertubeClient && !Constants.SUPPORTED_CLIENTS.includes(env.customInnertubeClient)) {
|
||||||
|
console.error("CUSTOM_INNERTUBE_CLIENT is invalid. Provided client is not supported.");
|
||||||
|
console.error(`Supported clients are: ${Constants.SUPPORTED_CLIENTS.join(', ')}\n`);
|
||||||
|
throw new Error("Invalid CUSTOM_INNERTUBE_CLIENT");
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
env,
|
env,
|
||||||
genericUserAgent,
|
genericUserAgent,
|
||||||
|
@ -23,6 +23,10 @@ export async function runTest(url, params, expect) {
|
|||||||
if (expect.status !== result.body.status) {
|
if (expect.status !== result.body.status) {
|
||||||
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
|
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
|
||||||
error.push(`status mismatch: ${detail}`);
|
error.push(`status mismatch: ${detail}`);
|
||||||
|
|
||||||
|
if (result.body.status === 'error') {
|
||||||
|
error.push(`error code: ${result.body?.error?.code}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expect.errorCode && expect.errorCode !== result.body?.error?.code) {
|
if (expect.errorCode && expect.errorCode !== result.body?.error?.code) {
|
||||||
|
@ -227,7 +227,8 @@ export default async function({ host, patternMatch, params }) {
|
|||||||
|
|
||||||
case "facebook":
|
case "facebook":
|
||||||
r = await facebook({
|
r = await facebook({
|
||||||
...patternMatch
|
...patternMatch,
|
||||||
|
dispatcher
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -8,8 +8,8 @@ const headers = {
|
|||||||
'Sec-Fetch-Site': 'none',
|
'Sec-Fetch-Site': 'none',
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveUrl = (url) => {
|
const resolveUrl = (url, dispatcher) => {
|
||||||
return fetch(url, { headers })
|
return fetch(url, { headers, dispatcher })
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (r.headers.get('location')) {
|
if (r.headers.get('location')) {
|
||||||
return decodeURIComponent(r.headers.get('location'));
|
return decodeURIComponent(r.headers.get('location'));
|
||||||
@ -23,13 +23,13 @@ const resolveUrl = (url) => {
|
|||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function({ id, shareType, shortLink }) {
|
export default async function({ id, shareType, shortLink, dispatcher }) {
|
||||||
let url = `https://web.facebook.com/i/videos/${id}`;
|
let url = `https://web.facebook.com/i/videos/${id}`;
|
||||||
|
|
||||||
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
|
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
|
||||||
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`);
|
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`, dispatcher);
|
||||||
|
|
||||||
const html = await fetch(url, { headers })
|
const html = await fetch(url, { headers, dispatcher })
|
||||||
.then(r => r.text())
|
.then(r => r.text())
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
|
@ -305,12 +305,12 @@ export default function instagram(obj) {
|
|||||||
if (sidecar) {
|
if (sidecar) {
|
||||||
const picker = sidecar.edges.filter(e => e.node?.display_url)
|
const picker = sidecar.edges.filter(e => e.node?.display_url)
|
||||||
.map((e, i) => {
|
.map((e, i) => {
|
||||||
const type = e.node?.is_video ? "video" : "photo";
|
const type = e.node?.is_video && e.node?.video_url ? "video" : "photo";
|
||||||
|
|
||||||
let url;
|
let url;
|
||||||
if (type === 'video') {
|
if (type === "video") {
|
||||||
url = e.node?.video_url;
|
url = e.node?.video_url;
|
||||||
} else if (type === 'photo') {
|
} else if (type === "photo") {
|
||||||
url = e.node?.display_url;
|
url = e.node?.display_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ export default async function(o) {
|
|||||||
|
|
||||||
let fileMetadata = {
|
let fileMetadata = {
|
||||||
title: videoData.movie.title.trim(),
|
title: videoData.movie.title.trim(),
|
||||||
author: (videoData.author?.name || videoData.compilationTitle).trim(),
|
author: (videoData.author?.name || videoData.compilationTitle)?.trim(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestVideo) return {
|
if (bestVideo) return {
|
||||||
|
@ -23,7 +23,7 @@ export default async function(o) {
|
|||||||
|
|
||||||
const videoLink = [...html.matchAll(videoRegex)]
|
const videoLink = [...html.matchAll(videoRegex)]
|
||||||
.map(([, link]) => link)
|
.map(([, link]) => link)
|
||||||
.find(a => a.endsWith('.mp4') && a.includes('720p'));
|
.find(a => a.endsWith('.mp4'));
|
||||||
|
|
||||||
if (videoLink) return {
|
if (videoLink) return {
|
||||||
urls: videoLink,
|
urls: videoLink,
|
||||||
|
@ -24,6 +24,11 @@ const badContainerEnd = new Date(1702605600000);
|
|||||||
|
|
||||||
function needsFixing(media) {
|
function needsFixing(media) {
|
||||||
const representativeId = media.source_status_id_str ?? media.id_str;
|
const representativeId = media.source_status_id_str ?? media.id_str;
|
||||||
|
|
||||||
|
// syndication api doesn't have media ids in its response,
|
||||||
|
// so we just assume it's all good
|
||||||
|
if (!representativeId) return false;
|
||||||
|
|
||||||
const mediaTimestamp = new Date(
|
const mediaTimestamp = new Date(
|
||||||
Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
|
Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
|
||||||
);
|
);
|
||||||
@ -53,6 +58,25 @@ const getGuestToken = async (dispatcher, forceReload = false) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestSyndication = async(dispatcher, tweetId) => {
|
||||||
|
// thank you
|
||||||
|
// https://github.com/yt-dlp/yt-dlp/blob/05c8023a27dd37c49163c0498bf98e3e3c1cb4b9/yt_dlp/extractor/twitter.py#L1334
|
||||||
|
const token = (id) => ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, '');
|
||||||
|
const syndicationUrl = new URL("https://cdn.syndication.twimg.com/tweet-result");
|
||||||
|
|
||||||
|
syndicationUrl.searchParams.set("id", tweetId);
|
||||||
|
syndicationUrl.searchParams.set("token", token(tweetId));
|
||||||
|
|
||||||
|
const result = await fetch(syndicationUrl, {
|
||||||
|
headers: {
|
||||||
|
"user-agent": genericUserAgent
|
||||||
|
},
|
||||||
|
dispatcher
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
||||||
const graphqlTweetURL = new URL(graphqlURL);
|
const graphqlTweetURL = new URL(graphqlURL);
|
||||||
|
|
||||||
@ -87,36 +111,24 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
|||||||
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
|
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
|
||||||
updateCookie(cookie, result.headers);
|
updateCookie(cookie, result.headers);
|
||||||
|
|
||||||
// we might have been missing the `ct0` cookie, retry
|
// we might have been missing the ct0 cookie, retry
|
||||||
if (result.status === 403 && result.headers.get('set-cookie')) {
|
if (result.status === 403 && result.headers.get('set-cookie')) {
|
||||||
result = await fetch(graphqlTweetURL, {
|
const cookieValues = cookie?.values();
|
||||||
headers: {
|
if (cookieValues?.ct0) {
|
||||||
...headers,
|
result = await fetch(graphqlTweetURL, {
|
||||||
'x-csrf-token': cookie.values().ct0
|
headers: {
|
||||||
},
|
...headers,
|
||||||
dispatcher
|
'x-csrf-token': cookieValues.ct0
|
||||||
});
|
},
|
||||||
|
dispatcher
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
const extractGraphqlMedia = async (tweet, dispatcher, id, guestToken, cookie) => {
|
||||||
const cookie = await getCookie('twitter');
|
|
||||||
|
|
||||||
let guestToken = await getGuestToken(dispatcher);
|
|
||||||
if (!guestToken) return { error: "fetch.fail" };
|
|
||||||
|
|
||||||
let tweet = await requestTweet(dispatcher, id, guestToken);
|
|
||||||
|
|
||||||
// get new token & retry if old one expired
|
|
||||||
if ([403, 429].includes(tweet.status)) {
|
|
||||||
guestToken = await getGuestToken(dispatcher, true);
|
|
||||||
tweet = await requestTweet(dispatcher, id, guestToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
tweet = await tweet.json();
|
|
||||||
|
|
||||||
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
||||||
|
|
||||||
if (!tweetTypename) {
|
if (!tweetTypename) {
|
||||||
@ -127,13 +139,13 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|||||||
const reason = tweet?.data?.tweetResult?.result?.reason;
|
const reason = tweet?.data?.tweetResult?.result?.reason;
|
||||||
switch(reason) {
|
switch(reason) {
|
||||||
case "Protected":
|
case "Protected":
|
||||||
return { error: "content.post.private" }
|
return { error: "content.post.private" };
|
||||||
case "NsfwLoggedOut":
|
case "NsfwLoggedOut":
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
||||||
tweet = await tweet.json();
|
tweet = await tweet.json();
|
||||||
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
||||||
} else return { error: "content.post.age" }
|
} else return { error: "content.post.age" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +162,69 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|||||||
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
|
repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
|
return (repostedTweet?.media || baseTweet?.extended_entities?.media);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testResponse = (result) => {
|
||||||
|
const contentLength = result.headers.get("content-length");
|
||||||
|
|
||||||
|
if (!contentLength || contentLength === '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.headers.get("content-type").startsWith("application/json")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
||||||
|
const cookie = await getCookie('twitter');
|
||||||
|
|
||||||
|
let syndication = false;
|
||||||
|
|
||||||
|
let guestToken = await getGuestToken(dispatcher);
|
||||||
|
if (!guestToken) return { error: "fetch.fail" };
|
||||||
|
|
||||||
|
// for now we assume that graphql api will come back after some time,
|
||||||
|
// so we try it first
|
||||||
|
|
||||||
|
let tweet = await requestTweet(dispatcher, id, guestToken);
|
||||||
|
|
||||||
|
// get new token & retry if old one expired
|
||||||
|
if ([403, 429].includes(tweet.status)) {
|
||||||
|
guestToken = await getGuestToken(dispatcher, true);
|
||||||
|
if (cookie) {
|
||||||
|
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
||||||
|
} else {
|
||||||
|
tweet = await requestTweet(dispatcher, id, guestToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testGraphql = testResponse(tweet);
|
||||||
|
|
||||||
|
// if graphql requests fail, then resort to tweet embed api
|
||||||
|
if (!testGraphql) {
|
||||||
|
syndication = true;
|
||||||
|
tweet = await requestSyndication(dispatcher, id);
|
||||||
|
|
||||||
|
const testSyndication = testResponse(tweet);
|
||||||
|
|
||||||
|
// if even syndication request failed, then cry out loud
|
||||||
|
if (!testSyndication) {
|
||||||
|
return { error: "fetch.fail" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tweet = await tweet.json();
|
||||||
|
|
||||||
|
let media =
|
||||||
|
syndication
|
||||||
|
? tweet.mediaDetails
|
||||||
|
: await extractGraphqlMedia(tweet, dispatcher, id, guestToken, cookie);
|
||||||
|
|
||||||
|
if (!media) return { error: "fetch.empty" };
|
||||||
|
|
||||||
// check if there's a video at given index (/video/<index>)
|
// check if there's a video at given index (/video/<index>)
|
||||||
if (index >= 0 && index < media?.length) {
|
if (index >= 0 && index < media?.length) {
|
||||||
@ -163,7 +237,7 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|||||||
service: "twitter",
|
service: "twitter",
|
||||||
type: "proxy",
|
type: "proxy",
|
||||||
url, filename,
|
url, filename,
|
||||||
})
|
});
|
||||||
|
|
||||||
switch (media?.length) {
|
switch (media?.length) {
|
||||||
case undefined:
|
case undefined:
|
||||||
|
@ -41,6 +41,8 @@ const hlsCodecList = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientsWithNoCipher = ['IOS', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID'];
|
||||||
|
|
||||||
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
|
const videoQualities = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
|
||||||
|
|
||||||
const transformSessionData = (cookie) => {
|
const transformSessionData = (cookie) => {
|
||||||
@ -149,7 +151,7 @@ export default async function (o) {
|
|||||||
useHLS = false;
|
useHLS = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let innertubeClient = o.innertubeClient || "ANDROID";
|
let innertubeClient = o.innertubeClient || env.customInnertubeClient || "ANDROID";
|
||||||
|
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
useHLS = false;
|
useHLS = false;
|
||||||
@ -193,7 +195,7 @@ export default async function (o) {
|
|||||||
if (playability.reason.endsWith("bot")) {
|
if (playability.reason.endsWith("bot")) {
|
||||||
return { error: "youtube.login" }
|
return { error: "youtube.login" }
|
||||||
}
|
}
|
||||||
if (playability.reason.endsWith("age")) {
|
if (playability.reason.endsWith("age") || playability.reason.endsWith("inappropriate for some users.")) {
|
||||||
return { error: "content.video.age" }
|
return { error: "content.video.age" }
|
||||||
}
|
}
|
||||||
if (playability?.error_screen?.reason?.text === "Private video") {
|
if (playability?.error_screen?.reason?.text === "Private video") {
|
||||||
@ -428,6 +430,10 @@ export default async function (o) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (video?.drm_families || audio?.drm_families) {
|
||||||
|
return { error: "youtube.drm" };
|
||||||
|
}
|
||||||
|
|
||||||
const fileMetadata = {
|
const fileMetadata = {
|
||||||
title: basicInfo.title.trim(),
|
title: basicInfo.title.trim(),
|
||||||
artist: basicInfo.author.replace("- Topic", "").trim()
|
artist: basicInfo.author.replace("- Topic", "").trim()
|
||||||
@ -474,7 +480,7 @@ export default async function (o) {
|
|||||||
urls = audio.uri;
|
urls = audio.uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (innertubeClient === "WEB" && innertube) {
|
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
|
||||||
urls = audio.decipher(innertube.session.player);
|
urls = audio.decipher(innertube.session.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -509,7 +515,7 @@ export default async function (o) {
|
|||||||
filenameAttributes.resolution = `${video.width}x${video.height}`;
|
filenameAttributes.resolution = `${video.width}x${video.height}`;
|
||||||
filenameAttributes.extension = codecList[codec].container;
|
filenameAttributes.extension = codecList[codec].container;
|
||||||
|
|
||||||
if (innertubeClient === "WEB" && innertube) {
|
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
|
||||||
video = video.decipher(innertube.session.player);
|
video = video.decipher(innertube.session.player);
|
||||||
audio = audio.decipher(innertube.session.player);
|
audio = audio.decipher(innertube.session.player);
|
||||||
} else {
|
} else {
|
||||||
|
@ -98,6 +98,14 @@ function aliasURL(url) {
|
|||||||
if (url.hostname === 'xhslink.com' && parts.length === 3) {
|
if (url.hostname === 'xhslink.com' && parts.length === 3) {
|
||||||
url = new URL(`https://www.xiaohongshu.com/a/${parts[2]}`);
|
url = new URL(`https://www.xiaohongshu.com/a/${parts[2]}`);
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "loom":
|
||||||
|
const idPart = parts[parts.length - 1];
|
||||||
|
if (idPart.length > 32) {
|
||||||
|
url.pathname = `/share/${idPart.slice(-32)}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
|
@ -53,14 +53,25 @@ async function handleYoutubeStream(streamInfo, res) {
|
|||||||
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
|
const cleanup = () => (res.end(), closeRequest(streamInfo.controller));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const req = await fetch(streamInfo.url, {
|
let req, attempts = 3;
|
||||||
headers: getHeaders('youtube'),
|
while (attempts--) {
|
||||||
method: 'HEAD',
|
req = await fetch(streamInfo.url, {
|
||||||
dispatcher: streamInfo.dispatcher,
|
headers: getHeaders('youtube'),
|
||||||
signal
|
method: 'HEAD',
|
||||||
});
|
dispatcher: streamInfo.dispatcher,
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
streamInfo.url = req.url;
|
||||||
|
if (req.status === 403 && streamInfo.transplant) {
|
||||||
|
try {
|
||||||
|
await streamInfo.transplant(streamInfo.dispatcher);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else break;
|
||||||
|
}
|
||||||
|
|
||||||
streamInfo.url = req.url;
|
|
||||||
const size = BigInt(req.headers.get('content-length'));
|
const size = BigInt(req.headers.get('content-length'));
|
||||||
|
|
||||||
if (req.status !== 200 || !size) {
|
if (req.status !== 200 || !size) {
|
||||||
|
@ -4,6 +4,7 @@ import { env } from "../config.js";
|
|||||||
import { runTest } from "../misc/run-test.js";
|
import { runTest } from "../misc/run-test.js";
|
||||||
import { loadJSON } from "../misc/load-from-fs.js";
|
import { loadJSON } from "../misc/load-from-fs.js";
|
||||||
import { Red, Bright } from "../misc/console-text.js";
|
import { Red, Bright } from "../misc/console-text.js";
|
||||||
|
import { setGlobalDispatcher, ProxyAgent } from "undici";
|
||||||
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
import { randomizeCiphers } from "../misc/randomize-ciphers.js";
|
||||||
|
|
||||||
import { services } from "../processing/service-config.js";
|
import { services } from "../processing/service-config.js";
|
||||||
@ -13,7 +14,11 @@ const getTests = (service) => loadJSON(getTestPath(service));
|
|||||||
|
|
||||||
// services that are known to frequently fail due to external
|
// services that are known to frequently fail due to external
|
||||||
// factors (e.g. rate limiting)
|
// factors (e.g. rate limiting)
|
||||||
const finnicky = new Set(['bilibili', 'instagram', 'facebook', 'youtube', 'vk', 'twitter', 'reddit']);
|
const finnicky = new Set(
|
||||||
|
process.env.TEST_IGNORE_SERVICES
|
||||||
|
? process.env.TEST_IGNORE_SERVICES.split(',')
|
||||||
|
: ['bilibili', 'instagram', 'facebook', 'youtube', 'vk', 'twitter', 'reddit']
|
||||||
|
);
|
||||||
|
|
||||||
const runTestsFor = async (service) => {
|
const runTestsFor = async (service) => {
|
||||||
const tests = getTests(service);
|
const tests = getTests(service);
|
||||||
@ -64,6 +69,14 @@ const printHeader = (service, padLen) => {
|
|||||||
console.log(service + '='.repeat(50));
|
console.log(service + '='.repeat(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (env.externalProxy) {
|
||||||
|
setGlobalDispatcher(new ProxyAgent(env.externalProxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
env.streamLifespan = 10000;
|
||||||
|
env.apiURL = 'http://x/';
|
||||||
|
randomizeCiphers();
|
||||||
|
|
||||||
const action = process.argv[2];
|
const action = process.argv[2];
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "get-services":
|
case "get-services":
|
||||||
@ -86,9 +99,6 @@ switch (action) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "run-tests-for":
|
case "run-tests-for":
|
||||||
env.streamLifespan = 10000;
|
|
||||||
env.apiURL = 'http://x/';
|
|
||||||
randomizeCiphers();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { softFails } = await runTestsFor(process.argv[3]);
|
const { softFails } = await runTestsFor(process.argv[3]);
|
||||||
@ -104,10 +114,6 @@ switch (action) {
|
|||||||
const maxHeaderLen = Object.keys(services).reduce((n, v) => v.length > n ? v.length : n, 0);
|
const maxHeaderLen = Object.keys(services).reduce((n, v) => v.length > n ? v.length : n, 0);
|
||||||
const failCounters = {};
|
const failCounters = {};
|
||||||
|
|
||||||
env.streamLifespan = 10000;
|
|
||||||
env.apiURL = 'http://x/';
|
|
||||||
randomizeCiphers();
|
|
||||||
|
|
||||||
for (const service in services) {
|
for (const service in services) {
|
||||||
printHeader(service, maxHeaderLen);
|
printHeader(service, maxHeaderLen);
|
||||||
const { fails, softFails } = await runTestsFor(service);
|
const { fails, softFails } = await runTestsFor(service);
|
||||||
|
@ -29,7 +29,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shortlink video",
|
"name": "shortlink video",
|
||||||
"url": "https://fb.watch/r1K6XHMfGT/",
|
"url": "https://fb.watch/r1K6XHMfGT/",
|
||||||
"canFail": true,
|
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
@ -39,7 +38,6 @@
|
|||||||
{
|
{
|
||||||
"name": "reel video",
|
"name": "reel video",
|
||||||
"url": "https://web.facebook.com/reel/730293269054758",
|
"url": "https://web.facebook.com/reel/730293269054758",
|
||||||
"canFail": true,
|
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
@ -48,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "shared video link",
|
"name": "shared video link",
|
||||||
"url": "https://www.facebook.com/share/v/NEf87jbPTvFE8LsL/",
|
"url": "https://www.facebook.com/share/v/6EJK4Z8EAEAHtz8K/",
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
|
@ -102,9 +102,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "retweeted video",
|
"name": "retweeted video",
|
||||||
"url": "https://twitter.com/uwukko/status/1696901469633421344",
|
"url": "https://twitter.com/schlizzawg/status/1869017025055793405",
|
||||||
"params": {},
|
"params": {},
|
||||||
"canFail": true,
|
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
@ -145,7 +144,7 @@
|
|||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "tunnel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -170,6 +169,15 @@
|
|||||||
"status": "tunnel"
|
"status": "tunnel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "gif",
|
||||||
|
"url": "https://x.com/thelastromances/status/1897839691212202479",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "tunnel"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "inexistent post",
|
"name": "inexistent post",
|
||||||
"url": "https://twitter.com/test/status/9487653",
|
"url": "https://twitter.com/test/status/9487653",
|
||||||
@ -203,11 +211,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "bookmarked photo",
|
"name": "bookmarked photo",
|
||||||
"url": "https://twitter.com/i/bookmarks?post_id=1837430141179289876",
|
"url": "https://twitter.com/i/bookmarks?post_id=1887450602164396149",
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "tunnel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
@ -48,7 +48,6 @@
|
|||||||
{
|
{
|
||||||
"name": "short link, wrong id",
|
"name": "short link, wrong id",
|
||||||
"url": "https://xhslink.com/a/aaaaaa",
|
"url": "https://xhslink.com/a/aaaaaa",
|
||||||
"canFail": true,
|
|
||||||
"params": {},
|
"params": {},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 400,
|
"code": 400,
|
||||||
|
@ -18,7 +18,7 @@ if you need help with installing docker, follow *only the first step* of these t
|
|||||||
```
|
```
|
||||||
i'm using `nano` in this example, it may not be available in your distro. you can use any other text editor.
|
i'm using `nano` in this example, it may not be available in your distro. you can use any other text editor.
|
||||||
|
|
||||||
3. copy and paste the [sample config from here](examples/docker-compose.example.yml) for either web or api instance (or both, if you wish) and edit it to your needs.
|
3. copy and paste the [sample config from here](examples/docker-compose.example.yml) and edit it to your needs.
|
||||||
make sure to replace default URLs with your own or cobalt won't work correctly.
|
make sure to replace default URLs with your own or cobalt won't work correctly.
|
||||||
|
|
||||||
4. finally, start the cobalt container (from cobalt directory):
|
4. finally, start the cobalt container (from cobalt directory):
|
||||||
@ -80,6 +80,7 @@ sudo service nscd start
|
|||||||
| `API_REDIS_URL` | ➖ | `redis://localhost:6379` | when set, cobalt uses redis instead of internal memory for the tunnel cache. |
|
| `API_REDIS_URL` | ➖ | `redis://localhost:6379` | when set, cobalt uses redis instead of internal memory for the tunnel cache. |
|
||||||
| `API_INSTANCE_COUNT` | ➖ | `2` | supported only on Linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. |
|
| `API_INSTANCE_COUNT` | ➖ | `2` | supported only on Linux and node.js `>=23.1.0`. when configured, cobalt will spawn multiple sub-instances amongst which requests will be balanced. |
|
||||||
| `DISABLED_SERVICES` | ➖ | `bilibili,youtube` | comma-separated list which disables certain services from being used. |
|
| `DISABLED_SERVICES` | ➖ | `bilibili,youtube` | comma-separated list which disables certain services from being used. |
|
||||||
|
| `CUSTOM_INNERTUBE_CLIENT` | ➖ | `IOS` | innertube client that will be used instead of the default one. |
|
||||||
|
|
||||||
\* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)).
|
\* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)).
|
||||||
|
|
||||||
|
@ -56,8 +56,8 @@ importers:
|
|||||||
specifier: 1.0.3
|
specifier: 1.0.3
|
||||||
version: 1.0.3
|
version: 1.0.3
|
||||||
youtubei.js:
|
youtubei.js:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.1.0
|
||||||
version: 13.0.0
|
version: 13.1.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.23.8
|
version: 3.23.8
|
||||||
@ -1468,8 +1468,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==}
|
resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
jintr@3.2.0:
|
jintr@3.2.1:
|
||||||
resolution: {integrity: sha512-psD1yf05kMKDNsUdW1l5YhO59pHScQ6OIHHb8W5SKSM2dCOFPsqolmIuSHgVA8+3Dc47NJR181CXZ4alCAPTkA==}
|
resolution: {integrity: sha512-yjKUBuwTTg4nc4izMysxuIk0BKh45hnbc1KnXE6LxagIGZn5od+I2elpuRY9IIm3EiKiUZxhxV89a0iX+xoEZg==}
|
||||||
|
|
||||||
joycon@3.1.1:
|
joycon@3.1.1:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
@ -2290,8 +2290,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
youtubei.js@13.0.0:
|
youtubei.js@13.1.0:
|
||||||
resolution: {integrity: sha512-b1QkN9bfgphK+5tI4qteSK54kNxmPhoedvMw0jl4uSn+L8gbDbJ4z52amNuYNcOdp4X/SI3JuUb+f5V0DPJ8Vw==}
|
resolution: {integrity: sha512-uL4TyojAYET0c5NGFD7+ScCod/k8Pc/B+D5tLrunFcz1GaBjRMOGRPcNGaRmnhwisegU7ibtw0iUxCN+BZ0ang==}
|
||||||
|
|
||||||
zod@3.23.8:
|
zod@3.23.8:
|
||||||
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||||
@ -3523,7 +3523,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 8.0.2
|
'@isaacs/cliui': 8.0.2
|
||||||
|
|
||||||
jintr@3.2.0:
|
jintr@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.14.0
|
acorn: 8.14.0
|
||||||
|
|
||||||
@ -4248,10 +4248,10 @@ snapshots:
|
|||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
youtubei.js@13.0.0:
|
youtubei.js@13.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@bufbuild/protobuf': 2.1.0
|
'@bufbuild/protobuf': 2.1.0
|
||||||
jintr: 3.2.0
|
jintr: 3.2.1
|
||||||
tslib: 2.6.3
|
tslib: 2.6.3
|
||||||
undici: 5.28.4
|
undici: 5.28.4
|
||||||
|
|
||||||
|
@ -30,7 +30,8 @@
|
|||||||
"api.capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!",
|
"api.capacity": "cobalt is at capacity and can't process your request at the moment. try again in a few seconds!",
|
||||||
|
|
||||||
"api.generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!",
|
"api.generic": "something went wrong and i couldn't get anything for you, try again in a few seconds. if the issue sticks, please report it!",
|
||||||
"api.unknown_response": "couldn't read the response from the processing instance. this could be caused by a version mismatch between cobalt instances.",
|
"api.unknown_response": "couldn't read the response from the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
|
||||||
|
"api.invalid_body": "couldn't send the request to the processing instance. this is probably caused by the web app being out of date. reload the app and try again!",
|
||||||
|
|
||||||
"api.service.unsupported": "this service is not supported yet. have you pasted the right link?",
|
"api.service.unsupported": "this service is not supported yet. have you pasted the right link?",
|
||||||
"api.service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!",
|
"api.service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!",
|
||||||
@ -66,5 +67,6 @@
|
|||||||
"api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
|
"api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
|
||||||
"api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
|
"api.youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
|
||||||
"api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
|
"api.youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
|
||||||
"api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!"
|
"api.youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
|
||||||
|
"api.youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@imput/cobalt-web",
|
"name": "@imput/cobalt-web",
|
||||||
"version": "10.6",
|
"version": "10.7.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -50,7 +50,9 @@
|
|||||||
<div class="picker-body">
|
<div class="picker-body">
|
||||||
{#if items}
|
{#if items}
|
||||||
{#each items as item, i}
|
{#each items as item, i}
|
||||||
<PickerItem {item} number={i + 1} />
|
{#if item?.url}
|
||||||
|
<PickerItem {item} number={i + 1} />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,18 +14,28 @@
|
|||||||
export let number: number;
|
export let number: number;
|
||||||
|
|
||||||
let imageLoaded = false;
|
let imageLoaded = false;
|
||||||
const isTunnel = new URL(item.url).pathname === "/tunnel";
|
|
||||||
|
let validUrl = false;
|
||||||
|
try {
|
||||||
|
new URL(item.url);
|
||||||
|
validUrl = true;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const isTunnel = validUrl && new URL(item.url).pathname === "/tunnel";
|
||||||
|
|
||||||
$: itemType = item.type ?? "photo";
|
$: itemType = item.type ?? "photo";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="picker-item"
|
class="picker-item"
|
||||||
on:click={() =>
|
on:click={() => {
|
||||||
downloadFile({
|
if (validUrl) {
|
||||||
url: item.url,
|
downloadFile({
|
||||||
urlType: isTunnel ? "tunnel" : "redirect",
|
url: item.url,
|
||||||
})}
|
urlType: isTunnel ? "tunnel" : "redirect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="picker-type">
|
<div class="picker-type">
|
||||||
{#if itemType === "video"}
|
{#if itemType === "video"}
|
||||||
|
@ -72,6 +72,9 @@ export const youtubeLanguages = [
|
|||||||
"ur",
|
"ur",
|
||||||
"uz",
|
"uz",
|
||||||
"vi",
|
"vi",
|
||||||
|
"zh",
|
||||||
|
"zh-Hans",
|
||||||
|
"zh-Hant",
|
||||||
"zh-CN",
|
"zh-CN",
|
||||||
"zh-HK",
|
"zh-HK",
|
||||||
"zh-TW",
|
"zh-TW",
|
||||||
|
Loading…
Reference in New Issue
Block a user