feat(subscriptions): 按 MP 收敛订阅默认值与表单

- 定时主动搜索默认关(DEFAULT_SEARCH_INTERVAL_HOURS = 0),新建 / 手动照常各搜一次,之后靠 RSS 与站点快照
- 多版本自动补全系统缺省改为关
- 表单:基础页 = 作品与范围、下载、洗版;收录版本、优先级、自动补全折叠到进阶页「多版本」
- 对照文档 §7.3 记录落实范围

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 22:34:48 +08:00
1 parent 709414778e
commit ed083fd138
12 files changed
+111 -74

No files matched your search

@@ -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({
@@ -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<SubscriptionRow, "id" | "params" | "media_kind" | "media_entity_id">,
@@ -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;
@@ -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({
@@ -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);
@@ -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",
});
@@ -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 });
@@ -671,47 +671,11 @@ export function SubscriptionFormDialog({
</FormField>
</Section>
<Section title="收录版本">
{editing ? (
slotsQ.isPending ? (
<p className="text-caption text-muted-foreground">读取收录版本…</p>
) : subSlots.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有收录版本。在对比视图里新建。</p>
) : (
<FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐">
<SlotOrderList
items={order ?? subSlots.map((s) => ({ id: s.id, name: s.name }))}
onChange={setOrderState}
disabled={pending}
/>
</FormField>
)
) : (
<FormField
label="模板"
htmlFor={f("tpl")}
hint={
target?.kind === "entity"
? "作品还没有收录版本时按模板顺序创建,第一个是主版本"
: "作品加入媒体库后按模板创建收录版本"
}
>
<Pick
id={f("tpl")}
value={d.policyRevisionId}
onChange={(v) => patch({ policyRevisionId: v })}
placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"}
options={templates.map((p) => ({
value: p.id,
label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
}))}
/>
</FormField>
)}
<Section title="洗版">
<FormField
label="升级策略"
label="洗版"
htmlFor={f("upgrade")}
hint="作用于这个订阅的全部收录版本;已入库低于目标档位时继续找更好的"
hint="已入库的低于规则组最高档时继续找更好的,拿到最高档即完成"
>
<Pick
id={f("upgrade")}
@@ -826,18 +790,18 @@ export function SubscriptionFormDialog({
</FormField>
</Section>
<Section title="搜索与优先级">
<div className="grid gap-3 sm:grid-cols-3">
<Section title="搜索">
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="主动搜索间隔(小时)"
label="定时搜索间隔(小时)"
htmlFor={f("interval")}
error={err("searchIntervalHours")}
hint="0 表示不主动搜索"
hint="空或 0 = 不定时搜索:新建时搜一次,之后靠 RSS 和站点最新资源追更"
>
<Input
id={f("interval")}
inputMode="decimal"
placeholder="24"
placeholder="0"
value={d.searchIntervalHours}
aria-invalid={!!err("searchIntervalHours")}
onChange={(e) => patch({ searchIntervalHours: e.target.value })}
@@ -851,20 +815,65 @@ export function SubscriptionFormDialog({
onChange={(e) => patch({ stopAfter: e.target.value })}
/>
</FormField>
<FormField label="优先级" htmlFor={f("prio")} hint="只影响资源分配顺序">
<Pick
id={f("prio")}
value={d.priority}
onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
/>
</FormField>
</div>
</Section>
<Section title="自动补全其他版本">
<AutoFillFields d={d} patch={patch} errors={errors} idPrefix={id} />
</Section>
<details className="group min-w-0 border-t border-border/60 pt-4">
<summary className="cursor-pointer text-meta font-semibold tracking-wide text-muted-foreground">
多版本(一般不用改)
</summary>
<div className="mt-3 space-y-5">
<Section title="收录版本">
{editing ? (
slotsQ.isPending ? (
<p className="text-caption text-muted-foreground">读取收录版本…</p>
) : subSlots.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有收录版本。在对比视图里新建。</p>
) : (
<FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐">
<SlotOrderList
items={order ?? subSlots.map((s) => ({ id: s.id, name: s.name }))}
onChange={setOrderState}
disabled={pending}
/>
</FormField>
)
) : (
<FormField
label="模板"
htmlFor={f("tpl")}
hint={
target?.kind === "entity"
? "作品还没有收录版本时按模板顺序创建,第一个是主版本"
: "作品加入媒体库后按模板创建收录版本"
}
>
<Pick
id={f("tpl")}
value={d.policyRevisionId}
onChange={(v) => patch({ policyRevisionId: v })}
placeholder={defaultTpl ? `按已发布收录策略(${defaultTpl.title})` : "按已发布收录策略"}
options={templates.map((p) => ({
value: p.id,
label: `${p.title} r${p.revision}:${tplNames(p.id).join(" → ") || "无模板"}`,
}))}
/>
</FormField>
)}
<FormField label="优先级" htmlFor={f("prio")} hint="只影响下载排队先后">
<Pick
id={f("prio")}
value={d.priority}
onChange={(v) => patch({ priority: (v || "tracking") as AcquisitionPriority })}
options={AcquisitionPriorityEnum.options.map((v) => ({ value: v, label: PRIORITY[v] }))}
/>
</FormField>
</Section>
<Section title="自动补全其他版本">
<AutoFillFields d={d} patch={patch} errors={errors} idPrefix={id} />
</Section>
</div>
</details>
</TabsContent>
</Tabs>
<DialogFooter>