merge: 10.6 updates

This commit is contained in:
jj 2025-01-21 13:34:19 +00:00
commit c4c47bdc27
No known key found for this signature in database
25 changed files with 466 additions and 79 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "@imput/cobalt-api", "name": "@imput/cobalt-api",
"description": "save what you love", "description": "save what you love",
"version": "10.5.4", "version": "10.6",
"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": "^5.19.1", "undici": "^5.19.1",
"url-pattern": "1.0.3", "url-pattern": "1.0.3",
"youtubei.js": "^12.2.0", "youtubei.js": "^13.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"optionalDependencies": { "optionalDependencies": {

View file

@ -35,7 +35,7 @@ const streamTunnel = (req, res) => {
...Object.entries(req.headers) ...Object.entries(req.headers)
]); ]);
return stream(res, { type: 'internal', ...streamInfo }); return stream(res, { type: 'internal', data: streamInfo });
} }
export const setupTunnelHandler = () => { export const setupTunnelHandler = () => {

View file

@ -1,8 +1,16 @@
export function getRedirectingURL(url) { const redirectStatuses = new Set([301, 302, 303, 307, 308]);
return fetch(url, { redirect: 'manual' }).then((r) => {
if ([301, 302, 303].includes(r.status) && r.headers.has('location')) export async function getRedirectingURL(url, dispatcher) {
const location = await fetch(url, {
redirect: 'manual',
dispatcher,
}).then((r) => {
if (redirectStatuses.has(r.status) && r.headers.has('location')) {
return r.headers.get('location'); return r.headers.get('location');
}
}).catch(() => null); }).catch(() => null);
return location;
} }
export function merge(a, b) { export function merge(a, b) {
@ -29,3 +37,7 @@ export function splitFilenameExtension(filename) {
return [ parts.join('.'), ext ] return [ parts.join('.'), ext ]
} }
} }
export function zip(a, b) {
return a.map((value, i) => [ value, b[i] ]);
}

View file

@ -15,7 +15,8 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
filename: r.filenameAttributes ? filename: r.filenameAttributes ?
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename, createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
fileMetadata: !disableMetadata ? r.fileMetadata : false, fileMetadata: !disableMetadata ? r.fileMetadata : false,
requestIP requestIP,
originalRequest: r.originalRequest
}, },
params = {}; params = {};
@ -47,7 +48,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}); });
case "photo": case "photo":
responseType = "redirect"; params = { type: "proxy" };
break; break;
case "gif": case "gif":
@ -83,6 +84,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "twitter": case "twitter":
case "snapchat": case "snapchat":
case "bsky": case "bsky":
case "xiaohongshu":
params = { picker: r.picker }; params = { picker: r.picker };
break; break;
@ -143,6 +145,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "ok": case "ok":
case "vk": case "vk":
case "tiktok": case "tiktok":
case "xiaohongshu":
params = { type: "proxy" }; params = { type: "proxy" };
break; break;

View file

@ -28,6 +28,7 @@ import snapchat from "./services/snapchat.js";
import loom from "./services/loom.js"; import loom from "./services/loom.js";
import facebook from "./services/facebook.js"; import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js"; import bluesky from "./services/bluesky.js";
import xiaohongshu from "./services/xiaohongshu.js";
let freebind; let freebind;
@ -239,6 +240,15 @@ export default async function({ host, patternMatch, params }) {
}); });
break; break;
case "xiaohongshu":
r = await xiaohongshu({
...patternMatch,
h265: params.tiktokH265,
isAudioOnly,
dispatcher,
});
break;
default: default:
return createResponse("error", { return createResponse("error", {
code: "error.api.service.unsupported" code: "error.api.service.unsupported"

View file

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

View file

@ -71,4 +71,8 @@ export const testers = {
"bsky": pattern => "bsky": pattern =>
pattern.user?.length <= 128 && pattern.post?.length <= 128, pattern.user?.length <= 128 && pattern.post?.length <= 128,
"xiaohongshu": pattern =>
pattern.id?.length <= 24 && pattern.token?.length <= 64
|| pattern.shareId?.length <= 12,
} }

View file

@ -71,6 +71,24 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
return { picker }; return { picker };
} }
const extractGif = ({ url, filename }) => {
const gifUrl = new URL(url);
if (!gifUrl || gifUrl.hostname !== "media.tenor.com") {
return { error: "fetch.empty" };
}
// remove downscaling params from gif url
// such as "?hh=498&ww=498"
gifUrl.search = "";
return {
urls: gifUrl,
isPhoto: true,
filename: `${filename}.gif`,
}
}
export default async function ({ user, post, alwaysProxy, dispatcher }) { export default async function ({ user, post, alwaysProxy, dispatcher }) {
const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0"); const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
apiEndpoint.searchParams.set( apiEndpoint.searchParams.set(
@ -102,22 +120,37 @@ export default async function ({ user, post, alwaysProxy, dispatcher }) {
const embedType = getPost?.thread?.post?.embed?.$type; const embedType = getPost?.thread?.post?.embed?.$type;
const filename = `bluesky_${user}_${post}`; const filename = `bluesky_${user}_${post}`;
if (embedType === "app.bsky.embed.video#view") { switch (embedType) {
return extractVideo({ case "app.bsky.embed.video#view":
media: getPost.thread?.post?.embed, return extractVideo({
filename, media: getPost.thread?.post?.embed,
}) filename,
} });
if (embedType === "app.bsky.embed.recordWithMedia#view") { case "app.bsky.embed.images#view":
return extractVideo({ return extractImages({
media: getPost.thread?.post?.embed?.media, getPost,
filename, filename,
}) alwaysProxy
} });
if (embedType === "app.bsky.embed.images#view") { case "app.bsky.embed.external#view":
return extractImages({ getPost, filename, alwaysProxy }); return extractGif({
url: getPost?.thread?.post?.embed?.external?.uri,
filename,
});
case "app.bsky.embed.recordWithMedia#view":
if (getPost?.thread?.post?.embed?.media?.$type === "app.bsky.embed.external#view") {
return extractGif({
url: getPost?.thread?.post?.embed?.media?.external?.uri,
filename,
});
}
return extractVideo({
media: getPost.thread?.post?.embed?.media,
filename,
});
} }
return { error: "fetch.empty" }; return { error: "fetch.empty" };

View file

@ -30,7 +30,7 @@ export default async function(obj) {
if (!postId) return { error: "fetch.short_link" }; if (!postId) return { error: "fetch.short_link" };
// should always be /video/, even for photos // should always be /video/, even for photos
const res = await fetch(`https://tiktok.com/@i/video/${postId}`, { const res = await fetch(`https://www.tiktok.com/@i/video/${postId}`, {
headers: { headers: {
"user-agent": genericUserAgent, "user-agent": genericUserAgent,
cookie, cookie,

View file

@ -0,0 +1,116 @@
import { extract, normalizeURL } from "../url.js";
import { genericUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
import { getRedirectingURL } from "../../misc/utils.js";
const https = (url) => {
return url.replace(/^http:/i, 'https:');
}
export default async function ({ id, token, shareId, h265, isAudioOnly, dispatcher }) {
let noteId = id;
let xsecToken = token;
if (!noteId) {
const extractedURL = await getRedirectingURL(
`https://xhslink.com/a/${shareId}`,
dispatcher
);
if (extractedURL) {
const { patternMatch } = extract(normalizeURL(extractedURL));
if (patternMatch) {
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

@ -149,7 +149,7 @@ export default async function (o) {
useHLS = false; useHLS = false;
} }
let innertubeClient = "ANDROID"; let innertubeClient = o.innertubeClient || "ANDROID";
if (cookie) { if (cookie) {
useHLS = false; useHLS = false;
@ -240,12 +240,12 @@ export default async function (o) {
const quality = o.quality === "max" ? 9000 : Number(o.quality); const quality = o.quality === "max" ? 9000 : Number(o.quality);
const normalizeQuality = res => { const normalizeQuality = res => {
const shortestSide = res.height > res.width ? res.width : res.height; const shortestSide = Math.min(res.height, res.width);
return videoQualities.find(qual => qual >= shortestSide); return videoQualities.find(qual => qual >= shortestSide);
} }
let video, audio, dubbedLanguage, let video, audio, dubbedLanguage,
codec = o.format || "h264"; codec = o.format || "h264", itag = o.itag;
if (useHLS) { if (useHLS) {
const hlsManifest = info.streaming_data.hls_manifest_url; const hlsManifest = info.streaming_data.hls_manifest_url;
@ -351,17 +351,21 @@ export default async function (o) {
Number(b.bitrate) - Number(a.bitrate) Number(b.bitrate) - Number(a.bitrate)
).forEach(format => { ).forEach(format => {
Object.keys(codecList).forEach(yCodec => { Object.keys(codecList).forEach(yCodec => {
const matchingItag = slot => !itag?.[slot] || itag[slot] === format.itag;
const sorted = sorted_formats[yCodec]; const sorted = sorted_formats[yCodec];
const goodFormat = checkFormat(format, yCodec); const goodFormat = checkFormat(format, yCodec);
if (!goodFormat) return; if (!goodFormat) return;
if (format.has_video) { if (format.has_video && matchingItag('video')) {
sorted.video.push(format); sorted.video.push(format);
if (!sorted.bestVideo) sorted.bestVideo = format; if (!sorted.bestVideo)
sorted.bestVideo = format;
} }
if (format.has_audio) {
if (format.has_audio && matchingItag('audio')) {
sorted.audio.push(format); sorted.audio.push(format);
if (!sorted.bestAudio) sorted.bestAudio = format; if (!sorted.bestAudio)
sorted.bestAudio = format;
} }
}) })
}); });
@ -448,6 +452,18 @@ export default async function (o) {
youtubeDubName: dubbedLanguage || false, youtubeDubName: dubbedLanguage || false,
} }
itag = {
video: video?.itag,
audio: audio?.itag
};
const originalRequest = {
...o,
dispatcher: undefined,
itag,
innertubeClient
};
if (audio && o.isAudioOnly) { if (audio && o.isAudioOnly) {
let bestAudio = codec === "h264" ? "m4a" : "opus"; let bestAudio = codec === "h264" ? "m4a" : "opus";
let urls = audio.url; let urls = audio.url;
@ -469,6 +485,7 @@ export default async function (o) {
fileMetadata, fileMetadata,
bestAudio, bestAudio,
isHLS: useHLS, isHLS: useHLS,
originalRequest
} }
} }
@ -491,12 +508,12 @@ 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;
video = video.url;
audio = audio.url;
if (innertubeClient === "WEB" && innertube) { if (innertubeClient === "WEB" && 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 {
video = video.url;
audio = audio.url;
} }
} }
@ -512,6 +529,7 @@ export default async function (o) {
filenameAttributes, filenameAttributes,
fileMetadata, fileMetadata,
isHLS: useHLS, isHLS: useHLS,
originalRequest
} }
} }

View file

@ -92,9 +92,14 @@ function aliasURL(url) {
url.hostname = 'vk.com'; url.hostname = 'vk.com';
} }
break; break;
case "xhslink":
if (url.hostname === 'xhslink.com' && parts.length === 3) {
url = new URL(`https://www.xiaohongshu.com/a/${parts[2]}`);
}
} }
return url return url;
} }
function cleanURL(url) { function cleanURL(url) {
@ -114,36 +119,41 @@ function cleanURL(url) {
break; break;
case "vk": case "vk":
if (url.pathname.includes('/clip') && url.searchParams.get('z')) { if (url.pathname.includes('/clip') && url.searchParams.get('z')) {
limitQuery('z') limitQuery('z');
} }
break; break;
case "youtube": case "youtube":
if (url.searchParams.get('v')) { if (url.searchParams.get('v')) {
limitQuery('v') limitQuery('v');
} }
break; break;
case "rutube": case "rutube":
if (url.searchParams.get('p')) { if (url.searchParams.get('p')) {
limitQuery('p') limitQuery('p');
} }
break; break;
case "twitter": case "twitter":
if (url.searchParams.get('post_id')) { if (url.searchParams.get('post_id')) {
limitQuery('post_id') limitQuery('post_id');
}
break;
case "xiaohongshu":
if (url.searchParams.get('xsec_token')) {
limitQuery('xsec_token');
} }
break; break;
} }
if (stripQuery) { if (stripQuery) {
url.search = '' url.search = '';
} }
url.username = url.password = url.port = url.hash = '' url.username = url.password = url.port = url.hash = '';
if (url.pathname.endsWith('/')) if (url.pathname.endsWith('/'))
url.pathname = url.pathname.slice(0, -1); url.pathname = url.pathname.slice(0, -1);
return url return url;
} }
function getHostIfValid(url) { function getHostIfValid(url) {

View file

@ -7,7 +7,7 @@ const CHUNK_SIZE = BigInt(8e6); // 8 MB
const min = (a, b) => a < b ? a : b; const min = (a, b) => a < b ? a : b;
async function* readChunks(streamInfo, size) { async function* readChunks(streamInfo, size) {
let read = 0n; let read = 0n, chunksSinceTransplant = 0;
while (read < size) { while (read < size) {
if (streamInfo.controller.signal.aborted) { if (streamInfo.controller.signal.aborted) {
throw new Error("controller aborted"); throw new Error("controller aborted");
@ -22,6 +22,16 @@ async function* readChunks(streamInfo, size) {
signal: streamInfo.controller.signal signal: streamInfo.controller.signal
}); });
if (chunk.statusCode === 403 && chunksSinceTransplant >= 3 && streamInfo.transplant) {
chunksSinceTransplant = 0;
try {
await streamInfo.transplant(streamInfo.dispatcher);
continue;
} catch {}
}
chunksSinceTransplant++;
const expected = min(CHUNK_SIZE, size - read); const expected = min(CHUNK_SIZE, size - read);
const received = BigInt(chunk.headers['content-length']); const received = BigInt(chunk.headers['content-length']);

View file

@ -9,6 +9,7 @@ import { env } from "../config.js";
import { closeRequest } from "./shared.js"; import { closeRequest } from "./shared.js";
import { decryptStream, encryptStream } from "../misc/crypto.js"; import { decryptStream, encryptStream } from "../misc/crypto.js";
import { hashHmac } from "../security/secrets.js"; import { hashHmac } from "../security/secrets.js";
import { zip } from "../misc/utils.js";
// optional dependency // optional dependency
const freebind = env.freebindCIDR && await import('freebind').catch(() => {}); const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
@ -40,6 +41,7 @@ export function createStream(obj) {
audioFormat: obj.audioFormat, audioFormat: obj.audioFormat,
isHLS: obj.isHLS || false, isHLS: obj.isHLS || false,
originalRequest: obj.originalRequest
}; };
// FIXME: this is now a Promise, but it is not awaited // FIXME: this is now a Promise, but it is not awaited
@ -110,6 +112,7 @@ export function createInternalStream(url, obj = {}) {
controller, controller,
dispatcher, dispatcher,
isHLS: obj.isHLS, isHLS: obj.isHLS,
transplant: obj.transplant
}); });
let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`); let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`);
@ -125,13 +128,17 @@ export function createInternalStream(url, obj = {}) {
return streamLink.toString(); return streamLink.toString();
} }
export function destroyInternalStream(url) { function getInternalTunnelId(url) {
url = new URL(url); url = new URL(url);
if (url.hostname !== '127.0.0.1') { if (url.hostname !== '127.0.0.1') {
return; return;
} }
const id = url.searchParams.get('id'); return url.searchParams.get('id');
}
export function destroyInternalStream(url) {
const id = getInternalTunnelId(url);
if (internalStreamCache.has(id)) { if (internalStreamCache.has(id)) {
closeRequest(getInternalTunnel(id)?.controller); closeRequest(getInternalTunnel(id)?.controller);
@ -139,9 +146,68 @@ export function destroyInternalStream(url) {
} }
} }
const transplantInternalTunnels = function(tunnelUrls, transplantUrls) {
if (tunnelUrls.length !== transplantUrls.length) {
return;
}
for (const [ tun, url ] of zip(tunnelUrls, transplantUrls)) {
const id = getInternalTunnelId(tun);
const itunnel = getInternalStream(id);
if (!itunnel) continue;
itunnel.url = url;
}
}
const transplantTunnel = async function (dispatcher) {
if (this.pendingTransplant) {
await this.pendingTransplant;
return;
}
let finished;
this.pendingTransplant = new Promise(r => finished = r);
try {
const handler = await import(`../processing/services/${this.service}.js`);
const response = await handler.default({
...this.originalRequest,
dispatcher
});
if (!response.urls) {
return;
}
response.urls = [response.urls].flat();
if (this.originalRequest.isAudioOnly && response.urls.length > 1) {
response.urls = [response.urls[1]];
} else if (this.originalRequest.isAudioMuted) {
response.urls = [response.urls[0]];
}
const tunnels = [this.urls].flat();
if (tunnels.length !== response.urls.length) {
return;
}
transplantInternalTunnels(tunnels, response.urls);
}
catch {}
finally {
finished();
delete this.pendingTransplant;
}
}
function wrapStream(streamInfo) { function wrapStream(streamInfo) {
const url = streamInfo.urls; const url = streamInfo.urls;
if (streamInfo.originalRequest) {
streamInfo.transplant = transplantTunnel.bind(streamInfo);
}
if (typeof url === 'string') { if (typeof url === 'string') {
streamInfo.urls = createInternalStream(url, streamInfo); streamInfo.urls = createInternalStream(url, streamInfo);
} else if (Array.isArray(url)) { } else if (Array.isArray(url)) {

View file

@ -10,7 +10,7 @@ export default async function(res, streamInfo) {
return await stream.proxy(streamInfo, res); return await stream.proxy(streamInfo, res);
case "internal": case "internal":
return await internalStream(streamInfo, res); return await internalStream(streamInfo.data, res);
case "merge": case "merge":
return await stream.merge(streamInfo, res); return await stream.merge(streamInfo, res);

View file

@ -54,7 +54,25 @@
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
"status": "redirect" "status": "tunnel"
}
},
{
"name": "gif with a quoted post",
"url": "https://bsky.app/profile/imlunahey.com/post/3lgajpn5dtk2t",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "gif alone in a post",
"url": "https://bsky.app/profile/imlunahey.com/post/3lgah3ovxnc2q",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
} }
}, },
{ {

View file

@ -54,7 +54,7 @@
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
"status": "redirect" "status": "tunnel"
} }
}, },
{ {
@ -63,7 +63,7 @@
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
"status": "redirect" "status": "tunnel"
} }
}, },
{ {
@ -72,7 +72,7 @@
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
"status": "redirect" "status": "tunnel"
} }
}, },
{ {
@ -81,7 +81,7 @@
"params": {}, "params": {},
"expected": { "expected": {
"code": 200, "code": 200,
"status": "redirect" "status": "tunnel"
} }
} }
] ]

View file

@ -0,0 +1,58 @@
[
{
"name": "long link video",
"url": "https://www.xiaohongshu.com/discovery/item/6789065900000000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "picker with multiple live photos",
"url": "https://www.xiaohongshu.com/explore/67847fa1000000000203e6ed?xsec_token=CBzyP7Y44PPpsM20lgxqrIIJMHqOLemusDsRcmsX0cTpk",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "one photo",
"url": "https://www.xiaohongshu.com/explore/6788b56200000000210008c8?xsec_token=CBSDiWU4N-DgirHrOVbIWrlKfUNFHKwm-Wsjqz7dIMc_k",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "short link, might expire eventually",
"url": "https://xhslink.com/a/czn4z6c1tic4",
"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",
"canFail": true,
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]

View file

@ -68,7 +68,7 @@ Content-Type: application/json
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. | | `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. | | `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. | | `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. | | `tiktokH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. |
| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif | | `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif |
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. | | `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |

View file

@ -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: ^12.2.0 specifier: ^13.0.0
version: 12.2.0 version: 13.0.0
zod: zod:
specifier: ^3.23.8 specifier: ^3.23.8
version: 3.23.8 version: 3.23.8
@ -2513,8 +2513,8 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
youtubei.js@12.2.0: youtubei.js@13.0.0:
resolution: {integrity: sha512-G+50qrbJCToMYhu8jbaHiS3Vf+RRul+CcDbz3hEGwHkGPh+zLiWwD6SS+YhYF+2/op4ZU5zDYQJrGqJ+wKh7Gw==} resolution: {integrity: sha512-b1QkN9bfgphK+5tI4qteSK54kNxmPhoedvMw0jl4uSn+L8gbDbJ4z52amNuYNcOdp4X/SI3JuUb+f5V0DPJ8Vw==}
zod@3.23.8: zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
@ -4694,7 +4694,7 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
youtubei.js@12.2.0: youtubei.js@13.0.0:
dependencies: dependencies:
'@bufbuild/protobuf': 2.1.0 '@bufbuild/protobuf': 2.1.0
jintr: 3.2.0 jintr: 3.2.0

View file

@ -40,9 +40,9 @@
"video.twitter.gif.title": "convert looping videos to GIF", "video.twitter.gif.title": "convert looping videos to GIF",
"video.twitter.gif.description": "GIF conversion is inefficient, converted file may be obnoxiously big and low quality.", "video.twitter.gif.description": "GIF conversion is inefficient, converted file may be obnoxiously big and low quality.",
"video.tiktok.h265": "tiktok", "video.h265": "high efficiency video codec",
"video.tiktok.h265.title": "prefer HEVC/H265 format", "video.h265.title": "allow h265 for videos",
"video.tiktok.h265.description": "allows downloading videos in 1080p at cost of compatibility.", "video.h265.description": "allows downloading videos from platforms like tiktok and xiaohongshu in higher quality at cost of compatibility.",
"audio.format": "audio format", "audio.format": "audio format",
"audio.format.best": "best", "audio.format.best": "best",

View file

@ -1,6 +1,6 @@
{ {
"name": "@imput/cobalt-web", "name": "@imput/cobalt-web",
"version": "10.5.1", "version": "10.6",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {

View file

@ -11,6 +11,7 @@
import dialogs from "$lib/state/dialogs"; import dialogs from "$lib/state/dialogs";
import { link } from "$lib/state/omnibox"; import { link } from "$lib/state/omnibox";
import { updateSetting } from "$lib/state/settings"; import { updateSetting } from "$lib/state/settings";
import { pasteLinkFromClipboard } from "$lib/clipboard";
import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile"; import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile";
import type { Optional } from "$lib/types/generic"; import type { Optional } from "$lib/types/generic";
@ -41,7 +42,7 @@
const validLink = (url: string) => { const validLink = (url: string) => {
try { try {
return /^https:/i.test(new URL(url).protocol); return /^https?\:/i.test(new URL(url).protocol);
} catch {} } catch {}
}; };
@ -59,22 +60,24 @@
goto("/", { replaceState: true }); goto("/", { replaceState: true });
} }
const pasteClipboard = () => { const pasteClipboard = async () => {
if ($dialogs.length > 0 || isDisabled || isLoading) { if ($dialogs.length > 0 || isDisabled || isLoading) {
return; return;
} }
navigator.clipboard.readText().then(async (text: string) => { const pastedData = await pasteLinkFromClipboard();
let matchLink = text.match(/https:\/\/[^\s]+/g); if (!pastedData) return;
if (matchLink) {
$link = matchLink[0];
if (!isBotCheckOngoing) { const linkMatch = pastedData.match(/https?\:\/\/[^\s]+/g);
await tick(); // wait for button to render
downloadButton.download($link); if (linkMatch) {
} $link = linkMatch[0].split('')[0];
if (!isBotCheckOngoing) {
await tick(); // wait for button to render
downloadButton.download($link);
} }
}); }
}; };
const changeDownloadMode = (mode: DownloadModeOption) => { const changeDownloadMode = (mode: DownloadModeOption) => {

17
web/src/lib/clipboard.ts Normal file
View file

@ -0,0 +1,17 @@
const allowedLinkTypes = new Set(["text/plain", "text/uri-list"]);
export const pasteLinkFromClipboard = async () => {
const clipboard = await navigator.clipboard.read();
if (clipboard?.length) {
const clipboardItem = clipboard[0];
for (const type of clipboardItem.types) {
if (allowedLinkTypes.has(type)) {
const blob = await clipboardItem.getType(type);
const blobText = await blob.text();
return blobText;
}
}
}
}

View file

@ -69,6 +69,15 @@
/> />
</SettingsCategory> </SettingsCategory>
<SettingsCategory sectionId="h265" title={$t("settings.video.h265")}>
<SettingsToggle
settingContext="save"
settingId="tiktokH265"
title={$t("settings.video.h265.title")}
description={$t("settings.video.h265.description")}
/>
</SettingsCategory>
<SettingsCategory sectionId="twitter" title={$t("settings.video.twitter.gif")}> <SettingsCategory sectionId="twitter" title={$t("settings.video.twitter.gif")}>
<SettingsToggle <SettingsToggle
settingContext="save" settingContext="save"
@ -78,11 +87,3 @@
/> />
</SettingsCategory> </SettingsCategory>
<SettingsCategory sectionId="tiktok" title={$t("settings.video.tiktok.h265")}>
<SettingsToggle
settingContext="save"
settingId="tiktokH265"
title={$t("settings.video.tiktok.h265.title")}
description={$t("settings.video.tiktok.h265.description")}
/>
</SettingsCategory>