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 = { 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 { 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(); 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 { 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 { 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] }; } }