- 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]>
157 lines
5.3 KiB
TypeScript
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;
|
|
}
|