Files
capcut-mate/desktop-client/nodeapi/resumableDownload.js
2026-08-18 14:01:29 +08:00

272 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const path = require("path");
const axios = require("axios");
const { createWriteStream } = require("fs");
const fs = require("fs").promises;
const logger = require("./logger");
/** 等待响应头的时间,与原 axios timeout 对齐 */
const HEADER_TIMEOUT_MS = 30000;
/** 传输过程中无数据则中断,便于上层重试续传;不限制总下载时长 */
const STREAM_IDLE_TIMEOUT_MS = 60000;
/** 小于该尺寸的半成品不值得发 Range按整文件重下 */
const MIN_PARTIAL_SIZE = 1024;
const DEFAULT_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
Accept: "*/*",
"Accept-Encoding": "identity",
"Cache-Control": "no-cache",
};
async function getLocalFileSize(filePath) {
if (!filePath) return 0;
try {
const st = await fs.stat(filePath);
return st.isFile() ? st.size : 0;
} catch {
return 0;
}
}
function parseContentRange(contentRange) {
if (!contentRange) return { start: null, end: null, total: null };
const match = /^bytes\s+(?:(\d+)-(\d+)|\*)\/(\d+|\*)$/i.exec(
String(contentRange).trim()
);
if (!match) return { start: null, end: null, total: null };
return {
start: match[1] != null ? Number(match[1]) : null,
end: match[2] != null ? Number(match[2]) : null,
total: match[3] === "*" ? null : Number(match[3]),
};
}
function parseContentLength(headers) {
const raw = headers?.["content-length"];
if (raw == null || raw === "") return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function destroyStream(stream) {
if (!stream) return;
try {
stream.destroy();
} catch {
// ignore
}
}
async function requestDownloadStream(fileUrl, headers) {
const controller = new AbortController();
const headerTimer = setTimeout(() => controller.abort(), HEADER_TIMEOUT_MS);
try {
return await axios({
method: "GET",
url: fileUrl,
responseType: "stream",
timeout: 0,
maxContentLength: Infinity,
maxBodyLength: Infinity,
maxRedirects: 5,
decompress: false,
signal: controller.signal,
headers,
validateStatus: (status) =>
status === 200 || status === 206 || status === 416,
});
} catch (error) {
if (error.code === "ERR_CANCELED" || error.name === "CanceledError" || error.name === "AbortError") {
throw new Error("[error] download stream error: header timeout");
}
throw error;
} finally {
clearTimeout(headerTimer);
}
}
function writeStreamToFile(readable, filePath, { flags, startSize, expectedSize }) {
return new Promise((resolve, reject) => {
const writer = createWriteStream(filePath, { flags, mode: 0o666 });
let received = startSize;
let settled = false;
let idleTimer;
const cleanup = () => {
clearTimeout(idleTimer);
};
const fail = (err) => {
if (settled) return;
settled = true;
cleanup();
destroyStream(readable);
writer.destroy();
reject(err);
};
const resetIdle = () => {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
fail(new Error("[error] download stream error: idle timeout"));
}, STREAM_IDLE_TIMEOUT_MS);
};
resetIdle();
readable.on("data", (chunk) => {
received += chunk.length;
resetIdle();
});
writer.on("close", () => {
if (settled) return;
cleanup();
if (expectedSize != null && received !== expectedSize) {
settled = true;
reject(
new Error(
`[error] download stream error: incomplete file ${received}/${expectedSize}`
)
);
return;
}
settled = true;
resolve(received);
});
writer.on("error", (err) => {
fail(new Error(`[error] write file failed: ${err.message}`));
});
readable.on("error", (err) => {
fail(new Error(`[error] download stream error: ${err.message}`));
});
readable.pipe(writer);
});
}
async function moveIfNeeded(fromPath, toPath) {
if (!toPath || path.normalize(fromPath) === path.normalize(toPath)) {
return fromPath;
}
await fs.mkdir(path.dirname(toPath), { recursive: true });
await fs.unlink(toPath).catch(() => {});
await fs.rename(fromPath, toPath);
return toPath;
}
async function finalizeDestPath(filePath, responseHeaders, resolveDestPath) {
if (typeof resolveDestPath !== "function") return filePath;
const finalPath = resolveDestPath(responseHeaders);
if (!finalPath) return filePath;
return moveIfNeeded(filePath, finalPath);
}
/**
* 下载二进制资源:有半成品且服务端返回 206 时断点续传;
* 服务端忽略 Range 返回 200 时回退为整文件覆盖(原下载模式)。
* 中断时保留半成品,供上层重试接着下。
*
* @param {string} fileUrl
* @param {{ destPath: string, resolveDestPath?: (headers: object) => string, requestHeaders?: object }} options
* @returns {Promise<string>} 最终文件路径
*/
async function downloadBinaryToFile(fileUrl, options = {}) {
const { destPath, resolveDestPath = null, requestHeaders = {} } = options;
if (!destPath) {
throw new Error("[error] download dest path is required");
}
const targetPath = destPath;
const existingSize = await getLocalFileSize(targetPath);
const canResume = existingSize >= MIN_PARTIAL_SIZE;
const headers = {
...DEFAULT_HEADERS,
...requestHeaders,
Accept: "*/*",
"Accept-Encoding": "identity",
"Cache-Control": "no-cache",
};
if (canResume) {
headers.Range = `bytes=${existingSize}-`;
logger.info(`[log] resume download from byte ${existingSize}: ${fileUrl}`);
}
const response = await requestDownloadStream(fileUrl, headers);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
if (response.status === 416) {
const total = parseContentRange(response.headers["content-range"]).total;
destroyStream(response.data);
const size = await getLocalFileSize(targetPath);
if (total != null && size === total) {
logger.info(`[log] local file already complete: ${targetPath}`);
return finalizeDestPath(targetPath, response.headers, resolveDestPath);
}
await fs.unlink(targetPath).catch(() => {});
throw new Error(
"[error] download stream error: range not satisfiable, will restart"
);
}
if (response.status !== 200 && response.status !== 206) {
destroyStream(response.data);
throw new Error(
`[error] [stream] request failed, status code: ${response.status}`
);
}
const contentRange = parseContentRange(response.headers["content-range"]);
const contentLength = parseContentLength(response.headers);
const rangeStart = contentRange.start;
const rangeMatches =
rangeStart == null || rangeStart === existingSize;
let flags = "w";
let startSize = 0;
let expectedSize = contentLength;
if (response.status === 206 && canResume && rangeMatches) {
flags = "a";
startSize = existingSize;
expectedSize = contentRange.total;
logger.info(
`[log] server supports resume (HTTP 206), append to ${targetPath}`
);
} else if (response.status === 206 && !canResume && (rangeStart == null || rangeStart === 0)) {
expectedSize = contentRange.total ?? contentLength;
logger.info(`[log] received HTTP 206 from byte 0, write full file: ${targetPath}`);
} else if (response.status === 206) {
destroyStream(response.data);
await fs.unlink(targetPath).catch(() => {});
throw new Error(
"[error] download stream error: resume offset mismatch, will restart"
);
} else {
expectedSize = contentLength;
if (canResume) {
logger.warn(
`[warn] server does not support resume (HTTP 200), fallback to full download: ${fileUrl}`
);
}
}
logger.info(`[log] start create writable stream: ${targetPath}`);
await writeStreamToFile(response.data, targetPath, {
flags,
startSize,
expectedSize,
});
return finalizeDestPath(targetPath, response.headers, resolveDestPath);
}
module.exports = {
downloadBinaryToFile,
};