Files
UHD_Media_Tool/packages/adapters/src/fs/scan.ts
T
rootandClaude Fable 5.1 ef2e504a94 feat(adapters): qBittorrent, filesystem, TMDB and Emby adapters with testkit mocks (W1-3a)
- qbittorrent: WebUI API v2 client (cookie/API-key auth, add/info/files/filePrio,
  pause/resume with 5.x stop/start fallback, tags, delete with explicit deleteFiles,
  maindata), error classes, idempotent submit with local infohash (own bencode)
  and post-timeout hash/probe-tag lookup; state mapping per MoviePilot
- fs: copyWithVerify (tmp + fsync + size/sha256/sample + no-clobber commit),
  moveSafe (EXDEV surfaced), resumable crossDeviceMove, statSafe/checkRoot with
  root sentinel, budgeted incremental scan with cursor, ffprobe, statfs usage
- tmdb: multi-language search/details/seasons, LRU+TTL cache with coalescing,
  rate-limit backoff, MoviePilot match chain (name normalization, year +-1)
- emby: Emby/Jellyfin items/seasons/episodes/MediaSources, failures -> unknown
- testkit: mock qB / TMDB / Emby servers, torrent builder, temp-tree fixtures
- docs: W1-3a porting table appended to downloader-and-sites ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:07:48 +08:00

157 lines
5.3 KiB
TypeScript

// 本项目新增(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<ScanResult> {
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<void> => {
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;
}