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 @@ /> + + + + - - -