From ed083fd13881c14ce0a84ac094f02981a22ad84c Mon Sep 17 00:00:00 2001 From: root Date: Fri, 2 Oct 2026 22:34:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(subscriptions):=20=E6=8C=89=20MP=20?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E8=AE=A2=E9=98=85=E9=BB=98=E8=AE=A4=E5=80=BC?= =?UTF-8?q?=E4=B8=8E=E8=A1=A8=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 定时主动搜索默认关(DEFAULT_SEARCH_INTERVAL_HOURS = 0),新建 / 手动照常各搜一次,之后靠 RSS 与站点快照 - 多版本自动补全系统缺省改为关 - 表单:基础页 = 作品与范围、下载、洗版;收录版本、优先级、自动补全折叠到进阶页「多版本」 - 对照文档 §7.3 记录落实范围 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + .../routes/subscriptions.ts | 5 +- .../acquisition-organize/services/coverage.ts | 2 +- .../src/modules/sources-identity/tasks.ts | 5 +- .../acquisition-organize/subscription.test.ts | 11 +- .../subscription-search.test.ts | 2 +- .../subscriptions/overview-domain.test.ts | 4 +- .../subscriptions/subscriptions-v2.test.ts | 11 +- .../subscriptions/subscription-form.tsx | 119 ++++++++++-------- docs/porting/moviepilot-v3-comparison.md | 12 +- packages/contracts/src/policy.ts | 5 +- packages/domain/src/acquisition/fill.ts | 4 +- 12 files changed, 111 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a98fe89..fdce0e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ ### 修复 - OurBits 详情页:简介是页面里未渲染的 BBCode(站点靠前端脚本渲染),之前原样显示 `[quote]`、`[img]` 且换行全丢;改为按 BBCode 解析,保留换行、去掉标记,图片进截图,引用块里的 MediaInfo 单独取;文件列表按需取 `viewfilelist.php`。已缓存的详情在过期或手动刷新后生效。 +### 调整(订阅,对照 MP) +- 定时主动搜索默认关闭:新建与手动搜索照常各搜一次,之后靠 RSS 与站点最新资源追更;需要时在订阅里填搜索间隔。没填过间隔的旧订阅也随之不再定时搜。 +- 多版本自动补全默认关闭(订阅或规则组显式打开仍生效)。 +- 订阅表单收敛:基础页只留作品与范围、下载、洗版;收录版本、优先级、自动补全折叠到进阶页的「多版本」。 + ### 新增 - 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。 - 内置公开 BT 站:Nyaa、蜜柑计划、AniBT、AnimeGarden(聚合动漫花园 / 蜜柑 / ANi 等)、萌番组。添加站点 → 公开 BT 站里一键添加,不需要账号;支持资源搜索、定时抓最新资源、按资源 ID 取种子或磁力(蜜柑取剧集页的种子,AnimeGarden 只有磁力)。默认不进资源搜索的默认站点集合;萌番组部分网络直连不通,打开站点的「代理」即可。原来手填 RSS 的方式保留。 diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts index 02e86142..46385d26 100644 --- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts @@ -1,6 +1,7 @@ import { type AcquisitionSlot, Api, + DEFAULT_SEARCH_INTERVAL_HOURS, type SlotTemplate, type Subscription, type SubscriptionGroup, @@ -181,7 +182,7 @@ function overviewItem(row: SubscriptionRow & { media_entity_title?: string | nul missing: primary?.missing ?? 0, slots, lastUpdatedAt: lastUpdatedOf(m).toISOString(), - searchEnabled: p.searchIntervalHours !== 0, + searchEnabled: (p.searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS) > 0, sites: [] as { id: string; name: string }[], ruleGroups: [] as { id: string; name: string }[], defaultRuleGroups: m.defaultRuleGroups, @@ -477,7 +478,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { extra: { nextSearchNow?: boolean } = {}, ) => { const now = d.ports.now(); - const searchOn = subParams(sub).searchIntervalHours !== 0; + const searchOn = (subParams(sub).searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS) > 0; const r = await db() .updateTable("subscription") .set({ diff --git a/apps/server/src/modules/acquisition-organize/services/coverage.ts b/apps/server/src/modules/acquisition-organize/services/coverage.ts index 8d1bbbd7..5cbdcb40 100644 --- a/apps/server/src/modules/acquisition-organize/services/coverage.ts +++ b/apps/server/src/modules/acquisition-organize/services/coverage.ts @@ -85,7 +85,7 @@ const segOf = (u: { segment: string } | undefined) => const editionKey = (e: string) => `edition:${e}`; const ymd = (d: Date) => d.toISOString().slice(0, 10); -/** 订阅有效自动补全:params.autoFill → 规则链上后者优先的 auto_fill → 系统缺省开 */ +/** 订阅有效自动补全:params.autoFill → 规则链上后者优先的 auto_fill → 系统缺省关 */ export function subscriptionAutoFill( cat: RuleCatalog, sub: Pick, diff --git a/apps/server/src/modules/sources-identity/tasks.ts b/apps/server/src/modules/sources-identity/tasks.ts index bf813872..f6287bc6 100644 --- a/apps/server/src/modules/sources-identity/tasks.ts +++ b/apps/server/src/modules/sources-identity/tasks.ts @@ -1,3 +1,4 @@ +import { DEFAULT_SEARCH_INTERVAL_HOURS } from "@umt/contracts"; import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks"; import { z } from "zod"; import type { SourcesRuntime } from "./runtime.ts"; @@ -123,8 +124,8 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu // ── 订阅主动搜索(W5-C,对照 MP SubscribeSearchOwner):按作品标题/原名/别名在已开启站点搜索 ── -/** 主动搜索周期缺省 24h;subscription.params.searchIntervalHours 覆盖(0 = 关闭周期搜索) */ -export const DEFAULT_SEARCH_INTERVAL_HOURS = 24; +/** 周期主动搜索缺省值见 contracts DEFAULT_SEARCH_INTERVAL_HOURS(0 = 关);新建与手动搜索照常各搜一次 */ +export { DEFAULT_SEARCH_INTERVAL_HOURS }; /** 每次最多用几个检索词(标题、原名、别名),控制站点请求量 */ export const SEARCH_MAX_KEYWORDS = 3; diff --git a/apps/server/test/acquisition-organize/subscription.test.ts b/apps/server/test/acquisition-organize/subscription.test.ts index b316c7dd..a779fc9e 100644 --- a/apps/server/test/acquisition-organize/subscription.test.ts +++ b/apps/server/test/acquisition-organize/subscription.test.ts @@ -50,7 +50,13 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () = async function seriesSlot( title: string, - opts: { mode?: "once" | "continuous"; status?: "active" | "pending"; hard?: object; upgrade?: object } = {}, + opts: { + mode?: "once" | "continuous"; + status?: "active" | "pending"; + hard?: object; + upgrade?: object; + params?: object; + } = {}, ) { const entityId = await seedEntity(h.db, { kind: "series", title, year: 2024 }); const sub = await db() @@ -60,6 +66,7 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () = media_kind: "series", mode: opts.mode ?? "continuous", status: opts.status ?? "active", + ...(opts.params ? { params: JSON.stringify(opts.params) } : {}), }) .returning("id") .executeTakeFirstOrThrow(); @@ -275,7 +282,7 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () = }, 90_000); it("多个目标版本按各自已接收单元完成,单版本入库不提前完成订阅", async () => { - const { entityId, subId } = await seriesSlot("双版本剧", { mode: "once" }); + const { entityId, subId } = await seriesSlot("双版本剧", { mode: "once", params: { autoFill: "on" } }); await db() .insertInto("acquisition_slot") .values({ diff --git a/apps/server/test/sources-identity/subscription-search.test.ts b/apps/server/test/sources-identity/subscription-search.test.ts index 6f33298e..967e41e2 100644 --- a/apps/server/test/sources-identity/subscription-search.test.ts +++ b/apps/server/test/sources-identity/subscription-search.test.ts @@ -55,7 +55,7 @@ describe.skipIf(!up)("订阅主动搜索与季总集数", () => { it("按标题在已开启站点搜索:结果 upsert 为 source_release(新结果发 release.observed),排下次搜索并入队订阅重评", async () => { const e = await entity("Dune"); - const subId = await subscribe(e); + const subId = await subscribe(e, { params: JSON.stringify({ searchIntervalHours: 24 }) }); const searches = () => mock.requests.filter((r) => r.path.includes("search=")).length; const before = searches(); const r = await searchSubscription(h.rt, subId); diff --git a/apps/server/test/subscriptions/overview-domain.test.ts b/apps/server/test/subscriptions/overview-domain.test.ts index f84b3019..e4c45127 100644 --- a/apps/server/test/subscriptions/overview-domain.test.ts +++ b/apps/server/test/subscriptions/overview-domain.test.ts @@ -86,7 +86,7 @@ describe("coverageRow", () => { }); describe("自动补全开关与补全预算", () => { - it("订阅显式值优先;inherit 取规则链上最后一个设了 autoFill 的组;都没设为开", () => { + it("订阅显式值优先;inherit 取规则链上最后一个设了 autoFill 的组;都没设为关(MP 没有多版本补全)", () => { expect(resolveAutoFill("off", [{ id: "g", autoFill: true }])).toMatchObject({ enabled: false, source: "subscription", @@ -99,7 +99,7 @@ describe("自动补全开关与补全预算", () => { ]), ).toEqual({ enabled: false, mode: "inherit", source: "rule_group", ruleGroupId: "b" }); expect(resolveAutoFill(undefined, [{ id: "a", autoFill: null }])).toEqual({ - enabled: true, + enabled: false, mode: "inherit", source: "default", }); diff --git a/apps/server/test/subscriptions/subscriptions-v2.test.ts b/apps/server/test/subscriptions/subscriptions-v2.test.ts index 25214608..8aceca38 100644 --- a/apps/server/test/subscriptions/subscriptions-v2.test.ts +++ b/apps/server/test/subscriptions/subscriptions-v2.test.ts @@ -309,7 +309,10 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => { const e1 = await seedEntity(db(), { kind: "series", title: `${tag}甲`, year: 2025 }); await season(e1, 1, 3); const e1u = await episode(e1, 1, 1); - const s1 = await sub(e1, { lastProgressAt: new Date("2026-01-03T00:00:00Z"), params: { siteIds: [base.siteId] } }); + const s1 = await sub(e1, { + lastProgressAt: new Date("2026-01-03T00:00:00Z"), + params: { siteIds: [base.siteId], searchIntervalHours: 24 }, + }); const s1p = await slot(e1, s1, "main"); const a1 = await asset(e1); await cover(a1, [e1u]); @@ -349,7 +352,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => { missing: 2, sites: [{ id: base.siteId, name: "测试站" }], defaultRuleGroups: true, - autoFill: { enabled: true, mode: "inherit", source: "default" }, + autoFill: { enabled: false, mode: "inherit", source: "default" }, searchEnabled: true, lastUpdatedAt: "2026-01-03T00:00:00.000Z", }); @@ -391,7 +394,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => { const e = await seedEntity(db(), { kind: "series", title: "Fill Show", year: 2025 }); await season(e, 1, 10); const u1 = await episode(e, 1, 1); - const s = await sub(e); + const s = await sub(e, { params: { autoFill: "on" } }); const p = await slot(e, s, "4K", { position: 0, hard: { resolutions: ["2160p"] } }); const q = await slot(e, s, "1080p", { position: 1, hard: { resolutions: ["1080p"] } }); const a = await asset(e, { quality: { resolution: "2160p", source: "REMUX" } }); @@ -613,7 +616,7 @@ describe.skipIf(!hasPg)("订阅重做(真实 PG)", () => { it("暂停 / 恢复 / 重新订阅 / 批量 / 收录版本排序", async () => { const e = await seedEntity(db(), { kind: "movie", title: "动作片", year: 2024 }); - const s = await sub(e, { kind: "movie", mode: "once" }); + const s = await sub(e, { kind: "movie", mode: "once", params: { searchIntervalHours: 24 } }); const a = await slot(e, s, "A", { position: 0 }); const b = await slot(e, s, "B", { position: 1 }); diff --git a/apps/web/app/components/subscriptions/subscription-form.tsx b/apps/web/app/components/subscriptions/subscription-form.tsx index 4c0fd884..e5476159 100644 --- a/apps/web/app/components/subscriptions/subscription-form.tsx +++ b/apps/web/app/components/subscriptions/subscription-form.tsx @@ -671,47 +671,11 @@ export function SubscriptionFormDialog({ -
- {editing ? ( - slotsQ.isPending ? ( -

读取收录版本…

- ) : subSlots.length === 0 ? ( -

还没有收录版本。在对比视图里新建。

- ) : ( - - ({ id: s.id, name: s.name }))} - onChange={setOrderState} - disabled={pending} - /> - - ) - ) : ( - - patch({ policyRevisionId: v })} - placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"} - options={templates.map((p) => ({ - value: p.id, - label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`, - }))} - /> - - )} +
-
-
+
+
patch({ searchIntervalHours: e.target.value })} @@ -851,20 +815,65 @@ export function SubscriptionFormDialog({ onChange={(e) => patch({ stopAfter: e.target.value })} /> - - patch({ priority: (v || "tracking") as AcquisitionPriority })} - options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))} - /> -
-
- -
+
+ + 多版本(一般不用改) + +
+
+ {editing ? ( + slotsQ.isPending ? ( +

读取收录版本…

+ ) : subSlots.length === 0 ? ( +

还没有收录版本。在对比视图里新建。

+ ) : ( + + ({ id: s.id, name: s.name }))} + onChange={setOrderState} + disabled={pending} + /> + + ) + ) : ( + + patch({ policyRevisionId: v })} + placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"} + options={templates.map((p) => ({ + value: p.id, + label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`, + }))} + /> + + )} + + patch({ priority: (v || "tracking") as AcquisitionPriority })} + options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))} + /> + +
+
+ +
+
+
diff --git a/docs/porting/moviepilot-v3-comparison.md b/docs/porting/moviepilot-v3-comparison.md index 84cd5882..c996e9c5 100644 --- a/docs/porting/moviepilot-v3-comparison.md +++ b/docs/porting/moviepilot-v3-comparison.md @@ -309,7 +309,7 @@ MP 手动添加入口识别失败时返回 `requires_confirmation`;前端 `Add 这是为本项目资产管理补齐的行为,参考 MP 就地刮削语义,并非声称 MP 有同名模式。未改动自动接管配置或加入链接方式。实现与验证边界见发布记录和整理手册。 -## 7 订阅:追更、选片、洗版与完成(待讨论,2026-10-02) +## 7 订阅:追更、选片、洗版与完成(2026-10-02) MP 依据 §1 固定提交 `app/chain/subscribe/*`,旧版逐函数记录见 [订阅移植对照](moviepilot-subscribe.md)(基线 `e6820a8`,本轮抽查完成判定、整理后事件、状态机未见行为变化)。本项目依据 `acquisition-organize/services/{subscriptions,acquisition,coverage}.ts`、`contracts/src/policy.ts`、订阅表单。测试环境现有 5 条订阅均已取消,收录版本(Slot)0 个:多版本相关机制尚无实际使用记录。 @@ -340,4 +340,12 @@ MP 依据 §1 固定提交 `app/chain/subscribe/*`,旧版逐函数记录见 [ 3. 第 5、11 项:延后重评、持续后续季是否保留。 4. 第 3 项:主动搜索默认开关。 -以上均为对照与建议,尚未授权实施。 +### 7.3 本轮落实(用户授权自主收尾,v0.3.17) + +只改默认值和表单布局,不动数据模型与评估逻辑: + +- 第 3 项:定时主动搜索默认关(`DEFAULT_SEARCH_INTERVAL_HOURS = 0`)。新建、手动搜索照常各搜一次,之后靠 RSS/站点快照追更;单个订阅填间隔才定时搜。旧订阅没填间隔的随之变为不定时搜索。 +- 第 8 项:多版本自动补全系统缺省改为关(订阅或规则组显式打开仍生效),代码保留。 +- 第 1、4、12 项:表单收敛为「基础」=作品与范围、下载、洗版;「进阶」=过滤(规则组、包含/排除、分辨率、质量、特效、关键词)、搜索;收录版本模板/顺序、优先级、自动补全折叠到「多版本(一般不用改)」。 +- 第 7 项:洗版已是 MP 三种(电影洗版、按集洗版、全集洗版),文案改为「拿到规则组最高档即完成」,不再另做。 +- 第 5、10、11、13、14 项维持现状;第 15 项不做。 diff --git a/packages/contracts/src/policy.ts b/packages/contracts/src/policy.ts index 2d213a61..5dcd23b1 100644 --- a/packages/contracts/src/policy.ts +++ b/packages/contracts/src/policy.ts @@ -76,7 +76,7 @@ export const Subscription = EntityBase.extend({ statusReason: z.string().nullable(), lastCheckedAt: Timestamp.nullable(), createdBy: z.string().nullable(), - /** 上次主动搜索时间 / 下次到期(按 params.searchIntervalHours,缺省 24h) */ + /** 上次主动搜索时间 / 下次到期(按 params.searchIntervalHours,缺省不周期搜索) */ lastSearchedAt: Timestamp.nullable(), nextSearchAt: Timestamp.nullable(), /** 上次进展:任一收录版本的单元转为已入库、或总集数变化时写入;总览"上次更新"另合并计划预留时间 */ @@ -126,6 +126,9 @@ export type SubscriptionEffect = z.infer; * 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…), * 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。 */ +/** 周期主动搜索缺省关闭(对照 MP:订阅默认靠站点 RSS / 首页刷新匹配);params.searchIntervalHours > 0 才按周期搜 */ +export const DEFAULT_SEARCH_INTERVAL_HOURS = 0; + export const SubscriptionParams = z .object({ siteIds: z.array(Id).optional(), diff --git a/packages/domain/src/acquisition/fill.ts b/packages/domain/src/acquisition/fill.ts index cc2d0330..24c3c0d8 100644 --- a/packages/domain/src/acquisition/fill.ts +++ b/packages/domain/src/acquisition/fill.ts @@ -1,5 +1,5 @@ // 多版本自动补全(docs/design/subscriptions.md §2.2):非主收录版本在主版本已满足的单元上补下其他版本。 -// 开关按 订阅 → 规则组(链上后者优先)→ 系统缺省(开)取值;补全预算按订阅、按自然日计,超出记延后。 +// 开关按 订阅 → 规则组(链上后者优先)→ 系统缺省(关,MP 没有多版本补全)取值;补全预算按订阅、按自然日计,超出记延后。 export type AutoFillModeValue = "inherit" | "on" | "off"; @@ -15,7 +15,7 @@ export interface AutoFillResolution { export function resolveAutoFill( mode: AutoFillModeValue | undefined, groupFlags: readonly { id: string; autoFill: boolean | null }[] = [], - systemDefault = true, + systemDefault = false, ): AutoFillResolution { if (mode === "on" || mode === "off") return { enabled: mode === "on", mode, source: "subscription" }; for (let k = groupFlags.length - 1; k >= 0; k--) {