Files
capcut-mate/desktop-client/nodeapi/download.js
T
2025-12-25 20:00:09 +08:00

583 lines
16 KiB
JavaScript

const path = require("path");
const axios = require("axios");
const { app, dialog, shell } = require("electron");
const { createWriteStream } = require("fs");
const fs = require("fs").promises; // 使用 fs.promises 进行异步文件操作
const logger = require("./logger");
const { v4: uuidv4 } = require('uuid');
const RECORD_MAX = 500;
const LOG_MAX = 2000;
const axiosConfig = {
method: "GET",
timeout: 30000, // 30秒超时
headers: {
// 添加常见的浏览器User-Agent
"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",
},
};
function getConfigPath() {
return path.join(app.getPath("userData"), "app-config.json");
}
async function readConfig() {
const configPath = getConfigPath();
logger.info("[log] Config path:", configPath);
try {
const data = await fs.readFile(configPath, "utf8");
return JSON.parse(data) || {};
} catch (error) {
return {};
}
}
async function writeConfig(config) {
const configPath = getConfigPath();
try {
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
return true;
} catch (error) {
logger.error("写入配置文件失败:", error);
return false;
}
}
function getDownloadLogPath() {
return path.join(app.getPath("userData"), "download-log.json");
}
async function readDownloadLog() {
const logPath = getDownloadLogPath();
logger.info("[log] Log path:", logPath);
try {
const data = await fs.readFile(logPath, "utf8");
return JSON.parse(data);
} catch (error) {
return [];
}
}
/**
*
* @param {*} entry {time: Date, level: 'error', message: '日志内容' }
*/
async function appendDownloadLog(entry, parentWindow) {
const logPath = getDownloadLogPath();
let logs = [];
try {
logs = await readDownloadLog();
} catch (error) {
// 如果文件不存在或无法读取,初始化为空数组
logs = [];
}
entry.time = new Date();
console.log(`appendDownloadLog: ${JSON.stringify(entry)}`);
await parentWindow.webContents.send("file-operation-log", entry);
logs.push(entry);
if (logs.length > LOG_MAX) {
logs.shift();
}
try {
await fs.writeFile(logPath, JSON.stringify(logs, null, 2), "utf8");
} catch (writeErr) {
logger.error("写入日志文件失败:", writeErr);
}
}
async function clearDownloadLog() {
const logPath = getDownloadLogPath();
try {
await fs.writeFile(logPath, JSON.stringify([], null, 2), "utf8");
return true;
} catch (error) {
logger.error("清空日志文件失败:", error);
return false;
}
}
function getHistoryRecordPath() {
return path.join(app.getPath("userData"), "history-record.json");
}
async function readHistoryRecord() {
const recordPath = getHistoryRecordPath();
console.info("[History] Record path:", recordPath);
try {
const data = await fs.readFile(recordPath, "utf8");
return JSON.parse(data);
} catch (error) {
return [];
}
}
/**
*
* @param {*} entry {id: 'uuid', time: Date, draft_id: 'draft_id' draft_url: 'draft_url' }
*/
async function appendHistoryRecord(entry) {
const recordPath = getHistoryRecordPath();
let records = [];
try {
records = await readHistoryRecord();
} catch (error) {
// 如果文件不存在或无法读取,初始化为空数组
records = [];
}
console.log(`appendHistoryRecord: ${JSON.stringify(entry)}`);
records.push(entry);
if (records.length > RECORD_MAX) {
records.shift();
}
try {
await fs.writeFile(recordPath, JSON.stringify(records, null, 2), "utf8");
} catch (writeErr) {
console.error("写入草稿历史记录文件失败:", writeErr);
}
}
// 更精确的错误处理
function errorHandler(error = {}, url = "") {
if (error.code === "ECONNREFUSED") {
throw new Error(`[error] not connect to server: ${url}`);
} else if (error.code === "ENOTFOUND") {
throw new Error(`[error] domain not found: ${url}`);
} else if (error.response) {
// 服务器返回了错误状态码(如4xx, 5xx)
throw new Error(`[error] server error (${error.response.status}): ${url}`);
} else {
throw error; // 重新抛出其他未知错误
}
}
async function getDraftUrls(remoteUrl, parentWindow) {
logger.info("[info] get draft url");
try {
const response = await axios({
...axiosConfig,
url: remoteUrl,
responseType: "json",
});
// 检查HTTP状态码
if (response.status !== 200) {
await appendDownloadLog(
{ level: "error", message: `获取草稿地址信息失败` },
parentWindow
);
throw new Error(
`[error] [draft url] request failed, status code: ${response.status}`
);
}
logger.info("[success] get draft url");
return response.data;
} catch (error) {
errorHandler(error, remoteUrl);
}
}
async function updateDraftPath(parentWindow) {
const targetDir = await getTargetDirectory(parentWindow, true);
if (!targetDir) {
return { success: false, error: "用户取消了目录选择" };
}
try {
const configPath = getConfigPath();
let config = {};
// 尝试读取现有配置
try {
const data = await fs.readFile(configPath, "utf8");
config = JSON.parse(data);
} catch (error) {
// 如果文件不存在,保持config为空对象
}
config.targetDirectory = targetDir;
// 写回配置文件
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
logger.info('默认草稿路径已更新为:', targetDir);
return { success: true, targetDir };
} catch (error) {
logger.error('更新默认草稿路径失败:', error);
return { success: false, error: error.message };
}
}
// 提取出来的函数,可选参数parentWindow用于显示对话框时附加到对话框
async function getTargetDirectory(parentWindow = null, isUpdate = false) {
let config = await readConfig();
if (!isUpdate && config.targetDirectory) {
try {
await fs.access(config.targetDirectory);
return config.targetDirectory;
} catch (accessErr) {
logger.warn("配置的目录已不存在,将重新选择。");
}
}
const dialogOptions = {
properties: ["openDirectory"],
title: "请选择目标目录",
buttonLabel: "选择此目录",
defaultPath: isUpdate ? config.targetDirectory : undefined
};
// 如果有父窗口,则附加到父窗口
if (parentWindow) {
dialogOptions.window = parentWindow;
}
const result = await dialog.showOpenDialog(dialogOptions);
if (!result.canceled && result.filePaths.length > 0) {
const selectedDir = result.filePaths[0];
config.targetDirectory = selectedDir;
await writeConfig(config);
return selectedDir;
} else {
return '';
}
}
function updateValue(current, finalKey, targetDir, oldVal, targetId) {
if (oldVal) {
// 找到ID在路径中的位置
const idIndex = oldVal.indexOf(targetId);
if (idIndex === -1) return;
// 提取ID及之后的部分作为将要下载的路径
const relativePath = oldVal.substring(idIndex).replaceAll("/", path.sep); // 替换为系统路径分隔符
// targetDir 已包含 targetId 目录,所以relativePath中的targetId要去重
const newRelativePath = relativePath.replace(`${targetId}${path.sep}`, "");
const newValue = path.join(targetDir, newRelativePath);
current[finalKey] = newValue;
logger.info(`✅ newValue to:`, newValue);
}
}
// 递归遍历对象,更新所有名为path的属性
function recursivelyUpdatePaths(obj, targetDir, targetId) {
// 处理数组
if (Array.isArray(obj)) {
obj.forEach((item) => {
recursivelyUpdatePaths(item, targetDir, targetId);
});
return;
}
// 处理对象
if (obj && typeof obj === "object") {
// 检查是否有path属性
if (obj.path && typeof obj.path === "string") {
updateValue(obj, "path", targetDir, obj.path, targetId);
}
// 递归处理所有属性
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
recursivelyUpdatePaths(obj[key], targetDir, targetId);
}
}
}
}
async function downloadJsonFile(
{ fileUrl, filePath, targetDir, targetId },
parentWindow
) {
// 1. 使用 Axios 下载 JSON 文件
try {
const response = await axios({
method: "GET",
url: fileUrl,
responseType: "json", // 直接告诉 Axios 解析 JSON
});
// 检查HTTP状态码
if (response.status !== 200) {
await appendDownloadLog(
{ level: "error", message: `下载草稿内容文件失败` },
parentWindow
);
throw new Error(
`[error] [json] request failed, status code: ${response.status}`
);
}
// 2. 解析获取到的数据(Axios 会根据 responseType: 'json' 自动解析)
const jsonData = response.data;
// 3. 修改 JSON 数据中指定键的值
if (jsonData?.materials) {
logger.info(`[log] start modifyJsonValue: materials`);
recursivelyUpdatePaths(jsonData.materials, targetDir, targetId);
}
await appendDownloadLog(
{
level: "loading",
message: `正在将草稿内容文件写入本地草稿目录 ${targetDir}`,
},
parentWindow
);
// 4. 将修改后的 JSON 对象转换为格式化的字符串并写入本地文件
const jsonString = JSON.stringify(jsonData, null, 2); // 使用 2 个空格进行缩进,美化输出
await fs.writeFile(filePath, jsonString, "utf8"); // 指定编码为 utf8
} catch (error) {
errorHandler(error, fileUrl);
}
}
async function downloadNotJsonFile(
{ fileUrl, filePath, targetDir },
parentWindow
) {
try {
// 1. 使用 Axios 下载非 JSON 文件
const response = await axios({
...axiosConfig,
url: fileUrl,
responseType: "stream", // 设置响应类型为 'stream' 以处理大文件
});
// 检查HTTP状态码
if (response.status !== 200) {
await appendDownloadLog(
{ level: "error", message: `下载草稿内容文件失败` },
parentWindow
);
throw new Error(
`[error] [stream] request failed, status code: ${response.status}`
);
}
logger.info(`[log] start create writable stream: ${filePath}`);
await appendDownloadLog(
{
level: "loading",
message: `正在将草稿内容文件写入本地草稿目录 ${targetDir}`,
},
parentWindow
);
// 创建可写流
const writer = response.data.pipe(createWriteStream(filePath));
return new Promise((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", (err) => {
// 尝试删除可能不完整的文件
fs.unlink(filePath).catch(() => { });
reject(new Error(`[error] write file failed: ${err.message}`));
});
response.data.on("error", (err) => {
reject(new Error(`[error] download stream error: ${err.message}`));
});
});
} catch (error) {
errorHandler(error, fileUrl);
}
}
/**
* 下载单个文件并保存到指定路径的辅助函数
* @param {string} url 远程文件的URL
* @param {string} filePath 要保存到的本地文件路径
*/
async function downloadSingleFile(config, parentWindow) {
const filePath = config.filePath;
const fileUrl = config.fileUrl;
if (fileUrl.endsWith(".json")) {
logger.info(`[log] start download json file : ${filePath}`);
await downloadJsonFile(config, parentWindow);
} else {
logger.info(`[log] start download non-json file : ${filePath}`);
await downloadNotJsonFile(config, parentWindow);
}
}
// 打开目录
async function openDraftDirectory(dirPath) {
try {
const errorMsg = await shell.openPath(dirPath);
if (errorMsg) {
logger.error(`[error] Failed to open path: ${errorMsg}`);
return { success: false, error: errorMsg };
}
return { success: true };
} catch (error) {
logger.error(`[error] Error opening path: ${error}`);
return { success: false, error: error.message };
}
}
async function downloadFiles(
{ sourceUrl, remoteFileUrls, targetId, isOpenDir },
parentWindow
) {
try {
let baseTargetDir = "";
// 然后获取目标目录,将主窗口作为父窗口传递
try {
baseTargetDir = await getTargetDirectory(parentWindow);
} catch (error) {
logger.error("[log] get target dir fail:", error);
baseTargetDir = '';
}
if (!baseTargetDir) {
await appendDownloadLog(
{ level: "error", message: `获取目录失败:${error}` },
parentWindow
);
return;
}
logger.info("[log] get target dir:", baseTargetDir);
await appendDownloadLog(
{ level: "info", message: `创建剪映草稿目录:${targetId}` },
parentWindow
);
let i = 0;
let relativePath = "";
// 2. 遍历远程文件URL数组
for (const fileUrl of remoteFileUrls) {
try {
// 从URL中提取相对路径部分
// 假设你的URL结构固定,可以从特定部分之后开始提取
const urlObj = new URL(fileUrl);
// 提取路径名中可能包含的部分路径(根据你的URL结构调整)
let fullPath = urlObj.pathname;
// 找到ID在路径中的位置
const idIndex = fullPath.indexOf(targetId);
if (idIndex === -1) continue;
// 提取ID及之后的部分作为将要下载的路径
relativePath = fullPath.substring(idIndex).replaceAll("/", path.sep); // 替换为系统路径分隔符
const fullTargetPath = path.join(baseTargetDir, relativePath);
const targetDir = path.dirname(fullTargetPath);
logger.info("[log] fullTargetPath: " + fullTargetPath);
logger.info("[log] targetDir: " + targetDir);
// 3. 确保目标目录存在
await fs.mkdir(targetDir, { recursive: true }); // recursive: true 可以创建多级目录
// return { success: true, message: targetDir };
// 4. 下载文件
logger.info(`[log] start get file context : ${fileUrl}`);
await appendDownloadLog(
{
level: "loading",
message: `正在下载草稿内容文件: ${relativePath}`,
},
parentWindow
);
await downloadSingleFile(
{ fileUrl, filePath: fullTargetPath, targetDir, targetId },
parentWindow
);
logger.info(`[log] file saved to : ${fullTargetPath}`);
await appendDownloadLog(
{ level: "success", message: `第 ${++i} 个草稿信息文件保存成功` },
parentWindow
);
} catch (error) {
logger.error(`[error] download file ${fileUrl} failed:`, error);
await appendDownloadLog(
{ level: "error", message: `第 ${++i} 个草稿信息文件保存失败` },
parentWindow
);
// 你可以决定是继续下载其他文件还是直接抛出错误
// 这里记录错误但继续尝试下载下一个文件
}
}
await appendDownloadLog(
{
level: "all",
message: `下载完成:所有 ${targetId} 中的剪映草稿已下载!`,
},
parentWindow
);
// {id: 'uuid', time: Date, draft_id: 'draft_id', draft_url: 'draft_url' }
await appendHistoryRecord({
id: uuidv4(),
time: new Date(),
draft_id: targetId,
draft_url: sourceUrl,
});
const jointPath = path.join(baseTargetDir, targetId);
logger.info(`[finish] all download: ${jointPath}`);
if (isOpenDir) await openDraftDirectory(jointPath);
return {
success: true,
message: `文件批量保存完成,保存至目录: ${jointPath}`,
};
} catch (error) {
logger.error(`[error] 批量保存过程发生错误:`, error);
await appendDownloadLog(
{
level: "error",
message: `下载失败:批量保存 ${targetId} 中的剪映草稿过程发生错误!`,
},
parentWindow
);
return { success: false, message: `保存失败: ${error.message} ` };
}
}
async function checkUrlAccessRight(url) {
try {
const response = await axios({
...axiosConfig,
method: 'HEAD',
url: url,
timeout: 5000
});
logger.info(`URL Accessibility Check Result: ${url} - ${response.status}`);
return { accessible: response.status < 400 };
} catch (error) {
logger.error(`URL Accessibility Check Failed: ${url}`, error);
return { accessible: false, error: error.message };
}
}
module.exports = {
readDownloadLog,
clearDownloadLog,
updateDraftPath,
readConfig,
getDraftUrls,
downloadFiles,
checkUrlAccessRight,
readHistoryRecord
};