feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/adapters",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
@@ -114,12 +114,15 @@ describe("能力声明", () => {
paths[p!] = { ...paths[p!], [m!]: {} };
}
const doc = (version: string) => ({ info: { version }, paths });
const ok = camofoxCapabilitiesFromOpenApi(doc("1.17.0"));
expect(Object.keys(CAMOFOX_VERIFIED_RELEASES)).toContain("1.17.0");
for (const k of CAMOFOX_VERIFIED_RELEASES["1.17.0"]!.keys)
expect(ok[k]).toMatchObject({ supported: true, verified: true });
expect(camofoxCapabilitiesFromOpenApi(doc("1.17.0"), { takeover: false }).takeover.verified).toBe(false);
const other = camofoxCapabilitiesFromOpenApi(doc("1.18.0"));
for (const version of ["1.17.0", "1.18.0"]) {
const ok = camofoxCapabilitiesFromOpenApi(doc(version));
expect(Object.keys(CAMOFOX_VERIFIED_RELEASES)).toContain(version);
expect(CAMOFOX_VERIFIED_RELEASES[version]!.keys).toHaveLength(10);
for (const k of CAMOFOX_VERIFIED_RELEASES[version]!.keys)
expect(ok[k]).toMatchObject({ supported: true, verified: true });
expect(camofoxCapabilitiesFromOpenApi(doc(version), { takeover: false }).takeover.verified).toBe(false);
}
const other = camofoxCapabilitiesFromOpenApi(doc("1.19.0"));
expect(other.snapshot.verified).toBe(false);
// 静态声明不知道上游版本:一律未核验
expect(camofoxStaticCapabilities().snapshot.verified).toBe(false);
@@ -84,6 +84,22 @@ export const CAMOFOX_VERIFIED_RELEASES: Readonly<
"persistentProfile",
],
},
"1.18.0": {
verifiedAt: "2026-10-01",
source: "jo-inc/camofox-browser v1.18.0 (f94c81b),docker/camofox/Dockerfile 构建",
keys: [
"sessions",
"tabs",
"navigate",
"snapshot",
"screenshot",
"interact",
"extract",
"downloadCapture",
"takeover",
"persistentProfile",
],
},
};
/** 某上游版本的已核验能力 + 配置覆盖(配置里显式 false 可撤销) */
@@ -1134,10 +1134,66 @@ export class CamofoxBrowserProvider implements BrowserProvider {
* 当前页面内容(CF-2):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。只读,不要求自动化控制权;
* 上游不支持 evaluate 时返回 null。HTML 只给服务端解析器用,不进模型与日志。
*/
async pageContent(ref: TabRef, opts: { maxHtmlBytes?: number } = {}): Promise<PageContent | null> {
async pageContent(
ref: TabRef,
opts: { maxHtmlBytes?: number; currentVisible?: boolean } = {},
): Promise<PageContent | null> {
const s = this.state(ref.sessionId);
const max = Math.max(1024, Math.min(opts.maxHtmlBytes ?? 8 * 1024 * 1024, 16 * 1024 * 1024));
return this.withProfile(s, async () => {
// The desktop can create popups outside this adapter. Resolve its visible tab only
// for the explicit resource handoff; regular Agent actions keep their selected tab.
if (opts.currentVisible && !ref.tabId) {
const listed = await this.json(
await this.request("GET", "/tabs", { query: { userId: s.session.profileKey } }),
"/tabs",
);
const tabs = Array.isArray(listed.tabs) ? (listed.tabs as Json[]) : [];
const visible: string[] = [];
let uncertain = false;
for (const upstream of tabs) {
const id = str(upstream.tabId) ?? str(upstream.targetId);
if (!id) continue;
const at = this.now().toISOString();
const url = str(upstream.url);
if (!s.tabs.has(id))
s.tabs.set(id, {
id: this.newId(),
sessionId: ref.sessionId,
upstreamTabId: id,
url: url ? redactBrowserUrl(url) : null,
title: str(upstream.title) ?? null,
status: "open",
lastSnapshotAt: null,
closedAt: null,
createdAt: at,
updatedAt: at,
});
try {
const probePath = `/tabs/${encodeURIComponent(id)}/evaluate`;
const probe = await this.json(
await this.request("POST", probePath, {
body: {
userId: s.session.profileKey,
expression: '/* umt-visible-tab */ (() => ({ visible: document.visibilityState === "visible" }))()',
},
timeoutMs: s.budget.timeoutMs,
}),
probePath,
);
const state = probe.result && typeof probe.result === "object" ? (probe.result as Json).visible : undefined;
if (state === true) visible.push(id);
else if (state !== false) uncertain = true;
} catch {
// Missing/unsupported visibility cannot prove that the old tab is current.
uncertain = true;
}
}
if (uncertain || visible.length !== 1)
throw new BrowserError("bad_request", "无法唯一确定桌面当前标签页,请回到资源详情页后重试,或指定标签页");
if (s.currentTabId !== visible[0]) s.refsValid = false;
s.currentTabId = visible[0]!;
}
const tab = this.tabOf(s, ref);
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
let out: Json;
+2 -1
View File
@@ -71,8 +71,9 @@ export interface BrowserProvider {
/**
* 当前页面内容(CF-2 挑战识别与浏览器代抓):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。
* 只读,不要求自动化控制权;上游不支持时返回 null。HTML 只给服务端解析器,不进模型与日志。
* currentVisible 仅用于用户从桌面详情页交接资源:未指定 tabId 时发现唯一可见标签,不能确定则拒绝。
*/
pageContent?(ref: TabRef, opts?: { maxHtmlBytes?: number }): Promise<PageContent | null>;
pageContent?(ref: TabRef, opts?: { maxHtmlBytes?: number; currentVisible?: boolean }): Promise<PageContent | null>;
// ── BR-2 凭据统一 ──
/**
@@ -206,6 +206,44 @@ describe("camofox 适配器:身份与出口", () => {
expect(cookies.find((c) => c.name === "cf_clearance")?.value).toBe("clr-1");
});
it("resource handoff discovers a visible desktop popup while ordinary pageContent keeps its old selection", async () => {
mock = await createMockCamofox({ accessKey: "k", site: cfSite("auto") });
const { p, ref } = await openCf(mock);
const original = [...mock.tabs.values()][0]!;
original.visible = false;
mock.tabs.set("desktop-popup", {
...original,
tabId: "desktop-popup",
url: "https://audiences.example/details.php?id=2",
visible: true,
});
expect((await p.pageContent!(ref))?.url).toBe("https://audiences.example/torrents.php");
expect((await p.pageContent!(ref, { currentVisible: true }))?.url).toBe(
"https://audiences.example/details.php?id=2",
);
expect(p.sessionInfo(ref.sessionId)?.tabs.some((t) => t.upstreamTabId === "desktop-popup")).toBe(true);
expect((await p.pageContent!({ ...ref, tabId: original.tabId }, { currentVisible: true }))?.url).toBe(original.url);
});
it("resource handoff rejects ambiguous visibility and hidden old tabs", async () => {
mock = await createMockCamofox({ accessKey: "k", site: cfSite("auto") });
const { p, ref } = await openCf(mock);
const original = [...mock.tabs.values()][0]!;
original.visible = false;
await expect(p.pageContent!(ref, { currentVisible: true })).rejects.toMatchObject({ kind: "bad_request" });
original.visible = true;
mock.tabs.set("other-window", {
...original,
tabId: "other-window",
url: "https://audiences.example/details.php?id=3",
visible: true,
});
await expect(p.pageContent!(ref, { currentVisible: true })).rejects.toMatchObject({ kind: "bad_request" });
expect(p.sessionInfo(ref.sessionId)?.currentTabId).toBe(original.tabId);
mock.tabs.get("other-window")!.visible = null;
await expect(p.pageContent!(ref, { currentVisible: true })).rejects.toMatchObject({ kind: "bad_request" });
});
it("CF-2 坐标点击走插件真实鼠标事件(跨域 iframe 的 Turnstile 也点得到),不再 elementFromPoint", async () => {
mock = await createMockCamofox({ accessKey: "k", site: cfSite("interactive") });
const { p, ref } = await openCf(mock);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/agent",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
@@ -17,6 +17,7 @@ capabilities:
- organize.plan
- organize.execute
- metadata.enrich
- metadata.resolve_organize
sessions: [chat, investigate, child]
---
@@ -33,7 +34,7 @@ sessions: [chat, investigate, child]
- 已完成待整理 / 整理中 → 看关联的核验入库任务(task.inspect 看错误);
- 入库失败 → 读整理记录和错误,一句话说原因;
- 有「没识别出作品」的待处理事项 → decision.list 确认,说明要用户指定作品。
4. 身份问题:资源还没对上作品时可以用 identity.resolve 提出映射;TMDB 查不到的作品,网页搜索开启时用 metadata.enrich 出建议。
4. 身份问题:资源还没对上作品时可以用 identity.resolve 提出映射;TMDB 查不到的作品,网页搜索开启时用 metadata.enrich 出建议。整理要用的资源连常规识别也对不上作品时,用 metadata.resolve_organize 补全译名重查 TMDB,仍没有合适条目才建立带来源证据的本地作品。
5. 需要重新整理时:organize.plan 生成计划(不动文件),再 organize.execute。
## 需要批准
+6 -2
View File
@@ -12,10 +12,10 @@ import type {
} from "@umt/contracts";
// pi-ai 内置 provider → 能力表。认证方式、模型表直接读 builtinProviders();
// 登录方式、env 字段、条款提示是研究文档(docs/design/pi-providers.md §1.3/§2/§5)核对 0.87.1 源码后的静态表,
// 登录方式、env 字段、条款提示是研究文档(docs/design/pi-providers.md §1.3/§2/§5)及 0.99.2 升级核对的静态表,
// 升级 pi-ai 时随文档一起复核。
export const PI_AI_VERSION = "0.87.1";
export const PI_AI_VERSION = "0.99.2";
let cached: readonly Provider[] | null = null;
/** 内置 provider 实例(无状态的共享;Radius 这类动态 provider 按上游另建新实例) */
@@ -32,6 +32,7 @@ export function builtinProvider(id: string, fresh = false): Provider | undefined
/** 订阅/OAuth 登录方式(第一个是推荐项;有 device 的优先 device,不用粘 URL) */
export const OAUTH_MODES: Record<string, PiOAuthMode[]> = {
anthropic: ["browser"],
openai: ["browser"],
"openai-codex": ["device", "browser"],
"github-copilot": ["device"],
xai: ["device"],
@@ -45,6 +46,7 @@ export const OAUTH_MODES: Record<string, PiOAuthMode[]> = {
/** 浏览器模式 pi 会在服务端监听的回调端口(占用时 Anthropic 会直接失败) */
export const OAUTH_CALLBACK_PORTS: Record<string, number> = {
anthropic: 53692,
openai: 1455,
"openai-codex": 1455,
radius: 1456,
};
@@ -117,6 +119,8 @@ const NOTES: Record<string, string> = {
"opencode-go": "OpenCode Go:没有订阅登录,用 opencode.ai 控制台生成的 key(与 Zen 同一个 OPENCODE_API_KEY)",
openrouter: "登录换得永久 API Key,不需要刷新",
radius: "Pi 官方网关,模型列表动态刷新",
openai: "API Key 或原生 Sign in with ChatGPT;OAuth 请求使用官方 Responses 端点",
typesafe: "分类服务,不提供 Agent 聊天模型",
"amazon-bedrock": "API Key 填 Bedrock bearer token;或选“不需要凭证”使用服务端 AWS 凭证链",
"google-vertex": "API Key 填 Google Cloud API key;或选“不需要凭证”使用服务端 ADC / 服务账号",
};
@@ -114,6 +114,7 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [
{ cap: "web.search", note: "搜索" },
{ cap: "web.fetch", note: "读其中一条结果的正文" },
{ cap: "metadata.enrich", note: "TMDB 查不到的作品出元数据建议" },
{ cap: "metadata.resolve_organize", note: "整理用的资源对不上作品时兜底识别或建本地作品" },
],
},
];
+18 -4
View File
@@ -46,10 +46,10 @@ const spec = (s: Partial<UpstreamSpec> & Pick<UpstreamSpec, "provider" | "authKi
});
describe("能力表", () => {
it("41 个内置 provider + openai-compatible + fake,id 与合同枚举一致", () => {
it("42 个内置 provider + openai-compatible + fake,id 与合同枚举一致", () => {
const c = providerCatalog({ models: false });
const ids = c.providers.map((p) => p.id);
expect(ids).toHaveLength(43);
expect(ids).toHaveLength(44);
expect(new Set(ids)).toEqual(new Set(PiProviderId.options));
expect(ids.filter((i) => i !== "fake" && i !== "openai-compatible").sort()).toEqual(
[...PI_BUILTIN_PROVIDER_IDS].sort(),
@@ -58,11 +58,21 @@ describe("能力表", () => {
expect(c.providers.every((p) => p.models.length === 0)).toBe(true);
});
it("订阅登录:8 种 OAuth;订阅型带条款提示;Codex 只有 oauth 且优先 device", () => {
it("订阅登录:9 种 OAuth;Codex 只有 oauth 且优先 device;OpenAI 支持原生 ChatGPT 登录", () => {
const c = providerCatalog();
const oauth = c.providers.filter((p) => p.oauth && p.id !== "fake").map((p) => p.id);
expect(oauth.sort()).toEqual(
["anthropic", "github-copilot", "kimi-coding", "meta", "openai-codex", "openrouter", "radius", "xai"].sort(),
[
"anthropic",
"github-copilot",
"kimi-coding",
"meta",
"openai",
"openai-codex",
"openrouter",
"radius",
"xai",
].sort(),
);
const byId = Object.fromEntries(c.providers.map((p) => [p.id, p]));
expect(byId["openai-codex"]!.authKinds).toEqual(["oauth"]);
@@ -80,6 +90,10 @@ describe("能力表", () => {
expect(byId["amazon-bedrock"]!.authKinds).toContain("none");
expect(byId.openai!.models.length).toBeGreaterThan(10);
expect(byId.openai!.baseUrlOverridable).toBe(true);
expect(byId.openai!.authKinds).toEqual(["oauth", "api_key"]);
expect(byId.openai!.oauth!.modes).toEqual(["browser"]);
expect(byId.openai!.termsNote).toBeNull();
expect(byId.typesafe!.models).toEqual([]);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/contracts",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+72 -2
View File
@@ -6,6 +6,7 @@ import * as browser from "./browser.ts";
import * as cache from "./cache.ts";
import { MetadataEnrichOutput, WebSearchTestInput, WebSearchTestResult } from "./capabilities-io.ts";
import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts";
import * as classification from "./classification.ts";
import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts";
import * as content from "./content.ts";
import * as board from "./download-board.ts";
@@ -18,6 +19,7 @@ import * as libraryVersions from "./library-versions.ts";
import * as mcp from "./mcp.ts";
import * as ops from "./ops.ts";
import * as organization from "./organization.ts";
import * as organizeSources from "./organize-sources.ts";
import * as pi from "./pi.ts";
import * as policy from "./policy.ts";
import * as schedules from "./schedules.ts";
@@ -1114,8 +1116,7 @@ export const Api = {
method: "post",
path: "/acquisition-plans",
scope: "organize.execute",
summary:
"手动提交下载:与自动收录同一流程(download.plan → download.submit,含硬条件/预算/缓存阈值/授权检查);未生成计划时返回拒绝原因",
summary: "独立手动下载:不要求订阅,支持未知资源确认、下载器与目录选择,以及本次正常或洗版入库",
body: execution.ManualAcquisitionRequest,
response: execution.ManualAcquisitionResult,
}),
@@ -1459,6 +1460,66 @@ export const Api = {
},
// ── 文件管理(docs/plan/06-file-manager-spec.md;只浏览已登记根,写操作两段式 dryRun → planId) ──
classification: {
get: ep({
method: "get",
path: "/classification",
scope: "organize.read",
summary: "分类配置",
response: classification.ClassificationConfigView,
}),
put: ep({
method: "put",
path: "/classification",
scope: "admin",
summary: "保存分类配置",
body: classification.ClassificationPut,
response: classification.ClassificationConfigView,
}),
preview: ep({
method: "post",
path: "/classification/preview",
scope: "organize.read",
summary: "测试分类规则",
body: classification.ClassificationPreviewInput,
response: classification.ClassificationPreviewResult,
}),
},
organizeSources: {
list: ep({
method: "get",
path: "/organize-sources",
scope: "organize.read",
summary: "整理来源",
response: organizeSources.OrganizeSourceList,
}),
save: ep({
method: "put",
path: "/organize-sources/{id}",
scope: "admin",
summary: "保存整理来源",
params: IdParam,
body: organizeSources.OrganizeSourceInput,
response: organizeSources.OrganizeSourceView,
}),
remove: ep({
method: "delete",
path: "/organize-sources/{id}",
scope: "admin",
summary: "删除整理来源",
params: IdParam,
response: z.object({ deleted: z.boolean() }),
}),
stock: ep({
method: "post",
path: "/organize-sources/{id}/stock",
scope: "organize.execute",
summary: "扫描存量文件",
params: IdParam,
body: organizeSources.OrganizeStockInput,
response: organizeSources.OrganizeStockResult,
}),
},
files: {
roots: ep({
method: "get",
@@ -2532,6 +2593,15 @@ export const Api = {
body: browser.BrowserExtract,
response: browser.BrowserExtractResult,
}),
resource: ep({
method: "post",
path: "/browser/sessions/{id}/resource",
scope: "organize.execute",
summary: "解析当前原站资源详情,供独立手动下载使用",
params: browser.BrowserSessionParams,
body: browser.BrowserResourceRequest,
response: browser.BrowserResourceResult,
}),
download: ep({
method: "post",
path: "/browser/sessions/{id}/download",
+10
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { ManualAcquisitionResult } from "./execution.ts";
import { InstanceHealth } from "./instances.ts";
import { WorkbenchRelease } from "./sources.ts";
// 浏览器能力合同(REQ §4.4、§4.7.2–4.7.4、§12 浏览器实体)。
// 这是本项目自有接口,不等于上游同名接口:上游对接由 @umt/adapters 的 BrowserProvider 实现
@@ -539,6 +540,15 @@ export const BrowserDownloadResult = z.object({
});
export type BrowserDownloadResult = z.infer<typeof BrowserDownloadResult>;
/** 工具操作栏按当前详情页取得资源,复用手动下载入口。 */
export const BrowserResourceRequest = z.object({ tabId: z.string().min(1).optional() });
export type BrowserResourceRequest = z.infer<typeof BrowserResourceRequest>;
export const BrowserResourceResult = z.object({
release: WorkbenchRelease,
detailUrl: z.string(),
});
export type BrowserResourceResult = z.infer<typeof BrowserResourceResult>;
export const BrowserControlAcquire = z.object({
reason: z.string().min(1).max(200).default("人工接管"),
/** 租约时长;到期未交还 → 会话转"等待处理",不自动恢复自动化 */
@@ -8,6 +8,7 @@ import { Id, JsonObject, NonNegInt } from "./common.ts";
import { MediaEntity, MediaKind } from "./content.ts";
import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from "./execution.ts";
import { EnrichmentStatus, EnrichSuggestion, EnrichUsage, ExternalEntityType, ExternalIdentity } from "./identity.ts";
import { OrganizeMetadataInput, OrganizeMetadataOutput } from "./local-metadata.ts";
import { ServerMapping } from "./ops.ts";
import { OrganizationAction, OrganizationRecord } from "./organization.ts";
import {
@@ -949,6 +950,7 @@ export const CAPABILITY_IO = {
"agent.propose_options": { input: AgentProposeOptionsInput, output: AgentProposeOptionsOutput },
"web.search": { input: WebSearchInput, output: WebSearchOutput },
"web.fetch": { input: WebFetchInput, output: WebFetchOutput },
"metadata.resolve_organize": { input: OrganizeMetadataInput, output: OrganizeMetadataOutput },
"metadata.enrich": { input: MetadataEnrichInput, output: MetadataEnrichOutput },
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
+14
View File
@@ -92,6 +92,7 @@ export const CAPABILITY_IDS = [
"web.search",
"web.fetch",
"metadata.enrich",
"metadata.resolve_organize",
] as const;
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
@@ -591,6 +592,18 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
false,
),
// META-1:只产出建议(存补全记录,不写作品表);消耗网页搜索次数,受每日预算约束。应用建议走控制台 /identity-pool/{id}/enrich/apply
"metadata.resolve_organize": d(
"metadata.resolve_organize",
"整理元数据兜底",
"先常规识别,再由网页与 PT 详情补全译名重查 TMDB;没有合适条目时建立有来源证据的本地作品,供本次整理使用",
"execute",
"OrganizeMetadata",
"organize.execute",
"medium",
true,
false,
"never",
),
"metadata.enrich": d(
"metadata.enrich",
"补全元数据",
@@ -662,6 +675,7 @@ export const CAPABILITY_ACTIVITY: Record<KnownCapabilityId, CapabilityActivity>
"agent.propose_options": { ...act("给出", "方案"), hidden: true },
"web.search": act("搜", "网页", "次"),
"web.fetch": act("读", "网页", "个"),
"metadata.resolve_organize": act("补全", "整理元数据"),
"metadata.enrich": act("补全", "元数据"),
};
+49
View File
@@ -0,0 +1,49 @@
// copy-allow-file: 本文件包含用户分类 YAML 配置,键后的冒号必须保留半角语法。
import { z } from "zod";
import { JsonObject } from "./common.ts";
export const ClassificationConfigView = z.object({
yaml: z.string(),
categories: z.object({ movie: z.array(z.string()), tv: z.array(z.string()) }),
});
export type ClassificationConfigView = z.infer<typeof ClassificationConfigView>;
export const ClassificationPut = z.object({ yaml: z.string().min(1).max(100_000) });
export type ClassificationPut = z.infer<typeof ClassificationPut>;
export const ClassificationPreviewInput = z.object({
yaml: z.string().min(1).max(100_000).optional(),
mediaType: z.enum(["movie", "tv"]),
tmdb: JsonObject,
});
export type ClassificationPreviewInput = z.infer<typeof ClassificationPreviewInput>;
export const ClassificationPreviewResult = z.object({
category: z.string().nullable(),
matchedFields: z.array(z.string()),
fallback: z.boolean(),
});
export type ClassificationPreviewResult = z.infer<typeof ClassificationPreviewResult>;
export const DEFAULT_CLASSIFICATION_YAML = `movie:
动画电影:
genre_ids: '16'
演唱会:
genre_ids: '10402'
华语电影:
original_language: 'zh,cn,bo,za'
亚洲电影:
production_countries: 'JP,KR,KP,TH,VN,IN,SG,MY,ID,PH,KH,LA,MM,BD,PK,NP,LK,MN,TR,IR,IQ,SA'
欧美电影: null
tv:
儿童剧集:
genre_ids: '10762'
动漫剧集:
genre_ids: '16'
纪录片:
genre_ids: '99'
综艺:
genre_ids: '10764,10767'
华语剧集:
origin_country: 'CN,TW,HK'
亚洲剧集:
origin_country: 'JP,KR,KP,TH,VN,IN,SG,MY,ID,PH,KH,LA,MM,BD,PK,NP,LK,MN,TR,IR,IQ,SA'
欧美剧集: null
`;
+12 -6
View File
@@ -97,7 +97,7 @@ export type PlanStatus = z.infer<typeof PlanStatus>;
* 剧集 Slot 可同时有多个单元不相交的 reserved/submitted 计划(Slot×单元预留);电影 Slot 同时只有一个。
*/
export const AcquisitionPlan = EntityBase.extend({
slotId: Id,
slotId: Id.nullable(),
sourceReleaseId: Id,
taskId: Id.nullable(),
purpose: PlanPurpose,
@@ -133,27 +133,31 @@ export const AcquisitionPlanCreate = AcquisitionPlan.omit({ ...SERVER_MANAGED, s
});
export type AcquisitionPlanCreate = z.infer<typeof AcquisitionPlanCreate>;
/**
* 手动提交下载(工作台下载入口,POST /acquisition-plans):与自动收录同一流程——
* download.plan(evaluateAcquisition,硬条件/预算/阈值/Slot 忙)→ download.submit(预留 + submitDownload 任务)。
* 目标 Slot:slotId 优先;否则 entityId(缺省取来源资源已识别的作品)下按创建顺序逐个尝试,第一个生成计划的 Slot 胜出。
/** 手动下载为一次性计划,不要求订阅或Slot;未识别先提示一次,确认后可继续下载。
* slotId保留兼容入口,手动计划不预留或创建订阅Slot。
*/
export const ManualAcquisitionRequest = z.object({
sourceReleaseId: Id,
slotId: Id.optional(),
entityId: Id.optional(),
note: z.string().max(2000).optional(),
downloaderInstanceId: Id.optional(),
savePath: z.string().trim().min(1).max(4096).optional(),
allowUnrecognized: z.boolean().optional(),
importMode: z.enum(["normal", "replace"]).default("normal"),
replacementAssetIds: z.array(Id).default([]),
});
export type ManualAcquisitionRequest = z.infer<typeof ManualAcquisitionRequest>;
export const ManualAcquisitionResult = z.object({
/** true = 计划已预留并入队 submitDownload */
submitted: z.boolean(),
requiresConfirmation: z.boolean().optional(),
plan: AcquisitionPlan.nullable(),
slotId: Id.nullable(),
/** 业务任务 ID(submitDownload) */
taskId: Id.nullable(),
/** 未生成计划时逐个 Slot 的拒绝原因 */
/** 未生成计划的原因;未识别时附requiresConfirmation */
rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })),
});
export type ManualAcquisitionResult = z.infer<typeof ManualAcquisitionResult>;
@@ -188,6 +192,8 @@ export const DownloadTask = EntityBase.extend({
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
acquisitionPlanId: Id.nullable(),
/** 本次手动整理计划;保留原下载计划关联用于订阅推进 */
importPlanId: Id.nullable().optional(),
sourceReleaseId: Id.nullable(),
/** 关联作品:计划 / 站点资源 / 接管任务按文件名识别 / 手动指定 / 手动整理后回填(QB-1) */
mediaEntityId: Id.nullable().optional(),
+26 -8
View File
@@ -268,6 +268,8 @@ export const FilesRenameResult = z.object({
export type FilesRenameResult = z.infer<typeof FilesRenameResult>;
export const FilesTransferMode = z.enum(["move", "copy"]);
/** 原地入库保留路径与名称;仅用于整理,不扩展文件复制/移动 API。 */
export const FilesOrganizeMode = z.enum(["copy", "move", "in_place"]);
export type FilesTransferMode = z.infer<typeof FilesTransferMode>;
export const FilesMoveRequest = z.object({
@@ -373,16 +375,17 @@ export const FilesOrganizeItem = FileRef.extend({
episodes: z.array(z.number().int().min(0).max(99999)).max(50).optional(),
slotId: Id.optional(),
/** W5-A2:逐项操作方式(缺省取请求的 action);不同操作分到不同组 */
operation: FilesTransferMode.optional(),
operation: FilesOrganizeMode.optional(),
/** W5-A2:目标目录覆写(writable 库根下已存在目录;不建路径绑定,文件按模板命名放入该目录) */
targetDir: FileRef.optional(),
});
export type FilesOrganizeItem = z.infer<typeof FilesOrganizeItem>;
export const FilesOrganizeRequest = z.object({
sourceConfigId: Id.optional(),
idempotencyKey: IdempotencyKey,
items: z.array(FilesOrganizeItem).min(1).max(500),
action: FilesTransferMode,
action: FilesOrganizeMode,
targetLibraryRootId: Id.optional(),
dryRun: z.boolean().default(true),
/** 执行时必填:dryRun 返回的全部组 planId */
@@ -395,6 +398,10 @@ export const FilesOrganizeRequest = z.object({
naming: FilesNamingMode.optional(),
/** W9-H(G5):执行成功后对每个作品排一次元数据刷新(字段级,锁定字段不变) */
refreshMetadata: z.boolean().optional(),
/** 跳过已有真正成功整理记录的文件;目标已存在策略独立生效。 */
skipSuccessfulRecords: z.boolean().optional(),
/** 本次入库后写 NFO/图片;缺省沿用已有设置,Local 保留自动补全行为。 */
scrapeAfterImport: z.boolean().optional(),
});
export type FilesOrganizeRequest = z.infer<typeof FilesOrganizeRequest>;
@@ -428,7 +435,7 @@ export const FilesOrganizeGroup = z.object({
/** 无法应用的逐项覆写(如电影项的季/集、非连续集号、不属于该作品的 Slot/单元);能应用的覆写已反映在 files 里 */
warnings: z.array(z.string()),
/** W5-A2:该组的操作方式(逐项 operation 覆写后) */
action: FilesTransferMode.optional(),
action: FilesOrganizeMode.optional(),
/** W5-A2:该组的目标目录覆写(本机绝对路径) */
targetDir: z.string().nullable().optional(),
/** W5-A2:该组核验通过后推进的 Slot */
@@ -649,7 +656,12 @@ export const FileIdentifyItem = z.object({
skipped: z.literal("below_min_size").nullable().optional(),
/** W9-H(G5):该源文件已有的整理记录(向导标"有 n 条整理记录") */
records: z
.object({ count: NonNegInt, lastRecordId: Id.nullable(), lastStatus: OrganizationStatus.nullable() })
.object({
count: NonNegInt,
lastRecordId: Id.nullable(),
lastStatus: OrganizationStatus.nullable(),
successfulCount: NonNegInt.optional(),
})
.optional(),
});
export type FileIdentifyItem = z.infer<typeof FileIdentifyItem>;
@@ -726,10 +738,14 @@ export const FilesScrapeResult = z.object({
});
export type FilesScrapeResult = z.infer<typeof FilesScrapeResult>;
export const FilesScrapeRecordsQuery = z.object({
/** 逗号分隔的刮削记录 id(≤100) */
ids: z.string().min(1).max(4000),
});
export const FilesScrapeRecordsQuery = z
.object({
/** 逗号分隔的刮削记录 id(≤100) */
ids: z.string().min(1).max(4000).optional(),
/** 查询本次整理关联的刮削记录;与 ids 同时提供时取交集。 */
organizationRecordId: Id.optional(),
})
.refine((q) => !!q.ids || !!q.organizationRecordId, { message: "需要 ids 或 organizationRecordId" });
export type FilesScrapeRecordsQuery = z.infer<typeof FilesScrapeRecordsQuery>;
export const FilesScrapeRecordsResult = z.object({ records: z.array(ScrapeRecord) });
export type FilesScrapeRecordsResult = z.infer<typeof FilesScrapeRecordsResult>;
@@ -909,6 +925,8 @@ export type FilesTreeResult = z.infer<typeof FilesTreeResult>;
export const FilesRecordSourcesQuery = z.object({ recordId: Id });
export type FilesRecordSourcesQuery = z.infer<typeof FilesRecordSourcesQuery>;
export const FilesRecordSourcesResult = z.object({
/** 原整理计划保存的来源策略;不存在时由当前入口重新匹配。 */
sourceConfigId: Id.optional(),
recordId: Id,
mediaEntityId: Id.nullable(),
action: z.string(),
+3
View File
@@ -7,6 +7,7 @@ export * from "./browser.ts";
export * from "./cache.ts";
export * from "./capabilities-io.ts";
export * from "./capability.ts";
export * from "./classification.ts";
export * from "./common.ts";
export * from "./content.ts";
export * from "./download-board.ts";
@@ -15,8 +16,10 @@ export * from "./execution.ts";
export * from "./identity.ts";
export * from "./instances.ts";
export * from "./library-versions.ts";
export * from "./local-metadata.ts";
export * from "./ops.ts";
export * from "./organization.ts";
export * from "./organize-sources.ts";
export * from "./pi.ts";
export * from "./policy.ts";
export * from "./reasons.ts";
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
import { Id } from "./common.ts";
/** A one-time metadata recovery for an unidentified download, before normal organization. */
export const OrganizeMetadataInput = z.object({ sourceReleaseId: Id });
export const OrganizeMetadataOutput = z.object({
mediaEntityId: Id.nullable(),
mode: z.enum(["tmdb", "local"]).nullable(),
enrichmentId: Id.nullable(),
reason: z.string().nullable(),
});
export type OrganizeMetadataInput = z.infer<typeof OrganizeMetadataInput>;
export type OrganizeMetadataOutput = z.infer<typeof OrganizeMetadataOutput>;
+5 -2
View File
@@ -16,7 +16,7 @@ export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/**
* Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原
* Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原 / 原地入库
* (后三者只由文件管理的显式删除/还原产生,记录已结束,不经 planImport)
*/
export const OrganizationAction = z.enum([
@@ -28,6 +28,7 @@ export const OrganizationAction = z.enum([
"trash",
"delete",
"restore",
"in_place",
]);
export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
@@ -287,6 +288,8 @@ export const OrganizationListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
status: OrganizationStatus.optional(),
/** 工作队列与历史记录共用存储,历史只含终态。 */
phase: z.enum(["active", "history"]).optional(),
trigger: OrganizationTrigger.optional(),
mediaEntityId: Id.optional(),
downloadTaskId: Id.optional(),
@@ -379,7 +382,7 @@ export const OrganizeRequest = z
sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(),
mediaEntityId: Id.optional(),
contentUnitId: Id.optional(),
action: z.enum(["copy", "move"]),
action: z.enum(["copy", "move", "in_place"]),
targetLibraryRootId: Id.optional(),
/** true 只生成计划(organize.plan),不执行 */
dryRun: z.boolean().default(false),
@@ -0,0 +1,60 @@
import { z } from "zod";
import { Id, JsonObject, Timestamp } from "./common.ts";
// Independent reference shape avoids settings → instances → settings import cycles.
const SourceRef = z.object({
root: z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i),
path: z
.string()
.max(4096)
.refine((p) => !p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === ".")),
});
export const OrganizeSourceSnapshotRow = z.object({
sourceId: Id,
snapshot: JsonObject,
baselineAt: Timestamp.nullable(),
lastScanAt: Timestamp.nullable(),
lastError: z.string().nullable(),
});
export type OrganizeSourceSnapshotRow = z.infer<typeof OrganizeSourceSnapshotRow>;
export const OrganizeSourceInput = z.object({
name: z.string().trim().min(1).max(100),
enabled: z.boolean(),
source: SourceRef,
target: SourceRef.refine((v) => v.root.startsWith("lib:"), { message: "目标必须为媒体库" }),
mediaType: z.enum(["all", "movie", "tv"]),
category: z
.string()
.trim()
.min(1)
.max(100)
.refine(
(s) => !/[\\/<>:"|?*]/.test(s) && !Array.from(s).some((ch) => ch.charCodeAt(0) < 32) && s !== "." && s !== "..",
)
.nullable(),
action: z.enum(["copy", "move"]),
trigger: z.enum(["manual", "qb_tag", "snapshot"]),
downloaderInstanceId: Id.nullable(),
tag: z.string().trim().min(1).max(100).nullable(),
intervalSec: z.number().int().min(60).max(86400),
});
export type OrganizeSourceInput = z.infer<typeof OrganizeSourceInput>;
export const OrganizeSourceConfig = OrganizeSourceInput.extend({ id: Id });
export type OrganizeSourceConfig = z.infer<typeof OrganizeSourceConfig>;
export const OrganizeSourceView = OrganizeSourceConfig.extend({
baselineAt: Timestamp.nullable(),
lastScanAt: Timestamp.nullable(),
lastError: z.string().nullable(),
fileCount: z.number().int().nonnegative(),
});
export type OrganizeSourceView = z.infer<typeof OrganizeSourceView>;
export const OrganizeSourceList = z.object({ items: z.array(OrganizeSourceView) });
export const OrganizeStockInput = z.object({ path: SourceRef.shape.path.optional() });
export const OrganizeStockResult = z.object({
items: z.array(SourceRef),
skipped: z.number().int().nonnegative(),
unavailable: z.number().int().nonnegative(),
truncated: z.boolean(),
});
export type OrganizeStockResult = z.infer<typeof OrganizeStockResult>;
+4 -3
View File
@@ -3,9 +3,9 @@ import { Id, Timestamp } from "./common.ts";
// Pi 模型上游(W7-A):一个上游 = 一个 pi-ai provider 的一份凭据(API Key / 订阅登录 token / 环境凭据)。
// 同一 provider 可以建多个上游(例如两个 Codex 账号);Agent 运行按上游 id 取凭据。
// 研究结论与映射见 docs/design/pi-providers.md;provider 列表锁定 pi-ai 0.87.1 的 builtinProviders()。
// 研究结论与映射见 docs/design/pi-providers.md;provider 列表锁定 pi-ai 0.99.2 的 builtinProviders()。
/** pi-ai 0.87.1 内置的 41 个 provider id */
/** pi-ai 0.99.2 内置的 42 个 provider id(含 TypeSafe 分类服务) */
export const PI_BUILTIN_PROVIDER_IDS = [
"amazon-bedrock",
"ant-ling",
@@ -40,6 +40,7 @@ export const PI_BUILTIN_PROVIDER_IDS = [
"qwen-token-plan-individual",
"radius",
"together",
"typesafe",
"vercel-ai-gateway",
"xai",
"xiaomi",
@@ -51,7 +52,7 @@ export const PI_BUILTIN_PROVIDER_IDS = [
] as const;
/**
* 上游可选的 provider:41 个内置 + openai-compatible(自定义 OpenAI 兼容端点,必须填 baseUrl 与模型)
* 上游可选的 provider:42 个内置 + openai-compatible(自定义 OpenAI 兼容端点,必须填 baseUrl 与模型)
* + fake(脚本化假模型,不联网,演示与测试用)。
*/
export const PiProviderId = z.enum([...PI_BUILTIN_PROVIDER_IDS, "openai-compatible", "fake"]);
+5
View File
@@ -9,6 +9,11 @@ export type ReasonText = {
};
const REASONS: Record<string, ReasonText> = {
in_place_failed: { text: "原地入库未完成", action: "在整理记录里查看未登记的文件" },
in_place_outside_library: { text: "文件不在所选媒体库目录内", action: "先登记该目录为媒体库,或改用复制/移动" },
in_place_cannot_replace: { text: "原地入库不替换已有版本", action: "洗版请使用复制/移动入库" },
source_config_mismatch: { text: "文件或媒体类型不属于所选整理来源", action: "检查来源目录和媒体类型" },
classification_unavailable: { text: "暂时无法确定媒体分类", action: "检查 TMDB 连接或选择固定分类后重新整理" },
identity_unknown: { text: "没识别出作品", action: "指定作品后重新整理" },
identity_ambiguous: { text: "没识别出作品", action: "指定作品" },
identity_unresolved: { text: "没识别出作品", action: "指定作品" },
+11 -1
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import { DEFAULT_CLASSIFICATION_YAML } from "./classification.ts";
import { Id, Timestamp } from "./common.ts";
import { OrganizeSourceConfig } from "./organize-sources.ts";
import { SiteRateLimit } from "./sources.ts";
// 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。
@@ -67,6 +69,8 @@ export type CacheSettings = z.infer<typeof CacheSettings>;
export const OrganizeMode = z.enum(["copy", "move"]);
export type OrganizeMode = z.infer<typeof OrganizeMode>;
export const OrganizeSettings = z.object({
classificationYaml: z.string().min(1).max(100_000),
sources: z.array(OrganizeSourceConfig),
defaultMode: OrganizeMode,
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
allowCrossVolumeMove: z.boolean(),
@@ -632,6 +636,8 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
globalImageCacheDays: 7,
},
organize: {
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
sources: [],
defaultMode: "copy",
allowCrossVolumeMove: true,
minSizeBytes: 50 * 1024 * 1024,
@@ -717,7 +723,7 @@ export const GlobalSettingsUpdate = z.object({
.extend({ workerLanes: WorkerLaneSettings.partial().optional() })
.optional(),
cache: CacheShape.partial().optional(),
organize: OrganizeSettings.partial().optional(),
organize: OrganizeSettings.omit({ sources: true }).partial().optional(),
retention: RetentionSettings.partial().optional(),
/**
* defaultUpstreamId 只能经 /pi/upstreams(makeDefault)修改,保证指向存在的上游;
@@ -953,11 +959,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "roots", label: "媒体库目录" },
{ id: "classification", label: "分类规则" },
{ id: "sources", label: "整理来源" },
{ id: "organize", label: "整理方式" },
{ id: "scrape", label: "刮削" },
{ id: "naming", label: "命名模板" },
],
fields: [
"organize.classificationYaml",
"organize.sources",
"organize.defaultMode",
"organize.allowCrossVolumeMove",
"organize.minSizeBytes",
+1
View File
@@ -66,6 +66,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
MediaServerInstance: { row: C.MediaServerInstance },
Observation: { row: C.Observation },
OrganizationFileResult: { row: C.OrganizationFileResult },
OrganizeSourceSnapshot: { row: C.OrganizeSourceSnapshotRow },
OrganizationRecord: { row: C.OrganizationRecord, dbOnly: ["changeXid"] },
OutboxEvent: { row: C.OutboxEvent },
PathBinding: { row: C.PathBinding },
+3 -3
View File
@@ -41,9 +41,9 @@ describe("PiUpstreamCreate", () => {
expect(ok({ name: "x", provider: "openai", authKind: "token" })).toBe(false);
});
it("provider 枚举 = 41 个内置 + openai-compatible + fake", () => {
expect(C.PI_BUILTIN_PROVIDER_IDS).toHaveLength(41);
expect(C.PiProviderId.options).toHaveLength(43);
it("provider 枚举 = 42 个内置 + openai-compatible + fake", () => {
expect(C.PI_BUILTIN_PROVIDER_IDS).toHaveLength(42);
expect(C.PiProviderId.options).toHaveLength(44);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/db",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
@@ -0,0 +1,20 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// Persistent baseline: unavailable storage must never look like an empty source.
// biome-ignore lint/suspicious/noExplicitAny: migrations are independent of application types
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`create table organize_source_snapshot (
source_id uuid primary key,
snapshot jsonb not null default '{}'::jsonb,
baseline_at timestamptz,
last_scan_at timestamptz,
last_error text
)`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: migrations are independent of application types
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, "drop table if exists organize_source_snapshot");
}
@@ -0,0 +1,18 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// One-off manual downloads are plans without a subscription slot.
// biome-ignore lint/suspicious/noExplicitAny: migrations are independent of application types
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
"alter table acquisition_plan alter column slot_id drop not null; alter table download_task add column import_plan_id uuid references acquisition_plan(id) on delete set null",
);
}
// biome-ignore lint/suspicious/noExplicitAny: migrations are independent of application types
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
"alter table download_task drop column import_plan_id; alter table acquisition_plan alter column slot_id set not null",
);
}
@@ -0,0 +1,19 @@
import type { Kysely } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
const old = ["copy", "move", "rename", "relocate", "hardlink", "trash", "delete", "restore"];
const swap = (values: string[]) => `
alter table organization_record drop constraint organization_record_action_check;
alter table organization_record add constraint organization_record_action_check ${oneOf("action", values)};
alter table organization_file_result drop constraint organization_file_result_mode_check;
alter table organization_file_result add constraint organization_file_result_mode_check ${oneOf("mode", values)};
`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, swap([...old, "in_place"]));
}
// 回退需先处置新版记录;不以回退为由删除用户历史。
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, swap(old));
}
+18 -3
View File
@@ -174,7 +174,8 @@ export type OrganizationFileResultMode =
| "hardlink"
| "trash"
| "delete"
| "restore";
| "restore"
| "in_place";
export type OrganizationFileResultStatus = "queued" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
export type OrganizationRecordTrigger = "auto" | "manual" | "agent";
export type OrganizationRecordActorType = "user" | "agent" | "system" | "api_key";
@@ -187,7 +188,8 @@ export type OrganizationRecordAction =
| "hardlink"
| "trash"
| "delete"
| "restore";
| "restore"
| "in_place";
export type OrganizationRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled";
export type OutboxEventStatus = "pending" | "dispatched" | "failed" | "dead";
export type PathBindingBindingKind = "entity" | "season";
@@ -263,7 +265,7 @@ export type WebauthnChallengePurpose = "register" | "login";
export interface AcquisitionPlanTable {
id: Generated<string>;
slot_id: string;
slot_id: string | null;
source_release_id: string;
task_id: string | null;
purpose: Generated<AcquisitionPlanPurpose>;
@@ -709,6 +711,7 @@ export interface DownloadTaskTable {
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
media_entity_id: string | null;
import_plan_id: string | null;
}
export interface DownloaderInstanceTable {
@@ -1052,6 +1055,14 @@ export interface OrganizationRecordTable {
updated_at: GeneratedTimestamptz;
}
export interface OrganizeSourceSnapshotTable {
source_id: string;
snapshot: Generated<Json>;
baseline_at: Timestamptz | null;
last_scan_at: Timestamptz | null;
last_error: string | null;
}
export interface OutboxEventTable {
id: Generated<string>;
at: GeneratedTimestamptz;
@@ -1735,6 +1746,7 @@ export interface DB {
observation: ObservationTable;
organization_file_result: OrganizationFileResultTable;
organization_record: OrganizationRecordTable;
organize_source_snapshot: OrganizeSourceSnapshotTable;
outbox_event: OutboxEventTable;
path_binding: PathBindingTable;
path_mapping: PathMappingTable;
@@ -1910,6 +1922,9 @@ export type OrganizationFileResultUpdate = Updateable<OrganizationFileResultTabl
export type OrganizationRecordRow = Selectable<OrganizationRecordTable>;
export type NewOrganizationRecord = Insertable<OrganizationRecordTable>;
export type OrganizationRecordUpdate = Updateable<OrganizationRecordTable>;
export type OrganizeSourceSnapshotRow = Selectable<OrganizeSourceSnapshotTable>;
export type NewOrganizeSourceSnapshot = Insertable<OrganizeSourceSnapshotTable>;
export type OrganizeSourceSnapshotUpdate = Updateable<OrganizeSourceSnapshotTable>;
export type OutboxEventRow = Selectable<OutboxEventTable>;
export type NewOutboxEvent = Insertable<OutboxEventTable>;
export type OutboxEventUpdate = Updateable<OutboxEventTable>;
+2
View File
@@ -62,6 +62,7 @@ const EXPECTED_TABLES = [
"reconcile_run",
"server_mapping",
"organization_record",
"organize_source_snapshot",
"organization_file_result",
// tasks 包自有表(0013)与 agent 事件(0014)
"tasks_run_meta",
@@ -129,6 +130,7 @@ const EXPECTED_TABLES = [
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
const NON_ENTITY_TABLES = [
"organize_source_snapshot",
"settings",
"audit_event",
"outbox_event",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/domain",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { classifyTmdb, DEFAULT_CLASSIFICATION_YAML, parseClassificationYaml } from "./classification.ts";
const config = parseClassificationYaml(DEFAULT_CLASSIFICATION_YAML);
describe("ordered TMDB classification", () => {
it("defaults honor genre priority and normalize raw movie and TV facts", () => {
expect(classifyTmdb(config, "movie", { genres: [{ id: 16 }], original_language: "zh" })).toEqual({
category: "动画电影",
matchedFields: ["genre_ids"],
fallback: false,
});
expect(classifyTmdb(config, "movie", { genres: [{ id: 10402 }] }).category).toBe("演唱会");
for (const language of ["zh", "cn", "bo", "za"])
expect(classifyTmdb(config, "movie", { original_language: language }).category).toBe("华语电影");
for (const country of ["JP", "VN", "IR", "SA"]) {
expect(classifyTmdb(config, "movie", { production_countries: [{ iso_3166_1: country }] }).category).toBe(
"亚洲电影",
);
expect(classifyTmdb(config, "tv", { origin_country: [country] }).category).toBe("亚洲剧集");
}
expect(classifyTmdb(config, "tv", { genres: [{ id: 16 }, { id: 10762 }], origin_country: ["CN"] }).category).toBe(
"儿童剧集",
);
expect(classifyTmdb(config, "tv", { genre_ids: [10764], origin_country: ["CN"] }).category).toBe("综艺");
expect(classifyTmdb(config, "tv", { origin_country: ["TW"] }).category).toBe("华语剧集");
expect(classifyTmdb(config, "movie", { original_language: "en" })).toEqual({
category: "欧美电影",
matchedFields: [],
fallback: true,
});
});
it("AND fields, OR scalar/list values, exclusions and year ranges", () => {
const c = parseClassificationYaml(`movie:
合作:
original_language: [en, fr]
production_countries: 'JP,!US'
release_year: '2000-2010,2020'
adult: false
其它: null
tv:
年份:
release_year: '!2000-2010'
`);
const raw = {
original_language: "en",
production_countries: [{ iso_3166_1: "JP" }],
release_date: "2010-02-03",
adult: false,
};
expect(classifyTmdb(c, "movie", raw).matchedFields).toEqual([
"original_language",
"production_countries",
"release_year",
"adult",
]);
expect(classifyTmdb(c, "movie", { ...raw, adult: true }).category).toBe("其它");
expect(
classifyTmdb(c, "movie", { ...raw, production_countries: [{ iso_3166_1: "JP" }, { iso_3166_1: "US" }] }).category,
).toBe("其它");
expect(classifyTmdb(c, "movie", { ...raw, release_date: "2020-01-01" }).category).toBe("合作");
expect(classifyTmdb(c, "tv", { first_air_date: "2011-01-01" }).category).toBe("年份");
expect(classifyTmdb(c, "tv", { title: "Missing date" }).category).toBeNull();
});
it("supports arbitrary first-level scalar/list fields and numeric category ordering", () => {
const c = parseClassificationYaml(`movie:
'20':
custom: [true, 3]
'1':
custom: true
tv: {}`);
expect(c.movie.map((r) => r.category)).toEqual(["20", "1"]);
expect(classifyTmdb(c, "movie", { custom: [true, 4] }).category).toBe("20");
});
it("no fallback without actual raw data, missing exclusions do not match", () => {
expect(classifyTmdb(config, "movie", null).category).toBeNull();
expect(classifyTmdb(config, "movie", {}).category).toBeNull();
const c = parseClassificationYaml("movie:\n 未排除:\n original_language: '!zh'\ntv: {}\n");
expect(classifyTmdb(c, "movie", { title: "Unknown" }).category).toBeNull();
expect(classifyTmdb(c, "movie", { original_language: "" }).category).toBeNull();
});
it.each([
"movie: {}\nmovie: {}\ntv: {}",
"movie:\n 重复: null\n 重复: null\ntv: {}",
"movie:\n 分类:\n genre_ids: 16\n genre_ids: 99\ntv: {}",
"movie:\n ../逃逸: null\ntv: {}",
"movie:\n 无条件: null\n 动画:\n genre_ids: 16\ntv: {}",
"movie:\n 条件:\n release_year: '2020-2000'\ntv: {}",
"movie:\n 条件:\n nested: {invalid: value}\ntv: {}",
"movies: {}\ntv: {}",
])("rejects invalid config: %s", (yaml) => expect(() => parseClassificationYaml(yaml)).toThrow());
});
+132
View File
@@ -0,0 +1,132 @@
import { parseDocument } from "yaml";
/** Ordered TMDB rules; first matching category wins. */
export interface ClassificationRule {
category: string;
conditions: { field: string; values: string[] }[];
}
export interface ClassificationConfig {
movie: ClassificationRule[];
tv: ClassificationRule[];
}
export interface ClassificationResult {
category: string | null;
matchedFields: string[];
fallback: boolean;
}
export { DEFAULT_CLASSIFICATION_YAML } from "@umt/contracts";
const scalar = (v: unknown): v is string | number | boolean =>
typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v));
export function parseClassificationYaml(yaml: string): ClassificationConfig {
const doc = parseDocument(yaml, { uniqueKeys: true, schema: "core" });
if (doc.errors.length) throw new Error(doc.errors.map((e) => e.message).join("; "));
const raw: unknown = doc.toJS({ mapAsMap: true, maxAliasCount: 0 });
if (!(raw instanceof Map) || [...raw.keys()].some((k) => k !== "movie" && k !== "tv"))
throw new Error("分类配置的一级名称只能是 movie、tv");
const config: ClassificationConfig = { movie: [], tv: [] };
for (const type of ["movie", "tv"] as const) {
const categories: unknown = raw.get(type);
if (!(categories instanceof Map)) throw new Error(`${type} 必须是分类名称映射`);
for (const [category, fields] of categories) {
if (
typeof category !== "string" ||
!category.trim() ||
category.trim() !== category ||
/[\\/<>:"|?*]/.test(category) ||
Array.from(category).some((ch) => ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127) ||
category === "." ||
category === ".." ||
/[. ]$/.test(category) ||
/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(category)
)
throw new Error(`${type} 分类名称必须是安全的单层目录名`);
if (fields !== null && !(fields instanceof Map)) throw new Error(`${type}.${category} 必须是条件映射或 null`);
const conditions: ClassificationRule["conditions"] = [];
for (const [field, input] of fields ?? []) {
if (typeof field !== "string" || !field.trim()) throw new Error(`${category} 的条件字段必须是非空字符串`);
const list: unknown[] = Array.isArray(input) ? input : [input];
if (!list.length || !list.every(scalar)) throw new Error(`${category}.${field} 条件必须是标量或标量列表`);
const values = list.flatMap((v) =>
String(v)
.split(",")
.map((s) => s.trim()),
);
if (values.some((v) => !v || v === "!")) throw new Error(`${category}.${field} 条件值不能为空`);
if (field === "release_year") {
for (const value of values) {
const year = value.replace(/^!/, "");
if (!/^\d{4}(?:-\d{4})?$/.test(year) || (year.includes("-") && year.slice(0, 4) > year.slice(5)))
throw new Error(`${category}.release_year 请使用 YYYY 或 YYYY-YYYY`);
}
}
conditions.push({ field, values });
}
config[type].push({ category, conditions });
}
if (config[type].some((r, i) => !r.conditions.length && i !== config[type].length - 1))
throw new Error(`${type} 无条件兜底分类必须放在最后`);
}
return config;
}
function actualValues(raw: Record<string, unknown>, type: "movie" | "tv", field: string): string[] {
let input = field === "release_year" ? raw[type === "movie" ? "release_date" : "first_air_date"] : raw[field];
if (field === "genre_ids" && input === undefined) input = raw.genres;
const list = Array.isArray(input) ? input : [input];
return list.flatMap((item) => {
let value = item;
if (value && typeof value === "object") {
const o = value as Record<string, unknown>;
value =
field === "production_countries"
? o.iso_3166_1
: field === "genre_ids" || field === "genres"
? o.id
: undefined;
}
if (!scalar(value) || String(value).trim() === "") return [];
if (field === "release_year") {
const year = String(value)
.match(/^\d{4}(?:-|$)/)?.[0]
.slice(0, 4);
return year ? [year] : [];
}
return [String(value)];
});
}
function matches(actual: string[], values: string[], field: string): boolean {
if (!actual.length) return false;
const hit = (value: string) =>
actual.some((a) => {
if (field === "release_year" && value.includes("-")) {
const [start, end] = value.split("-");
return a >= start! && a <= end!;
}
return a === value;
});
const excluded = values.filter((v) => v.startsWith("!")).map((v) => v.slice(1));
const included = values.filter((v) => !v.startsWith("!"));
return !excluded.some(hit) && (!included.length || included.some(hit));
}
export function classifyTmdb(
config: ClassificationConfig,
type: "movie" | "tv",
raw: Record<string, unknown> | null | undefined,
): ClassificationResult {
const none: ClassificationResult = { category: null, matchedFields: [], fallback: false };
if (!raw || !Object.keys(raw).length) return none;
for (const rule of config[type]) {
if (rule.conditions.every(({ field, values }) => matches(actualValues(raw, type, field), values, field)))
return {
category: rule.category,
matchedFields: rule.conditions.map((c) => c.field),
fallback: !rule.conditions.length,
};
}
return none;
}
+1
View File
@@ -2,6 +2,7 @@
export const PACKAGE_NAME = "@umt/domain";
export * from "./acquisition/index.ts";
export * from "./classification.ts";
export * from "./download-state/index.ts";
export * from "./identity/index.ts";
export * from "./library/versions.ts";
+1
View File
@@ -15,5 +15,6 @@ export {
planOrganize,
type SourceFile,
} from "./plan.ts";
export * from "./replacement.ts";
export { type DefaultSubtitleLang, sidecarLanguageSuffix, sidecarStem } from "./sidecars.ts";
export { findOtherVersions } from "./versions.ts";
+25 -10
View File
@@ -13,7 +13,7 @@ import {
splitExt,
} from "./sidecars.ts";
export type OrganizeOperation = "copy" | "move" | "rename";
export type OrganizeOperation = "copy" | "move" | "rename" | "in_place";
export type OrganizeFileKind = "video" | "subtitle" | "audio" | "nfo" | "other";
export type ExistingPolicy = "skip" | "replace_with_verify" | "conflict";
@@ -52,6 +52,8 @@ export interface OrganizeInput {
/** 主视频最小体积,默认 50 MiB;低于此值视为样例/垃圾 */
minVideoBytes?: number;
grants?: { replace?: boolean };
/** Explicit one-time replacement grants, restricted to the selected old library paths. */
replacePaths?: readonly string[];
parse?: ParseOptions;
/** 随行字幕里此语言加 .default 标记(对照 MP DEFAULT_SUB);不设不加 */
defaultSubtitleLang?: DefaultSubtitleLang;
@@ -198,6 +200,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
const existing = new Map((input.existingTargets ?? []).map((e) => [e.path, e]));
const targetDevice = input.target.device;
const isRename = input.operation === "rename";
const inPlace = input.operation === "in_place";
const videos: { f: SourceFile; parsed: ParsedRelease; dst: string }[] = [];
const sidecars: SourceFile[] = [];
@@ -232,6 +235,15 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
excluded.push({ src: f.path, kind, reason: "extra_content" });
continue;
}
if (inPlace) {
if (input.mediaType === "tv" && !parsed.episodes && !parsed.airDate) {
excluded.push({ src: f.path, kind, reason: "no_episode" });
continue;
}
if (input.mediaType === "tv" && parsed.season === undefined) parsed.season = 1;
videos.push({ f, parsed, dst: f.path });
continue;
}
const [, ext] = splitExt(basename(f.path));
let dir: string | undefined;
let named: ReturnType<typeof renderMediaFileName>;
@@ -276,7 +288,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
kind: "video",
role: "main",
action: "transfer",
...(sameDevice(v.f.device, targetDevice) !== undefined
...(!inPlace && sameDevice(v.f.device, targetDevice) !== undefined
? { crossDevice: sameDevice(v.f.device, targetDevice) }
: {}),
...(v.parsed.season !== undefined ? { season: v.parsed.season } : {}),
@@ -310,7 +322,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
const [mainStem] = splitExt(main.dst);
const [, ext] = splitExt(basename(s.path));
const suffix = sidecarLanguageSuffix(tags, input.defaultSubtitleLang);
let dst = `${mainStem}${suffix}.${ext.toLowerCase()}`;
let dst = inPlace ? s.path : `${mainStem}${suffix}.${ext.toLowerCase()}`;
for (let n = 1; usedSidecarDst.has(dst); n++) dst = `${mainStem}${suffix}.${n}.${ext.toLowerCase()}`;
usedSidecarDst.add(dst);
planned.push({
@@ -321,7 +333,9 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
role: "sidecar",
mainSrc: main.f.path,
action: "transfer",
...(sameDevice(s.device, targetDevice) !== undefined ? { crossDevice: sameDevice(s.device, targetDevice) } : {}),
...(!inPlace && sameDevice(s.device, targetDevice) !== undefined
? { crossDevice: sameDevice(s.device, targetDevice) }
: {}),
});
}
@@ -337,7 +351,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
// 目标已存在
const replaced: string[] = [];
for (const p of planned) {
if (p.action !== "transfer") continue;
if (inPlace || p.action !== "transfer") continue;
const e = existing.get(p.dst);
if (!e) continue;
if (e.size === p.size && e.sha256 !== undefined && e.sha256 === input.files.find((f) => f.path === p.src)?.sha256) {
@@ -349,7 +363,7 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
p.action = "skip";
p.skipReason = "target_exists";
conflicts.push({ kind: "target_exists", blocking: false, src: p.src, dst: p.dst, detail: "skipped" });
} else if (onExisting === "replace_with_verify") {
} else if (onExisting === "replace_with_verify" && (!input.replacePaths || input.replacePaths.includes(p.dst))) {
p.action = "replace";
replaced.push(p.dst);
if (!input.grants?.replace)
@@ -397,10 +411,11 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
bytes,
});
}
preconditions.push({
kind: "target_absent",
paths: active.filter((p) => p.action === "transfer").map((p) => p.dst),
});
if (!inPlace)
preconditions.push({
kind: "target_absent",
paths: active.filter((p) => p.action === "transfer").map((p) => p.dst),
});
preconditions.push({ kind: "source_unchanged", files: active.map((p) => ({ path: p.src, size: p.size })) });
const verify = active.filter((p) => !isRename && needsCopy(p)).map((p) => p.dst);
if (verify.length > 0) preconditions.push({ kind: "verify_after_copy", paths: verify });
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { replacementCovers, replacementScope } from "./replacement.ts";
describe("one-time replacement coverage", () => {
it("movies require the same Part", () => {
const old = replacementScope("Movie.2020.720p.Part1.mkv", "movie");
expect(replacementCovers(old, [replacementScope("Movie.2020.1080p.Part2.mkv", "movie")])).toBe(false);
expect(replacementCovers(old, [replacementScope("Movie.2020.1080p.Part1.mkv", "movie")])).toBe(true);
});
it("replaces only episodes fully covered by successful files, preserving indivisible merged ranges", () => {
const success = [1, 2, 3, 5, 6].map((n) => replacementScope(`Show.S01E${String(n).padStart(2, "0")}.mkv`, "tv"));
expect(replacementCovers(replacementScope("Show.S01E01.mkv", "tv"), success)).toBe(true);
expect(replacementCovers(replacementScope("Show.S01E04.mkv", "tv"), success)).toBe(false);
expect(replacementCovers(replacementScope("Show.S01E07.mkv", "tv"), success)).toBe(false);
expect(replacementCovers(replacementScope("Show.S01E01-E10.mkv", "tv"), success)).toBe(false);
expect(replacementCovers(replacementScope("Show.S02E01.mkv", "tv"), success)).toBe(false);
expect(replacementCovers(replacementScope("Show.Special.mkv", "tv"), success)).toBe(false);
});
});
@@ -0,0 +1,35 @@
import { parseReleasePath } from "../release-parser/path.ts";
/** Physical files are indivisible: every covered unit must have a successful replacement. */
export interface ReplacementScope {
mediaType: "movie" | "tv";
part: string;
season?: number;
episodes?: number[];
}
export function replacementScope(
path: string,
mediaType: "movie" | "tv",
units?: { season?: number; episodes?: number[] },
): ReplacementScope {
const parsed = parseReleasePath(path);
const episodes =
units?.episodes ??
(parsed.episodes
? Array.from({ length: parsed.episodes.end - parsed.episodes.start + 1 }, (_, i) => parsed.episodes!.start + i)
: undefined);
const season = units?.season ?? parsed.season;
return {
mediaType,
part: (parsed.part ?? "").toLowerCase(),
...(season !== undefined ? { season } : {}),
...(episodes ? { episodes } : {}),
};
}
export function replacementCovers(old: ReplacementScope, successful: readonly ReplacementScope[]): boolean {
const compatible = successful.filter((s) => s.mediaType === old.mediaType && s.part === old.part);
if (old.mediaType === "movie") return compatible.length > 0;
if (old.season === undefined || !old.episodes?.length) return false;
const episodes = new Set(compatible.filter((s) => s.season === old.season).flatMap((s) => s.episodes ?? []));
return old.episodes.every((e) => episodes.has(e));
}
+33
View File
@@ -332,3 +332,36 @@ describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", ()
]);
});
});
describe("原地入库计划", () => {
it("保留视频与配对字幕路径,不依赖命名模板和目标,不要求搬运空间或目标不存在", () => {
const input = tvInput({
operation: "in_place",
target: { device: 2 },
templates: { version: 2, movie: "{{invalid}}", tv: "{{invalid}}", variety: "{{invalid}}" },
});
const p = planOrganize(input);
expect(p.files).toHaveLength(4);
expect(p.files.every((f) => f.src === f.dst && f.action === "transfer")).toBe(true);
expect(p.requiresApproval).toBe(false);
expect(p.crossDevice).toBe(false);
expect(p.preconditions.map((p) => p.kind)).toEqual(["source_unchanged"]);
expect(p.files[0]?.season).toBe(1);
expect(p.files[0]?.episodes).toEqual({ start: 1, end: 1 });
});
it("不将当前文件视作目标冲突,仍排除样例与无法定位的剧集", () => {
const input = tvInput({
operation: "in_place",
files: [
{ path: `${ROOT}/Unknown.mkv`, size: GiB },
{ path: `${ROOT}/Known.S02E03.mkv`, size: GiB },
{ path: `${ROOT}/sample.mkv`, size: GiB },
],
});
const p = planOrganize({ ...input, existingTargets: input.files.map((f) => ({ path: f.path, size: f.size })) });
expect(p.files).toHaveLength(1);
expect(p.files[0]?.dst).toBe(`${ROOT}/Known.S02E03.mkv`);
expect(p.excluded.map((f) => f.reason)).toEqual(["no_episode", "sample"]);
expect(p.requiresApproval).toBe(false);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/mapping",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+7 -1
View File
@@ -21,9 +21,15 @@ import type {
// 整理计划(domain.planOrganize)→ organization_record / organization_file_result,以及 organize.plan 能力输出。
const OPERATION: Record<OrganizeOperation, OrganizationAction> = { copy: "copy", move: "move", rename: "rename" };
const OPERATION: Record<OrganizeOperation, OrganizationAction> = {
copy: "copy",
move: "move",
rename: "rename",
in_place: "in_place",
};
const ACTION_BACK: Record<OrganizationAction, OrganizeOperation | null> = {
copy: "copy",
in_place: "in_place",
move: "move",
rename: "rename",
// domain 不生成目录调整/硬链接计划
+1 -1
View File
@@ -126,7 +126,7 @@ describe("Slot / 内容类型 / 分段 / 整理动作", () => {
});
it("整理操作、文件类型、计划动作都有映射", () => {
expect(sorted(M.DOMAIN_ORGANIZE_OPERATIONS)).toEqual(sorted(["copy", "move", "rename"]));
expect(sorted(M.DOMAIN_ORGANIZE_OPERATIONS)).toEqual(sorted(["copy", "move", "rename", "in_place"]));
for (const op of M.DOMAIN_ORGANIZE_OPERATIONS) expect(M.toOrganizeOperation(M.toOrganizationAction(op))).toBe(op);
for (const a of C.OrganizationAction.options) {
const op = M.toOrganizeOperation(a);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/tasks",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/testkit",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/index.ts" },
"tasks": {
"test": "deno run -A npm:vitest run",
@@ -100,6 +100,8 @@ export interface MockCamofoxTab {
marks: Map<string, string>;
downloads: MockCamofoxDownload[];
error: string | null;
/** Desktop visibility fixture, independent of the adapter selected tab. */
visible?: boolean | null;
}
export interface MockCamofoxDownload {
@@ -652,7 +654,11 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
marks: new Map(),
downloads: [],
error: null,
visible: true,
};
// A newly opened desktop tab becomes visible; visibility is independent
// of the adapter's currentTabId and can be changed explicitly by fixtures.
for (const other of tabs.values()) if (other.userId === t.userId) other.visible = false;
tabs.set(tabId, t);
const url = typeof b.url === "string" ? go(t, b.url) : go(t, "about:blank");
return { json: { tabId, url, httpStatus: b.url ? 200 : null, navigationOk: true } };
@@ -787,6 +793,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
},
},
};
if (expr.includes("umt-visible-tab"))
return { json: { ok: true, result: { visible: t.visible === null ? null : (t.visible ?? true) } } };
// 页面内容(CF-2):地址 / 标题 / HTML / 正文 / UA
if (expr.includes("umt-page")) {
const n = (contentReads.get(t.userId) ?? 0) + 1;