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

+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",