Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6678238
api/package: bump youtubei.js to 16.0.0
dumbmoron Oct 13, 2025
d8ca585
api/youtube: try new session server
dumbmoron Oct 13, 2025
e7b6d3b
youtube: fix custom fetch breakage
dumbmoron Oct 13, 2025
c8245a0
youtube: provide own JS interpreter
dumbmoron Oct 13, 2025
cb5eaa8
youtube: await decipher()s
dumbmoron Oct 13, 2025
b439ee9
api/bilibili: use another user agent (#1472)
zImPatrick Dec 20, 2025
e6bbd94
api/twitter: strip "tag" param from video urls
dumbmoron Dec 20, 2025
ae51869
web/download: do not show additional dialog on top of dialog in openURL
dumbmoron Dec 20, 2025
c857054
all: update dependencies
wukko Dec 23, 2025
728d225
web: fix state_referenced_locally warnings with proper runes
wukko Dec 23, 2025
eb5cf3b
web/storage/memory: fix the type of chunks
wukko Dec 23, 2025
07bb16d
api/soundcloud: fix extraction of client_id (#1497)
zImPatrick Jan 9, 2026
f520d2b
api/package: roll back to redis 4
wukko Jan 9, 2026
e1ce1db
api/service-config: add 'pro' subdomain to tiktok (#1507)
FiireWiinter Jan 24, 2026
2283901
youtube: introduce `YOUTUBE_PLAYER_ID` envvar
dumbmoron Mar 7, 2026
1bf45bb
youtube: wrap eval shim in v8 isolate
dumbmoron Mar 7, 2026
3120d7d
ci: install lts node version for tests
dumbmoron Mar 7, 2026
c5d8929
api: bump version to 11.6
dumbmoron Mar 7, 2026
25df76e
api: bump youtubei.js to 17.0.1
dumbmoron Mar 22, 2026
e6a987c
web/i18n/error: update youtube main instance error text
wukko Mar 30, 2026
23463d5
api/facebook: update video and share urls (#1531)
wukko Mar 30, 2026
8cefaab
api/tests: update outdated tests
wukko Mar 30, 2026
008e1e4
api/rutube: fix yappy downloading
wukko Mar 30, 2026
f2466ba
api: remove support for xiaohongshu
wukko Mar 30, 2026
3e29844
web/i18n/settings: remove mentions of xiaohongshu
wukko Mar 30, 2026
f72ea13
api & web: update dependencies
wukko Apr 6, 2026
4f65467
api/package: update min node version
wukko Apr 6, 2026
94eaae6
api/tests/tumblr: update audio link
wukko Apr 6, 2026
5d8b6ba
api/package: update freebind to 0.2.3
wukko Apr 6, 2026
1566724
api/vk: update api endpoints & client version
wukko Apr 6, 2026
5465572
api & web: bump and sync version to 11.7
wukko Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test-services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }}
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 0 additions & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
37 changes: 19 additions & 18 deletions api/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
}
}
1 change: 1 addition & 0 deletions api/src/core/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions api/src/processing/helpers/youtube-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand All @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions api/src/processing/match-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ export default function({
case "twitter":
case "snapchat":
case "bsky":
case "xiaohongshu":
params = { picker: r.picker };
break;

Expand Down Expand Up @@ -145,6 +144,7 @@ export default function({
params = { type: r.type };
break;

case "rutube":
case "vimeo":
if (Array.isArray(r.urls)) {
params = { type: "merge" };
Expand Down Expand Up @@ -179,7 +179,6 @@ export default function({
break;

case "ok":
case "xiaohongshu":
case "newgrounds":
params = { type: "proxy" };
break;
Expand Down
10 changes: 0 additions & 10 deletions api/src/processing/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 1 addition & 9 deletions api/src/processing/service-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export const services = {
":user/photo/:postId",
"v/:postId.html"
],
subdomains: ["vt", "vm", "m", "t"],
subdomains: ["vt", "vm", "m", "t", "pro"],
},
tumblr: {
patterns: [
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 0 additions & 4 deletions api/src/processing/service-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
4 changes: 2 additions & 2 deletions api/src/processing/services/bilibili.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions api/src/processing/services/facebook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion api/src/processing/services/rutube.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
27 changes: 15 additions & 12 deletions api/src/processing/services/soundcloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
12 changes: 11 additions & 1 deletion api/src/processing/services/twitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 14 additions & 10 deletions api/src/processing/services/vk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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: {
Expand All @@ -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;
}

Expand All @@ -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",
Expand All @@ -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()
})
Expand Down
Loading
Loading