mirror of
https://github.com/Hommy-master/capcut-mate.git
synced 2026-09-08 16:59:51 +08:00
272 lines
8.0 KiB
JavaScript
272 lines
8.0 KiB
JavaScript
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,
|
||
};
|