diff --git a/api/package.json b/api/package.json
index fccbddff..829106c3 100644
--- a/api/package.json
+++ b/api/package.json
@@ -1,7 +1,7 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
- "version": "10.5.4",
+ "version": "10.6",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",
@@ -39,7 +39,7 @@
"set-cookie-parser": "2.6.0",
"undici": "^5.19.1",
"url-pattern": "1.0.3",
- "youtubei.js": "^12.2.0",
+ "youtubei.js": "^13.0.0",
"zod": "^3.23.8"
},
"optionalDependencies": {
diff --git a/api/src/core/itunnel.js b/api/src/core/itunnel.js
index fea4cb76..e16c0345 100644
--- a/api/src/core/itunnel.js
+++ b/api/src/core/itunnel.js
@@ -35,7 +35,7 @@ const streamTunnel = (req, res) => {
...Object.entries(req.headers)
]);
- return stream(res, { type: 'internal', ...streamInfo });
+ return stream(res, { type: 'internal', data: streamInfo });
}
export const setupTunnelHandler = () => {
diff --git a/api/src/misc/utils.js b/api/src/misc/utils.js
index fd497d18..331528d4 100644
--- a/api/src/misc/utils.js
+++ b/api/src/misc/utils.js
@@ -1,8 +1,16 @@
-export function getRedirectingURL(url) {
- return fetch(url, { redirect: 'manual' }).then((r) => {
- if ([301, 302, 303].includes(r.status) && r.headers.has('location'))
+const redirectStatuses = new Set([301, 302, 303, 307, 308]);
+
+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');
+ }
}).catch(() => null);
+
+ return location;
}
export function merge(a, b) {
@@ -29,3 +37,7 @@ export function splitFilenameExtension(filename) {
return [ parts.join('.'), ext ]
}
}
+
+export function zip(a, b) {
+ return a.map((value, i) => [ value, b[i] ]);
+}
diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js
index 64f86836..363cb403 100644
--- a/api/src/processing/match-action.js
+++ b/api/src/processing/match-action.js
@@ -15,7 +15,8 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
filename: r.filenameAttributes ?
createFilename(r.filenameAttributes, filenameStyle, isAudioOnly, isAudioMuted) : r.filename,
fileMetadata: !disableMetadata ? r.fileMetadata : false,
- requestIP
+ requestIP,
+ originalRequest: r.originalRequest
},
params = {};
@@ -47,7 +48,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
});
case "photo":
- responseType = "redirect";
+ params = { type: "proxy" };
break;
case "gif":
@@ -83,6 +84,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "twitter":
case "snapchat":
case "bsky":
+ case "xiaohongshu":
params = { picker: r.picker };
break;
@@ -143,6 +145,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "ok":
case "vk":
case "tiktok":
+ case "xiaohongshu":
params = { type: "proxy" };
break;
diff --git a/api/src/processing/match.js b/api/src/processing/match.js
index 57f04b36..9fabf379 100644
--- a/api/src/processing/match.js
+++ b/api/src/processing/match.js
@@ -28,6 +28,7 @@ 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";
let freebind;
@@ -239,6 +240,15 @@ export default async function({ host, patternMatch, params }) {
});
break;
+ case "xiaohongshu":
+ r = await xiaohongshu({
+ ...patternMatch,
+ h265: params.tiktokH265,
+ isAudioOnly,
+ dispatcher,
+ });
+ break;
+
default:
return createResponse("error", {
code: "error.api.service.unsupported"
diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js
index 81afaf39..86352f9a 100644
--- a/api/src/processing/service-config.js
+++ b/api/src/processing/service-config.js
@@ -166,6 +166,14 @@ export const services = {
subdomains: ["m"],
altDomains: ["vkvideo.ru", "vk.ru"],
},
+ xiaohongshu: {
+ patterns: [
+ "explore/:id?xsec_token=:token",
+ "discovery/item/:id?xsec_token=:token",
+ "a/:shareId"
+ ],
+ altDomains: ["xhslink.com"],
+ },
youtube: {
patterns: [
"watch?v=:id",
diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js
index e8c46639..42f64d26 100644
--- a/api/src/processing/service-patterns.js
+++ b/api/src/processing/service-patterns.js
@@ -71,4 +71,8 @@ export const testers = {
"bsky": pattern =>
pattern.user?.length <= 128 && pattern.post?.length <= 128,
+
+ "xiaohongshu": pattern =>
+ pattern.id?.length <= 24 && pattern.token?.length <= 64
+ || pattern.shareId?.length <= 12,
}
diff --git a/api/src/processing/services/bluesky.js b/api/src/processing/services/bluesky.js
index bc887437..598e9739 100644
--- a/api/src/processing/services/bluesky.js
+++ b/api/src/processing/services/bluesky.js
@@ -71,6 +71,24 @@ const extractImages = ({ getPost, filename, alwaysProxy }) => {
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 }) {
const apiEndpoint = new URL("https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?depth=0&parentHeight=0");
apiEndpoint.searchParams.set(
@@ -102,22 +120,37 @@ export default async function ({ user, post, alwaysProxy, dispatcher }) {
const embedType = getPost?.thread?.post?.embed?.$type;
const filename = `bluesky_${user}_${post}`;
- if (embedType === "app.bsky.embed.video#view") {
- return extractVideo({
- media: getPost.thread?.post?.embed,
- filename,
- })
- }
+ switch (embedType) {
+ case "app.bsky.embed.video#view":
+ return extractVideo({
+ media: getPost.thread?.post?.embed,
+ filename,
+ });
- if (embedType === "app.bsky.embed.recordWithMedia#view") {
- return extractVideo({
- media: getPost.thread?.post?.embed?.media,
- filename,
- })
- }
+ case "app.bsky.embed.images#view":
+ return extractImages({
+ getPost,
+ filename,
+ alwaysProxy
+ });
- if (embedType === "app.bsky.embed.images#view") {
- return extractImages({ getPost, filename, alwaysProxy });
+ case "app.bsky.embed.external#view":
+ 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" };
diff --git a/api/src/processing/services/tiktok.js b/api/src/processing/services/tiktok.js
index 6978e071..6fec01d8 100644
--- a/api/src/processing/services/tiktok.js
+++ b/api/src/processing/services/tiktok.js
@@ -30,7 +30,7 @@ export default async function(obj) {
if (!postId) return { error: "fetch.short_link" };
// 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: {
"user-agent": genericUserAgent,
cookie,
diff --git a/api/src/processing/services/xiaohongshu.js b/api/src/processing/services/xiaohongshu.js
new file mode 100644
index 00000000..bbb53ab1
--- /dev/null
+++ b/api/src/processing/services/xiaohongshu.js
@@ -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('')[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 };
+}
diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js
index 6559978c..f0766b3f 100644
--- a/api/src/processing/services/youtube.js
+++ b/api/src/processing/services/youtube.js
@@ -149,7 +149,7 @@ export default async function (o) {
useHLS = false;
}
- let innertubeClient = "ANDROID";
+ let innertubeClient = o.innertubeClient || "ANDROID";
if (cookie) {
useHLS = false;
@@ -240,12 +240,12 @@ export default async function (o) {
const quality = o.quality === "max" ? 9000 : Number(o.quality);
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);
}
let video, audio, dubbedLanguage,
- codec = o.format || "h264";
+ codec = o.format || "h264", itag = o.itag;
if (useHLS) {
const hlsManifest = info.streaming_data.hls_manifest_url;
@@ -351,17 +351,21 @@ export default async function (o) {
Number(b.bitrate) - Number(a.bitrate)
).forEach(format => {
Object.keys(codecList).forEach(yCodec => {
+ const matchingItag = slot => !itag?.[slot] || itag[slot] === format.itag;
const sorted = sorted_formats[yCodec];
const goodFormat = checkFormat(format, yCodec);
if (!goodFormat) return;
- if (format.has_video) {
+ if (format.has_video && matchingItag('video')) {
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);
- if (!sorted.bestAudio) sorted.bestAudio = format;
+ if (!sorted.bestAudio)
+ sorted.bestAudio = format;
}
})
});
@@ -448,6 +452,18 @@ export default async function (o) {
youtubeDubName: dubbedLanguage || false,
}
+ itag = {
+ video: video?.itag,
+ audio: audio?.itag
+ };
+
+ const originalRequest = {
+ ...o,
+ dispatcher: undefined,
+ itag,
+ innertubeClient
+ };
+
if (audio && o.isAudioOnly) {
let bestAudio = codec === "h264" ? "m4a" : "opus";
let urls = audio.url;
@@ -469,6 +485,7 @@ export default async function (o) {
fileMetadata,
bestAudio,
isHLS: useHLS,
+ originalRequest
}
}
@@ -491,12 +508,12 @@ export default async function (o) {
filenameAttributes.resolution = `${video.width}x${video.height}`;
filenameAttributes.extension = codecList[codec].container;
- video = video.url;
- audio = audio.url;
-
if (innertubeClient === "WEB" && innertube) {
video = video.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,
fileMetadata,
isHLS: useHLS,
+ originalRequest
}
}
diff --git a/api/src/processing/url.js b/api/src/processing/url.js
index 8f0e7dc2..cfbbecc0 100644
--- a/api/src/processing/url.js
+++ b/api/src/processing/url.js
@@ -92,9 +92,14 @@ function aliasURL(url) {
url.hostname = 'vk.com';
}
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) {
@@ -114,36 +119,41 @@ function cleanURL(url) {
break;
case "vk":
if (url.pathname.includes('/clip') && url.searchParams.get('z')) {
- limitQuery('z')
+ limitQuery('z');
}
break;
case "youtube":
if (url.searchParams.get('v')) {
- limitQuery('v')
+ limitQuery('v');
}
break;
case "rutube":
if (url.searchParams.get('p')) {
- limitQuery('p')
+ limitQuery('p');
}
break;
case "twitter":
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;
}
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('/'))
url.pathname = url.pathname.slice(0, -1);
- return url
+ return url;
}
function getHostIfValid(url) {
diff --git a/api/src/stream/internal.js b/api/src/stream/internal.js
index 2cfc990c..3a863bb6 100644
--- a/api/src/stream/internal.js
+++ b/api/src/stream/internal.js
@@ -7,7 +7,7 @@ const CHUNK_SIZE = BigInt(8e6); // 8 MB
const min = (a, b) => a < b ? a : b;
async function* readChunks(streamInfo, size) {
- let read = 0n;
+ let read = 0n, chunksSinceTransplant = 0;
while (read < size) {
if (streamInfo.controller.signal.aborted) {
throw new Error("controller aborted");
@@ -22,6 +22,16 @@ async function* readChunks(streamInfo, size) {
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 received = BigInt(chunk.headers['content-length']);
diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js
index 10d25384..0e6d496a 100644
--- a/api/src/stream/manage.js
+++ b/api/src/stream/manage.js
@@ -9,6 +9,7 @@ import { env } from "../config.js";
import { closeRequest } from "./shared.js";
import { decryptStream, encryptStream } from "../misc/crypto.js";
import { hashHmac } from "../security/secrets.js";
+import { zip } from "../misc/utils.js";
// optional dependency
const freebind = env.freebindCIDR && await import('freebind').catch(() => {});
@@ -40,6 +41,7 @@ export function createStream(obj) {
audioFormat: obj.audioFormat,
isHLS: obj.isHLS || false,
+ originalRequest: obj.originalRequest
};
// FIXME: this is now a Promise, but it is not awaited
@@ -110,6 +112,7 @@ export function createInternalStream(url, obj = {}) {
controller,
dispatcher,
isHLS: obj.isHLS,
+ transplant: obj.transplant
});
let streamLink = new URL('/itunnel', `http://127.0.0.1:${env.tunnelPort}`);
@@ -125,13 +128,17 @@ export function createInternalStream(url, obj = {}) {
return streamLink.toString();
}
-export function destroyInternalStream(url) {
+function getInternalTunnelId(url) {
url = new URL(url);
if (url.hostname !== '127.0.0.1') {
return;
}
- const id = url.searchParams.get('id');
+ return url.searchParams.get('id');
+}
+
+export function destroyInternalStream(url) {
+ const id = getInternalTunnelId(url);
if (internalStreamCache.has(id)) {
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) {
const url = streamInfo.urls;
+ if (streamInfo.originalRequest) {
+ streamInfo.transplant = transplantTunnel.bind(streamInfo);
+ }
+
if (typeof url === 'string') {
streamInfo.urls = createInternalStream(url, streamInfo);
} else if (Array.isArray(url)) {
diff --git a/api/src/stream/stream.js b/api/src/stream/stream.js
index 6de52793..e714f38e 100644
--- a/api/src/stream/stream.js
+++ b/api/src/stream/stream.js
@@ -10,7 +10,7 @@ export default async function(res, streamInfo) {
return await stream.proxy(streamInfo, res);
case "internal":
- return await internalStream(streamInfo, res);
+ return await internalStream(streamInfo.data, res);
case "merge":
return await stream.merge(streamInfo, res);
diff --git a/api/src/util/tests/bsky.json b/api/src/util/tests/bsky.json
index 840f1169..6e1d6b2b 100644
--- a/api/src/util/tests/bsky.json
+++ b/api/src/util/tests/bsky.json
@@ -54,7 +54,25 @@
"params": {},
"expected": {
"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"
}
},
{
diff --git a/api/src/util/tests/pinterest.json b/api/src/util/tests/pinterest.json
index 2f15fb0b..6308adb4 100644
--- a/api/src/util/tests/pinterest.json
+++ b/api/src/util/tests/pinterest.json
@@ -54,7 +54,7 @@
"params": {},
"expected": {
"code": 200,
- "status": "redirect"
+ "status": "tunnel"
}
},
{
@@ -63,7 +63,7 @@
"params": {},
"expected": {
"code": 200,
- "status": "redirect"
+ "status": "tunnel"
}
},
{
@@ -72,7 +72,7 @@
"params": {},
"expected": {
"code": 200,
- "status": "redirect"
+ "status": "tunnel"
}
},
{
@@ -81,7 +81,7 @@
"params": {},
"expected": {
"code": 200,
- "status": "redirect"
+ "status": "tunnel"
}
}
]
\ No newline at end of file
diff --git a/api/src/util/tests/xiaohongshu.json b/api/src/util/tests/xiaohongshu.json
new file mode 100644
index 00000000..425a6a2e
--- /dev/null
+++ b/api/src/util/tests/xiaohongshu.json
@@ -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"
+ }
+ }
+]
diff --git a/docs/api.md b/docs/api.md
index 6da93a44..fb1a1450 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -68,7 +68,7 @@ Content-Type: application/json
| `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`. |
| `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 |
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9ce1c962..007ac4be 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,8 +56,8 @@ importers:
specifier: 1.0.3
version: 1.0.3
youtubei.js:
- specifier: ^12.2.0
- version: 12.2.0
+ specifier: ^13.0.0
+ version: 13.0.0
zod:
specifier: ^3.23.8
version: 3.23.8
@@ -2513,8 +2513,8 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- youtubei.js@12.2.0:
- resolution: {integrity: sha512-G+50qrbJCToMYhu8jbaHiS3Vf+RRul+CcDbz3hEGwHkGPh+zLiWwD6SS+YhYF+2/op4ZU5zDYQJrGqJ+wKh7Gw==}
+ youtubei.js@13.0.0:
+ resolution: {integrity: sha512-b1QkN9bfgphK+5tI4qteSK54kNxmPhoedvMw0jl4uSn+L8gbDbJ4z52amNuYNcOdp4X/SI3JuUb+f5V0DPJ8Vw==}
zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
@@ -4694,7 +4694,7 @@ snapshots:
yocto-queue@0.1.0: {}
- youtubei.js@12.2.0:
+ youtubei.js@13.0.0:
dependencies:
'@bufbuild/protobuf': 2.1.0
jintr: 3.2.0
diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json
index fa8d8e0f..f47ab405 100644
--- a/web/i18n/en/settings.json
+++ b/web/i18n/en/settings.json
@@ -40,9 +40,9 @@
"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.tiktok.h265": "tiktok",
- "video.tiktok.h265.title": "prefer HEVC/H265 format",
- "video.tiktok.h265.description": "allows downloading videos in 1080p at cost of compatibility.",
+ "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.",
"audio.format": "audio format",
"audio.format.best": "best",
diff --git a/web/package.json b/web/package.json
index b2f86dc6..5c220c13 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@imput/cobalt-web",
- "version": "10.5.1",
+ "version": "10.6",
"type": "module",
"private": true,
"scripts": {
diff --git a/web/src/components/save/Omnibox.svelte b/web/src/components/save/Omnibox.svelte
index a496c1fa..13797298 100644
--- a/web/src/components/save/Omnibox.svelte
+++ b/web/src/components/save/Omnibox.svelte
@@ -11,6 +11,7 @@
import dialogs from "$lib/state/dialogs";
import { link } from "$lib/state/omnibox";
import { updateSetting } from "$lib/state/settings";
+ import { pasteLinkFromClipboard } from "$lib/clipboard";
import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile";
import type { Optional } from "$lib/types/generic";
@@ -41,7 +42,7 @@
const validLink = (url: string) => {
try {
- return /^https:/i.test(new URL(url).protocol);
+ return /^https?\:/i.test(new URL(url).protocol);
} catch {}
};
@@ -59,22 +60,24 @@
goto("/", { replaceState: true });
}
- const pasteClipboard = () => {
+ const pasteClipboard = async () => {
if ($dialogs.length > 0 || isDisabled || isLoading) {
return;
}
- navigator.clipboard.readText().then(async (text: string) => {
- let matchLink = text.match(/https:\/\/[^\s]+/g);
- if (matchLink) {
- $link = matchLink[0];
+ const pastedData = await pasteLinkFromClipboard();
+ if (!pastedData) return;
- if (!isBotCheckOngoing) {
- await tick(); // wait for button to render
- downloadButton.download($link);
- }
+ const linkMatch = pastedData.match(/https?\:\/\/[^\s]+/g);
+
+ if (linkMatch) {
+ $link = linkMatch[0].split(',')[0];
+
+ if (!isBotCheckOngoing) {
+ await tick(); // wait for button to render
+ downloadButton.download($link);
}
- });
+ }
};
const changeDownloadMode = (mode: DownloadModeOption) => {
diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts
new file mode 100644
index 00000000..221d17ae
--- /dev/null
+++ b/web/src/lib/clipboard.ts
@@ -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;
+ }
+ }
+ }
+}
diff --git a/web/src/routes/settings/video/+page.svelte b/web/src/routes/settings/video/+page.svelte
index 9897d7eb..88f9a5ca 100644
--- a/web/src/routes/settings/video/+page.svelte
+++ b/web/src/routes/settings/video/+page.svelte
@@ -69,6 +69,15 @@
/>
+
+
+
+
-
-
-