mirror of
https://github.com/wukko/cobalt.git
synced 2025-01-22 02:36:21 +01:00
merge: 10.6 updates
This commit is contained in:
commit
c4c47bdc27
25 changed files with 466 additions and 79 deletions
|
@ -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": {
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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] ]);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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" };
|
||||
|
|
|
@ -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,
|
||||
|
|
116
api/src/processing/services/xiaohongshu.js
Normal file
116
api/src/processing/services/xiaohongshu.js
Normal 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 };
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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']);
|
||||
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
58
api/src/util/tests/xiaohongshu.json
Normal file
58
api/src/util/tests/xiaohongshu.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
|
@ -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. |
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@imput/cobalt-web",
|
||||
"version": "10.5.1",
|
||||
"version": "10.6",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
17
web/src/lib/clipboard.ts
Normal file
17
web/src/lib/clipboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -69,6 +69,15 @@
|
|||
/>
|
||||
</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")}>
|
||||
<SettingsToggle
|
||||
settingContext="save"
|
||||
|
@ -78,11 +87,3 @@
|
|||
/>
|
||||
</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>
|
||||
|
|
Loading…
Reference in a new issue