chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user