mirror of
https://github.com/imputnet/cobalt.git
synced 2026-04-20 11:58:09 +00:00
update: cobalt 11.7 (#1535)
This commit is contained in:
commit
9f7953c4c6
6
.github/workflows/test-services.yml
vendored
6
.github/workflows/test-services.yml
vendored
@ -15,6 +15,9 @@ jobs:
|
||||
services: ${{ steps.checkServices.outputs.service_list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- uses: pnpm/action-setup@v4
|
||||
- id: checkServices
|
||||
run: pnpm i --frozen-lockfile && echo "service_list=$(node api/src/util/test get-services)" >> "$GITHUB_OUTPUT"
|
||||
@ -29,6 +32,9 @@ jobs:
|
||||
name: "test service: ${{ matrix.service }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- uses: pnpm/action-setup@v4
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: node api/src/util/test run-tests-for ${{ matrix.service }}
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@ -10,6 +10,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Check that lockfile does not need an update
|
||||
run: pnpm install --frozen-lockfile
|
||||
@ -32,5 +35,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- uses: pnpm/action-setup@v4
|
||||
- run: .github/test.sh api
|
||||
|
||||
@ -37,7 +37,6 @@ if the desired service isn't supported yet, feel free to create an appropriate i
|
||||
| twitter/x | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||
| vimeo | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| vk videos & clips | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||
| xiaohongshu | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||
| youtube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
| emoji | meaning |
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@imput/cobalt-api",
|
||||
"description": "save what you love",
|
||||
"version": "11.5",
|
||||
"version": "11.7",
|
||||
"author": "imput",
|
||||
"exports": "./src/cobalt.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/cobalt",
|
||||
@ -23,28 +23,29 @@
|
||||
},
|
||||
"homepage": "https://github.com/imputnet/cobalt#readme",
|
||||
"dependencies": {
|
||||
"@datastructures-js/priority-queue": "^6.3.1",
|
||||
"@datastructures-js/priority-queue": "^6.3.5",
|
||||
"@imput/psl": "^2.0.4",
|
||||
"@imput/version-info": "workspace:^",
|
||||
"content-disposition-header": "0.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"ffmpeg-static": "^5.1.0",
|
||||
"hls-parser": "^0.10.7",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"mime": "^4.0.4",
|
||||
"nanoid": "^5.0.9",
|
||||
"set-cookie-parser": "2.6.0",
|
||||
"undici": "^6.21.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.22.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"hls-parser": "^0.16.0",
|
||||
"ipaddr.js": "2.3.0",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"mime": "^4.1.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"set-cookie-parser": "2.7.2",
|
||||
"undici": "^6.24.1",
|
||||
"url-pattern": "1.0.3",
|
||||
"youtubei.js": "15.1.1",
|
||||
"zod": "^3.23.8"
|
||||
"youtubei.js": "17.0.1",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"freebind": "^0.2.2",
|
||||
"rate-limit-redis": "^4.2.0",
|
||||
"redis": "^4.7.0"
|
||||
"freebind": "^0.2.3",
|
||||
"rate-limit-redis": "^4.3.1",
|
||||
"redis": "^5.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,6 +121,7 @@ export const loadEnvs = (env = process.env) => {
|
||||
ytSessionReloadInterval: 300,
|
||||
ytSessionInnertubeClient: env.YOUTUBE_SESSION_INNERTUBE_CLIENT,
|
||||
ytAllowBetterAudio: env.YOUTUBE_ALLOW_BETTER_AUDIO !== "0",
|
||||
ytPlayerIds: env.YOUTUBE_PLAYER_ID?.split(',')?.map(p => p.trim()),
|
||||
|
||||
// "never" | "session" | "always"
|
||||
forceLocalProcessing: env.FORCE_LOCAL_PROCESSING ?? "never",
|
||||
|
||||
@ -9,6 +9,10 @@ const defaultAgent = new Agent();
|
||||
let session;
|
||||
|
||||
const validateSession = (sessionResponse) => {
|
||||
sessionResponse.visitor_data ??= sessionResponse.contentBinding;
|
||||
sessionResponse.potoken ??= sessionResponse.poToken;
|
||||
sessionResponse.updated ??= new Date().getTime();
|
||||
|
||||
if (!sessionResponse.potoken) {
|
||||
throw "no poToken in session response";
|
||||
}
|
||||
@ -33,11 +37,11 @@ const updateSession = (newSession) => {
|
||||
|
||||
const loadSession = async () => {
|
||||
const sessionServerUrl = new URL(env.ytSessionServer);
|
||||
sessionServerUrl.pathname = "/token";
|
||||
sessionServerUrl.pathname = "/get_pot";
|
||||
|
||||
const newSession = await fetch(
|
||||
sessionServerUrl,
|
||||
{ dispatcher: defaultAgent }
|
||||
{ method: 'POST', dispatcher: defaultAgent }
|
||||
).then(a => a.json());
|
||||
|
||||
validateSession(newSession);
|
||||
|
||||
@ -103,7 +103,6 @@ export default function({
|
||||
case "twitter":
|
||||
case "snapchat":
|
||||
case "bsky":
|
||||
case "xiaohongshu":
|
||||
params = { picker: r.picker };
|
||||
break;
|
||||
|
||||
@ -145,6 +144,7 @@ export default function({
|
||||
params = { type: r.type };
|
||||
break;
|
||||
|
||||
case "rutube":
|
||||
case "vimeo":
|
||||
if (Array.isArray(r.urls)) {
|
||||
params = { type: "merge" };
|
||||
@ -179,7 +179,6 @@ export default function({
|
||||
break;
|
||||
|
||||
case "ok":
|
||||
case "xiaohongshu":
|
||||
case "newgrounds":
|
||||
params = { type: "proxy" };
|
||||
break;
|
||||
|
||||
@ -28,7 +28,6 @@ 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";
|
||||
import newgrounds from "./services/newgrounds.js";
|
||||
|
||||
let freebind;
|
||||
@ -260,15 +259,6 @@ export default async function({ host, patternMatch, params, authType }) {
|
||||
});
|
||||
break;
|
||||
|
||||
case "xiaohongshu":
|
||||
r = await xiaohongshu({
|
||||
...patternMatch,
|
||||
h265: params.allowH265,
|
||||
isAudioOnly,
|
||||
dispatcher,
|
||||
});
|
||||
break;
|
||||
|
||||
case "newgrounds":
|
||||
r = await newgrounds({
|
||||
...patternMatch,
|
||||
|
||||
@ -205,14 +205,6 @@ export const services = {
|
||||
subdomains: ["m"],
|
||||
altDomains: ["vkvideo.ru", "vk.ru"],
|
||||
},
|
||||
xiaohongshu: {
|
||||
patterns: [
|
||||
"explore/:id?xsec_token=:token",
|
||||
"discovery/item/:id?xsec_token=:token",
|
||||
":shareType/:shareId",
|
||||
],
|
||||
altDomains: ["xhslink.com"],
|
||||
},
|
||||
youtube: {
|
||||
patterns: [
|
||||
"watch?v=:id",
|
||||
|
||||
@ -81,10 +81,6 @@ export const testers = {
|
||||
(pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) ||
|
||||
(pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18),
|
||||
|
||||
"xiaohongshu": pattern =>
|
||||
pattern.id?.length <= 24 && pattern.token?.length <= 64 ||
|
||||
pattern.shareId?.length <= 24 && pattern.shareType?.length === 1,
|
||||
|
||||
"youtube": pattern =>
|
||||
pattern.id?.length <= 11,
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { genericUserAgent, env } from "../../config.js";
|
||||
import { env } from "../../config.js";
|
||||
import { resolveRedirectingURL } from "../url.js";
|
||||
|
||||
// TO-DO: higher quality downloads (currently requires an account)
|
||||
@ -26,7 +26,7 @@ async function com_download(id, partId) {
|
||||
|
||||
const html = await fetch(url, {
|
||||
headers: {
|
||||
"user-agent": genericUserAgent
|
||||
"user-agent": "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
|
||||
}
|
||||
})
|
||||
.then(r => r.text())
|
||||
|
||||
@ -24,9 +24,9 @@ const resolveUrl = (url, dispatcher) => {
|
||||
}
|
||||
|
||||
export default async function({ id, shareType, shortLink, dispatcher }) {
|
||||
let url = `https://web.facebook.com/i/videos/${id}`;
|
||||
let url = `https://www.facebook.com/reel/${id}`;
|
||||
|
||||
if (shareType) url = `https://web.facebook.com/share/${shareType}/${id}`;
|
||||
if (shareType) url = `https://www.facebook.com/share/${shareType}/${id}`;
|
||||
if (shortLink) url = await resolveUrl(`https://fb.watch/${shortLink}`, dispatcher);
|
||||
|
||||
const html = await fetch(url, { headers, dispatcher })
|
||||
|
||||
@ -13,7 +13,7 @@ const delta = (a, b) => Math.abs(a - b);
|
||||
export default async function(obj) {
|
||||
if (obj.yappyId) {
|
||||
const yappy = await requestJSON(
|
||||
`https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`
|
||||
`https://rutube.ru/pangolin/api/web/yappy/v4/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=1`
|
||||
)
|
||||
const yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link;
|
||||
if (!yappyURL) return { error: "fetch.empty" };
|
||||
|
||||
@ -15,24 +15,27 @@ async function findClientID() {
|
||||
return cachedID.id;
|
||||
}
|
||||
|
||||
const scripts = sc.matchAll(/<script.+src="(.+)">/g);
|
||||
let clientid = sc.match(/"hydratable"\s*:\s*"apiClient"\s*,\s*"data"\s*:\s*\{\s*"id"\s*:\s*"([^"]+)"/)?.[1];
|
||||
if (!clientid) {
|
||||
const scripts = sc.matchAll(/<script.+src="(.+)">/g);
|
||||
|
||||
let clientid;
|
||||
for (let script of scripts) {
|
||||
const url = script[1];
|
||||
for (let script of scripts) {
|
||||
const url = script[1];
|
||||
|
||||
if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
|
||||
return;
|
||||
}
|
||||
if (!url?.startsWith('https://a-v2.sndcdn.com/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrf = await fetch(url).then(r => r.text()).catch(() => {});
|
||||
const id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
|
||||
const scrf = await fetch(url).then(r => r.text()).catch(() => {});
|
||||
const id = scrf.match(/,client_id:"([A-Za-z0-9]{32})",/);
|
||||
|
||||
if (id && typeof id[0] === 'string') {
|
||||
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
|
||||
break;
|
||||
if (id && id.length >= 2) {
|
||||
clientid = id[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedID.version = scVersion;
|
||||
cachedID.id = clientid;
|
||||
|
||||
|
||||
@ -37,9 +37,19 @@ function needsFixing(media) {
|
||||
}
|
||||
|
||||
function bestQuality(arr) {
|
||||
return arr.filter(v => v.content_type === "video/mp4")
|
||||
return stripVideoURL(
|
||||
arr.filter(v => v.content_type === "video/mp4")
|
||||
.reduce((a, b) => Number(a?.bitrate) > Number(b?.bitrate) ? a : b)
|
||||
.url
|
||||
);
|
||||
}
|
||||
|
||||
function stripVideoURL(maybeUrl) {
|
||||
if (maybeUrl) {
|
||||
const url = new URL(maybeUrl);
|
||||
url.searchParams.delete('tag');
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
let _cachedToken;
|
||||
|
||||
@ -2,14 +2,15 @@ import { env } from "../../config.js";
|
||||
|
||||
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"];
|
||||
|
||||
const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token";
|
||||
const apiUrl = "https://api.vk.com/method";
|
||||
const authUrl = "https://api.vk.ru/method/auth.getAnonymToken";
|
||||
const videoApiUrl = "https://api.vkvideo.ru/method/video.get";
|
||||
|
||||
const clientId = "51552953";
|
||||
const clientSecret = "qgr0yWwXCrsxA1jnRtRX";
|
||||
const clientVersion = "5.274";
|
||||
|
||||
// used in stream/shared.js for accessing media files
|
||||
export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119";
|
||||
export const vkClientAgent = "com.vk.vkvideo.prod/1955 (iPhone, iOS 16.7.15, iPhone10,4, Scale/2.0) SAK/1.135";
|
||||
|
||||
const cachedToken = {
|
||||
token: "",
|
||||
@ -24,10 +25,11 @@ const getToken = async () => {
|
||||
|
||||
const randomDeviceId = crypto.randomUUID().toUpperCase();
|
||||
|
||||
const anonymOauth = new URL(oauthUrl);
|
||||
const anonymOauth = new URL(authUrl);
|
||||
anonymOauth.searchParams.set("client_id", clientId);
|
||||
anonymOauth.searchParams.set("client_secret", clientSecret);
|
||||
anonymOauth.searchParams.set("device_id", randomDeviceId);
|
||||
anonymOauth.searchParams.set("v", clientVersion);
|
||||
|
||||
const oauthResponse = await fetch(anonymOauth.toString(), {
|
||||
headers: {
|
||||
@ -39,11 +41,13 @@ const getToken = async () => {
|
||||
}
|
||||
});
|
||||
|
||||
if (!oauthResponse) return;
|
||||
if (!oauthResponse || !oauthResponse.response) return;
|
||||
|
||||
if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") {
|
||||
cachedToken.token = oauthResponse.token;
|
||||
cachedToken.expiry = oauthResponse.expired_at;
|
||||
const res = oauthResponse.response;
|
||||
|
||||
if (res.token && res.expired_at && typeof res.expired_at === "number") {
|
||||
cachedToken.token = res.token;
|
||||
cachedToken.expiry = res.expired_at;
|
||||
cachedToken.device_id = randomDeviceId;
|
||||
}
|
||||
|
||||
@ -53,7 +57,7 @@ const getToken = async () => {
|
||||
}
|
||||
|
||||
const getVideo = async (ownerId, videoId, accessKey) => {
|
||||
const video = await fetch(`${apiUrl}/video.get`, {
|
||||
const video = await fetch(videoApiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
@ -63,7 +67,7 @@ const getVideo = async (ownerId, videoId, accessKey) => {
|
||||
anonymous_token: cachedToken.token,
|
||||
device_id: cachedToken.device_id,
|
||||
lang: "en",
|
||||
v: "5.244",
|
||||
v: clientVersion,
|
||||
videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`
|
||||
}).toString()
|
||||
})
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
import { resolveRedirectingURL } from "../url.js";
|
||||
import { genericUserAgent } from "../../config.js";
|
||||
import { createStream } from "../../stream/manage.js";
|
||||
|
||||
const https = (url) => {
|
||||
return url.replace(/^http:/i, 'https:');
|
||||
}
|
||||
|
||||
export default async function ({ id, token, shareType, shareId, h265, isAudioOnly, dispatcher }) {
|
||||
let noteId = id;
|
||||
let xsecToken = token;
|
||||
|
||||
if (!noteId) {
|
||||
const patternMatch = await resolveRedirectingURL(
|
||||
`https://xhslink.com/${shareType}/${shareId}`,
|
||||
dispatcher
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
@ -1,12 +1,27 @@
|
||||
import HLS from "hls-parser";
|
||||
import ivm from "isolated-vm";
|
||||
|
||||
import { fetch } from "undici";
|
||||
import { Innertube, Session } from "youtubei.js";
|
||||
import { fetch, Request } from "undici";
|
||||
import { Innertube, Platform, Session } from "youtubei.js";
|
||||
|
||||
import { env } from "../../config.js";
|
||||
import { getCookie } from "../cookie/manager.js";
|
||||
import { getYouTubeSession } from "../helpers/youtube-session.js";
|
||||
|
||||
// https://github.com/LuanRT/YouTube.js/pull/1052
|
||||
Platform.shim.eval = async (data) => {
|
||||
const isolate = new ivm.Isolate();
|
||||
|
||||
try {
|
||||
const context = await isolate.createContext();
|
||||
const code = `(() => { ${data.output} })()`;
|
||||
const script = await isolate.compileScript(code);
|
||||
return await script.run(context, { copy: true, timeout: 5000 });
|
||||
} finally {
|
||||
isolate.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const PLAYER_REFRESH_PERIOD = 1000 * 60 * 15; // ms
|
||||
|
||||
let innertube, lastRefreshedAt;
|
||||
@ -60,12 +75,20 @@ const cloneInnertube = async (customFetch, useSession) => {
|
||||
}
|
||||
|
||||
if (!innertube || shouldRefreshPlayer) {
|
||||
let player_id;
|
||||
if (env.ytPlayerIds) {
|
||||
player_id = env.ytPlayerIds[
|
||||
Math.floor(Math.random() * env.ytPlayerIds.length)
|
||||
];
|
||||
}
|
||||
|
||||
innertube = await Innertube.create({
|
||||
fetch: customFetch,
|
||||
retrieve_player,
|
||||
cookie,
|
||||
po_token: useSession ? sessionTokens?.potoken : undefined,
|
||||
visitor_data: useSession ? sessionTokens?.visitor_data : undefined,
|
||||
player_id,
|
||||
});
|
||||
lastRefreshedAt = +new Date();
|
||||
}
|
||||
@ -206,10 +229,24 @@ export default async function (o) {
|
||||
let yt;
|
||||
try {
|
||||
yt = await cloneInnertube(
|
||||
(input, init) => fetch(input, {
|
||||
...init,
|
||||
dispatcher: o.dispatcher
|
||||
}),
|
||||
(input, init) => {
|
||||
const url = typeof input === 'string'
|
||||
? new URL(input)
|
||||
: input instanceof URL
|
||||
? input
|
||||
: new URL(input.url);
|
||||
|
||||
const request = new Request(
|
||||
url,
|
||||
input instanceof Platform.shim.Request
|
||||
? input : undefined
|
||||
);
|
||||
|
||||
return fetch(request, {
|
||||
...init,
|
||||
dispatcher: o.dispatcher
|
||||
});
|
||||
},
|
||||
useSession
|
||||
);
|
||||
} catch (e) {
|
||||
@ -529,7 +566,7 @@ export default async function (o) {
|
||||
}
|
||||
|
||||
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
|
||||
urls = audio.decipher(innertube.session.player);
|
||||
urls = await audio.decipher(innertube.session.player);
|
||||
}
|
||||
|
||||
let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
|
||||
@ -576,8 +613,8 @@ export default async function (o) {
|
||||
filenameAttributes.extension = o.container === "auto" ? codecList[codec].container : o.container;
|
||||
|
||||
if (!clientsWithNoCipher.includes(innertubeClient) && innertube) {
|
||||
video = video.decipher(innertube.session.player);
|
||||
audio = audio.decipher(innertube.session.player);
|
||||
video = await video.decipher(innertube.session.player);
|
||||
audio = await audio.decipher(innertube.session.player);
|
||||
} else {
|
||||
video = video.url;
|
||||
audio = audio.url;
|
||||
|
||||
@ -97,12 +97,6 @@ function aliasURL(url) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "xhslink":
|
||||
if (url.hostname === 'xhslink.com' && parts.length === 3) {
|
||||
url = new URL(`https://www.xiaohongshu.com/${parts[1]}/${parts[2]}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "loom":
|
||||
const idPart = parts[parts.length - 1];
|
||||
if (idPart.length > 32) {
|
||||
@ -158,11 +152,6 @@ function cleanURL(url) {
|
||||
limitQuery('post_id');
|
||||
}
|
||||
break;
|
||||
case "xiaohongshu":
|
||||
if (url.searchParams.get('xsec_token')) {
|
||||
limitQuery('xsec_token');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (stripQuery) {
|
||||
@ -215,7 +204,7 @@ export function extract(url, enabledServices = env.enabledServices) {
|
||||
// show a different message when youtube is disabled on official instances
|
||||
// as it only happens when shit hits the fan
|
||||
if (new URL(env.apiURL).hostname.endsWith(".imput.net") && host === "youtube") {
|
||||
return { error: "youtube.temporary_disabled" };
|
||||
return { error: "youtube.disabled_main_instance" };
|
||||
}
|
||||
return { error: "service.disabled" };
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
},
|
||||
{
|
||||
"name": "bilibili.com link with part id",
|
||||
"url": "https://www.bilibili.com/video/BV1uo4y1K72s?spm_id_from=333.788.videopod.episodes&p=6",
|
||||
"url": "https://www.bilibili.com/video/BV1bK411W797?p=3&spm_id_from=333.788.videopod.episodes",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
|
||||
@ -26,15 +26,6 @@
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shortlink video",
|
||||
"url": "https://fb.watch/r1K6XHMfGT/",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reel video",
|
||||
"url": "https://web.facebook.com/reel/730293269054758",
|
||||
@ -46,15 +37,6 @@
|
||||
},
|
||||
{
|
||||
"name": "shared video link",
|
||||
"url": "https://www.facebook.com/share/v/6EJK4Z8EAEAHtz8K/",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shared video link v2",
|
||||
"url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/",
|
||||
"params": {},
|
||||
"expected": {
|
||||
|
||||
@ -19,15 +19,6 @@
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "russian region lock",
|
||||
"url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 400,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vertical video",
|
||||
"url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",
|
||||
@ -88,7 +79,17 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "region locked video, should fail",
|
||||
"name": "russian region lock",
|
||||
"url": "https://rutube.ru/video/b521653b4f71ece57b8ff54e57ca9b82/",
|
||||
"canFail": true,
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 400,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "region locked video",
|
||||
"canFail": true,
|
||||
"url": "https://rutube.ru/video/e7ac82708cc22bd068a3bf6a7004d1b1/",
|
||||
"params": {},
|
||||
@ -97,4 +98,4 @@
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@ -8,15 +8,6 @@
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "shortlinked spotlight",
|
||||
"url": "https://t.snapchat.com/4ZsiBLDi",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "story",
|
||||
"url": "https://www.snapchat.com/add/bazerkmakane",
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
},
|
||||
{
|
||||
"name": "tumblr audio",
|
||||
"url": "https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share",
|
||||
"url": "https://www.tumblr.com/firebuug/770611154490998784/bing",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "video (might have expired)",
|
||||
"url": "https://www.xiaohongshu.com/explore/685e63e1000000000b02ee3b?xsec_token=ABN8EQJCDMPcFX9RRggeIPSHLIJ8zkGceFDyBewLGUz30=",
|
||||
"canFail": true,
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "picker with multiple live photos (might have expired)",
|
||||
"url": "https://www.xiaohongshu.com/explore/687128a2000000001203d94c?xsec_token=CBlDi5QDXDWZu2uUmbUrpKwg8lEL3uC10mc59lGf43r9w=",
|
||||
"canFail": true,
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "picker"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "one photo (might have expired)",
|
||||
"url": "https://www.xiaohongshu.com/explore/64726b99000000000800e115?xsec_token=ABoD3qPHqVZolCfS-J8UP9QQaPXZ6Z6PVyODrhaiUg27U=",
|
||||
"canFail": true,
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "short link (might have expired)",
|
||||
"url": "https://xhslink.com/m/2wAnaTkLRc1",
|
||||
"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",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 400,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -68,6 +68,7 @@ this document is not final and will expand over time. feel free to improve it!
|
||||
| YOUTUBE_SESSION_INNERTUBE_CLIENT | `WEB_EMBEDDED` |
|
||||
| YOUTUBE_ALLOW_BETTER_AUDIO | `1` |
|
||||
| ENABLE_DEPRECATED_YOUTUBE_HLS | `key` |
|
||||
| YOUTUBE_PLAYER_ID | `abcdefff` |
|
||||
|
||||
[*view details*](#service-specific)
|
||||
|
||||
@ -271,6 +272,13 @@ innertube client that's compatible with botguard's (web) `poToken` and `visitor_
|
||||
|
||||
the value is a string.
|
||||
|
||||
### YOUTUBE_PLAYER_ID
|
||||
a comma-separated-list of player IDs to use for youtube fetching.
|
||||
if defined, cobalt chooses one of them at each client initialization, otherwise
|
||||
defaults to the current latest player ID.
|
||||
|
||||
the value is a string.
|
||||
|
||||
### YOUTUBE_ALLOW_BETTER_AUDIO
|
||||
when set to `1`, cobalt will try to use higher quality audio if user requests it via `youtubeBetterAudio`. will negatively impact the rate limit of a secondary youtube client with a session.
|
||||
|
||||
|
||||
@ -88,7 +88,7 @@ all keys except for `url` are optional. value options are separated by `/`.
|
||||
| `youtubeVideoContainer` | `string` | `auto / mp4 / webm / mkv` | `auto` |
|
||||
| `youtubeDubLang` | `string` | any valid ISO 639-1 language code | *none* |
|
||||
| `convertGif` | `boolean` | convert twitter gifs to the actual GIF format | `true` |
|
||||
| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok/xiaohongshu | `false` |
|
||||
| `allowH265` | `boolean` | allow H265/HEVC videos from tiktok | `false` |
|
||||
| `tiktokFullAudio` | `boolean` | download the original sound used in a video | `false` |
|
||||
| `youtubeBetterAudio` | `boolean` | prefer higher quality youtube audio if possible | `false` |
|
||||
| `youtubeHLS` | `boolean` | use HLS formats when downloading from youtube | `false` |
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
"author": "imput <meow@imput.net>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"prettier": "3.3.3",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.4.5"
|
||||
"prettier": "3.7.4",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
3290
pnpm-lock.yaml
generated
3290
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -57,7 +57,7 @@
|
||||
"youtube.token_expired": "couldn't get this video because the youtube token expired and wasn't refreshed. try again in a few seconds, but if it still doesn't work, please report this issue!",
|
||||
"youtube.no_hls_streams": "couldn't find any matching HLS streams for this video. try downloading it without HLS!",
|
||||
"youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
|
||||
"youtube.temporary_disabled": "youtube downloading is temporarily disabled due to restrictions from youtube's side. we're already looking for ways to go around them.\n\nwe apologize for the inconvenience and are doing our best to restore this functionality. check cobalt's socials or github for timely updates!",
|
||||
"youtube.disabled_main_instance": "youtube downloading is disabled on the main instance due to restrictions from youtube's side and infinite maintenance cost at scale.\n\nwe apologize for the inconvenience and encourage you to host your own API instance for this.",
|
||||
"youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
|
||||
"youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@
|
||||
|
||||
"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.",
|
||||
"video.h265.description": "allows downloading videos from platforms like tiktok in higher quality at cost of compatibility.",
|
||||
|
||||
"audio.format": "audio format",
|
||||
"audio.format.best": "best",
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
"youtube.api_error": "youtube что-то обновил в своём api, и я не смог получить инфу об этом видео. попробуй ещё раз через пару секунд, но если проблема останется, пожалуйста, сообщи о ней!",
|
||||
"youtube.drm": "это youtube-видео защищено widevine DRM, так что я не могу его скачать. попробуй другую ссылку!",
|
||||
"fetch.rate": "{{ service }} ограничил частоту запросов от инстанса обработки. попробуй ещё раз через пару секунд!",
|
||||
"youtube.temporary_disabled": "скачивание с youtube временно отключено из-за ограничений со стороны youtube. мы уже ищем способы их обойти.\n\nприносим извинения за неудобства и делаем всё возможное, чтобы восстановить эту функциональность. следи за обновлениями в соцсетях или на github!",
|
||||
"youtube.disabled_main_instance": "скачивание с youtube отключено на главном инстансе из-за ограничений со стороны youtube и бесконечных затрат на содержание его поддержки.\n\nприносим извинения за неудобства и советуем хостить свой инстанс для этой цели.",
|
||||
"content.video.age": "это видео ограничено по возрасту, поэтому я не могу получить его анонимно. попробуй ещё раз или попробуй другую ссылку!",
|
||||
"content.region": "этот контент ограничен по региону, а инстанс обработки находится в другом месте. попробуй другую ссылку!",
|
||||
"youtube.no_matching_format": "youtube не вернул ни одного подходящего формата. возможно, кобальт их не поддерживает или же они перекодируются на стороне youtube. попробуй ещё раз чуть позже, а если проблема останется, сообщи о ней!",
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
"audio.bitrate.description": "битрейт применяется только при конвертации аудио в формат с потерями. кобальт не может улучшить качество исходного аудио, поэтому выбор битрейта выше 128 кб/с может увеличить размер файла без заметной разницы в звуке. воспринимаемое качество может различаться в зависимости от формата.",
|
||||
"video.h265": "high efficiency video codec",
|
||||
"video.h265.title": "использовать h265 для видео",
|
||||
"video.h265.description": "позволяет скачивать видео с tiktok и xiaohongshu в более высоком качестве, но с потерей совместимости.",
|
||||
"video.h265.description": "позволяет скачивать видео с платформ как tiktok в более высоком качестве, но с потерей совместимости.",
|
||||
"video.youtube.hls": "форматы hls для youtube",
|
||||
"video.youtube.hls.description": "в этом режиме доступны только кодеки h264 и vp9. оригинальный аудио кодек aac перекодируется для совместимости, поэтому качество аудио может быть хуже чем у варианта без HLS.\n\nэта функция экспериментальна, поэтому может быть убрана или изменена в будущем.",
|
||||
"audio.format.best": "лучший",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@imput/cobalt-web",
|
||||
"version": "11.3",
|
||||
"version": "11.7",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@ -24,36 +24,35 @@
|
||||
},
|
||||
"homepage": "https://cobalt.tools/",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.5.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.0.13",
|
||||
"@fontsource/redaction-10": "^5.0.2",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/redaction-10": "^5.2.5",
|
||||
"@imput/libav.js-encode-cli": "6.8.7",
|
||||
"@imput/libav.js-remux-cli": "^6.8.7",
|
||||
"@imput/version-info": "workspace:^",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.20.7",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tabler/icons-svelte": "3.6.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/fluent-ffmpeg": "^2.1.25",
|
||||
"@types/node": "^20.14.10",
|
||||
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"eslint": "^9.16.0",
|
||||
"glob": "^11.0.0",
|
||||
"mdsvex": "^0.11.2",
|
||||
"mime": "^4.0.4",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"svelte-sitemap": "2.6.0",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^25.0.3",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.4",
|
||||
"glob": "^13.0.6",
|
||||
"mdsvex": "^0.12.6",
|
||||
"mime": "^4.1.0",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"svelte-sitemap": "2.7.1",
|
||||
"sveltekit-i18n": "^2.4.2",
|
||||
"ts-deepmerge": "^7.0.1",
|
||||
"tslib": "^2.4.1",
|
||||
"turnstile-types": "^1.2.2",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.18.0",
|
||||
"vite": "^5.4.4"
|
||||
"ts-deepmerge": "^7.0.3",
|
||||
"tslib": "^2.8.1",
|
||||
"turnstile-types": "^1.2.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,13 +22,16 @@
|
||||
let imageLoaded = $state(false);
|
||||
let hideSkeleton = $state(false);
|
||||
|
||||
let validUrl = false;
|
||||
try {
|
||||
new URL(item.url);
|
||||
validUrl = true;
|
||||
} catch {}
|
||||
const validUrl = $derived.by(() => {
|
||||
try {
|
||||
new URL(item.url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const isTunnel = validUrl && new URL(item.url).pathname === "/tunnel";
|
||||
const isTunnel = $derived(validUrl && new URL(item.url).pathname === "/tunnel");
|
||||
|
||||
const loaded = () => {
|
||||
imageLoaded = true;
|
||||
|
||||
@ -69,7 +69,7 @@
|
||||
if (file) {
|
||||
return openFile(file);
|
||||
} else if (url) {
|
||||
return openURL(url);
|
||||
return openURL(url, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
onImport,
|
||||
}: Props = $props();
|
||||
|
||||
let selectorStringMultiple = maxFileNumber > 1 ? ".multiple" : "";
|
||||
const selectorStringMultiple = $derived(maxFileNumber > 1 ? ".multiple" : "");
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
copyData = "",
|
||||
}: Props = $props();
|
||||
|
||||
const sectionURL = `${page.url.origin}${page.url.pathname}#${sectionId}`;
|
||||
const sectionURL = $derived(`${page.url.origin}${page.url.pathname}#${sectionId}`);
|
||||
|
||||
let copied = $state(false);
|
||||
</script>
|
||||
|
||||
@ -8,7 +8,11 @@
|
||||
|
||||
let { loading }: Props = $props();
|
||||
|
||||
let animated = $state(loading);
|
||||
let animated = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
animated = loading;
|
||||
});
|
||||
|
||||
/*
|
||||
initial spinner state is equal to loading state,
|
||||
|
||||
@ -51,7 +51,7 @@ export const shareFile = async (file: File) => {
|
||||
});
|
||||
}
|
||||
|
||||
export const openURL = (url: string) => {
|
||||
export const openURL = (url: string, hasDialog = false) => {
|
||||
if (!['http:', 'https:'].includes(new URL(url).protocol)) {
|
||||
return alert('error: invalid url!');
|
||||
}
|
||||
@ -59,7 +59,7 @@ export const openURL = (url: string) => {
|
||||
const open = window.open(url, "_blank", "noopener,noreferrer");
|
||||
|
||||
/* if new tab got blocked by user agent, show a saving dialog */
|
||||
if (!open) {
|
||||
if (!open && !hasDialog) {
|
||||
return openSavingDialog({
|
||||
url,
|
||||
body: get(t)("dialog.saving.blocked")
|
||||
|
||||
@ -4,7 +4,7 @@ import { uuid } from "$lib/util";
|
||||
export class MemoryStorage extends AbstractStorage {
|
||||
#chunkSize: number;
|
||||
#actualSize: number = 0;
|
||||
#chunks: Uint8Array[] = [];
|
||||
#chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
|
||||
constructor(chunkSize: number) {
|
||||
super();
|
||||
@ -33,7 +33,7 @@ export class MemoryStorage extends AbstractStorage {
|
||||
async res() {
|
||||
// if we didn't need as much space as we allocated for some reason,
|
||||
// shrink the buffers so that we don't inflate the file with zeroes
|
||||
const outputView: Uint8Array[] = [];
|
||||
const outputView: Uint8Array<ArrayBuffer>[] = [];
|
||||
|
||||
for (let i = 0; i < this.#chunks.length; ++i) {
|
||||
outputView.push(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user