Initial clean project import
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import logger from '../log/main';
|
||||
import { AppEnv } from '../env';
|
||||
import { getFFmpegExecutablePath, getFFprobeExecutablePath, resourceExists } from '../../lib/resource-path';
|
||||
|
||||
export async function downloadFile(url: string, saveDir?: string): Promise<string> {
|
||||
logger.info('[File:Download] 开始下载文件', { url });
|
||||
|
||||
try {
|
||||
const targetDir = saveDir || path.join(AppEnv.dataRoot, 'hub', 'file', getDatePath());
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
const urlPath = new URL(url).pathname;
|
||||
const originalFileName = path.basename(urlPath);
|
||||
const ext = path.extname(originalFileName) || '.mp4';
|
||||
const fileName = `cloud_${Date.now()}${ext}`;
|
||||
const filePath = path.join(targetDir, fileName);
|
||||
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'stream',
|
||||
timeout: 120000,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(filePath);
|
||||
response.data.pipe(writer);
|
||||
|
||||
const downloadedPath = await new Promise<string>((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
logger.info('[File:Download] 文件下载完成', { filePath });
|
||||
resolve(filePath);
|
||||
});
|
||||
|
||||
writer.on('error', (error) => {
|
||||
logger.error('[File:Download] 文件写入失败', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
response.data.on('error', (error: Error) => {
|
||||
logger.error('[File:Download] 下载失败', error);
|
||||
writer.close();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
const transcodedPath = await transcodeToH264IfNeeded(downloadedPath);
|
||||
return transcodedPath;
|
||||
} catch (error: any) {
|
||||
logger.error('[File:Download] 下载文件失败', {
|
||||
url,
|
||||
error: error.message
|
||||
});
|
||||
throw new Error(`下载文件失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function transcodeToH264IfNeeded(filePath: string): Promise<string> {
|
||||
try {
|
||||
const ffprobePath = getFFprobeExecutablePath();
|
||||
if (!resourceExists(ffprobePath)) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const codecName = await getVideoCodec(filePath, ffprobePath);
|
||||
logger.info('[File:Download] 视频编码检测', { filePath, codecName });
|
||||
|
||||
if (codecName === 'h264') {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
logger.info('[File:Download] 视频非H264编码,开始转码', { codecName });
|
||||
|
||||
const ffmpegPath = getFFmpegExecutablePath();
|
||||
if (!resourceExists(ffmpegPath)) {
|
||||
logger.warn('[File:Download] FFmpeg不存在,跳过转码');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath, path.extname(filePath));
|
||||
const transcodedPath = path.join(dir, `${baseName}_h264.mp4`);
|
||||
|
||||
await runFFmpeg(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'fast',
|
||||
'-crf', '23',
|
||||
'-c:a', 'copy',
|
||||
'-movflags', '+faststart',
|
||||
transcodedPath
|
||||
]);
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
const finalPath = path.join(dir, `${baseName}${path.extname(filePath)}`);
|
||||
fs.renameSync(transcodedPath, finalPath);
|
||||
logger.info('[File:Download] 转码完成', { finalPath });
|
||||
return finalPath;
|
||||
} catch (renameErr: any) {
|
||||
logger.info('[File:Download] 转码完成(保留新文件名)', { transcodedPath });
|
||||
return transcodedPath;
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn('[File:Download] 转码失败,使用原始文件', { error: err.message });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoCodec(filePath: string, ffprobePath: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(ffprobePath, [
|
||||
'-v', 'quiet',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
'-of', 'csv=p=0',
|
||||
filePath
|
||||
]);
|
||||
let output = '';
|
||||
proc.stdout.on('data', (data: Buffer) => { output += data.toString(); });
|
||||
proc.stderr.on('data', () => {});
|
||||
proc.on('close', () => {
|
||||
resolve(output.trim() || 'unknown');
|
||||
});
|
||||
proc.on('error', () => {
|
||||
resolve('unknown');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runFFmpeg(ffmpegPath: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(ffmpegPath, args);
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`FFmpeg exited with code ${code}`));
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function getDatePath(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
import {dialog, ipcMain} from "electron";
|
||||
import fileIndex from "./index";
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { downloadFile } from "./download";
|
||||
import { AppEnv } from "../env";
|
||||
import { normalizePath } from "../../lib/path-util";
|
||||
|
||||
ipcMain.handle("file:exists", async (_, filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
return existsSync(filePath);
|
||||
} catch (e) {
|
||||
console.error("[file:exists] 检查文件存在性失败:", filePath, e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("path:join", async (_, ...segments: string[]): Promise<string> => {
|
||||
try {
|
||||
return join(...segments);
|
||||
} catch (e) {
|
||||
console.error("[path:join] 路径拼接失败:", segments, e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// 🔧 新增:路径标准化接口(处理中文路径和空格)
|
||||
ipcMain.handle("file:normalizePath", async (_, filePath: string): Promise<string> => {
|
||||
try {
|
||||
return normalizePath(filePath);
|
||||
} catch (e) {
|
||||
console.error("[file:normalizePath] 路径标准化失败:", filePath, e);
|
||||
return filePath; // 失败时返回原路径
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("file:fullPath", async (_, filePath: string): Promise<string> => {
|
||||
try {
|
||||
console.log("[file:fullPath] 处理请求,路径:", filePath);
|
||||
const result = await fileIndex.fullPath(filePath);
|
||||
console.log("[file:fullPath] 返回结果:", result);
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
console.error("[file:fullPath] 获取完整路径失败:", filePath, e);
|
||||
throw new Error(`Failed to get full path: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("file:openFile", async (
|
||||
event,
|
||||
options: {
|
||||
filters?: {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}[],
|
||||
properties?: ("multiSelections" | "openFile")[]
|
||||
} = {}): Promise<string | string[] | null> => {
|
||||
options = Object.assign({
|
||||
filters: [],
|
||||
properties: [],
|
||||
}, options);
|
||||
if (!options.properties.includes("openFile")) {
|
||||
options.properties.push("openFile");
|
||||
}
|
||||
// @ts-ignore
|
||||
options.properties.push('noResolveAliases');
|
||||
const res = await dialog
|
||||
.showOpenDialog({
|
||||
...options,
|
||||
})
|
||||
.catch(e => {
|
||||
});
|
||||
if (!res || res.canceled) {
|
||||
return null;
|
||||
}
|
||||
if (options.properties.includes("multiSelections")) {
|
||||
return res.filePaths || null;
|
||||
}
|
||||
return res.filePaths?.[0] || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("file:openDirectory", async (_, options): Promise<string | null> => {
|
||||
const res = await dialog
|
||||
.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
...options,
|
||||
})
|
||||
.catch(e => {
|
||||
});
|
||||
if (!res || res.canceled) {
|
||||
return null;
|
||||
}
|
||||
return res.filePaths?.[0] || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("file:openSave", async (_, options): Promise<string | null> => {
|
||||
const res = await dialog
|
||||
.showSaveDialog({
|
||||
...options,
|
||||
})
|
||||
.catch(e => {
|
||||
});
|
||||
if (!res || res.canceled) {
|
||||
return null;
|
||||
}
|
||||
return res.filePath || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("file:temp", async (_, ext: string = "tmp", prefix: string = "file", suffix: string = ""): Promise<string> => {
|
||||
try {
|
||||
return await fileIndex.temp(ext, prefix, suffix);
|
||||
} catch (e: any) {
|
||||
console.error("[file:temp] 获取临时文件路径失败:", e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("file:writeBuffer", async (_, path: string, data: any, option?: { isDataPath?: boolean }): Promise<void> => {
|
||||
try {
|
||||
return await fileIndex.writeBuffer(path, data, option);
|
||||
} catch (e: any) {
|
||||
console.error("[file:writeBuffer] 写入缓冲区失败:", path, e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("file:hubSave", async (_, file: string, option?: any): Promise<string> => {
|
||||
try {
|
||||
return await fileIndex.hubSave(file, option);
|
||||
} catch (e: any) {
|
||||
console.error("[file:hubSave] Hub保存失败:", file, e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("file:deletes", async (_, path: string, option?: { isDataPath?: boolean }): Promise<void> => {
|
||||
try {
|
||||
return await fileIndex.deletes(path, option);
|
||||
} catch (e: any) {
|
||||
console.error("[file:deletes] 删除文件失败:", path, e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
const autoCleanTemp = async () => {
|
||||
fileIndex.autoCleanTemp(1).finally(() => {
|
||||
setTimeout(() => {
|
||||
autoCleanTemp();
|
||||
}, 10 * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
autoCleanTemp().then();
|
||||
}, 5000);
|
||||
|
||||
// 下载外部URL文件到本地
|
||||
ipcMain.handle("file:downloadUrl", async (_, url: string, saveDir?: string): Promise<string> => {
|
||||
try {
|
||||
console.log("[file:downloadUrl] 开始下载文件:", url);
|
||||
const filePath = await downloadFile(url, saveDir);
|
||||
console.log("[file:downloadUrl] 文件下载成功:", filePath);
|
||||
return filePath;
|
||||
} catch (error: any) {
|
||||
console.error("[file:downloadUrl] 下载文件失败:", error.message);
|
||||
throw new Error(`下载文件失败: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取输出目录(用于字体文件等临时文件)
|
||||
// 使用 AppEnv.dataRoot/output 目录,便携版模式下会在应用目录下
|
||||
ipcMain.handle("file:getOutputDir", async (): Promise<string> => {
|
||||
try {
|
||||
const outputDir = join(AppEnv.dataRoot, 'output');
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
console.log("[file:getOutputDir] 返回输出目录:", outputDir);
|
||||
return outputDir;
|
||||
} catch (error: any) {
|
||||
console.error("[file:getOutputDir] 获取输出目录失败:", error.message);
|
||||
throw new Error(`获取输出目录失败: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
...fileIndex,
|
||||
};
|
||||
|
||||
export const Files = {
|
||||
...fileIndex,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
import fileIndex from "./index";
|
||||
|
||||
const openFile = async (options: {} = {}) => {
|
||||
return ipcRenderer.invoke("file:openFile", options);
|
||||
};
|
||||
|
||||
const openDirectory = async (options: {} = {}) => {
|
||||
return ipcRenderer.invoke("file:openDirectory", options);
|
||||
};
|
||||
|
||||
const openSave = async (options: {} = {}) => {
|
||||
return ipcRenderer.invoke("file:openSave", options);
|
||||
};
|
||||
|
||||
const fullPath = async (path: string): Promise<string> => {
|
||||
return ipcRenderer.invoke("file:fullPath", path);
|
||||
};
|
||||
|
||||
const temp = async (ext: string = "tmp", prefix: string = "file", suffix: string = ""): Promise<string> => {
|
||||
return ipcRenderer.invoke("file:temp", ext, prefix, suffix);
|
||||
};
|
||||
|
||||
const writeBuffer = async (path: string, data: any, option?: { isDataPath?: boolean }): Promise<void> => {
|
||||
return ipcRenderer.invoke("file:writeBuffer", path, data, option);
|
||||
};
|
||||
|
||||
const hubSave = async (file: string, option?: any): Promise<string> => {
|
||||
return ipcRenderer.invoke("file:hubSave", file, option);
|
||||
};
|
||||
|
||||
const deletes = async (path: string, option?: { isDataPath?: boolean }): Promise<void> => {
|
||||
return ipcRenderer.invoke("file:deletes", path, option);
|
||||
};
|
||||
|
||||
const downloadUrl = async (url: string, saveDir?: string): Promise<string> => {
|
||||
return ipcRenderer.invoke("file:downloadUrl", url, saveDir);
|
||||
};
|
||||
|
||||
const getOutputDir = async (): Promise<string> => {
|
||||
return ipcRenderer.invoke("file:getOutputDir");
|
||||
};
|
||||
|
||||
// 🔧 新增:路径标准化方法(处理中文路径和空格)
|
||||
const normalizePath = async (filePath: string): Promise<string> => {
|
||||
return ipcRenderer.invoke("file:normalizePath", filePath);
|
||||
};
|
||||
|
||||
export default {
|
||||
...fileIndex,
|
||||
openFile,
|
||||
openDirectory,
|
||||
openSave,
|
||||
fullPath,
|
||||
temp,
|
||||
writeBuffer,
|
||||
hubSave,
|
||||
deletes,
|
||||
downloadUrl,
|
||||
getOutputDir,
|
||||
normalizePath,
|
||||
};
|
||||
Reference in New Issue
Block a user