diff --git a/api/src/misc/utils.js b/api/src/misc/utils.js
index fd497d188..9978ceed2 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) {
diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js
index 64f86836b..0d87b5a8c 100644
--- a/api/src/processing/match-action.js
+++ b/api/src/processing/match-action.js
@@ -47,7 +47,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
});
case "photo":
- responseType = "redirect";
+ params = { type: "proxy" };
break;
case "gif":
@@ -83,6 +83,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 +144,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 57f04b362..9fabf3793 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 81afaf39f..86352f9ae 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 e8c466399..42f64d264 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/xiaohongshu.js b/api/src/processing/services/xiaohongshu.js
new file mode 100644
index 000000000..bbb53ab19
--- /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/url.js b/api/src/processing/url.js
index 8f0e7dc26..cfbbecc09 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/util/tests/bsky.json b/api/src/util/tests/bsky.json
index 840f1169b..5a03538d7 100644
--- a/api/src/util/tests/bsky.json
+++ b/api/src/util/tests/bsky.json
@@ -54,7 +54,7 @@
"params": {},
"expected": {
"code": 200,
- "status": "redirect"
+ "status": "tunnel"
}
},
{
diff --git a/api/src/util/tests/pinterest.json b/api/src/util/tests/pinterest.json
index 2f15fb0b3..6308adb43 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 000000000..425a6a2ec
--- /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 6da93a44e..fb1a14509 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/web/i18n/en/settings.json b/web/i18n/en/settings.json
index c450b4b9e..418410bf1 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/src/routes/settings/video/+page.svelte b/web/src/routes/settings/video/+page.svelte
index 9897d7eb1..88f9a5cad 100644
--- a/web/src/routes/settings/video/+page.svelte
+++ b/web/src/routes/settings/video/+page.svelte
@@ -69,20 +69,21 @@
/>
-
+
-
+
+