Files
OD-Downloader/src/main.js

355 lines
10 KiB
JavaScript

const { app, BrowserWindow, dialog, ipcMain } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const http = require('http');
const https = require('https');
const { execFile } = require('child_process');
const state = {
hasWget: false,
};
function detectWget() {
return new Promise((resolve) => {
const cmd = process.platform === 'win32' ? 'where' : 'which';
execFile(cmd, ['wget'], (err, stdout) => {
if (err) {
resolve(false);
return;
}
resolve(Boolean(stdout && stdout.trim()));
});
});
}
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile(path.join(__dirname, 'index.html'));
}
function logToRenderer(message) {
const windows = BrowserWindow.getAllWindows();
if (!windows.length) return;
windows[0].webContents.send('log', message);
}
function progressToRenderer(payload) {
const windows = BrowserWindow.getAllWindows();
if (!windows.length) return;
windows[0].webContents.send('progress', payload);
}
function listLocalDir(dirPath) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
return entries.map((entry) => {
const fullPath = path.join(dirPath, entry.name);
let size = 0;
if (!entry.isDirectory()) {
try {
size = fs.statSync(fullPath).size;
} catch (err) {
size = 0;
}
}
return {
name: entry.name,
path: fullPath,
isDir: entry.isDirectory(),
size,
};
});
}
function fetchUrl(url) {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const req = client.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
res.resume();
return;
}
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => resolve(data));
});
req.on('error', reject);
});
}
function parseSizeToken(token) {
if (!token || token === '-') return null;
const match = token.trim().match(/^([\d.]+)\s*([KMGTP]?)(?:B)?$/i);
if (!match) return null;
const value = Number(match[1]);
if (Number.isNaN(value)) return null;
const unit = match[2] ? match[2].toUpperCase() : '';
const multipliers = {
'': 1,
K: 1024,
M: 1024 ** 2,
G: 1024 ** 3,
T: 1024 ** 4,
P: 1024 ** 5,
};
const multiplier = multipliers[unit];
if (!multiplier) return null;
return Math.round(value * multiplier);
}
function parseApacheIndex(html, baseUrl) {
const results = [];
const preMatch = html.match(/<pre>[\s\S]*?<\/pre>/i);
const block = preMatch ? preMatch[0] : html;
const preLines = preMatch ? preMatch[0].split(/\r?\n/) : block.split(/\r?\n/);
const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>/i;
for (const line of preLines) {
const match = line.match(linkRegex);
if (!match) continue;
const href = match[1];
const anchorText = match[2];
if (href === '../' || anchorText === '../') continue;
const isDir = href.endsWith('/');
const fullUrl = new URL(href, baseUrl).toString();
let name = anchorText;
try {
const urlObj = new URL(href, baseUrl);
let pathname = decodeURIComponent(urlObj.pathname);
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1);
}
const derived = pathname.split('/').pop();
if (derived) {
name = isDir ? `${derived}/` : derived;
}
} catch (err) {
name = anchorText;
}
let size = '-';
let sizeBytes = null;
const rest = line.slice(match.index + match[0].length).trim();
if (rest) {
const tokens = rest.split(/\s+/);
const sizeToken = tokens[tokens.length - 1];
if (sizeToken && sizeToken !== '-') {
size = sizeToken;
sizeBytes = parseSizeToken(sizeToken);
}
}
results.push({ name, href: fullUrl, isDir, size, sizeBytes });
}
return results;
}
async function collectRemoteFiles(baseUrl, relDir) {
const html = await fetchUrl(baseUrl);
const items = parseApacheIndex(html, baseUrl);
const files = [];
for (const item of items) {
const cleanName = item.name.replace(/\/$/, '');
const nextRel = relDir ? path.join(relDir, cleanName) : cleanName;
if (item.isDir) {
const childFiles = await collectRemoteFiles(item.href, nextRel);
files.push(...childFiles);
} else {
files.push({
url: item.href,
relPath: nextRel,
sizeBytes: item.sizeBytes ?? null,
});
}
}
return files;
}
function downloadFile(url, destPath, onProgress) {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const fileStream = fs.createWriteStream(destPath);
const req = client.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
res.resume();
return;
}
const totalBytes = Number(res.headers['content-length']) || null;
let downloaded = 0;
let lastEmit = Date.now();
onProgress?.({ phase: 'start', totalBytes });
res.pipe(fileStream);
res.on('data', (chunk) => {
downloaded += chunk.length;
const now = Date.now();
if (onProgress && (now - lastEmit > 200 || downloaded === totalBytes)) {
lastEmit = now;
onProgress({
phase: 'progress',
downloadedBytes: downloaded,
totalBytes,
});
}
});
fileStream.on('finish', () => {
fileStream.close(resolve);
});
});
req.on('error', (err) => {
fs.unlink(destPath, () => reject(err));
});
});
}
function getRemoteBasename(url) {
try {
const urlObj = new URL(url);
let pathname = decodeURIComponent(urlObj.pathname);
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1);
}
const name = pathname.split('/').pop();
return name || 'download';
} catch (err) {
return 'download';
}
}
async function downloadFolderWithPlan(baseUrl, destDir) {
progressToRenderer({ phase: 'scan-start' });
const files = await collectRemoteFiles(baseUrl, '');
progressToRenderer({
phase: 'scan-done',
fileCount: files.length,
});
const totalBytes = files.every((file) => typeof file.sizeBytes === 'number')
? files.reduce((sum, file) => sum + file.sizeBytes, 0)
: null;
progressToRenderer({
phase: 'aggregate-start',
totalBytes,
fileCount: files.length,
});
let completedBytes = 0;
for (const file of files) {
const targetPath = path.join(destDir, file.relPath);
const targetDir = path.dirname(targetPath);
fs.mkdirSync(targetDir, { recursive: true });
logToRenderer(`Downloading ${file.url}`);
progressToRenderer({ phase: 'file-start', fileName: file.relPath });
await downloadFile(file.url, targetPath, (progress) => {
const current = progress.downloadedBytes || 0;
const aggregate = completedBytes + current;
progressToRenderer({
phase: 'file-progress',
fileName: file.relPath,
downloadedBytes: current,
totalBytes: progress.totalBytes,
});
progressToRenderer({
phase: 'aggregate-progress',
downloadedBytes: aggregate,
totalBytes,
});
});
completedBytes += file.sizeBytes ?? 0;
progressToRenderer({ phase: 'file-done', fileName: file.relPath });
}
}
async function downloadWithWget(url, destDir) {
return new Promise((resolve, reject) => {
const args = ['-r', '-np', '-nH', '--cut-dirs=0', '-P', destDir, url];
const proc = execFile('wget', args, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
proc.stdout.on('data', (data) => logToRenderer(data.toString()));
proc.stderr.on('data', (data) => logToRenderer(data.toString()));
});
}
app.whenReady().then(async () => {
state.hasWget = await detectWget();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
ipcMain.handle('get-capabilities', () => {
return { hasWget: state.hasWget };
});
ipcMain.handle('select-local-dir', async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
ipcMain.handle('list-local-dir', (_event, dirPath) => {
return listLocalDir(dirPath);
});
ipcMain.handle('list-remote-dir', async (_event, url) => {
const html = await fetchUrl(url);
return parseApacheIndex(html, url);
});
ipcMain.handle('download-item', async (_event, payload) => {
const { url, destDir, isDir, useWget } = payload;
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
if (isDir) {
const folderName = getRemoteBasename(url);
const targetDir = path.join(destDir, folderName);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
if (useWget && state.hasWget) {
logToRenderer(`wget: ${url}`);
progressToRenderer({ phase: 'start', mode: 'wget', fileName: folderName });
await downloadWithWget(url, targetDir);
progressToRenderer({ phase: 'done' });
return;
}
await downloadFolderWithPlan(url, targetDir);
progressToRenderer({ phase: 'done' });
return;
}
const fileName = path.basename(new URL(url).pathname);
const destPath = path.join(destDir, fileName);
logToRenderer(`Downloading ${url}`);
progressToRenderer({ phase: 'start', fileName });
await downloadFile(url, destPath, (progress) => {
progressToRenderer({
phase: 'progress',
fileName,
downloadedBytes: progress.downloadedBytes,
totalBytes: progress.totalBytes,
});
});
progressToRenderer({ phase: 'done' });
});