feat(organize): 刮削写远端与 Alist 目录监控
- 目标在 Alist 时 NFO / 图片(含 clearlogo)本地生成后上传,已存在策略与本地一致 - 整理来源可选 Alist 根(手动 / 目录快照):按 MP 快照语义限深度、按目录修改时间跳过未变子树, 列表失败保留基线,复用两次观察稳定与失败记录 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
ccd2cc2c37
commit
c1bc389058
5 files changed
+305
-11
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
|
||||
import { DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type OrganizeSettings,
|
||||
@@ -24,6 +24,15 @@ import {
|
||||
seasonOfDirName,
|
||||
workNfo,
|
||||
} from "@umt/domain";
|
||||
import {
|
||||
isRemotePath,
|
||||
vlstat as lstat,
|
||||
vreadFile as readFile,
|
||||
vrealpath as realpath,
|
||||
vcheckRoot,
|
||||
vreaddirents,
|
||||
vwriteFileAtomic,
|
||||
} from "../../../storage/vfs.ts";
|
||||
import {
|
||||
createEntityEpisodeMapper,
|
||||
type EntityEpisodeMapper,
|
||||
@@ -587,6 +596,8 @@ export async function scrapeAfterImport(
|
||||
|
||||
/** 原子写:同目录临时文件 → rename;目录不存在时建(只在登记目录内) */
|
||||
async function atomicWrite(abs: string, content: Uint8Array | string): Promise<void> {
|
||||
// Alist 目标:本地生成后上传到对应目录(同 MP 刮削 _save_file → upload_file),已存在时上传旁名后替换
|
||||
if (isRemotePath(abs)) return vwriteFileAtomic(abs, content);
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
const tmp = join(dirname(abs), `.${randomUUID()}.umt-scrape.tmp`);
|
||||
try {
|
||||
@@ -621,7 +632,7 @@ async function pool<T, R>(items: readonly T[], n: number, fn: (t: T) => Promise<
|
||||
}
|
||||
|
||||
async function listDir(rootReal: string, rel: string) {
|
||||
const names = await readdir(join(rootReal, rel), { withFileTypes: true }).catch(() => []);
|
||||
const names = await vreaddirents(join(rootReal, rel)).catch(() => []);
|
||||
const visible = names.filter((n) => !n.name.startsWith(".")).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return {
|
||||
videos: visible
|
||||
@@ -948,7 +959,7 @@ export async function runScrapeRecord(
|
||||
.executeTakeFirst();
|
||||
if (!lr?.enabled) return await fail("scrape_root_offline");
|
||||
if (lr.management_mode !== "managed") return await fail("scrape_root_read_only");
|
||||
const health = await checkRoot(lr.local_path, lr.sentinel_file ?? DEFAULT_ROOT_SENTINEL);
|
||||
const health = await vcheckRoot(lr.local_path, lr.sentinel_file ?? DEFAULT_ROOT_SENTINEL);
|
||||
if (health.kind !== "online") return await fail("scrape_root_offline");
|
||||
}
|
||||
const rootReal = await realpath(rec.root_path).catch(() => null);
|
||||
|
||||
@@ -74,14 +74,19 @@ export async function saveOrganizeSource(d: FmDeps, id: string, input: Omit<Orga
|
||||
const target = await resolveIn(d, input.target.root, input.target.path);
|
||||
if (
|
||||
target.isSymlink ||
|
||||
target.def.kind !== "library" ||
|
||||
!target.lst?.isDirectory() ||
|
||||
!(await isWritableTarget(target.def, target.rootReal)) ||
|
||||
!(await access(target.abs, constants.W_OK).then(
|
||||
() => true,
|
||||
() => false,
|
||||
))
|
||||
(!target.def.storageId &&
|
||||
!(await access(target.abs, constants.W_OK).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)))
|
||||
)
|
||||
throw new FilesError("not_writable", "目标必须为可写的纳管媒体库目录");
|
||||
// Alist 来源(对照 MP 远程目录监控):只能手动或目录快照触发,qB 标签对网盘没有意义
|
||||
if (source.def.storageId && input.trigger === "qb_tag")
|
||||
throw new FilesError("bad_request", "Alist 来源只能用手动或目录快照触发");
|
||||
if (input.trigger === "qb_tag") {
|
||||
if (!input.downloaderInstanceId || !input.tag?.trim())
|
||||
throw new FilesError("bad_request", "标签触发需要下载器和标签");
|
||||
|
||||
@@ -3,6 +3,8 @@ import { basename, join } from "node:path";
|
||||
import { isDownloadComplete } from "@umt/adapters";
|
||||
import type { FileRef, OrganizeSourceConfig } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { storageEntry } from "../../storage/registry.ts";
|
||||
import { vreaddirStats } from "../../storage/vfs.ts";
|
||||
import { hostPathOf, loadInstances, upsertDecision } from "../acquisition-organize/services/common.ts";
|
||||
import { planImport } from "../acquisition-organize/services/organize.ts";
|
||||
import { downloadTasksUnder } from "../acquisition-organize/services/takeover.ts";
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
jsonb,
|
||||
recheck,
|
||||
relOf,
|
||||
remoteFilesError,
|
||||
resolveIn,
|
||||
rootDef,
|
||||
within,
|
||||
@@ -46,11 +49,17 @@ export function advanceSnapshot(previous: Snapshot | null, observed: Record<stri
|
||||
return { snapshot: { files }, eligible };
|
||||
}
|
||||
|
||||
export async function walkSource(d: FmDeps, config: OrganizeSourceConfig, path = config.source.path) {
|
||||
export async function walkSource(
|
||||
d: FmDeps,
|
||||
config: OrganizeSourceConfig,
|
||||
path = config.source.path,
|
||||
prev?: { files: Record<string, Omit<SnapshotFile, "accepted">>; since: number | null },
|
||||
) {
|
||||
if (!within(path, config.source.path) && config.source.path !== "")
|
||||
throw new FilesError("path_escape", "扫描路径必须在来源目录内");
|
||||
const start = await resolveIn(d, config.source.root, path);
|
||||
if (start.isSymlink || !start.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须为目录");
|
||||
if (start.def.storageId) return walkRemoteSource(start, prev);
|
||||
const files: Record<string, Omit<SnapshotFile, "accepted">> = {};
|
||||
const stack = [start.abs];
|
||||
let seen = 0;
|
||||
@@ -78,6 +87,59 @@ export async function walkSource(d: FmDeps, config: OrganizeSourceConfig, path =
|
||||
return { files, truncated, rootReal: start.rootReal };
|
||||
}
|
||||
|
||||
/**
|
||||
* Alist 来源快照(对照 MP StorageBase.snapshot,docs/design/storage-alist.md §8):BFS 列目录,深度 ≤ snapshotMaxDepth;
|
||||
* 开了「按目录修改时间跳过」时,子目录修改时间不晚于上次扫描就沿用上次快照里该子树的文件(根目录每轮都列,用于发现删除);
|
||||
* 子目录列表失败保留旧快照(MP「查询异常时保留基线」);根目录列表失败抛错(记 last_error,保留旧快照)。
|
||||
* 请求经实例限速器(QPS / 并发),不会打爆网盘。
|
||||
*/
|
||||
async function walkRemoteSource(
|
||||
start: Awaited<ReturnType<typeof resolveIn>>,
|
||||
prev?: { files: Record<string, Omit<SnapshotFile, "accepted">>; since: number | null },
|
||||
) {
|
||||
const opts = (await storageEntry(start.def.storageId!)).options;
|
||||
const files: Record<string, Omit<SnapshotFile, "accepted">> = {};
|
||||
const carry = (relDir: string) => {
|
||||
for (const [k, v] of Object.entries(prev?.files ?? {}))
|
||||
if (k === relDir || k.startsWith(`${relDir}/`)) files[k] = { size: v.size, mtimeMs: v.mtimeMs };
|
||||
};
|
||||
let seen = 0;
|
||||
let truncated = false;
|
||||
const queue: { abs: string; depth: number }[] = [{ abs: start.abs, depth: 0 }];
|
||||
while (queue.length && !truncated) {
|
||||
const { abs, depth } = queue.shift()!;
|
||||
let items: Awaited<ReturnType<typeof vreaddirStats>>;
|
||||
try {
|
||||
items = await vreaddirStats(abs);
|
||||
} catch (e) {
|
||||
if (depth === 0) throw remoteFilesError(e, start.def);
|
||||
carry(relOf(start.rootReal, abs));
|
||||
continue;
|
||||
}
|
||||
for (const it of items) {
|
||||
if (++seen > SOURCE_SCAN_LIMIT) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if (it.name.startsWith(".") || isTempName(it.name)) continue;
|
||||
const child = join(abs, it.name);
|
||||
const rel = relOf(start.rootReal, child);
|
||||
if (it.stats.isDirectory()) {
|
||||
if (depth + 1 >= opts.snapshotMaxDepth) continue;
|
||||
const unchanged =
|
||||
opts.snapshotCheckFolderModtime &&
|
||||
prev?.since != null &&
|
||||
it.stats.mtimeMs > 0 &&
|
||||
it.stats.mtimeMs <= prev.since;
|
||||
if (unchanged) carry(rel);
|
||||
else queue.push({ abs: child, depth: depth + 1 });
|
||||
} else if (kindOfName(it.name) === "video" && Number(it.stats.size) > 0)
|
||||
files[rel] = { size: Number(it.stats.size), mtimeMs: Number(it.stats.mtimeMs) };
|
||||
}
|
||||
}
|
||||
return { files, truncated, rootReal: start.rootReal };
|
||||
}
|
||||
|
||||
/** Query unfiltered QB state once: an untagged download may never have a DB task row. */
|
||||
async function activeDownloaderPaths(d: FmDeps, config: OrganizeSourceConfig): Promise<string[]> {
|
||||
const source = await rootDef(d.ctx.db, config.source.root);
|
||||
@@ -220,7 +282,10 @@ export async function scanOrganizeSource(d: FmDeps, input: { sourceId: string })
|
||||
const now = new Date();
|
||||
try {
|
||||
const liveActive = await activeDownloaderPaths(d, config);
|
||||
const scan = await walkSource(d, config);
|
||||
const scan = await walkSource(d, config, config.source.path, {
|
||||
files: row?.baseline_at ? ((row.snapshot as unknown as Snapshot).files ?? {}) : {},
|
||||
since: row?.baseline_at && row.last_scan_at ? new Date(row.last_scan_at).getTime() : null,
|
||||
});
|
||||
if (scan.truncated) throw new FilesError("too_large", "来源超过单次扫描上限,请选择更小的目录");
|
||||
const baseline = !row?.baseline_at;
|
||||
const advance = advanceSnapshot(baseline ? null : (row!.snapshot as unknown as Snapshot), scan.files);
|
||||
|
||||
Reference in new issue
Block a user