// 本项目新增(REQ §8.1/§8.2 分批增量扫描与中断续扫);MP 整理监控为全量目录遍历,无游标。 import type { Dirent } from "node:fs"; import { lstat, readdir } from "node:fs/promises"; import { join } from "node:path"; import { errnoOf } from "./errors"; import { checkRoot, DEFAULT_ROOT_SENTINEL } from "./stat"; export interface ScanEntry { /** 相对根目录的路径,/ 分隔 */ relPath: string; size: number; mtimeMs: number; ino: number; dev: number; nlink: number; isSymlink: boolean; change: "new" | "changed" | "unchanged"; } export interface ScanOptions { /** 上次返回的游标;空则从头扫 */ cursor?: string | null; /** 本批最多 lstat 的文件数(I/O 上限),默认 2000 */ maxStats?: number; /** 本批最多 readdir 的目录数,默认不限 */ maxDirs?: number; /** 哨兵文件名;false 表示不检查(不推荐) */ sentinel?: string | false; /** 已知记录,用于标注 new/changed/unchanged */ previous?: (relPath: string) => { size: number; mtimeMs: number } | undefined; /** 跳过的文件名判定(默认跳过 .uhd-partial 与 .!qB 未完成文件) */ skip?: (name: string) => boolean; signal?: AbortSignal; } export interface ScanResult { status: "complete" | "partial" | "root_offline" | "aborted"; entries: ScanEntry[]; /** 继续扫描用的游标;complete 时为 null */ cursor: string | null; statCount: number; dirCount: number; errors: { relPath: string; code: string }[]; } const defaultSkip = (n: string) => n.endsWith(".uhd-partial") || n.endsWith(".!qB"); function encodeCursor(after: string[]): string { return Buffer.from(JSON.stringify({ v: 1, after })).toString("base64url"); } function decodeCursor(c: string | null | undefined): string[] | null { if (!c) return null; try { const j = JSON.parse(Buffer.from(c, "base64url").toString()) as { v: number; after: string[] }; return j.v === 1 && Array.isArray(j.after) ? j.after : null; } catch { return null; } } function cmp(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } /** 段序比较(深度优先前序、名称排序的遍历顺序) */ function cmpSegs(a: string[], b: string[]): number { for (let i = 0; i < Math.min(a.length, b.length); i++) { const c = cmp(a[i]!, b[i]!); if (c) return c; } return a.length - b.length; } function isPrefix(p: string[], of: string[]): boolean { return p.length < of.length && p.every((s, i) => s === of[i]); } /** * 分批增量扫描:确定性顺序(名称排序、深度优先),每批受 maxStats/maxDirs 限制, * 返回游标供下一批续扫;进程中断后用最后持久化的游标续扫,不必从头遍历。 * 目录只 readdir 不 stat;文件 lstat(不跟随符号链接)。 */ export async function scanIncremental(root: string, opts: ScanOptions = {}): Promise { const result: ScanResult = { status: "complete", entries: [], cursor: null, statCount: 0, dirCount: 0, errors: [] }; if (opts.sentinel !== false) { const h = await checkRoot(root, opts.sentinel ?? DEFAULT_ROOT_SENTINEL); if (h.kind === "offline") return { ...result, status: "root_offline" }; } const maxStats = opts.maxStats ?? 2000; const maxDirs = opts.maxDirs ?? Number.POSITIVE_INFINITY; const skip = opts.skip ?? defaultSkip; const sentinelName = opts.sentinel === false ? null : (opts.sentinel ?? DEFAULT_ROOT_SENTINEL); const after = decodeCursor(opts.cursor); let last: string[] | null = after; let stopped: ScanResult["status"] | null = null; const walk = async (dirSegs: string[]): Promise => { if (result.dirCount >= maxDirs) { stopped = "partial"; return; } let ents: Dirent[]; try { ents = await readdir(join(root, ...dirSegs), { withFileTypes: true }); result.dirCount++; } catch (e) { result.errors.push({ relPath: dirSegs.join("/"), code: errnoOf(e) ?? "UNKNOWN" }); return; } ents.sort((a, b) => cmp(a.name, b.name)); for (const d of ents) { if (opts.signal?.aborted) { stopped = "aborted"; return; } const segs = [...dirSegs, d.name]; if (dirSegs.length === 0 && d.name === sentinelName) continue; if (after && cmpSegs(segs, after) <= 0 && !isPrefix(segs, after)) continue; if (d.isDirectory()) { await walk(segs); if (stopped) return; continue; } if (skip(d.name)) continue; if (result.statCount >= maxStats) { stopped = "partial"; return; } const rel = segs.join("/"); try { const st = await lstat(join(root, ...segs)); result.statCount++; if (st.isDirectory()) continue; const prev = opts.previous?.(rel); result.entries.push({ relPath: rel, size: st.size, mtimeMs: st.mtimeMs, ino: st.ino, dev: st.dev, nlink: st.nlink, isSymlink: st.isSymbolicLink(), change: !prev ? "new" : prev.size === st.size && prev.mtimeMs === st.mtimeMs ? "unchanged" : "changed", }); } catch (e) { result.statCount++; result.errors.push({ relPath: rel, code: errnoOf(e) ?? "UNKNOWN" }); } last = segs; } }; await walk([]); if (stopped) { result.status = stopped; result.cursor = last ? encodeCursor(last) : (opts.cursor ?? null); } return result; }