316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
import { getHeaders, getCommonParams, getMparam, listRootDir, listDirAllPages, formatBytes, humanDelay, makeQuery, listDir, QuarkFile } from './quark-api';
|
|
|
|
/**
|
|
* 容量信息 & 空间清理模块。
|
|
*/
|
|
const BASE_URL = 'https://drive-pc.quark.cn';
|
|
|
|
// ==================== Storage Info ====================
|
|
|
|
/** Cached used space, keyed by hour block (3h window) */
|
|
const cachedUsedSpace: { bytes: number; hourBlock: number } | null = null;
|
|
|
|
// We use a function-scoped cache instead of instance field
|
|
const storageCache: { bytes: number; hourBlock: number } = { bytes: 0, hourBlock: -1 };
|
|
|
|
/**
|
|
* Get total capacity from /capacity/detail API.
|
|
* Also does a quick used-space estimate by summing root-level file sizes + subdir sizes
|
|
* (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归).
|
|
* If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided.
|
|
*/
|
|
export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string): Promise<{ total: string; totalBytes: number; used: string; usedBytes: number }> {
|
|
try {
|
|
const mparam = getMparam(cookie);
|
|
const params = new URLSearchParams({
|
|
...getCommonParams(),
|
|
kps: mparam.kps || '',
|
|
sign: mparam.sign || '',
|
|
vcode: mparam.vcode || '',
|
|
});
|
|
const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
|
headers: getHeaders(cookie),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
let totalBytes = 0;
|
|
if (capResponse.ok) {
|
|
const data = await capResponse.json() as any;
|
|
if (data.status === 200 && data.data) {
|
|
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
|
if (totalBytes === 0) {
|
|
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
|
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quick used-space estimate: sum root-level file sizes + subdir sizes
|
|
let usedBytes = 0;
|
|
try {
|
|
const rootFiles = await listRootDir(cookie);
|
|
for (const f of rootFiles) {
|
|
usedBytes += f.size || 0;
|
|
}
|
|
} catch {}
|
|
|
|
// Cache the result (3h window)
|
|
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
|
storageCache.bytes = usedBytes;
|
|
storageCache.hourBlock = currentHourBlock;
|
|
|
|
if (totalBytes > 0) {
|
|
return {
|
|
total: formatBytes(totalBytes),
|
|
totalBytes,
|
|
used: formatBytes(usedBytes),
|
|
usedBytes,
|
|
};
|
|
}
|
|
} catch {}
|
|
|
|
// Fallback: try to parse from a human-readable string like "6 TB"
|
|
if (fallbackTotal) {
|
|
const match = fallbackTotal.match(/^([\d.]+)\s*([KMGT]B?)/i);
|
|
if (match) {
|
|
const num = parseFloat(match[1]);
|
|
const unit = match[2].toUpperCase();
|
|
const multipliers: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 };
|
|
const multiplier = multipliers[unit] || multipliers[unit.replace('B', '') + 'B'] || 0;
|
|
if (multiplier > 0) {
|
|
return { total: fallbackTotal, totalBytes: Math.round(num * multiplier), used: '-', usedBytes: 0 };
|
|
}
|
|
}
|
|
}
|
|
return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 };
|
|
}
|
|
|
|
/**
|
|
* Get storage info with used space calculation.
|
|
*/
|
|
export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
|
|
try {
|
|
const mparam = getMparam(cookie);
|
|
let totalBytes = 0;
|
|
const params = new URLSearchParams({
|
|
...getCommonParams(),
|
|
kps: mparam.kps || '',
|
|
sign: mparam.sign || '',
|
|
vcode: mparam.vcode || '',
|
|
});
|
|
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
|
headers: getHeaders(cookie),
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json() as any;
|
|
if (data.status === 200 && data.data) {
|
|
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
|
if (totalBytes === 0) {
|
|
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
|
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
const usedBytes = await calculateUsedSpace(cookie);
|
|
|
|
if (totalBytes > 0 || usedBytes > 0) {
|
|
return {
|
|
total: totalBytes > 0 ? formatBytes(totalBytes) : '-',
|
|
used: formatBytes(usedBytes),
|
|
usedBytes,
|
|
totalBytes: totalBytes > 0 ? totalBytes : 0,
|
|
};
|
|
}
|
|
return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 };
|
|
} catch {
|
|
return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate total used space by recursively traversing all files
|
|
* and summing their sizes. Uses 3-hour time window cache.
|
|
*/
|
|
export async function calculateUsedSpace(cookie: string): Promise<number> {
|
|
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
|
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
|
|
return storageCache.bytes;
|
|
}
|
|
let totalUsed = 0;
|
|
const stack: string[] = ['0'];
|
|
const visited = new Set<string>();
|
|
while (stack.length > 0) {
|
|
const fid = stack.pop()!;
|
|
if (visited.has(fid)) continue;
|
|
visited.add(fid);
|
|
const files = await listDirAllPages(cookie, fid);
|
|
if (!files.length) continue;
|
|
for (const f of files) {
|
|
if (f.dir) {
|
|
stack.push(f.fid);
|
|
} else {
|
|
totalUsed += f.size || 0;
|
|
}
|
|
}
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
storageCache.bytes = totalUsed;
|
|
storageCache.hourBlock = currentHourBlock;
|
|
return totalUsed;
|
|
}
|
|
|
|
// ==================== Cleanup ====================
|
|
|
|
/**
|
|
* Trash specified files/folders (move to recycle bin).
|
|
*/
|
|
export async function trashFiles(cookie: string, fids: string[]): Promise<boolean> {
|
|
if (!fids.length) return true;
|
|
try {
|
|
const response = await fetch(
|
|
`${BASE_URL}/1/clouddrive/file/trash?${makeQuery()}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action_type: 1, // 1 = move to trash
|
|
filelist: fids,
|
|
exclude_filelist: [],
|
|
}),
|
|
signal: AbortSignal.timeout(30000),
|
|
},
|
|
);
|
|
if (!response.ok) return false;
|
|
const data = await response.json() as any;
|
|
if (data.status === 200) return true;
|
|
console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
|
|
return false;
|
|
} catch (err: any) {
|
|
console.error(`[Quark] trashFiles error: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Empty the recycle bin — permanently delete all files in trash.
|
|
*/
|
|
export async function emptyTrash(cookie: string): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(
|
|
`${BASE_URL}/1/clouddrive/file/trash/clear?${makeQuery()}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
signal: AbortSignal.timeout(60000),
|
|
},
|
|
);
|
|
if (!response.ok) return false;
|
|
const data = await response.json() as any;
|
|
if (data.status === 200) return true;
|
|
console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
|
|
return false;
|
|
} catch (err: any) {
|
|
console.error(`[Quark] emptyTrash error: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
|
|
*/
|
|
export async function cleanupOldDateFolders(cookie: string, days: number): Promise<{ trashed: number; errors: string[] }> {
|
|
const errors: string[] = [];
|
|
const cutoff = new Date();
|
|
cutoff.setDate(cutoff.getDate() - days);
|
|
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
|
|
try {
|
|
const rootItems = await listRootDir(cookie);
|
|
const oldFolders = rootItems.filter(item => {
|
|
if (!item.dir) return false;
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
|
|
return item.file_name < cutoffStr;
|
|
});
|
|
|
|
if (oldFolders.length === 0) {
|
|
return { trashed: 0, errors: [] };
|
|
}
|
|
|
|
const fids = oldFolders.map(f => f.fid);
|
|
console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
|
|
const ok = await trashFiles(cookie, fids);
|
|
if (ok) {
|
|
return { trashed: fids.length, errors: [] };
|
|
}
|
|
return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
|
|
} catch (err: any) {
|
|
return { trashed: 0, errors: [err.message] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup: if used space exceeds thresholdPercent% of total,
|
|
* delete the oldest date folders until totalBytes * deletePercent/100
|
|
* of total capacity is freed.
|
|
*/
|
|
export async function cleanupBySpaceThreshold(
|
|
cookie: string,
|
|
thresholdPercent: number,
|
|
deletePercent: number,
|
|
): Promise<{ trashed: number; errors: string[] }> {
|
|
const errors: string[] = [];
|
|
|
|
try {
|
|
const storage = await getStorageInfo(cookie);
|
|
if (storage.totalBytes <= 0) return { trashed: 0, errors: [] };
|
|
|
|
const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
|
|
if (usagePercent < thresholdPercent) {
|
|
console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
|
|
return { trashed: 0, errors: [] };
|
|
}
|
|
|
|
const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);
|
|
|
|
const rootItems = await listRootDir(cookie);
|
|
const dateFolders = rootItems
|
|
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
|
|
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
|
|
|
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
|
|
|
|
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
|
|
let cumulativeSize = 0;
|
|
const foldersToTrash: typeof dateFolders = [];
|
|
|
|
if (hasSizes) {
|
|
for (const folder of dateFolders) {
|
|
foldersToTrash.push(folder);
|
|
cumulativeSize += folder.size || 0;
|
|
if (cumulativeSize >= targetBytesToFree) break;
|
|
}
|
|
} else {
|
|
const avgSizePerFolder = storage.usedBytes / dateFolders.length;
|
|
const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
|
|
foldersToTrash.push(...dateFolders.slice(0, estCount));
|
|
cumulativeSize = estCount * avgSizePerFolder;
|
|
}
|
|
|
|
const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
|
|
const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
|
|
const fidsToTrash = foldersToTrash.map(f => f.fid);
|
|
console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes/1024/1024/1024).toFixed(0)} GB total)`);
|
|
|
|
const ok = await trashFiles(cookie, fidsToTrash);
|
|
if (ok) {
|
|
console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
|
|
return { trashed: foldersToTrash.length, errors: [] };
|
|
}
|
|
return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
|
|
} catch (err: any) {
|
|
return { trashed: 0, errors: [err.message] };
|
|
}
|
|
}
|